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 differentportfolioIdeach time. - Only
matched/aggregated. A break resolves tounmatched, 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:
matchedoutranksaggregated.
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,differenceandcustodianValuediffer between the two messages. custodianValueis the custodian line's OWN face value. All four tranches of a group carry4,400,000,000— it is what they reconciled against, not a per-row share, so summing it across a group double-counts.differenceis 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
93594a72re-sent 75 of 99. - No terminal marker —
isLastRecordstaysfalsehere; 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.