Skip to content

Asset reconciliation — the event contract

For: the software side consuming SOFTWARE_RECORDS and MATCHED_RECORDS for a Fund Management asset session (reconciliationType=asset).

Short version: a record is created unmatched, then re-published with its status / difference / custodianValue filled, and the pair of RECORD ids that reconciled is published alongside. A record only ever moves out of unmatched, never back into it, and nothing is re-sent that matching had nothing to say about.


1. The shapes

Every message carries reconciliationType"bank" or "asset" — as a Pub/Sub attribute, alongside type, batch_id, record_count, content_type and isSecondStageReconciliation. A consumer can route on type + reconciliationType without deserialising the payload.

Software records — LedgerStatementRecordDto

Asset adds two type values, custodian and portfolio; the envelope is otherwise the money one, and data follows the interface for that type.

export interface LedgerStatementRecordDto {
  type: "ledger" | "statement" | "custodian" | "portfolio";
  data:
    | PartialGeneralLedgerDto
    | PartialAccountStatementDto
    | PartialCustodianReportDto
    | PartialPortfolioReportDto;
  isLastRecord?: boolean;
  reconciliationId: string;
}

export interface PartialCustodianReportDto {
  id: string;
  tenant: string;
  security: string;
  maturity?: string;
  value?: number;
  difference?: number;
  instrumentNo?: string;
  instrumentName?: string;
  settlementDate?: string;
  faceValue?: number;
  couponRate?: number;
  rate?: number;
  account?: string;
  participantCode?: string;
  code?: string;
  figi?: string;
  assetClassId: string;
  status?: AssetReconciliationStatusEnum;
}

export interface PartialPortfolioReportDto {
  id: string;
  tenant: string;
  security: string;
  maturity?: string;
  value?: number;
  difference?: number;
  instrumentNo?: string;
  instrumentName?: string;
  settlementDate?: string;
  faceValue?: number;
  couponRate?: number;
  rate?: number;
  account?: string;
  participantCode?: string;
  code?: string;
  figi?: string;
  portfolioId: string;
  assetClassId: string;
  status?: AssetReconciliationStatusEnum;
  custodianValue?: number;
}

Every field of both is published — no field is omitted and none is added — except portfolioId / custodianValue, which are absent from a custodian record rather than sent as null, since PartialCustodianReportDto does not declare them.

Match records

export interface CustodianPortfolioMatchDto {
  custodianId?: string;
  portfolioId: string;
  tenant: string;
  status: AssetReconciliationStatusEnum;
  reconciliationId: string;
  isLastRecord?: boolean;
}

export enum AssetReconciliationStatusEnum {
  MATCHED = "matched",
  AGGREGATED = "aggregated",
  UNMATCHED = "unmatched",
}

Both ids are RECORD ids — the data.id values published for each holding at create, not the custodian institution and not the portfolio the holding belongs to. The message reads "this custodian row reconciled against that internal row", which is the asset twin of the money path's {statementId, ledgerId} and what lets matched_custodian_report_id be set on the internal row.

status, difference and custodianValue are declared on the report DTOs, so those travel on the record itself — see §3.2.

aiStatistics

An asset session updates the four asset counters only — the money fields are left absent rather than zero-filled, so they cannot blank a real value.

class AIStatistics {
  totalGlRecords: number;
  totalStatementRecords: number;
  totalMatchedGlRecords: number;
  totalMatchedStatementRecords: number;
  totalChargebacks?: number;
  totalReconciledDates?: number;
  totalUnreconciledDates?: number;
  totalCustodianRecords?: number;            // ← asset
  totalPortfolioRecords?: number;            // ← asset
  totalMatchedCustodianReports?: number;     // ← asset
  totalMatchedPortfolioReports?: number;     // ← asset
}

A match pairs ONE custodian line with one or more internal rows, so the two matched counters are different numbers by design: the custodian side counts lines, the portfolio side counts every internal row those lines consumed.


2. Create — SOFTWARE_RECORDS, before matching

One message per holding, typed portfolio (the internal book) or custodian (the depository report), carrying PartialPortfolioReportDto / PartialCustodianReportDto.

{"type": "portfolio", "reconciliationId": "…", "isLastRecord": false,
 "data": {"id": "pf-1", "tenant": "abbey", "security": "13.98% FGN BOND 23-FEB-2028",
          "instrumentNo": "NGFG152028S0", "faceValue": 10.0, "value": 997.13,
          "assetClassId": "63de8efe-…", "portfolioId": "9eeb3382-…",
          "status": "unmatched", "difference": null, "custodianValue": null}}

