From 32c00dc5a3de078f4c776cf8a187924a146235a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:02:04 +0000 Subject: [PATCH 1/2] feat(7.1): BSP HOT multi-currency + exchange/conjunction cross-refs (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DISH Rev 23 KB (transaction vs reporting CUTP; IROE≠ICER; conjunction/ exchange/EMD/ADM separate; generic X12 misses fixed-width). Extend HOT types, synthetic DISH fixtures, parser, and matcher for cross-ref reconciliation. No live HOT dumps. Co-authored-by: telivity-otaip --- CLAUDE.md | 1 + docs/agents/stage-7-reconciliation.md | 12 +- docs/knowledge-base/bsp-hot-reconciliation.md | 175 ++++++++ .../__tests__/bsp-reconciliation.test.ts | 286 ++++++++++++- .../fixtures/hot-dish-rev23-synthetic.txt | 22 + .../src/bsp-reconciliation/hot-file-parser.ts | 335 +++++++++++++-- .../src/bsp-reconciliation/index.ts | 32 +- .../reconciliation-matcher.ts | 396 ++++++++++++++---- .../src/bsp-reconciliation/types.ts | 130 +++++- packages/agents/reconciliation/src/index.ts | 3 + 10 files changed, 1234 insertions(+), 158 deletions(-) create mode 100644 docs/knowledge-base/bsp-hot-reconciliation.md create mode 100644 packages/agents/reconciliation/src/bsp-reconciliation/__tests__/fixtures/hot-dish-rev23-synthetic.txt diff --git a/CLAUDE.md b/CLAUDE.md index 4d412f0..6f09164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ Follow the pattern in `packages/agents/reference/src/airport-code-resolver/`: ## Domain Knowledge - Air: `docs/knowledge-base/` (existing) - Lodging: `docs/knowledge-base/lodging.md` +- BSP HOT (Agent 7.1): `docs/knowledge-base/bsp-hot-reconciliation.md` (DISH Rev 23; multi-currency CUTP; IROE≠ICER; conjunction/exchange/EMD/ADM separate) - Agent definitions: `docs/agents/` ## Repository Structure diff --git a/docs/agents/stage-7-reconciliation.md b/docs/agents/stage-7-reconciliation.md index 72c9d2e..7ebcbc9 100644 --- a/docs/agents/stage-7-reconciliation.md +++ b/docs/agents/stage-7-reconciliation.md @@ -12,14 +12,18 @@ BSP/ARC reconciliation, commission management, interline settlement, financial r **Class:** `BSPReconciliation` **Status:** Implemented -Matches agency booking records against BSP HOT files, validates commission rates, identifies discrepancies (missing records, duplicates, amount/commission/currency mismatches, unmatched ADM/ACM), and flags issues before remittance deadline. +Matches agency booking records against BSP HOT files (DISH Rev 23), validates commission rates, identifies discrepancies (missing records, duplicates, amount/commission/currency mismatches, unmatched ADM/ACM, exchange/conjunction cross-refs), and flags issues before remittance deadline. + +**Domain knowledge:** [`docs/knowledge-base/bsp-hot-reconciliation.md`](../knowledge-base/bsp-hot-reconciliation.md) — HOT is DISH fixed-width (generic X12 parsers miss sections); **do not assume single-currency HOT**; transaction `CUTP` vs reporting/`BOH03` currency; **IROE ≠ ICER**; conjunction / exchange / EMD / ADM are separate; synthetic fixtures only (no live HOT dumps). **Input (`BSPReconciliationInput`):** -- `hot_records` -- BSP HOT file records (ticket number, passenger, origin/destination, airline, amounts, commission, transaction type, billing period) -- `agency_records` -- agency-side booking records for comparison +- `hot_records` -- BSP HOT file records (ticket number, passenger, origin/destination, airline, amounts, commission, transaction type / DISH TRNC, **transaction currency CUTP**, optional reporting currency, billing period, ORIT / RTDN / conjunction set) +- `agency_records` -- agency-side booking records for comparison (optional `original_ticket_number`, `related_ticket_number`, `conjunction_ticket_numbers`) **Output (`BSPReconciliationOutput`):** -- Matched records, unmatched records, discrepancies with severity and type, commission validation results +- Discrepancies with severity and type (includes `UNMATCHED_EXCHANGE`, `CONJUNCTION_SET_MISMATCH`) +- Summary with `currencies_present` (multi-currency HOT awareness) +- Commission / amount compares only when agency and HOT transaction currencies match (no silent IROE/ICER FX) --- diff --git a/docs/knowledge-base/bsp-hot-reconciliation.md b/docs/knowledge-base/bsp-hot-reconciliation.md new file mode 100644 index 0000000..2225df5 --- /dev/null +++ b/docs/knowledge-base/bsp-hot-reconciliation.md @@ -0,0 +1,175 @@ +# BSP HOT Reconciliation — Domain Knowledge (Agent 7.1) + +Source: **IATA BSP Data Interchange Specifications Handbook (DISH) Revision 23** +(public overview: https://www.iata.org/en/publications/bspdish/; handbook Attachment A to PSC Resolution 750). +IROE / ICER distinction: IATA Exchange Rates Services (public). + +This file is the authoritative domain input for `@otaip/agents-reconciliation` BSP HOT matching. +Anything missing is captured as an open `DOMAIN_QUESTION` at the bottom — **never invent**. + +**Do not commit live HOT dumps or real agency data.** Tests use synthetic HOT-like fixtures only. + +--- + +## 1. HOT is DISH record-level — not generic X12 + +Airline Accounting/Sales data (**HOT**) is specified in DISH Chapter 6 as **fixed-width positional records** with Standard Message Identifiers (`BFH01`, `BCH02`, `BOH03`, `BKT06`, `BKS24`, `BKS30`, `BKS39`, `BKS45`, `BKS46`, `BKP84-*`, totals `BOT93`/`BOT94`/`BCT95`/`BFT99`, etc.). + +| Guard | Rule | +| --- | --- | +| Generic EDI X12 parsers | **STOP.** A generic X12 segment parser will miss DISH fixed-width sections and mis-map fields. Layout varies by BSP market within the DISH grids. | +| Hybrid / simplified test fixtures | Lab fixtures may use tagged or simplified layouts for readability; production ingestion must follow the market’s DISH HOT record grids. | + +--- + +## 2. Multi-currency — do **not** assume a single-currency HOT + +DISH §6.5.3 (Currency): + +- **BOH03 `CUTP`** — national / default currency of the BSP (or reporting-office currency context). +- **Per-transaction `CUTP`** — currency in which **that** transaction is processed. Present on amount-bearing records (`BKS30`, `BKS39`, `BKP84`, …). +- Totals are **per currency**: `BOT93` (office subtotals by TRNC+CUTP), `BOT94`, `BCT95`, `BFT99`. +- Where an agent may accept more than one currency, **any individual sale must be paid and reported in one currency only** (`CUTP` on that transaction). +- Multi-currency BSPs may sort HOT by agent (mixed currencies) or by currency (one file per currency). Empty currency files may still be produced. + +**Matching rule for Agent 7.1:** compare agency vs HOT amounts **only when transaction `CUTP` values are equal**. Never silently convert currencies. Never treat file-level default/`BOH03` currency as the amount currency for every row. + +### Transaction currency vs reporting currency + +| Concept | DISH / IATA field | Use in reconciliation | +| --- | --- | --- | +| **Transaction currency** | Record-level `CUTP` on the transaction’s amount records | Primary match key for fare / tax / commission / remittance amounts | +| **Reporting / default currency** | `BOH03` `CUTP` (and currency-sorted file identity) | Remittance totals context; **not** a substitute for missing per-txn `CUTP` | +| **Equivalent fare paid** | `EQFR` (BAR64 / IT07) | Present when payment currency differs from entered fare currency (PSC Reso 722 §3.18) — do not confuse with ROE tables | + +### IROE ≠ ICER + +| Rate product | Role | Must not be used for | +| --- | --- | --- | +| **IROE** (IATA Rates of Exchange) | Monthly rates for **fare / NUC construction** (Reso 024c) | Converting HOT remittance amounts between currencies during matching | +| **ICER** (IATA Consolidated Exchange Rates) | Daily rates for converting fares/taxes/fees to an **alternate payment currency** at pricing/ticketing | Same — not a HOT amount FX engine unless domain explicitly supplies the rate source used for that BSP period | + +If a shop needs cross-currency discrepancy totals, surface **DOMAIN_QUESTION** for which published rate table and date apply — do not invent FX. + +--- + +## 3. Transaction types are separate (TRNC) + +DISH `TRNC` (BKS24) values used in HOT reconciliation (non-exhaustive; see DISH Glossary): + +| TRNC | Meaning | Reconciliation category | +| --- | --- | --- | +| `TKTT` | Electronic ticket sale (automated) | Issue / sale | +| `EMDA` / `EMDS` | EMD associated / standalone | **EMD — separate from TKTT** | +| `RFND` | Refund | Refund | +| `ADMA` / `ACMA` | Agency debit / credit memo | **ADM / ACM — separate** | +| `CANX` / `CANN` / `RFNC` / … | Cancel / reversal variants | Out of simple sale match; see DISH notes | +| `TASF` / `SPCR` / `SPDR` / `SSAC` / `SSAD` | Fees / settlement-plan / summary adjustments | Separate categories | + +**Exchange is not a standalone `TRNC`.** Straight / ADC exchanges are **issue transactions** (`TKTT` / EMD) whose Form of Payment includes **`BKP84-EX`**, with original-document linkage on **`BKS46` `ORIT`** (and related RET `IT03` patterns). Agent 7.1 therefore treats **exchange-linked issues** as a distinct matching path (new `TDNR` + `ORIT` / related docs), not as “same ticket number as the original.” + +**Conjunction / exchange / EMD / ADM are separate** structural patterns — do not collapse them into a single ticket-number join. + +--- + +## 4. Cross-reference patterns + +All records belonging to one logical transaction share the same HOT **`TRNN`** (Transaction Number). RET `TRNN` ≠ HOT `TRNN` for the same commercial event (DISH Glossary). + +### 4.1 Conjunction sets + +- Primary document: `BKS24` with `TDNR`. +- Conjunction documents: `BKS24-CNJ` (and related `*-CNJ` records). `CJCP` = `CNJ` on secondary documents. +- Same `TRNN` across primary + conjunction documents. +- Billing Analysis shows sequential documents against **one** Balance Payable / Remittance Amount entry (even when straight exchange remittance is zero). +- Partial refund of a conjunction set: `BKS45` `RTDN` names the **actual** STD whose coupons were refunded (not necessarily the primary). + +**Matching:** resolve conjunction membership (`primary` + `conjunction_ticket_numbers`) before calling a row “missing.” Flag set-level gaps as conjunction mismatches, not isolated ticket misses, when the rest of the set is present. + +### 4.2 Exchange-linked tickets + +- New document `TDNR` (issue) + `BKP84-EX` (+ optional ADC cash/card FOP). +- Original document: `BKS46` **`ORIT`** (and `ORID` / `ORIL` / `ORIA` when present). +- Even exchange: monetary fields on `BKS30` / `BKS39` / `BKP84-CA` are zeros (DISH §6.5.6 note). + +**Matching keys (shop-style):** `(new_ticket, ORIT)` and/or agency exchange id — **not** ticket-number equality alone. + +### 4.3 Refund / ADM / ACM linkage (`BKS45`) + +`BKS45` is **only** for Refund, ADM, ACM (and sales-summary / minor-adjustment) transactions: + +| Element | Role | +| --- | --- | +| `RTDN` | Related ticket/document number | +| `RCPN` | Related coupon identifier(s) | +| `DIRD` | Date of issue of related document | +| `WAVR` / `RMIC` | Waiver / reason-for-memo (first `BKS45` only) | +| `RMED` | Remittance period ending date (must match `BOH03`) | + +Indirect refunds may place Refund Authority number in `BKS24` `TDNR` while `RTDN` holds the ticket being refunded. + +**Matching:** ADM/ACM/RFND rows must join to agency via `RTDN` (and coupons when present), not only via memo/`TDNR` equality. + +### 4.4 EMD + +EMD uses its own record set (`BMD75` / `BMD76`, CNJ variants). Coupon association to tickets is EMD-side; the ticket HOT row does **not** carry the EMD reference (DISH examples). Treat EMD reconciliation as a **separate** document stream. + +--- + +## 5. Commission fields (do not flatten) + +HOT commission is not a single “rate vs claimed” check: + +| Element / record | Role | +| --- | --- | +| `BKS39` `COAM` | Commission amount (signed rules differ for ADM/ACM) | +| `SPAM` / related | Supplementary / incentive components when present | +| `EFCO` | Effective commission accumulation across multiple `BKS39` | +| `CCAI` | Commission control adjustment indicator (DISH 23) | +| `TOCA` (`BKS42`) | Tax on commission (ADM/ACM-oriented rules) | + +Expected rates still depend on carrier contracts (route, fare basis, class, date, net remit). See Agent 7.3 / commission KB — do not invent contract tables here. + +--- + +## 6. Matching algorithm notes (Agent 7.1) + +Recommended join order for discrepancy detection: + +1. **Normalize** HOT rows into transaction envelopes keyed by `TRNN` (primary `TDNR`, `TRNC`, `CUTP`, amounts, FOP flags, `ORIT`, `RTDN`s, conjunction `TDNR`s). +2. **Index** agency rows by ticket number **and** by original/related ticket when the agency record is an exchange / memo / refund. +3. **Match** within the same logical category (sale / exchange-linked issue / EMD / refund / ADM / ACM) — categories are separate. +4. **Currency gate:** require equal transaction `CUTP` before amount/commission compare. +5. **Threshold:** apply `min_threshold` in the transaction currency (do not FX-convert the threshold via IROE/ICER). +6. **Conjunction:** if either side declares a set, require set coverage; emit set-level discrepancy when one member is absent. +7. **ADM/ACM:** unmatched memo with `RTDN` → `UNMATCHED_ADM` / `UNMATCHED_ACM`; still surface related-ticket context. +8. **Period boundary:** filter / warn using billing period (`BAED`) and remittance ending (`RMED`) — see remittance caveats below. + +Tolerances beyond `min_threshold` are **shop policy** — not invented here. + +--- + +## 7. Remittance calendar caveats + +DISH defines: + +- **Reporting Period** — agent reporting window. +- **Billing Period** — one or more reporting periods billed together (`BAED`). +- **Remittance Period** — span for remittance to the clearing bank; **not shorter than** one Billing Period and **may cover more than one** Billing Period (`RMED` on `BOH03` / `BKS45`). + +DISH also notes there can be **several remittances per month**, and example patterns such as weekly reporting with fortnightly billing. **Market calendars (weekly / bi-monthly / monthly) and payment grace days are BSP-market specific.** + +Public IATA pages describe the DISH standard and remittance *concepts*; they do **not** publish a complete per-market grace calendar usable as code defaults. + +→ See **DOMAIN_QUESTION** DQ-HOT-1. + +--- + +## 8. Open DOMAIN_QUESTIONs + +| ID | Question | +| --- | --- | +| **DQ-HOT-1** | For each target BSP market (e.g. BSP UK, BSP DE), what are the remittance frequency, `RMED` relative to period end, and payment grace days? Cite the market’s public BSP calendar or BSPlink notice — do not hardcode. | +| **DQ-HOT-2** | Which commission contract source feeds Agent 7.1 expected rates (7.3 agreements vs external tariff file), and how are override / supplementary (`SPAM`) components expected to appear on agency records? | +| **DQ-HOT-3** | When agency mid-office stores only reporting currency, which explicit ICER (or other) rate date may be used for display-only FX — never for silent match conversion? | +| **DQ-HOT-4** | Market-specific HOT sort (by agent mixed-currency vs by currency file) and whether empty per-currency HOT files are delivered to this agency. | diff --git a/packages/agents/reconciliation/src/bsp-reconciliation/__tests__/bsp-reconciliation.test.ts b/packages/agents/reconciliation/src/bsp-reconciliation/__tests__/bsp-reconciliation.test.ts index 89d379b..73ae5c9 100644 --- a/packages/agents/reconciliation/src/bsp-reconciliation/__tests__/bsp-reconciliation.test.ts +++ b/packages/agents/reconciliation/src/bsp-reconciliation/__tests__/bsp-reconciliation.test.ts @@ -141,6 +141,64 @@ describe('HOT File Parser', () => { ); expect(records.length).toBe(1); }); + + it('auto-detects synthetic DISH Rev 23 format', () => { + const content = loadFixture('hot-dish-rev23-synthetic.txt'); + expect(HOTFileParser.detectFormat(content)).toBe('DISH_REV23'); + }); + + it('parses multi-currency CUTP from synthetic DISH HOT (never assumes single currency)', () => { + const content = loadFixture('hot-dish-rev23-synthetic.txt'); + const parser = new HOTFileParser(); + const records = parser.parse(content); + const currencies = [...new Set(records.map((r) => r.currency))].sort(); + expect(currencies).toEqual(['EUR', 'GBP', 'HKD', 'USD']); + expect(records.every((r) => r.reporting_currency === 'GBP')).toBe(true); + }); + + it('parses exchange-linked ticket via FPTP=EX and ORIT', () => { + const content = loadFixture('hot-dish-rev23-synthetic.txt'); + const parser = new HOTFileParser(); + const records = parser.parse(content); + const exch = records.find((r) => r.ticket_number === '1259999000001'); + expect(exch).toBeDefined(); + expect(exch!.transaction_type).toBe('EXCHANGE'); + expect(exch!.original_ticket_number).toBe('1259999000000'); + expect(exch!.payment_type).toBe('EX'); + }); + + it('parses conjunction set sharing TRNN', () => { + const content = loadFixture('hot-dish-rev23-synthetic.txt'); + const parser = new HOTFileParser(); + const records = parser.parse(content); + const primary = records.find((r) => r.ticket_number === '1258888000000'); + const cnj = records.find((r) => r.ticket_number === '1258888000001'); + expect(primary).toBeDefined(); + expect(cnj).toBeDefined(); + expect(primary!.transaction_number).toBe(cnj!.transaction_number); + expect(primary!.conjunction_ticket_numbers).toContain('1258888000001'); + expect(cnj!.is_conjunction).toBe(true); + expect(cnj!.conjunction_primary).toBe('1258888000000'); + }); + + it('parses ADM as separate TRNC with RTDN related document', () => { + const content = loadFixture('hot-dish-rev23-synthetic.txt'); + const parser = new HOTFileParser(); + const records = parser.parse(content); + const adm = records.find((r) => r.transaction_type === 'ADM'); + expect(adm).toBeDefined(); + expect(adm!.transaction_code).toBe('ADMA'); + expect(adm!.related_documents?.[0]?.ticket_number).toBe('1251234567895'); + }); + + it('parses EMD as separate category from TKTT/ADM', () => { + const content = loadFixture('hot-dish-rev23-synthetic.txt'); + const parser = new HOTFileParser(); + const records = parser.parse(content); + const emd = records.find((r) => r.transaction_type === 'EMD'); + expect(emd).toBeDefined(); + expect(emd!.transaction_code).toBe('EMDS'); + }); }); // --------------------------------------------------------------------------- @@ -354,7 +412,7 @@ describe('BSP Reconciliation', () => { it('has correct metadata', () => { expect(agent.id).toBe('7.1'); expect(agent.name).toBe('BSP Reconciliation'); - expect(agent.version).toBe('0.1.0'); + expect(agent.version).toBe('0.2.0'); }); it('reports healthy', async () => { @@ -377,4 +435,230 @@ describe('BSP Reconciliation', () => { expect((await uninit.health()).status).toBe('unhealthy'); }); }); + + describe('Multi-currency + cross-refs (DISH Rev 23 / #143)', () => { + it('lists currencies_present and warns on multi-currency HOT', async () => { + const input = makeInput({ + agency_records: [ + makeAgency({ ticket_number: '1251234567890', currency: 'GBP', ticket_amount: '550.00' }), + makeAgency({ + ticket_number: '1251234567891', + currency: 'EUR', + ticket_amount: '780.00', + commission_amount: '54.60', + tax_amount: '95.00', + airline_code: 'AF', + }), + ], + hot_records: [ + makeHot({ + ticket_number: '1251234567890', + currency: 'GBP', + reporting_currency: 'GBP', + ticket_amount: '550.00', + }), + makeHot({ + ticket_number: '1251234567891', + currency: 'EUR', + reporting_currency: 'GBP', + ticket_amount: '780.00', + commission_amount: '54.60', + tax_amount: '95.00', + airline_code: 'AF', + }), + ], + threshold_currency: 'GBP', + }); + const result = await agent.execute({ data: input }); + expect(result.data.summary.currencies_present).toEqual(['EUR', 'GBP']); + expect(result.warnings!.some((w) => w.includes('Multi-currency HOT'))).toBe(true); + expect(result.warnings!.some((w) => w.includes('IROE'))).toBe(true); + expect(result.data.passed).toBe(true); + }); + + it('flags currency mismatch without converting via IROE/ICER', async () => { + const input = makeInput({ + agency_records: [makeAgency({ currency: 'GBP' })], + hot_records: [makeHot({ currency: 'EUR', reporting_currency: 'GBP' })], + }); + const result = await agent.execute({ data: input }); + const curr = result.data.discrepancies.find((d) => d.type === 'CURRENCY_MISMATCH'); + expect(curr).toBeDefined(); + expect(curr!.description).toMatch(/IROE/); + expect(result.data.discrepancies.some((d) => d.type === 'AMOUNT_MISMATCH')).toBe(false); + }); + + it('matches exchange-linked tickets via ORIT cross-ref', async () => { + const input = makeInput({ + agency_records: [ + makeAgency({ + ticket_number: '1259999000001', + transaction_type: 'EXCHANGE', + original_ticket_number: '1259999000000', + ticket_amount: '100.00', + commission_amount: '7.00', + tax_amount: '20.00', + currency: 'GBP', + }), + ], + hot_records: [ + makeHot({ + ticket_number: '1259999000001', + transaction_type: 'EXCHANGE', + transaction_code: 'TKTT', + original_ticket_number: '1259999000000', + payment_type: 'EX', + ticket_amount: '100.00', + commission_amount: '7.00', + tax_amount: '20.00', + currency: 'GBP', + reporting_currency: 'GBP', + }), + ], + threshold_currency: 'GBP', + }); + const result = await agent.execute({ data: input }); + expect(result.data.discrepancies).toHaveLength(0); + expect(result.data.summary.matched_count).toBe(1); + }); + + it('flags unmatched exchange when ORIT linkage is missing on HOT', async () => { + const input = makeInput({ + agency_records: [ + makeAgency({ + ticket_number: '1259999000001', + transaction_type: 'EXCHANGE', + original_ticket_number: '1259999000000', + ticket_amount: '100.00', + currency: 'GBP', + }), + ], + hot_records: [], + threshold_currency: 'GBP', + }); + const result = await agent.execute({ data: input }); + const unmatched = result.data.discrepancies.find((d) => d.type === 'UNMATCHED_EXCHANGE'); + expect(unmatched).toBeDefined(); + expect(unmatched!.related_ticket_number).toBe('1259999000000'); + }); + + it('detects conjunction set mismatch', async () => { + const input = makeInput({ + agency_records: [ + makeAgency({ + ticket_number: '1258888000000', + conjunction_ticket_numbers: ['1258888000001'], + ticket_amount: '900.00', + commission_amount: '63.00', + currency: 'HKD', + airline_code: 'CX', + }), + ], + hot_records: [ + makeHot({ + ticket_number: '1258888000000', + ticket_amount: '900.00', + commission_amount: '63.00', + currency: 'HKD', + airline_code: 'CX', + // HOT missing conjunction companion + }), + ], + threshold_currency: 'HKD', + }); + const result = await agent.execute({ data: input }); + const cnj = result.data.discrepancies.find((d) => d.type === 'CONJUNCTION_SET_MISMATCH'); + expect(cnj).toBeDefined(); + expect(cnj!.severity).toBe('critical'); + }); + + it('matches ADM via RTDN related ticket', async () => { + const input = makeInput({ + agency_records: [ + makeAgency({ + ticket_number: '1256666000001', + transaction_type: 'ADM', + related_ticket_number: '1251234567895', + ticket_amount: '890.00', + commission_amount: '0.00', + tax_amount: '0.00', + airline_code: 'NH', + currency: 'USD', + }), + ], + hot_records: [ + makeHot({ + ticket_number: '1256666000001', + transaction_type: 'ADM', + transaction_code: 'ADMA', + ticket_amount: '890.00', + commission_amount: '0.00', + tax_amount: '0.00', + airline_code: 'NH', + currency: 'USD', + related_documents: [{ ticket_number: '1251234567895', coupons: '1230' }], + }), + ], + }); + const result = await agent.execute({ data: input }); + expect(result.data.discrepancies).toHaveLength(0); + expect(result.data.passed).toBe(true); + }); + + it('end-to-end: parse synthetic DISH fixture and reconcile exchange + multi-currency', async () => { + const content = loadFixture('hot-dish-rev23-synthetic.txt'); + const hot_records = new HOTFileParser().parse(content); + const input = makeInput({ + agency_records: [ + makeAgency({ + ticket_number: '1251234567890', + currency: 'GBP', + ticket_amount: '550.00', + commission_amount: '38.50', + }), + makeAgency({ + ticket_number: '1259999000001', + transaction_type: 'EXCHANGE', + original_ticket_number: '1259999000000', + ticket_amount: '100.00', + commission_amount: '7.00', + tax_amount: '20.00', + currency: 'GBP', + }), + makeAgency({ + ticket_number: '1258888000000', + conjunction_ticket_numbers: ['1258888000001'], + ticket_amount: '900.00', + commission_amount: '63.00', + tax_amount: '110.00', + currency: 'HKD', + airline_code: 'CX', + }), + makeAgency({ + ticket_number: '1258888000001', + conjunction_ticket_numbers: ['1258888000000'], + ticket_amount: '0.00', + commission_amount: '0.00', + tax_amount: '0.00', + currency: 'HKD', + airline_code: 'CX', + }), + ], + hot_records, + threshold_currency: 'GBP', + min_threshold: '10.00', + }); + + const result = await agent.execute({ data: input }); + expect(result.data.summary.currencies_present.length).toBeGreaterThan(1); + expect( + result.data.discrepancies.some( + (d) => + d.ticket_number === '1259999000001' && + (d.type === 'MISSING_IN_HOT' || d.type === 'UNMATCHED_EXCHANGE'), + ), + ).toBe(false); + expect(result.warnings!.some((w) => w.includes('Multi-currency'))).toBe(true); + }); + }); }); diff --git a/packages/agents/reconciliation/src/bsp-reconciliation/__tests__/fixtures/hot-dish-rev23-synthetic.txt b/packages/agents/reconciliation/src/bsp-reconciliation/__tests__/fixtures/hot-dish-rev23-synthetic.txt new file mode 100644 index 0000000..83af590 --- /dev/null +++ b/packages/agents/reconciliation/src/bsp-reconciliation/__tests__/fixtures/hot-dish-rev23-synthetic.txt @@ -0,0 +1,22 @@ +# SYNTHETIC DISH-Rev23-like HOT — NOT real agency data. Issue #143 fixtures. +# Production HOT is DISH fixed-width (BFH/BCH/BOH/BKT/BKS…). Generic X12 parsers miss those sections. +# BOH CUTP = reporting/default currency; each TXN CUTP = transaction currency (do not assume single-currency). +# IROE ≠ ICER — fixtures never imply FX conversion. +BFH|REVN=023|BSPI=GB|ISOC=GB|PERIOD=2026-P03 +BOH|AGTN=91234567|CUTP=GBP|RMED=260331|PERIOD=2026-P03 +# Multi-currency sales: GBP + EUR + USD in one file +TXN|TRNN=000001|TRNC=TKTT|TDNR=1251234567890|PXNM=SMITH/JOHN|ORAC=LHR|DSTC=JFK|AL=BA|DAIS=2026-03-15|TDAM=550.00|COAM=38.50|TMFA=120.00|FPTP=CC|CUTP=GBP|SQNR=00000001 +TXN|TRNN=000002|TRNC=TKTT|TDNR=1251234567891|PXNM=JONES/MARY|ORAC=CDG|DSTC=LAX|AL=AF|DAIS=2026-03-16|TDAM=780.00|COAM=54.60|TMFA=95.00|FPTP=CC|CUTP=EUR|SQNR=00000002 +TXN|TRNN=000003|TRNC=TKTT|TDNR=1251234567893|PXNM=BROWN/BOB|ORAC=SIN|DSTC=HKG|AL=SQ|DAIS=2026-03-18|TDAM=320.00|COAM=22.40|TMFA=45.00|FPTP=CA|CUTP=USD|SQNR=00000003 +# Exchange-linked issue: new TDNR with BKP84-EX (FPTP=EX) + ORIT original (DISH BKS46) +TXN|TRNN=000004|TRNC=TKTT|TDNR=1259999000001|PXNM=LEE/ANN|ORAC=LHR|DSTC=SIN|AL=BA|DAIS=2026-03-20|TDAM=100.00|COAM=7.00|TMFA=20.00|FPTP=EX|ORIT=1259999000000|CUTP=GBP|SQNR=00000004 +# Conjunction set: primary + CNJ share TRNN (DISH BKS24-CNJ / CJCP=CNJ) +TXN|TRNN=000005|TRNC=TKTT|TDNR=1258888000000|PXNM=CHEN/WEI|ORAC=HKG|DSTC=LAX|AL=CX|DAIS=2026-03-21|TDAM=900.00|COAM=63.00|TMFA=110.00|FPTP=CC|CUTP=HKD|SQNR=00000005 +CNJ|TRNN=000005|TDNR=1258888000001|PRIMARY=1258888000000|CJCP=CNJ|PXNM=CHEN/WEI|ORAC=LAX|DSTC=JFK|AL=CX|DAIS=2026-03-21|TDAM=0.00|COAM=0.00|TMFA=0.00|CUTP=HKD +# Refund with BKS45 RTDN linkage +TXN|TRNN=000006|TRNC=RFND|TDNR=1257777000000|PXNM=DOE/JANE|ORAC=JFK|DSTC=LHR|AL=BA|DAIS=2026-03-22|TDAM=0.00|COAM=0.00|TMFA=0.00|RFND=450.00|FPTP=CC|CUTP=GBP|SQNR=00000006|RTDN=1257777000000 +# ADM separate from TKTT — RTDN points at underlying ticket +TXN|TRNN=000007|TRNC=ADMA|TDNR=1256666000001|PXNM=WILSON/TOM|ORAC=NRT|DSTC=LAX|AL=NH|DAIS=2026-03-23|TDAM=890.00|COAM=0.00|TMFA=0.00|FPTP=CA|CUTP=USD|SQNR=00000007 +REL|TRNN=000007|RTDN=1251234567895|RCPN=1230|DIRD=2026-03-01 +# EMD standalone — separate category from TKTT / ADM / exchange +TXN|TRNN=000008|TRNC=EMDS|TDNR=1255555000001|PXNM=PARK/MIN|ORAC=ICN|DSTC=NRT|AL=KE|DAIS=2026-03-24|TDAM=75.00|COAM=0.00|TMFA=0.00|FPTP=CC|CUTP=USD|SQNR=00000008 diff --git a/packages/agents/reconciliation/src/bsp-reconciliation/hot-file-parser.ts b/packages/agents/reconciliation/src/bsp-reconciliation/hot-file-parser.ts index b4296f5..a3e0eb5 100644 --- a/packages/agents/reconciliation/src/bsp-reconciliation/hot-file-parser.ts +++ b/packages/agents/reconciliation/src/bsp-reconciliation/hot-file-parser.ts @@ -1,18 +1,37 @@ /** * HOT (Hand-Off Tape) File Parser * - * Parses BSP billing files in EDI X12 or fixed-width ASCII format. - * Auto-detects format based on content. + * Production HOT follows IATA DISH Rev 23 fixed-width record grids + * (BFH/BCH/BOH/BKT/BKS/…). Generic EDI X12 parsers miss those sections — + * see docs/knowledge-base/bsp-hot-reconciliation.md. + * + * Supported lab formats: + * - DISH_REV23: synthetic tagged records (never real agency data) + * - FIXED_WIDTH / EDI_X12: legacy simplified fixtures */ -import type { HOTFileRecord, HOTFileFormat } from './types.js'; +import type { + DishTransactionCode, + HOTFileFormat, + HOTFileRecord, + HOTTransactionType, + RelatedDocumentRef, +} from './types.js'; const EDI_DELIMITER = '*'; const EDI_SEGMENT_TERMINATOR = '~'; function detectFormat(content: string): HOTFileFormat { - const firstLine = content.split('\n')[0] ?? ''; - // EDI X12 files typically start with ISA or ST segment + const trimmed = content.trimStart(); + const firstLine = trimmed.split('\n')[0] ?? ''; + if ( + firstLine.startsWith('# SYNTHETIC DISH') || + firstLine.startsWith('BFH|') || + firstLine.startsWith('BOH|') || + firstLine.startsWith('TXN|') + ) { + return 'DISH_REV23'; + } if ( firstLine.includes(EDI_SEGMENT_TERMINATOR) || firstLine.startsWith('ISA') || @@ -23,30 +42,260 @@ function detectFormat(content: string): HOTFileFormat { return 'FIXED_WIDTH'; } -function parseTransactionType(code: string): 'SALE' | 'REFUND' | 'ADM' | 'ACM' { - switch (code.trim().toUpperCase()) { +function mapDishCodeToType( + code: string, + paymentType?: string, +): { transaction_type: HOTTransactionType; transaction_code: DishTransactionCode } { + const c = code.trim().toUpperCase(); + const pay = (paymentType ?? '').trim().toUpperCase(); + + if (c === 'EXCH' || pay === 'EX' || pay === 'EXCH') { + return { transaction_type: 'EXCHANGE', transaction_code: c === 'EXCH' ? 'EXCH' : 'TKTT' }; + } + + switch (c) { + case 'TKTT': case 'SALE': case 'S': - case 'TKTT': - return 'SALE'; + return { transaction_type: 'SALE', transaction_code: c === 'S' || c === 'SALE' ? 'TKTT' : 'TKTT' }; + case 'RFND': case 'REFUND': case 'R': - case 'RFND': - return 'REFUND'; + return { transaction_type: 'REFUND', transaction_code: 'RFND' }; + case 'ADMA': case 'ADM': case 'D': - return 'ADM'; + return { transaction_type: 'ADM', transaction_code: 'ADMA' }; + case 'ACMA': case 'ACM': case 'C': - return 'ACM'; + return { transaction_type: 'ACM', transaction_code: 'ACMA' }; + case 'EMDA': + return { transaction_type: 'EMD', transaction_code: 'EMDA' }; + case 'EMDS': + return { transaction_type: 'EMD', transaction_code: 'EMDS' }; + case 'CANX': + return { transaction_type: 'SALE', transaction_code: 'CANX' }; + case 'TASF': + return { transaction_type: 'SALE', transaction_code: 'TASF' }; default: - return 'SALE'; + return { transaction_type: 'SALE', transaction_code: 'OTHER' }; + } +} + +function parseTransactionType(code: string): HOTTransactionType { + return mapDishCodeToType(code).transaction_type; +} + +function normalizeTicket(raw: string): string { + const digits = raw.replace(/\D/g, ''); + // DISH TDNR is up to 14 AN; agency matching uses 13-digit form (drop check digit when present) + if (digits.length === 14) return digits.slice(0, 13); + return digits; +} + +function parseKvLine(line: string): Record { + const out: Record = {}; + const parts = line.split('|'); + // parts[0] is record tag (BFH, BOH, TXN, CNJ, REL, …) + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + if (!part) continue; + const eq = part.indexOf('='); + if (eq <= 0) continue; + const key = part.slice(0, eq).trim().toUpperCase(); + const value = part.slice(eq + 1).trim(); + out[key] = value; + } + return out; +} + +/** + * Synthetic DISH Rev 23–like tagged HOT. + * Lines: BFH|…, BOH|CUTP=…, TXN|TRNC=…|TDNR=…|CUTP=…, CNJ|…, REL|… + * Never parse live agency HOT dumps into fixtures. + */ +function parseDishRev23(content: string): HOTFileRecord[] { + const records: HOTFileRecord[] = []; + const lines = content.split('\n').map((l) => l.trim()).filter(Boolean); + + let reportingCurrency: string | undefined; + let billingPeriod: string | undefined; + // Conjunction / related docs keyed by TRNN until we flush TXN rows + const conjunctionByTrnn = new Map(); + const relatedByTrnn = new Map(); + const pendingByTrnn = new Map & { ticket_number: string }>(); + + const flushPending = (): void => { + for (const partial of pendingByTrnn.values()) { + const trnn = partial.transaction_number ?? ''; + const cnj = conjunctionByTrnn.get(trnn) ?? []; + const related = relatedByTrnn.get(trnn) ?? []; + const ticket = partial.ticket_number; + const others = cnj.filter((t) => t !== ticket); + const pay = partial.payment_type; + const mapped = mapDishCodeToType( + partial.transaction_code ?? partial.transaction_type ?? 'TKTT', + pay, + ); + + records.push({ + ticket_number: ticket, + passenger_name: partial.passenger_name ?? '', + origin: partial.origin ?? '', + destination: partial.destination ?? '', + airline_code: partial.airline_code ?? 'XX', + issue_date: partial.issue_date ?? '', + ticket_amount: partial.ticket_amount ?? '0.00', + commission_amount: partial.commission_amount ?? '0.00', + tax_amount: partial.tax_amount ?? '0.00', + ...(partial.refund_amount !== undefined ? { refund_amount: partial.refund_amount } : {}), + transaction_type: mapped.transaction_type, + transaction_code: mapped.transaction_code, + ...(partial.issue_sequence !== undefined ? { issue_sequence: partial.issue_sequence } : {}), + ...(pay !== undefined ? { payment_type: pay } : {}), + currency: partial.currency ?? reportingCurrency ?? 'USD', + ...(reportingCurrency !== undefined ? { reporting_currency: reportingCurrency } : {}), + ...(billingPeriod !== undefined ? { billing_period: billingPeriod } : {}), + ...(trnn ? { transaction_number: trnn } : {}), + ...(partial.is_conjunction !== undefined ? { is_conjunction: partial.is_conjunction } : {}), + ...(partial.conjunction_primary !== undefined + ? { conjunction_primary: partial.conjunction_primary } + : {}), + ...(others.length > 0 ? { conjunction_ticket_numbers: others } : {}), + ...(partial.original_ticket_number !== undefined + ? { original_ticket_number: partial.original_ticket_number } + : {}), + ...(related.length > 0 ? { related_documents: related } : {}), + }); + } + pendingByTrnn.clear(); + }; + + for (const line of lines) { + if (line.startsWith('#')) continue; + const tag = line.split('|')[0]?.toUpperCase() ?? ''; + const kv = parseKvLine(line); + + if (tag === 'BFH') { + if (kv['PERIOD']) billingPeriod = kv['PERIOD']; + continue; + } + if (tag === 'BOH') { + if (kv['CUTP']) reportingCurrency = kv['CUTP']; + if (kv['PERIOD']) billingPeriod = kv['PERIOD']; + continue; + } + if (tag === 'CNJ') { + const trnn = kv['TRNN'] ?? ''; + const tdnr = normalizeTicket(kv['TDNR'] ?? ''); + const primary = normalizeTicket(kv['PRIMARY'] ?? ''); + if (!trnn || !tdnr) continue; + const list = conjunctionByTrnn.get(trnn) ?? []; + if (primary && !list.includes(primary)) list.push(primary); + if (!list.includes(tdnr)) list.push(tdnr); + conjunctionByTrnn.set(trnn, list); + + // Also register a pending CNJ document row if not already a TXN + if (!pendingByTrnn.has(`${trnn}:${tdnr}`)) { + pendingByTrnn.set(`${trnn}:${tdnr}`, { + ticket_number: tdnr, + transaction_number: trnn, + is_conjunction: true, + conjunction_primary: primary || undefined, + transaction_type: 'SALE', + transaction_code: 'TKTT', + currency: kv['CUTP'] ?? reportingCurrency ?? 'USD', + passenger_name: kv['PXNM'] ?? '', + origin: kv['ORAC'] ?? '', + destination: kv['DSTC'] ?? '', + airline_code: kv['AL'] ?? 'XX', + issue_date: kv['DAIS'] ?? '', + ticket_amount: kv['TDAM'] ?? '0.00', + commission_amount: kv['COAM'] ?? '0.00', + tax_amount: kv['TMFA'] ?? '0.00', + }); + } else { + const existing = pendingByTrnn.get(`${trnn}:${tdnr}`)!; + existing.is_conjunction = true; + if (primary) existing.conjunction_primary = primary; + } + continue; + } + if (tag === 'REL') { + const trnn = kv['TRNN'] ?? ''; + const rtdn = normalizeTicket(kv['RTDN'] ?? ''); + if (!trnn || !rtdn) continue; + const list = relatedByTrnn.get(trnn) ?? []; + list.push({ + ticket_number: rtdn, + ...(kv['RCPN'] !== undefined ? { coupons: kv['RCPN'] } : {}), + ...(kv['DIRD'] !== undefined ? { issue_date: kv['DIRD'] } : {}), + }); + relatedByTrnn.set(trnn, list); + continue; + } + if (tag === 'TXN') { + const trnn = kv['TRNN'] ?? String(pendingByTrnn.size + 1).padStart(6, '0'); + const tdnr = normalizeTicket(kv['TDNR'] ?? ''); + if (!tdnr) continue; + const pay = kv['FPTP'] ?? kv['PAY']; + const mapped = mapDishCodeToType(kv['TRNC'] ?? 'TKTT', pay); + const key = `${trnn}:${tdnr}`; + pendingByTrnn.set(key, { + ticket_number: tdnr, + passenger_name: kv['PXNM'] ?? '', + origin: kv['ORAC'] ?? '', + destination: kv['DSTC'] ?? '', + airline_code: kv['AL'] ?? 'XX', + issue_date: kv['DAIS'] ?? '', + ticket_amount: kv['TDAM'] ?? '0.00', + commission_amount: kv['COAM'] ?? '0.00', + tax_amount: kv['TMFA'] ?? '0.00', + ...(kv['RFND'] !== undefined ? { refund_amount: kv['RFND'] } : {}), + transaction_type: mapped.transaction_type, + transaction_code: mapped.transaction_code, + issue_sequence: kv['SQNR'], + payment_type: pay, + currency: kv['CUTP'] ?? reportingCurrency ?? 'USD', + transaction_number: trnn, + ...(kv['ORIT'] !== undefined + ? { original_ticket_number: normalizeTicket(kv['ORIT']) } + : {}), + ...(kv['CJCP']?.toUpperCase() === 'CNJ' ? { is_conjunction: true } : {}), + ...(kv['PRIMARY'] !== undefined + ? { conjunction_primary: normalizeTicket(kv['PRIMARY']) } + : {}), + }); + + // Inline RTDN on TXN for convenience in fixtures + if (kv['RTDN']) { + const list = relatedByTrnn.get(trnn) ?? []; + list.push({ ticket_number: normalizeTicket(kv['RTDN']) }); + relatedByTrnn.set(trnn, list); + } + continue; + } + } + + flushPending(); + + // Attach conjunction companions onto primary TXN rows sharing TRNN + for (const rec of records) { + if (!rec.transaction_number) continue; + const set = conjunctionByTrnn.get(rec.transaction_number); + if (!set || set.length === 0) continue; + const others = set.filter((t) => t !== rec.ticket_number); + if (others.length > 0) { + rec.conjunction_ticket_numbers = others; + } } + + return records; } function parseEdiX12(content: string): HOTFileRecord[] { const records: HOTFileRecord[] = []; - // Split by segment terminator, filter to transaction segments const segments = content .split(EDI_SEGMENT_TERMINATOR) .map((s) => s.trim()) @@ -56,11 +305,14 @@ function parseEdiX12(content: string): HOTFileRecord[] { const fields = segment.split(EDI_DELIMITER); const segType = fields[0]?.trim(); - // BHT = transaction detail in HOT files - // We look for segments with ticket data — format: TKT*ticketnum*paxname*origin*dest*airline*date*amount*commission*tax*refund*txntype*seq*payment*currency*period + // Lab fixture format — not DISH. Generic X12 alone is insufficient for production HOT. if (segType === 'TKT' && fields.length >= 15) { + const pay = fields[13]?.trim() || undefined; + const mapped = mapDishCodeToType(fields[11]?.trim() ?? 'SALE', pay); + const original = fields[16]?.trim(); + const related = fields[17]?.trim(); records.push({ - ticket_number: fields[1]?.trim() ?? '', + ticket_number: normalizeTicket(fields[1]?.trim() ?? ''), passenger_name: fields[2]?.trim() ?? '', origin: fields[3]?.trim() ?? '', destination: fields[4]?.trim() ?? '', @@ -70,11 +322,16 @@ function parseEdiX12(content: string): HOTFileRecord[] { commission_amount: fields[8]?.trim() ?? '0.00', tax_amount: fields[9]?.trim() ?? '0.00', refund_amount: fields[10]?.trim() || undefined, - transaction_type: parseTransactionType(fields[11]?.trim() ?? 'SALE'), + transaction_type: mapped.transaction_type, + transaction_code: mapped.transaction_code, issue_sequence: fields[12]?.trim() || undefined, - payment_type: fields[13]?.trim() || undefined, + payment_type: pay, currency: fields[14]?.trim() ?? 'USD', billing_period: fields[15]?.trim() || undefined, + ...(original ? { original_ticket_number: normalizeTicket(original) } : {}), + ...(related + ? { related_documents: [{ ticket_number: normalizeTicket(related) }] } + : {}), }); } } @@ -87,33 +344,20 @@ function parseFixedWidth(content: string): HOTFileRecord[] { const lines = content.split('\n').filter((line) => line.trim().length > 0); for (const line of lines) { - // Skip header/trailer lines if (line.startsWith('HDR') || line.startsWith('TRL') || line.startsWith('#')) { continue; } - // Fixed-width layout: - // Cols 1-13: ticket_number - // Cols 14-43: passenger_name - // Cols 44-46: origin - // Cols 47-49: destination - // Cols 50-51: airline_code - // Cols 52-61: issue_date (YYYY-MM-DD) - // Cols 62-73: ticket_amount (right-aligned, 2 decimals) - // Cols 74-85: commission_amount - // Cols 86-97: tax_amount - // Cols 98-109: refund_amount - // Cols 110-113: transaction_type - // Cols 114-123: issue_sequence - // Cols 124-128: payment_type - // Cols 129-131: currency - // Cols 132-139: billing_period - + // Legacy lab fixed-width (not a market DISH grid). if (line.length < 131) continue; - const ticketNumber = line.slice(0, 13).trim(); + const ticketNumber = normalizeTicket(line.slice(0, 13).trim()); if (!/^\d{13}$/.test(ticketNumber)) continue; + const txnCode = line.slice(109, 113).trim(); + const mapped = mapDishCodeToType(txnCode); + const currency = line.slice(128, 131).trim() || 'USD'; + records.push({ ticket_number: ticketNumber, passenger_name: line.slice(13, 43).trim(), @@ -125,11 +369,16 @@ function parseFixedWidth(content: string): HOTFileRecord[] { commission_amount: line.slice(73, 85).trim(), tax_amount: line.slice(85, 97).trim(), refund_amount: line.slice(97, 109).trim() || undefined, - transaction_type: parseTransactionType(line.slice(109, 113).trim()), + transaction_type: mapped.transaction_type, + transaction_code: mapped.transaction_code, issue_sequence: line.slice(113, 123).trim() || undefined, payment_type: line.slice(123, 128).trim() || undefined, - currency: line.slice(128, 131).trim() || 'USD', + currency, + // Explicit: do not assume file-wide single currency — each row carries CUTP-equivalent billing_period: line.length >= 139 ? line.slice(131, 139).trim() || undefined : undefined, + ...(line.length >= 152 && line.slice(139, 152).trim() + ? { original_ticket_number: normalizeTicket(line.slice(139, 152).trim()) } + : {}), }); } @@ -149,6 +398,8 @@ export class HOTFileParser { const format = this.format ?? detectFormat(content); switch (format) { + case 'DISH_REV23': + return parseDishRev23(content); case 'EDI_X12': return parseEdiX12(content); case 'FIXED_WIDTH': @@ -160,3 +411,5 @@ export class HOTFileParser { return detectFormat(content); } } + +export { parseTransactionType, mapDishCodeToType }; diff --git a/packages/agents/reconciliation/src/bsp-reconciliation/index.ts b/packages/agents/reconciliation/src/bsp-reconciliation/index.ts index f8174c6..63250d6 100644 --- a/packages/agents/reconciliation/src/bsp-reconciliation/index.ts +++ b/packages/agents/reconciliation/src/bsp-reconciliation/index.ts @@ -5,6 +5,7 @@ * validates commission, identifies discrepancies, flags issues * before remittance deadline. * + * Domain: docs/knowledge-base/bsp-hot-reconciliation.md (DISH Rev 23). * Implements the base Agent interface from @otaip/core. */ @@ -19,7 +20,7 @@ const CARRIER_RE = /^[A-Z0-9]{2}$/; export class BSPReconciliation implements Agent { readonly id = '7.1'; readonly name = 'BSP Reconciliation'; - readonly version = '0.1.0'; + readonly version = '0.2.0'; private initialized = false; @@ -47,6 +48,12 @@ export class BSPReconciliation implements Agent 0) { warnings.push(`${result.summary.patterns.length} recurring pattern(s) detected.`); } + if (result.summary.currencies_present.length > 1) { + warnings.push( + `Multi-currency HOT: transaction CUTP values [${result.summary.currencies_present.join(', ')}]. ` + + `Do not assume single-currency; IROE≠ICER — amounts compared only within matching CUTP.`, + ); + } if (input.data.remittance_deadline) { const now = input.data.current_datetime ? new Date(input.data.current_datetime) : new Date(); const deadline = new Date(input.data.remittance_deadline); @@ -57,6 +64,7 @@ export class BSPReconciliation implements Agent h.transaction_type === ar.transaction_type); + if (direct) return direct; + } + + // Exchange: agency new ticket ↔ HOT issue with ORIT = original + if (ar.transaction_type === 'EXCHANGE') { + const byNew = allHot.find( + (h) => + h.ticket_number === ar.ticket_number && + (h.transaction_type === 'EXCHANGE' || + (h.transaction_type === 'SALE' && h.original_ticket_number !== undefined)), + ); + if (byNew) return byNew; + + if (ar.original_ticket_number) { + const byOrit = allHot.find( + (h) => + h.original_ticket_number === ar.original_ticket_number && + (h.transaction_type === 'EXCHANGE' || h.payment_type?.toUpperCase() === 'EX'), + ); + if (byOrit) return byOrit; + } + } + + // Agency still holding original while HOT shows exchange of that original + if (ar.transaction_type === 'SALE') { + const exch = allHot.find( + (h) => + h.original_ticket_number === ar.ticket_number && + (h.transaction_type === 'EXCHANGE' || h.payment_type?.toUpperCase() === 'EX'), + ); + // Do not treat original as matched to the exchange row for amount compare — + // caller uses this only when direct miss; return undefined so original can + // be reconciled separately unless agency marks EXCHANGE. + void exch; + } + + // Refund / ADM / ACM: HOT TDNR may be memo id; RTDN holds ticket + if (ar.transaction_type === 'REFUND' || ar.transaction_type === 'ADM' || ar.transaction_type === 'ACM') { + const related = ar.related_ticket_number ?? ar.ticket_number; + const viaRtdn = allHot.find( + (h) => + h.transaction_type === ar.transaction_type && + (h.ticket_number === ar.ticket_number || + h.related_documents?.some((r) => r.ticket_number === related) || + h.related_documents?.some((r) => r.ticket_number === ar.ticket_number)), + ); + if (viaRtdn) return viaRtdn; + } + + // Conjunction companion present under another TDNR sharing the set + if (ar.conjunction_ticket_numbers && ar.conjunction_ticket_numbers.length > 0) { + const set = new Set([ar.ticket_number, ...ar.conjunction_ticket_numbers]); + const companion = allHot.find( + (h) => + h.transaction_type === ar.transaction_type && + (set.has(h.ticket_number) || + h.conjunction_ticket_numbers?.some((t) => set.has(t)) || + (h.conjunction_primary !== undefined && set.has(h.conjunction_primary))), + ); + if (companion && companion.ticket_number === ar.ticket_number) return companion; + // Prefer the HOT row for this exact ticket within the set + const exact = allHot.find((h) => h.ticket_number === ar.ticket_number && set.has(h.ticket_number)); + if (exact) return exact; + } + + return undefined; +} + +function conjunctionSetOf(hot: HOTFileRecord): string[] { + const set = new Set([hot.ticket_number]); + if (hot.conjunction_primary) set.add(hot.conjunction_primary); + if (hot.conjunction_ticket_numbers) { + for (const t of hot.conjunction_ticket_numbers) set.add(t); + } + return [...set]; +} + function matchRecords(input: BSPReconciliationInput): BSPReconciliationOutput { const threshold = new Decimal(input.min_threshold ?? DEFAULT_THRESHOLD); + const thresholdCurrency = input.threshold_currency ?? 'USD'; const discrepancies: Discrepancy[] = []; - // Index HOT records by ticket number const hotByTicket = new Map(); for (const hot of input.hot_records) { const existing = hotByTicket.get(hot.ticket_number) ?? []; @@ -38,7 +156,6 @@ function matchRecords(input: BSPReconciliationInput): BSPReconciliationOutput { hotByTicket.set(hot.ticket_number, existing); } - // Index agency records by ticket number const agencyByTicket = new Map(); for (const ar of input.agency_records) { const existing = agencyByTicket.get(ar.ticket_number) ?? []; @@ -46,51 +163,87 @@ function matchRecords(input: BSPReconciliationInput): BSPReconciliationOutput { agencyByTicket.set(ar.ticket_number, existing); } + const matchedHotTickets = new Set(); let matchedCount = 0; - // Check each agency record against HOT for (const [ticketNum, agencyRecs] of agencyByTicket) { - const hotRecs = hotByTicket.get(ticketNum); + for (const ar of agencyRecs) { + const matchingHot = findMatchingHot(ar, hotByTicket.get(ticketNum), input.hot_records); + + if (!matchingHot) { + // If this ticket is only the original of an exchange that exists on HOT, + // and agency did not book a separate SALE for it in-period, skip critical miss + // when agency also has an EXCHANGE referencing it. + const coveredByAgencyExchange = input.agency_records.some( + (other) => + other.transaction_type === 'EXCHANGE' && other.original_ticket_number === ticketNum, + ); + const hotExchangeOfOriginal = input.hot_records.some( + (h) => h.original_ticket_number === ticketNum, + ); + if (coveredByAgencyExchange && hotExchangeOfOriginal && ar.transaction_type === 'SALE') { + matchedCount++; + matchedHotTickets.add(ticketNum); + continue; + } - if (!hotRecs || hotRecs.length === 0) { - // Missing in HOT - for (const ar of agencyRecs) { const amount = new Decimal(ar.ticket_amount); - if (amount.abs().greaterThanOrEqualTo(threshold)) { + const type = ar.transaction_type === 'EXCHANGE' ? 'UNMATCHED_EXCHANGE' : 'MISSING_IN_HOT'; + // Even exchanges may have zero amounts (DISH §6.5.6) — still surface unmatched EXCHANGE + if (type === 'UNMATCHED_EXCHANGE' || amount.abs().greaterThanOrEqualTo(threshold)) { discrepancies.push({ - type: 'MISSING_IN_HOT', - severity: 'critical', + type, + severity: type === 'UNMATCHED_EXCHANGE' ? 'high' : 'critical', ticket_number: ticketNum, + ...(ar.original_ticket_number + ? { related_ticket_number: ar.original_ticket_number } + : ar.related_ticket_number + ? { related_ticket_number: ar.related_ticket_number } + : {}), airline_code: ar.airline_code, agency_amount: ar.ticket_amount, currency: ar.currency, - description: `Ticket ${ticketNum} exists in agency records but not in BSP HOT file.`, + description: + type === 'UNMATCHED_EXCHANGE' + ? `Exchange ticket ${ticketNum}` + + (ar.original_ticket_number ? ` (ORIT ${ar.original_ticket_number})` : '') + + ` not matched in BSP HOT via TDNR/ORIT cross-ref.` + : `Ticket ${ticketNum} exists in agency records but not in BSP HOT file.`, }); } - } - continue; - } - - // Match each agency record to a HOT record - for (const ar of agencyRecs) { - const matchingHot = hotRecs.find((h) => h.transaction_type === ar.transaction_type); - - if (!matchingHot) { - discrepancies.push({ - type: 'MISSING_IN_HOT', - severity: 'critical', - ticket_number: ticketNum, - airline_code: ar.airline_code, - agency_amount: ar.ticket_amount, - currency: ar.currency, - description: `Ticket ${ticketNum} ${ar.transaction_type} not found in HOT file.`, - }); continue; } matchedCount++; + matchedHotTickets.add(matchingHot.ticket_number); + for (const rel of relatedTicketsFromHot(matchingHot)) matchedHotTickets.add(rel); - // Currency mismatch + // Conjunction set coverage + const hotSet = conjunctionSetOf(matchingHot); + const agencySet = new Set([ + ar.ticket_number, + ...(ar.conjunction_ticket_numbers ?? []), + ]); + if (hotSet.length > 1 || agencySet.size > 1) { + const missingFromHot = [...agencySet].filter((t) => !hotSet.includes(t)); + const missingFromAgency = hotSet.filter((t) => !agencySet.has(t)); + if (missingFromHot.length > 0 || missingFromAgency.length > 0) { + discrepancies.push({ + type: 'CONJUNCTION_SET_MISMATCH', + severity: 'critical', + ticket_number: ticketNum, + airline_code: ar.airline_code, + currency: ar.currency, + description: `Conjunction set mismatch for ${ticketNum}: agency=[${[...agencySet].join(',')}] hot=[${hotSet.join(',')}]` + + (missingFromHot.length ? `; missing in HOT: ${missingFromHot.join(',')}` : '') + + (missingFromAgency.length + ? `; missing in agency: ${missingFromAgency.join(',')}` + : ''), + }); + } + } + + // Currency gate — transaction CUTP must match; no IROE/ICER conversion if (ar.currency !== matchingHot.currency) { discrepancies.push({ type: 'CURRENCY_MISMATCH', @@ -98,12 +251,16 @@ function matchRecords(input: BSPReconciliationInput): BSPReconciliationOutput { ticket_number: ticketNum, airline_code: ar.airline_code, currency: ar.currency, - description: `Currency mismatch on ${ticketNum}: agency ${ar.currency}, BSP ${matchingHot.currency}.`, + description: `Currency mismatch on ${ticketNum}: agency transaction currency ${ar.currency}, HOT CUTP ${matchingHot.currency}` + + (matchingHot.reporting_currency + ? ` (HOT reporting/default ${matchingHot.reporting_currency}; IROE≠ICER — do not auto-convert).` + : ' (do not assume single-currency HOT; IROE≠ICER — do not auto-convert).'), }); continue; } - // Amount mismatch + // Amount mismatch (same CUTP only). Threshold is in transaction-currency units — + // never FX-convert via IROE/ICER (KB §2). const agencyAmt = new Decimal(ar.ticket_amount); const hotAmt = new Decimal(matchingHot.ticket_amount); const amtDiff = agencyAmt.minus(hotAmt).abs(); @@ -122,7 +279,6 @@ function matchRecords(input: BSPReconciliationInput): BSPReconciliationOutput { }); } - // Commission mismatch const agencyComm = new Decimal(ar.commission_amount); const hotComm = new Decimal(matchingHot.commission_amount); const commDiff = agencyComm.minus(hotComm).abs(); @@ -143,73 +299,135 @@ function matchRecords(input: BSPReconciliationInput): BSPReconciliationOutput { } } - // Check for HOT records missing in agency + // HOT rows missing in agency (consider cross-refs) for (const [ticketNum, hotRecs] of hotByTicket) { - if (!agencyByTicket.has(ticketNum)) { - for (const hot of hotRecs) { - const amount = new Decimal(hot.ticket_amount); - if (amount.abs().greaterThanOrEqualTo(threshold)) { - if (hot.transaction_type === 'ADM') { - discrepancies.push({ - type: 'UNMATCHED_ADM', - severity: 'high', - ticket_number: ticketNum, - airline_code: hot.airline_code, - bsp_amount: hot.ticket_amount, - currency: hot.currency, - description: `ADM ${ticketNum} in BSP HOT file not matched in agency records.`, - }); - } else if (hot.transaction_type === 'ACM') { - discrepancies.push({ - type: 'UNMATCHED_ACM', - severity: 'medium', - ticket_number: ticketNum, - airline_code: hot.airline_code, - bsp_amount: hot.ticket_amount, - currency: hot.currency, - description: `ACM ${ticketNum} in BSP HOT file not matched in agency records.`, - }); - } else { - discrepancies.push({ - type: 'MISSING_IN_AGENCY', - severity: 'critical', - ticket_number: ticketNum, - airline_code: hot.airline_code, - bsp_amount: hot.ticket_amount, - currency: hot.currency, - description: `Ticket ${ticketNum} in BSP HOT file but not in agency records.`, - }); + for (const hot of hotRecs) { + if (matchedHotTickets.has(ticketNum)) continue; + + const agencyHit = + agencyByTicket.has(ticketNum) || + input.agency_records.some((ar) => { + if (agencyCrossRefs(ar).includes(ticketNum)) return true; + if ( + hot.original_ticket_number && + (ar.ticket_number === hot.original_ticket_number || + ar.original_ticket_number === hot.original_ticket_number) + ) { + return true; } - } + if (hot.related_documents?.some((r) => r.ticket_number === ar.ticket_number)) { + return true; + } + if ( + ar.conjunction_ticket_numbers?.includes(ticketNum) || + hot.conjunction_ticket_numbers?.includes(ar.ticket_number) + ) { + return true; + } + return false; + }); + + if (agencyHit) { + matchedHotTickets.add(ticketNum); + continue; + } + + const amount = new Decimal(hot.ticket_amount); + if (amount.abs().lessThan(threshold) && hot.transaction_type !== 'ADM' && hot.transaction_type !== 'ACM') { + continue; + } + + if (hot.transaction_type === 'ADM') { + const related = hot.related_documents?.[0]?.ticket_number; + discrepancies.push({ + type: 'UNMATCHED_ADM', + severity: 'high', + ticket_number: ticketNum, + ...(related ? { related_ticket_number: related } : {}), + airline_code: hot.airline_code, + bsp_amount: hot.ticket_amount, + currency: hot.currency, + description: `ADM ${ticketNum} in BSP HOT` + + (related ? ` (RTDN ${related})` : '') + + ` not matched in agency records.`, + }); + } else if (hot.transaction_type === 'ACM') { + const related = hot.related_documents?.[0]?.ticket_number; + discrepancies.push({ + type: 'UNMATCHED_ACM', + severity: 'medium', + ticket_number: ticketNum, + ...(related ? { related_ticket_number: related } : {}), + airline_code: hot.airline_code, + bsp_amount: hot.ticket_amount, + currency: hot.currency, + description: `ACM ${ticketNum} in BSP HOT` + + (related ? ` (RTDN ${related})` : '') + + ` not matched in agency records.`, + }); + } else if (hot.transaction_type === 'EXCHANGE' || hot.original_ticket_number) { + discrepancies.push({ + type: 'UNMATCHED_EXCHANGE', + severity: 'high', + ticket_number: ticketNum, + ...(hot.original_ticket_number + ? { related_ticket_number: hot.original_ticket_number } + : {}), + airline_code: hot.airline_code, + bsp_amount: hot.ticket_amount, + currency: hot.currency, + description: `Exchange-linked HOT document ${ticketNum}` + + (hot.original_ticket_number ? ` (ORIT ${hot.original_ticket_number})` : '') + + ` not matched in agency records.`, + }); + } else { + discrepancies.push({ + type: 'MISSING_IN_AGENCY', + severity: 'critical', + ticket_number: ticketNum, + airline_code: hot.airline_code, + bsp_amount: hot.ticket_amount, + currency: hot.currency, + description: `Ticket ${ticketNum} in BSP HOT file but not in agency records.`, + }); } } } - // Duplicate detection within HOT + // Duplicate detection within HOT (same TDNR + category) for (const [ticketNum, hotRecs] of hotByTicket) { - const sales = hotRecs.filter((r) => r.transaction_type === 'SALE'); - if (sales.length > 1) { - discrepancies.push({ - type: 'DUPLICATE_TRANSACTION', - severity: 'high', - ticket_number: ticketNum, - airline_code: sales[0]!.airline_code, - bsp_amount: sales[0]!.ticket_amount, - currency: sales[0]!.currency, - description: `Duplicate SALE for ${ticketNum} in HOT file (${sales.length} occurrences).`, - }); + const byType = new Map(); + for (const h of hotRecs) { + const list = byType.get(h.transaction_type) ?? []; + list.push(h); + byType.set(h.transaction_type, list); + } + for (const [txnType, list] of byType) { + if (txnType === 'SALE' && list.length > 1) { + discrepancies.push({ + type: 'DUPLICATE_TRANSACTION', + severity: 'high', + ticket_number: ticketNum, + airline_code: list[0]!.airline_code, + bsp_amount: list[0]!.ticket_amount, + currency: list[0]!.currency, + description: `Duplicate SALE for ${ticketNum} in HOT file (${list.length} occurrences).`, + }); + } } } - // Pattern detection const patterns = detectPatterns(discrepancies, input); - // Summary const totalDiscrepancyAmount = discrepancies.reduce( (sum, d) => sum.plus(new Decimal(d.difference ?? d.agency_amount ?? d.bsp_amount ?? '0')), new Decimal(0), ); + const currenciesPresent = [ + ...new Set(input.hot_records.map((h) => h.currency).filter(Boolean)), + ].sort(); + const summary: ReconciliationSummary = { total_agency_records: input.agency_records.length, total_hot_records: input.hot_records.length, @@ -217,7 +435,8 @@ function matchRecords(input: BSPReconciliationInput): BSPReconciliationOutput { discrepancy_count: discrepancies.length, critical_count: discrepancies.filter((d) => d.severity === 'critical').length, total_discrepancy_amount: totalDiscrepancyAmount.toFixed(2), - currency: input.threshold_currency ?? 'USD', + currency: thresholdCurrency, + currencies_present: currenciesPresent, patterns, }; @@ -232,10 +451,8 @@ function detectPatterns( ): PatternDetection[] { const patterns: PatternDetection[] = []; - // Only detect patterns with sample size >= 10 if (discrepancies.length < 10) return patterns; - // Pattern: commission mismatches by airline const commByAirline = new Map(); for (const d of discrepancies) { if (d.type === 'COMMISSION_MISMATCH' && d.airline_code) { @@ -257,7 +474,6 @@ function detectPatterns( } } - // Pattern: missing transactions by airline const missingByAirline = new Map(); for (const d of discrepancies) { if ((d.type === 'MISSING_IN_HOT' || d.type === 'MISSING_IN_AGENCY') && d.airline_code) { diff --git a/packages/agents/reconciliation/src/bsp-reconciliation/types.ts b/packages/agents/reconciliation/src/bsp-reconciliation/types.ts index b4a175d..44fa74b 100644 --- a/packages/agents/reconciliation/src/bsp-reconciliation/types.ts +++ b/packages/agents/reconciliation/src/bsp-reconciliation/types.ts @@ -3,6 +3,8 @@ * * Agent 7.1: Matches agency records against BSP HOT files, * validates commission, identifies discrepancies. + * + * Domain: docs/knowledge-base/bsp-hot-reconciliation.md (DISH Rev 23). */ export type DiscrepancyType = @@ -13,45 +15,112 @@ export type DiscrepancyType = | 'AMOUNT_MISMATCH' | 'CURRENCY_MISMATCH' | 'UNMATCHED_ADM' - | 'UNMATCHED_ACM'; + | 'UNMATCHED_ACM' + | 'UNMATCHED_EXCHANGE' + | 'CONJUNCTION_SET_MISMATCH'; export type DiscrepancySeverity = 'critical' | 'high' | 'medium' | 'low'; -export type HOTFileFormat = 'EDI_X12' | 'FIXED_WIDTH'; +/** + * HOT parse formats. + * - DISH_REV23: synthetic DISH Rev 23–like tagged records (production HOT is fixed-width DISH). + * - FIXED_WIDTH / EDI_X12: legacy lab fixtures — NOT a substitute for market DISH grids. + * Generic X12 parsers miss DISH fixed-width sections (see KB). + */ +export type HOTFileFormat = 'DISH_REV23' | 'EDI_X12' | 'FIXED_WIDTH'; + +/** + * Logical reconciliation categories. + * DISH TRNC values map into these; exchange-linked issues are separate from plain TKTT sales. + * Conjunction / exchange / EMD / ADM are separate (KB §3). + */ +export type HOTTransactionType = 'SALE' | 'EXCHANGE' | 'REFUND' | 'ADM' | 'ACM' | 'EMD'; + +/** DISH TRNC codes we surface on parsed rows (non-exhaustive). */ +export type DishTransactionCode = + | 'TKTT' + | 'EMDA' + | 'EMDS' + | 'RFND' + | 'ADMA' + | 'ACMA' + | 'EXCH' + | 'CANX' + | 'TASF' + | 'OTHER'; + +export interface RelatedDocumentRef { + /** Related ticket/document number (DISH RTDN or ORIT) */ + ticket_number: string; + /** Related coupons when present (DISH RCPN) */ + coupons?: string; + /** Date of issue of related document (DISH DIRD / ORID) */ + issue_date?: string; +} export interface HOTFileRecord { - /** 13-digit ticket number */ + /** 13-digit ticket / document number (DISH TDNR, check-digit stripped when present) */ ticket_number: string; /** Passenger name */ passenger_name: string; - /** Origin airport code */ + /** Origin airport / city code */ origin: string; - /** Destination airport code */ + /** Destination airport / city code */ destination: string; - /** 2-char airline code */ + /** 2-char airline code (derived from TACN when only numeric present) */ airline_code: string; - /** Issue date (ISO) */ + /** Issue date (ISO preferred) */ issue_date: string; - /** Ticket face value amount (decimal string) */ + /** Ticket / document amount in transaction currency (decimal string) */ ticket_amount: string; - /** Commission amount (decimal string) */ + /** Commission amount in transaction currency (decimal string) */ commission_amount: string; /** Commission rate percentage */ commission_rate?: number; - /** Tax amount (decimal string) */ + /** Tax amount in transaction currency (decimal string) */ tax_amount: string; /** Refund amount if applicable (decimal string) */ refund_amount?: string; - /** Transaction type: SALE, REFUND, ADM, ACM */ - transaction_type: 'SALE' | 'REFUND' | 'ADM' | 'ACM'; - /** Issue sequence number */ + /** Logical transaction category used by the matcher */ + transaction_type: HOTTransactionType; + /** + * DISH TRNC when known (TKTT / EMDA / EMDS / RFND / ADMA / ACMA / …). + * Exchange-linked TKTT issues may still carry transaction_type EXCHANGE. + */ + transaction_code?: DishTransactionCode; + /** Issue sequence / SQNR */ issue_sequence?: string; - /** Payment type */ + /** Payment type / FOP family (CA, CC, EX, …) */ payment_type?: string; - /** Currency code */ + /** + * Transaction currency (DISH record-level CUTP). + * Do NOT assume this equals reporting_currency or a file-wide default. + */ currency: string; + /** + * Reporting / default currency context (DISH BOH03 CUTP) when known. + * Used for remittance totals context only — never as a silent FX substitute. + */ + reporting_currency?: string; /** BSP billing period (e.g., "2026-P03") */ billing_period?: string; + /** HOT transaction number (DISH TRNN) — shared across conjunction / envelope records */ + transaction_number?: string; + /** True when this row is a conjunction document (CJCP=CNJ / BKS24-CNJ) */ + is_conjunction?: boolean; + /** Primary ticket of the conjunction set when this row is a CNJ document */ + conjunction_primary?: string; + /** All other tickets in the conjunction set (excluding this row’s TDNR) */ + conjunction_ticket_numbers?: string[]; + /** + * Original issue ticket for exchange-linked issues (DISH BKS46 ORIT). + * Present on the NEW document’s HOT envelope. + */ + original_ticket_number?: string; + /** + * Related documents for RFND / ADM / ACM (DISH BKS45 RTDN list). + */ + related_documents?: RelatedDocumentRef[]; } export interface AgencyRecord { @@ -78,9 +147,20 @@ export interface AgencyRecord { /** Refund amount if applicable (decimal string) */ refund_amount?: string; /** Transaction type */ - transaction_type: 'SALE' | 'REFUND' | 'ADM' | 'ACM'; - /** Currency code */ + transaction_type: HOTTransactionType; + /** + * Transaction currency — must match HOT CUTP for amount compares. + * Do not assume agency reporting currency equals transaction currency. + */ currency: string; + /** Reporting currency if mid-office stores a separate book currency */ + reporting_currency?: string; + /** Original ticket when this agency row is an exchange reissue */ + original_ticket_number?: string; + /** Conjunction companions for this ticket */ + conjunction_ticket_numbers?: string[]; + /** Related ticket for refund / ADM / ACM agency rows */ + related_ticket_number?: string; } export interface Discrepancy { @@ -90,6 +170,8 @@ export interface Discrepancy { severity: DiscrepancySeverity; /** Ticket number (if applicable) */ ticket_number?: string; + /** Related / original ticket when cross-ref matching applies */ + related_ticket_number?: string; /** Airline code */ airline_code?: string; /** Agency amount (decimal string) */ @@ -98,7 +180,7 @@ export interface Discrepancy { bsp_amount?: string; /** Difference (decimal string) */ difference?: string; - /** Currency */ + /** Transaction currency of the compared amounts */ currency?: string; /** Human-readable description */ description: string; @@ -128,10 +210,12 @@ export interface ReconciliationSummary { discrepancy_count: number; /** Critical discrepancies */ critical_count: number; - /** Total discrepancy amount (decimal string) */ + /** Total discrepancy amount (decimal string) — same-currency rows only */ total_discrepancy_amount: string; - /** Currency */ + /** Threshold / reporting currency for summary (not an FX claim) */ currency: string; + /** Distinct transaction currencies observed in the HOT set */ + currencies_present: string[]; /** Patterns detected */ patterns: PatternDetection[]; } @@ -147,7 +231,11 @@ export interface BSPReconciliationInput { remittance_deadline?: string; /** Minimum discrepancy threshold (decimal string, default "10.00") */ min_threshold?: string; - /** Threshold currency (default "USD") */ + /** + * Threshold currency (default "USD"). + * Threshold applies only when the transaction currency equals this code — + * no IROE/ICER conversion (KB §2). + */ threshold_currency?: string; /** Current date/time (ISO) */ current_datetime?: string; diff --git a/packages/agents/reconciliation/src/index.ts b/packages/agents/reconciliation/src/index.ts index 21ba627..6ec5d90 100644 --- a/packages/agents/reconciliation/src/index.ts +++ b/packages/agents/reconciliation/src/index.ts @@ -10,6 +10,9 @@ export type { BSPReconciliationOutput, HOTFileRecord, HOTFileFormat, + HOTTransactionType, + DishTransactionCode, + RelatedDocumentRef, AgencyRecord, Discrepancy, DiscrepancyType, From 3575c38cde8b28eb947cef04f566b85b342ff015 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:04:18 +0000 Subject: [PATCH 2/2] chore: regenerate agent manifest after 7.1 version bump Co-authored-by: telivity-otaip --- agents.graph.json | 2 +- agents.manifest.json | 2 +- docs/agent-map.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agents.graph.json b/agents.graph.json index 56d5270..9bc99e5 100644 --- a/agents.graph.json +++ b/agents.graph.json @@ -439,7 +439,7 @@ "id": "7.1", "name": "BSP Reconciliation", "stage": "reconciliation", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/reconciliation/src/bsp-reconciliation/index.ts" diff --git a/agents.manifest.json b/agents.manifest.json index a01fe5b..58842e2 100644 --- a/agents.manifest.json +++ b/agents.manifest.json @@ -5402,7 +5402,7 @@ "id": "7.1", "name": "BSP Reconciliation", "stage": "reconciliation", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/reconciliation/src/bsp-reconciliation/index.ts" diff --git a/docs/agent-map.html b/docs/agent-map.html index db149b3..979a545 100644 --- a/docs/agent-map.html +++ b/docs/agent-map.html @@ -1897,7 +1897,7 @@

Every agent, by stage.

- +