Skip to content

MOD-118 — Member equity and share registry

Module: MOD-118 System: SD01 Core Banking Repo: bank-core Module type: Application Lambda + IaC FR scope: FR-533, FR-534, FR-535, FR-536 Policies satisfied: CLQ-001 (GATE), GOV-001 (LOG), CON-001 (AUTO), CON-004 (AUTO) Author: Claude (bank-core SD-01 agent) Date: 2026-05-19 Dependencies (Deployed): MOD-001, MOD-007, MOD-033, MOD-073, MOD-076, MOD-140, MOD-103, MOD-104 Dependencies (optional / deferred): MOD-113 (statement renderer), MOD-105 (eligibility)


Objective

Maintain the authoritative member shareholding register for mutual institutions, enforce a regulatory capital gate on redemptions (without which member shares cease to qualify as CET1 under RBNZ BS2A / APRA APS 110), and execute the dividend declaration + distribution workflow with consistent per-member calculation and automatic FY-end tax certificate dispatch. The module is gated by a tenant flag — inactive for proprietary tenants.


Internal architecture

                     ┌────── tenant gate (SSM /bank/{stage}/tenant/institution-type)
                     │       proprietary → 501 / 404 short-circuit
   API GW ─▶ create-member.handler          → MemberStore.create
          ─▶ purchase-shares.handler         → ShareTxService.purchase
          ─▶ redeem-shares.handler           → ShareTxService.redeem      (calls CapitalGateProvider)
          ─▶ declare-dividend.handler        → DividendService.declare
          ─▶ record-vote.handler             → MemberStore.recordVote
          ─▶ list-share-transactions.handler → ShareTransactionStore.listForMember

   Schedules ─▶ replay-blocked-redemptions  (daily 02:15 NZT)
              ▶ execute-dividend-batch       (daily 05:00 NZT)
              ▶ publish-fy-end-statements    (annual 30 Jun 23:00 NZT)

Each write path runs inside a single withTransaction block that locks the member row FOR UPDATE, calls MOD-001's posting API to commit the ledger movement, then INSERTs the immutable share_tx row and updates shares_held on the member register.


Key design decisions

Decision: CapitalGateProvider interface — Gap #1 ruling

Context: FR-534 requires MOD-118 to call MOD-033 for the current CET1 ratio before processing a redemption. MOD-033 is Snowflake-native and has no real-time API in v1; bank-risk-platform is delivering a Lambda wrapper per issue #38.

Choice: Implement the gate behind a CapitalGateProvider interface with two providers — StubCapitalGateProvider (dev / uat default, returns a healthy stub ratio configurable via CAPITAL_GATE_STUB_CET1) and Mod033CapitalGateProvider (prod path, calls the MOD-033 Lambda wrapper). Factory at the bottom of src/lib/capital-gate-provider.ts selects via CAPITAL_GATE_PROVIDER=stub | mod-033 env var. Handler code never imports a concrete class.

Reason: Unblocks the MOD-118 build today and keeps the swap to the prod provider mechanical (one env var flip + the wrapper URL). Pattern mirrors MOD-087's EnrichmentProvider.

Trade-off: v1 redemption gate uses a synthetic CET1 figure in dev/uat. Smoke + integration tests in those environments cover the orchestration shape; the actual regulatory math is exercised in prod once the MOD-033 wrapper ships.

Decision: core.share_transactions is Cat 1 immutable, INSERT-only

Context: GOV-001 LOG requires that share transactions form an immutable governance log.

Choice: V001 grants INSERT, SELECT only (no UPDATE/DELETE); V002 adds a row trigger (fn_share_transactions_reject_mutation) that raises on UPDATE/DELETE/TRUNCATE. Status is terminal at write timeSETTLED, BLOCKED, or REVERSED — never updated. A correction (reversal) is a NEW row with tx_type='CORRECTION' and negative shares, correlating to the original via correlated_tx_id.

Reason: Belt + braces; revocation guards superuser misuse, the trigger guards a future GRANT UPDATE ... TO some_user regression. Matches MOD-001's accounts.postings pattern.

Decision: PENDING redemption state lives in core.redemption_queue

Context: core.share_transactions is immutable; we still need a mutable record of "this redemption was blocked and we'll retry it tomorrow".

Choice: A separate mutable core.redemption_queue table holds each blocked-but-not-cancelled redemption. The replayer mutates the queue row's status (PENDING → PROCESSED / CANCELLED). Every capital-gate evaluation result writes a new share_transactions row (BLOCKED if the gate fails again, SETTLED if it finally clears), correlated to the queue row via redemption_queue_id.

Reason: Separates the audit trail (immutable share_tx rows) from the operational queue (mutable, indexable by jurisdiction + sequence_no for FIFO replay).

Decision: cross-domain references via party_id, no Postgres FK

Context: MOD-118.md initially specified core.customers(customer_id) as the FK target; customers actually live in bank-kyc's DB (MOD-012).

Choice: core.member_register.party_id is UUID NOT NULL with no FK. App-layer enforcement only.

Reason: Mirrors the established cross-domain pattern (accounts.account_party_relationships). Cross-database FKs aren't supported in Postgres; introducing a stub FK would silently allow orphan members.

Decision: dispatch FY-end artefacts via events — Gap #2 ruling Option C

Context: MOD-113 (statement / tax certificate renderer) is Not started, but FR-535 / FR-536 require dispatch of those artefacts.

Choice: MOD-118 emits bank.core.member_statement_due and bank.core.tax_certificate_due events at FY end with the full data payload (opening / closing shares, every share-tx in the FY, every dividend payment, withholding rates, totals). MOD-113 subscribes when deployed.