Three fields are match-time and go out at their pre-match value:

field at create why
status "unmatched" Nothing is reconciled before matching runs, so that is the true state. It is never null — and a record whose update never arrives reads "not reconciled", which is the safe direction to fail.
difference null A variance needs a counterpart, and there is none yet. 0.0 would be a lie: it is the value that means "these two agree".
custodianValue null Same reason. Portfolio records only.

data.id is ours, and deterministic (a v5 uuid hashed from the holding's identity). It is the join key for everything below. portfolioId and assetClassId are yours — the ids from the portfolios and asset_classes lists on the upload, echoed back unchanged.


3. After matching

Two publishes, in this order: the records' own values, then the pairing.

3.1 The pairing — MATCHED_RECORDS

{"custodianId": "b97c0e52-a3d6-52d5-af1d-bd577036db91",
 "portfolioId": "7356bde2-272a-5ea3-8c9e-d2d44a1a9a77",
 "tenant": "abbey", "status": "matched",
 "reconciliationId": "93594a72-a36e-4e21-8245-77883ab50d87", "isLastRecord": false}
  • One record per matched PAIR, so a match consuming four tranches emits four records: the same custodianId, a different portfolioId each time.
  • Only matched / aggregated. A break resolves to unmatched, which is what both its records were created with, so no pairing is published for it.
  • Both sides required — a pair with one id missing names nothing, so a one-sided break publishes nothing here.
  • One verdict per pair, whatever the arrival order: matched outranks aggregated.

3.2 The values — SOFTWARE_RECORDS again, same DTO

The three match-time fields are declared on PartialPortfolioReportDto / PartialCustodianReportDto, so the record carries them. The row is re-published with them filled and upserted on data.id:

portfolio  {"id": "5d2e4052-…", "security": "13.00% FGN BOND 21-JAN-2042",
             "faceValue": 2000000.0,
             "status": "matched", "difference": null, "custodianValue": 4400000000}

custodian  {"id": "b97c0e52-…", "security": "FG of Nigeria Bonds2042.01.21 Issue",
             "faceValue": 4400000000.0,
             "status": "matched", "difference": 0.0}
  • Same builder as the create pass, so no other field can drift; only status, difference and custodianValue differ between the two messages.
  • custodianValue is the custodian line's OWN face value. All four tranches of a group carry 4,400,000,000 — it is what they reconciled against, not a per-row share, so summing it across a group double-counts.
  • difference is the variance of the PAIR. On the custodian row always; on the internal row only when that row is the pair's only internal row. A tranche group has one variance, and repeating it four times would report the shortfall four times.
  • A custodian record never carries custodianValue — its DTO does not declare it, and the field would be its own figure anyway.
  • Only rows matching had something to say about are re-sent. A one-sided holding keeps what it was created with. Session 93594a72 re-sent 75 of 99.
  • No terminal markerisLastRecord stays false here; the session-end sentinel is the single end signal.

4. What is published for each outcome

outcome pairing (3.1) record update (3.2)
reconciled one per tranche, matched status: matched, custodianValue, difference
lump sum one per holding consumed, aggregated status: aggregated, custodianValue
break (paired, amounts differ) difference, custodianValue; status stays unmatched
one-sided break

A one-sided break appears in neither: no counterpart means no pairing to name and no variance to report, and its record already reads unmatched from create. It is still a break in the reconciliation trace and in unmatched_gl / unmatched_bank.

5. Session end

One terminal sentinel per session, isLastRecord: true, repeating the last published record's payload. The create stream marks its own terminator per type — the last portfolio message and the last custodian message each carry isLastRecord: true.


6. Where this lives

what where
create payload shared/pubsub/handler.py_asset_message, _ASSET_FIELDS
pre-match values _ASSET_PRE_MATCH_VALUES
pairing _extract_asset_match_records, precedence in _ASSET_VERDICT_RANK
record update publish_asset_record_updates, values from _asset_match_time_values
contract tests tests/test_asset_event_publish.py

Every rule above is asserted in tests/test_asset_event_publish.py, including the whole key set of both DTOs — so a field cannot be dropped, renamed or added without a failing test.