Reason: Lets MOD-118 complete its data computation and audit-trail responsibility independently of the renderer. CON-004 AUTO is satisfied: the dispatch is automatic, no member request required. FR-535 / FR-536 delivery is deferred until MOD-113 ships.


Data model

Table Mutability Notes
core.member_register mutable Status / shareholding / voting history; one row per party.
core.share_transactions Cat 1 immutable (ADR-048) INSERT-only; status terminal at write time; corrections are new CORRECTION rows.
core.dividend_declarations mutable Status: DECLARED → PROCESSING → PAID (or REVERSED).
core.dividend_payments mutable (append-once) One per (declaration, member); paid_at flipped from null when the net credit posts.
core.redemption_queue mutable FIFO queue for capital-gate-blocked redemptions; replayer mutates status.

Full DDL in migrations/V001-V003.


External dependencies

  • Database: bank on Neon (provisioned by MOD-103). Runtime role core_app_user; migration role core_migrate_user.
  • EventBridge: publishes on bank-core bus (ARN via /bank/{stage}/eventbridge/bank-core/arn).
  • MOD-001: all postings submitted via /postings on the SSM path /bank/{stage}/mod-001/api/base-url.
  • MOD-033: capital snapshot via CapitalGateProvider (stub in v1; Lambda wrapper API in prod per bank-risk-platform issue #38).
  • MOD-073: doc vault (consumed when MOD-113 deploys; no direct call from MOD-118 v1).

SSM outputs table

Output SSM path Consumers
API base URL /bank/{stage}/mod-118/api/base-url bank-app member equity UI
create-member Lambda ARN /bank/{stage}/mod-118/lambda/create-member/arn back-office onboarding
purchase-shares Lambda ARN /bank/{stage}/mod-118/lambda/purchase-shares/arn bank-app share-purchase flow
redeem-shares Lambda ARN /bank/{stage}/mod-118/lambda/redeem-shares/arn bank-app redemption flow
declare-dividend Lambda ARN /bank/{stage}/mod-118/lambda/declare-dividend/arn back-office board ops
execute-dividend-batch Lambda ARN /bank/{stage}/mod-118/lambda/execute-dividend-batch/arn scheduled trigger
replay-blocked-redemptions Lambda ARN /bank/{stage}/mod-118/lambda/replay-blocked-redemptions/arn scheduled trigger
publish-fy-end-statements Lambda ARN /bank/{stage}/mod-118/lambda/publish-fy-end-statements/arn scheduled trigger + ops
record-vote Lambda ARN /bank/{stage}/mod-118/lambda/record-vote/arn back-office AGM tool
list-share-transactions Lambda ARN /bank/{stage}/mod-118/lambda/list-share-transactions/arn bank-app statements
Error code enum /bank/{stage}/mod-118/error-codes bank-app PAYMENT_FAILED-style mapping

EventBridge contract

Direction Source Detail-type Schema
Publish bank.core bank.core.share_transaction_settled ShareTransactionSettledV1
Publish bank.core bank.core.share_redemption_blocked ShareRedemptionBlockedV1
Publish bank.core bank.core.dividend_declared DividendDeclaredV1
Publish bank.core bank.core.dividend_paid DividendPaidV1
Publish bank.core bank.core.member_statement_due MemberStatementDueV1 (Gap #2)
Publish bank.core bank.core.tax_certificate_due TaxCertificateDueV1 (Gap #2)
Publish bank.core bank.core.member_status_changed MemberStatusChangedV1

Typed payload shapes in contract/events/index.ts (@bank-core/mod-118-contracts/events).


Tenant gate

SSM /bank/{stage}/tenant/institution-type returns mutual | proprietary. Read at Lambda cold start by src/lib/tenant-flag.ts. Proprietary tenants get an immediate 501 MODULE_DISABLED_PROPRIETARY_TENANT from every handler — no DB writes, no events, no posting calls. The override env var TENANT_TYPE_OVERRIDE exists for tests.


Test approach

Test type Location Count
Unit tests/unit/ dividend-calc, capital-gate-provider, share-tx-validator, tenant-flag, logger, errors, emf (≥80% coverage gate on src/lib/* + pure-calc services)
Contract tests/contract/ event-types parity — service union ⇔ contract enum
FR integration tests/integration/fr-533, fr-534, fr-535, fr-536 one per FR
Policy satisfaction tests/policy/CLQ-001, GOV-001, CON-001, CON-004 one per row; CLQ-001 has a dedicated negative test
Deployment smoke tests/verify-deployment.mjs SSM + Lambda liveness

Integration + policy tests connect to the deployed dev Neon database; they skip cleanly when NEON_DIRECT_HOST / NEON_APP_PASSWORD aren't set. In CI, RUN_INTEGRATION=1 keeps the guard off so every test runs.


Open items / v2

  • Wire Mod033CapitalGateProvider once bank-risk-platform issue #38 ships GET /capital/current/{jurisdiction} + the /bank/{stage}/mod-033/api/base-url SSM output + BankCoreRole IAM grant. One env var flip (CAPITAL_GATE_PROVIDER=mod-033) plus the base-url env wires it; no source-code change.
  • Wire MOD-105 eligibility check when MOD-105 ships. v1 accepts any party with status='member'.
  • MOD-113 subscription on bank.core.{member_statement_due, tax_certificate_due} — once MOD-113 deploys, FR-535/FR-536 delivery completes.
  • Replace the ops-supplied paymentAccountResolver env shim with a proper MOD-007 lookup once the join path is stable.
  • The execute-dividend-batch scheduler is a daily 05:00 NZT cron; v2 should look at the declarations table and emit per-declaration events rather than a single daily kick. v1 is fine because declarations are board-driven and infrequent.