From 663801b434d2a4fe0c31a0e52db1a4e1c6015698 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:05:11 +0000 Subject: [PATCH 1/5] fix(domain): fail-closed passenger residual via Cat 33 + THB (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document passenger residual as Cat 33 + Historical Ticket Based (THB) valuation; reject original−used, original−change-fee, coupon-ratio, haversine, and MPA-P (interline-only). Agents 5.1/5.2/6.1 require an explicit method and return DOMAIN_INPUT_REQUIRED when unspecified. Co-authored-by: telivity-otaip --- CHANGELOG.md | 9 + CLAUDE.md | 6 +- docs/agents/stage-5-exchange.md | 17 +- docs/agents/stage-6-settlement.md | 8 +- .../partial-refund-residual-value.md | 167 +++++++++++++++++ .../__tests__/change-management.test.ts | 126 ++++++++----- .../src/change-management/change-engine.ts | 88 ++++++++- .../exchange/src/change-management/index.ts | 73 +++++++- .../exchange/src/change-management/types.ts | 31 +++- .../__tests__/exchange-reissue.test.ts | 98 +++++++--- .../exchange/src/exchange-reissue/index.ts | 49 ++++- .../src/exchange-reissue/reissue-engine.ts | 68 +++---- .../exchange/src/exchange-reissue/types.ts | 20 ++- packages/agents/exchange/src/index.ts | 3 + .../src/self-service-rebooking/index.ts | 8 +- packages/agents/settlement/src/index.ts | 1 + .../__tests__/refund-processing.test.ts | 170 +++++++++++++----- .../settlement/src/refund-processing/index.ts | 60 ++++++- .../src/refund-processing/refund-engine.ts | 148 +++++++++------ .../settlement/src/refund-processing/types.ts | 19 ++ packages/core/src/domain/index.ts | 13 +- packages/core/src/domain/types.ts | 43 +++++ packages/core/src/index.ts | 13 +- 23 files changed, 989 insertions(+), 249 deletions(-) create mode 100644 docs/knowledge-base/partial-refund-residual-value.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 43dbbee..69dc8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ > **Versioning policy:** Pre-v1.0, every release is a patch bump (`0.6.0 → 0.6.1 → 0.6.2 → …`). See [VERSIONING.md](VERSIONING.md) for the full policy and an explanation of the early-history version jumps (0.3.4 → 0.5.0 → 0.5.1 → 0.6.0) that predate this rule. v0.6.4 → v0.7.0 is the one post-policy exception — see VERSIONING.md. +## Unreleased + +### Domain — partial refund / residual value ([#150](https://github.com/TelivityAI/otaip/issues/150)) + +- New KB: `docs/knowledge-base/partial-refund-residual-value.md` — passenger residual = **Cat 33 + THB** (Historical Ticket Based); **MPA-P is interline only**; reject original−used / original−change-fee / coupon-ratio / haversine; conjunction all-or-none; worked examples; fail-closed interfaces. +- Agents **5.1 / 5.2 / 6.1** require explicit residual/partial valuation methods; return `DOMAIN_INPUT_REQUIRED` when method unspecified. +- Removed invented residual = original − change fee (5.1) and coupon-ratio partial proration (6.1). +- `@otaip/core`: export `PassengerResidualMethod`, `PassengerPartialValuation`, `REJECTED_PASSENGER_RESIDUAL_METHODS`. + ## 0.7.4 — Duffel order enrichment + activity/transfer agents Workspace-wide patch bump `0.7.3 → 0.7.4` so npm picks up [#123](https://github.com/TelivityAI/otaip/pull/123). diff --git a/CLAUDE.md b/CLAUDE.md index 4d412f0..212e40c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,13 +132,13 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | "The US DOT 24-hour rule means free cancellation within 24 hours of booking" | STOP. The rule requires EITHER 24hr free cancellation OR 24hr fare hold (carrier chooses). Only applies 7+ days before departure. Only applies to US flights. Implementation varies by carrier and booking channel. Check KB for specifics. | | "Waiver codes bypass the standard penalty — I'll just skip the fee calculation when a waiver is present" | STOP. Waivers have different types with different effects (reduce, eliminate, change rebooking class). Do not treat all waivers as "skip penalty." Surface as DOMAIN_QUESTION: what is the waiver type and its specific effect? | | "BASIC economy / non-refundable fares simply cannot be changed" | STOP. This varies by carrier, market, and regulation. Some carriers allow changes with penalty, some allow same-day standby. EU regulations may override carrier restrictions. Check KB before implementing blanket restrictions. | -| "Residual value for a partially flown ticket is the original fare minus the flown portion" | STOP. "Flown portion" pricing depends on published fare availability between flown city pairs. If none exists, carrier-specific proration applies. Check KB for the carrier's residual calculation method. | +| "Residual value for a partially flown ticket is the original fare minus the flown portion" | STOP. Passenger residual = Cat 33 + THB (Historical Ticket Based) when filed, else carrier-specific amounts. MPA-P is interline settlement — not pax residual. Never haversine-split a through fare. See `docs/knowledge-base/partial-refund-residual-value.md`. | ### Agent 5.2 — Exchange/Reissue Agent | Rationalization | Required response | |---|---| -| "Residual value is simply the original fare minus the change fee" | STOP. Residual-first reissue is NOT simple subtraction. The residual depends on flown vs unflown coupons, original fare construction rules, and carrier-specific handling of residual (forfeit vs MCO/EMD vs credit). Surface as DOMAIN_QUESTION. | +| "Residual value is simply the original fare minus the change fee" | STOP. Change fee is a separate Cat 31 collection. Fully unused residual = ticketed base. Partially used = Cat 33 + THB (or carrier-specific) unused value — never original − fee. MPA-P is not passenger residual. See `docs/knowledge-base/partial-refund-residual-value.md`. | | "Tax carryforward applies when the origin and destination haven't changed" | STOP. Tax carryforward rules vary per tax code. Some carry forward only with same airport (not city), some only within the same tax validity period, some never carry forward (certain YQ/YR). Surface as DOMAIN_QUESTION: which tax codes are involved? | | "I'll generate the Amadeus exchange command using standard Cryptic format" | STOP. GDS exchange commands differ by scenario (voluntary vs involuntary), by whether fare basis changed, by whether routing changed. Amadeus/Sabre/Travelport each use different command sequences. Check KB for the specific exchange scenario. | | "For conjunction tickets, the exchange applies to the specific coupon being changed" | STOP. Conjunction ticket exchange requires referencing ALL ticket numbers in the set. Residual calculation spans the entire conjunction fare. Check KB for conjunction exchange handling. | @@ -161,7 +161,7 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | Rationalization | Required response | |---|---| | "ATPCO Category 33 refund rules follow the same penalty structure as Cat 31" | STOP. Cat 33 and Cat 31 are independent categories with separate penalty structures. A fare can be non-refundable but changeable, or vice versa. Check KB for Cat 33 rules specifically. | -| "Partial refunds are calculated by subtracting the used portion from the original fare" | STOP. Partial refund proration depends on published fare availability for flown segments. Carrier-specific formulas apply when no published fare exists. Taxes prorated separately. Check KB for proration method. | +| "Partial refunds are calculated by subtracting the used portion from the original fare" | STOP. Reject original−used without a method. Passenger path = Cat 33 + THB (Historical Ticket Based) unused valuation, or carrier-specific amounts. Never coupon-ratio, haversine, or MPA-P (interline). Fail closed when method unspecified. See `docs/knowledge-base/partial-refund-residual-value.md`. | | "Commission recall on refund is straightforward — reverse the original commission" | STOP. Commission recall rules vary by carrier agreement. Some allow retention, some recall 100%, some proportionally. Net remit tickets have different rules. Check KB for carrier-specific terms. | | "Conjunction tickets — I'll process the refund on the specific coupon that's being refunded" | STOP. Conjunction ticket refunds are ALL-or-NONE. Cannot refund individual coupons independently. If some segments cancelled, it becomes a partial refund across the full conjunction fare. Check KB for conjunction refund handling. | | "For BSP reporting, I'll use the refund transaction type with the refund amount" | STOP. BSP refund reporting requires specific fields: original ticket reference, refund amount, penalty deducted, commission recall, tax breakdown. ARC format differs from BSP. Check KB for the market-specific format. | diff --git a/docs/agents/stage-5-exchange.md b/docs/agents/stage-5-exchange.md index bef85ba..f7be8d5 100644 --- a/docs/agents/stage-5-exchange.md +++ b/docs/agents/stage-5-exchange.md @@ -19,9 +19,12 @@ ATPCO Category 31 voluntary change assessment: change fees, fare difference, res - `requested_itinerary` -- new segments (carrier, flight, origin, destination, date, class, fare basis), new fare, new taxes - `waiver_code?` -- airline-provided waiver code - `current_datetime?` -- ISO datetime +- `ticket_usage?` -- `FULLY_UNUSED` (default) | `PARTIALLY_USED` +- `residual_valuation?` -- required when partially used: `CAT33_THB` or `CARRIER_SPECIFIC` unused base/taxes (see `docs/knowledge-base/partial-refund-residual-value.md`) -**Output (`ChangeManagementOutput`):** -- `assessment` -- action (`REISSUE | REBOOK | REJECT`), change fee, fare difference, additional collection, residual value, forfeited amount, tax difference, total due, free change flag, summary +**Output (`ChangeManagementResult`):** +- Success: `assessment` -- action (`REISSUE | REBOOK | REJECT`), change fee, fare difference, additional collection, residual value + residual_method, forfeited amount, tax difference, total due, free change flag, summary +- Or `DOMAIN_INPUT_REQUIRED` when partially used without an explicit residual method (fail closed; never original − change fee / MPA-P) --- @@ -37,17 +40,17 @@ Ticket reissue with residual value application, tax carryforward, conjunction ti - `original_ticket_number`, `conjunction_originals?`, `original_issue_date` - `issuing_carrier`, `passenger_name`, `record_locator` - `original_base_fare`, `original_taxes` -- from original ticket -- `change_fee`, `residual_value`, `waiver_code?` -- from Agent 5.1 +- `change_fee`, `residual_value`, `residual_method` -- from Agent 5.1 (`FULLY_UNUSED` | `CAT33_THB` | `CARRIER_SPECIFIC`) +- `waiver_code?` -- from Agent 5.1 - `new_segments` -- new flight segments - `new_fare`, `new_fare_currency`, `new_taxes`, `fare_calculation` - `form_of_payment` -- for additional collection - `gds?` -- GDS for command generation - `same_origin_destination` -- for tax carryforward eligibility -**Output (`ExchangeReissueOutput`):** -- `reissue` -- new ticket record with full audit trail, exchange commands, tax carryforward details -- `additional_collection` -- amount due -- `credit_amount` -- amount refundable if downgrade +**Output (`ExchangeReissueResult`):** +- Success: `reissue` -- new ticket record with full audit trail (includes residual_method), exchange commands, tax carryforward details; `additional_collection`; `credit_amount` +- Or `DOMAIN_INPUT_REQUIRED` if residual method is missing/invalid (does not invent residual = original − change fee) --- diff --git a/docs/agents/stage-6-settlement.md b/docs/agents/stage-6-settlement.md index b9ac7dd..2f51bf9 100644 --- a/docs/agents/stage-6-settlement.md +++ b/docs/agents/stage-6-settlement.md @@ -20,12 +20,14 @@ ATPCO Category 33 refund processing: penalty application, commission recall, BSP - `base_fare`, `base_fare_currency`, `taxes`, `commission?` - `refund_type` -- `'FULL' | 'PARTIAL' | 'TAX_ONLY'` - `coupons_to_refund?` -- specific coupons (for partial) +- `partial_valuation?` -- **required for PARTIAL**: `CAT33_THB` or `CARRIER_SPECIFIC` unused base + unused taxes (see `docs/knowledge-base/partial-refund-residual-value.md`) - `total_coupons`, `waiver_code?`, `fare_basis`, `is_refundable` - `settlement_system` -- `'BSP' | 'ARC'` -**Output (`RefundProcessingOutput`):** -- `refund` -- penalty applied, base fare refund, tax refund, tax breakdown, commission recalled, net refund, BSP/ARC reporting fields, audit trail -- `net_refund_amount`, `commission_recalled` +**Output (`RefundProcessingResult`):** +- Success: `refund` -- penalty applied, base fare refund, tax refund, tax breakdown, commission recalled, net refund, BSP/ARC reporting fields, audit trail; `net_refund_amount`, `commission_recalled` +- Or `DOMAIN_INPUT_REQUIRED` for PARTIAL without valuation method (fail closed; never original − used / coupon-ratio / MPA-P) +- Conjunction: PARTIAL rejected (all-or-none) --- diff --git a/docs/knowledge-base/partial-refund-residual-value.md b/docs/knowledge-base/partial-refund-residual-value.md new file mode 100644 index 0000000..2eef53b --- /dev/null +++ b/docs/knowledge-base/partial-refund-residual-value.md @@ -0,0 +1,167 @@ +# Partial refund / residual value — passenger path + +Source: GitHub issue #150 (TMC / revenue-accounting domain input). Authoritative for Agents **5.1**, **5.2**, and **6.1**. Anything missing here is an open `DOMAIN_QUESTION` — never invent. + +## Scope + +This document covers **passenger-facing** residual value and partial refunds on air tickets (voluntary change residual for reissue; Cat 33 voluntary refund of unused value after partial use). + +It does **not** cover airline-to-airline interline revenue allocation. + +## Forbidden arithmetic (reject as a general rule) + +| Invented formula | Why it is wrong | +| --- | --- | +| `residual = original − change fee` | Change fee is a **separate** Cat 31/16 collection. Residual is unused **ticketed fare value**, not fare-minus-penalty. | +| `partial refund = original − "used portion"` without a method | "Used portion" is undefined until a valuation method prices the flown sectors. | +| Coupon-count ratio (`base × refundable_coupons / total_coupons`) | Equal coupon split invents value; through fares are not linear in coupon count. | +| Haversine / great-circle split of a through fare | Distance approximation is not a published fare and is not a filed residual method. | +| MPA-P / TPM / PFM tables applied to the passenger | **MPA-P is airline interline settlement**, not passenger residual. Do not reuse interline proration for pax refunds. | + +## What passenger residual actually is + +**Passenger refund / residual = ATPCO Category 33 (penalty + eligibility) + THB valuation of the flown portion when the ticket is partially used.** + +- **Cat 33** — voluntary refund rules: whether refund is permitted, penalty amount / forfeit flags, and the filed **re-price indicator** for how to value flown sectors. +- **THB** — **Historical Ticket Based** fares (ATPCO Cat 33 Re-Price Indicator **A**): re-price the **flown** sectors using fares in effect on the **original ticket issue date** (historical ticket date), subject to the filing’s tariff/rule/fare-class constraints. Indicator **B** (Historical Travel Commencement Based) is a different filed choice — do not silently substitute THB for B. + +When the filing requires a carrier-specific formula instead of (or after) THB and that formula is not supplied as authoritative input → **fail closed** (`DOMAIN_INPUT_REQUIRED`). Do not invent TPM tables, MPA-P splits, or haversine. + +## Decision tree + +```text +Is the ticket fully unused (no flown coupons)? +├─ YES → Residual / refundable base = full ticketed base fare. +│ Apply Cat 31 change fee (5.1) or Cat 33 refund penalty (6.1) SEPARATELY. +│ Do NOT set residual = base − fee. +│ +└─ NO (partially used) + ├─ Cat 33 (or carrier residual filing) specifies THB (Re-Price Indicator A)? + │ ├─ YES, and caller supplies THB-priced flown / unused amounts + │ │ → unused_base = ticketed_base − THB_flown_base (amounts from historical pricing) + │ │ → apply Cat 33 penalty to unused_base (6.1) + │ │ → for reissue residual (5.1/5.2), unused residual is the unused fare value + │ │ after any Cat 31/33 interactions the filing requires + │ │ → taxes: value unused taxes per tax code rules (see Tax handling) + │ └─ YES, but THB amounts not supplied → DOMAIN_INPUT_REQUIRED + │ + ├─ Filing specifies CARRIER_SPECIFIC residual / proration method? + │ ├─ YES, and caller supplies the carrier-valued unused amounts → use as filed + │ └─ YES, but amounts / method details missing → DOMAIN_INPUT_REQUIRED + │ + └─ Method unspecified / only “original − used” asserted + → DOMAIN_INPUT_REQUIRED (fail closed) +``` + +### Explicitly out of scope for passenger agents + +- **MPA-P** (Multilateral Proration Agreement – Passenger) and Prorate Manual / PFM data — **interline settlement between airlines**, not passenger residual. +- Invented mileage tables, haversine splits, or equal coupon ratios. + +## Tax handling on partials + +Taxes are **not** the same as base fare residual. + +1. Identify which tax amounts remain **unused** after the flown sectors (per tax code: airport vs enroute vs carrier-imposed YQ/YR, etc.). +2. Cat 33 / regulatory rules may require full refund of some taxes even when base is reduced; others follow unused-sector valuation. +3. Do **not** invent a coupon-ratio tax split. Caller must supply unused tax breakdown (or a domain-approved tax valuation result) when method is THB or carrier-specific. +4. Open: per-code carryforward / refundability matrices remain `DOMAIN_QUESTION` until carrier/tax tables are ingested. + +## Conjunction tickets (Agent 6.1) + +Conjunction sets are **all-or-none** for refund: you cannot partially refund one document in the set while leaving others. Agent 6.1 already rejects `refund_type: PARTIAL` when `conjunction_tickets` is non-empty. + +**No known exception** is recorded in this KB. If a market documents an exception, open a `DOMAIN_QUESTION` with the market and carrier citation — do not code an exception here. + +Exchange (5.2) must reference **all** ticket numbers in a conjunction set when exchanging; residual spans the conjunction fare, not a single coupon. + +## Proposed agent interfaces (fail closed) + +Callers must declare a method. Engines never invent amounts. + +```ts +type PassengerResidualMethod = + | 'FULLY_UNUSED' // no flown coupons + | 'CAT33_THB' // Historical Ticket Based flown valuation supplied + | 'CARRIER_SPECIFIC'; // carrier residual amounts supplied + +interface PartialValuationInput { + method: Exclude; + /** Unused base after THB / carrier valuation (decimal string). */ + unused_base_fare: string; + /** Optional audit: THB / carrier value of flown base. */ + flown_base_fare?: string; + /** Unused taxes by code — required for PARTIAL money paths. */ + unused_taxes: Array<{ code: string; amount: string; currency: string }>; +} +``` + +- **5.1** — fully unused: `residual_value = original base`; change fee separate. Partially used: require `residual_valuation`; else `DOMAIN_INPUT_REQUIRED`. +- **5.2** — applies caller/5.1 residual; requires `residual_method`; does not recompute residual from `original − change_fee`. +- **6.1** — `PARTIAL` requires `partial_valuation`; else `DOMAIN_INPUT_REQUIRED`. No coupon-ratio fallback. + +## Worked examples (made-up amounts) + +Amounts below are **illustrative only** — not live tickets, not filed carrier data. + +### Example A — Fully unused one-way (reissue residual) + +- Ticketed base: **USD 450.00**, taxes **USD 120.00** +- Cat 31 change fee (filed): **USD 200.00** +- New fare: **USD 550.00** + +| Field | Correct | Forbidden | +| --- | --- | --- | +| Residual toward new fare | **450.00** (full unused base) | 450 − 200 = 250 | +| Change fee | **200.00** add-collect | baked into residual | +| Fare difference | 550 − 450 = **100.00** | — | +| Typical add-collect (fare + fee, taxes aside) | 100 + 200 = **300.00** | 550 − 250 + 200 = 500 (double-counts fee) | + +### Example B — Round-trip partially flown, Cat 33 + THB + +- RT ticketed base: **USD 800.00** (OUT + RTN as one through amount on the ticket) +- Pax flew OUT only; requests refund of RTN +- THB (historical ticket date) published OW for flown OUT city pair: **USD 480.00** +- Cat 33 penalty: **USD 150.00** +- Unused taxes supplied after tax-code review: **USD 55.00** + +| Step | Amount | +| --- | --- | +| Flown base (THB) | 480.00 | +| Unused base before penalty | 800 − 480 = **320.00** | +| Cat 33 penalty | 150.00 | +| Refundable base | 320 − 150 = **170.00** | +| Refundable tax | **55.00** (supplied — not 50% of original tax) | +| Net before commission | **225.00** | + +Rejected alternatives for the same ticket: + +- `800 × 1/2 = 400` coupon split — invented +- MPA-P / TPM split of the through fare — wrong domain (interline) +- Haversine share of 800 — invented +- `800 − "used"` with used undefined — fail closed + +### Example C — One-way multi-coupon partially flown, method missing + +- OW base **USD 600.00**, 2 coupons, coupon 1 flown +- No Cat 33 re-price indicator / no THB amounts / no carrier formula on input + +→ Engine returns `DOMAIN_INPUT_REQUIRED` with missing `partial_valuation` / `cat33_thb_flown_amounts`. **No** residual number is emitted. + +## Data dependencies (do not invent) + +| Dependency | Role | Invented substitute (forbidden) | +| --- | --- | --- | +| ATPCO Cat 33 filing (incl. Re-Price Indicator) | Penalty + whether THB vs travel-commencement vs other | Hardcoded “industry default” residual math | +| Historical fare quote (ticket date) for flown sectors | THB flown base | Current-day fare, haversine, coupon ratio | +| Carrier residual / SPA formula (when filed) | CARRIER_SPECIFIC path | MPA-P / TPM tables | +| Per-tax-code unused amounts | Partial tax refund | Pro-rata by coupon count | +| MPA-P / PFM / TPM | **Not used** for passenger residual | — | + +## Open DOMAIN_QUESTIONs + +- **DQ-R1** — Per-carrier ingestion of Cat 33 Re-Price Indicator (A=THB vs B=travel commencement) and calculation options (Method A/B per Cat 33 Data Application). +- **DQ-R2** — Authoritative source for historical ticket-date fare quotes used as THB flown valuation (GDS informative pricing vs ATPCO fare feed). +- **DQ-R3** — Tax-code matrix for unused-tax determination on partials (especially YQ/YR and non-refundable airport charges). +- **DQ-R4** — Any documented market exception to conjunction all-or-none refunds (none known; keep fail closed). +- **DQ-R5** — Cat 31 residual interactions on **partially used** voluntary changes beyond “unused fare value + separate change fee” (carrier-specific). diff --git a/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts b/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts index cca6b79..8d85c89 100644 --- a/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts +++ b/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts @@ -14,10 +14,20 @@ import { createRequire } from 'node:module'; import { ChangeManagement } from '../index.js'; import type { ChangeManagementInput, + ChangeManagementResult, Cat31Rules, OriginalTicketSummary, RequestedItinerary, } from '../types.js'; +import type { AgentOutput } from '@otaip/core'; +import { isDomainInputRequired } from '@otaip/core'; + +function assertAssessment(result: AgentOutput) { + if (isDomainInputRequired(result.data)) { + throw new Error(`unexpected DOMAIN_INPUT_REQUIRED: ${result.data.description}`); + } + return assertAssessment(result); +} const require = createRequire(import.meta.url); const TEST_CAT31_RULES = require('./fixtures/test-cat31-rules.json') as Cat31Rules; @@ -85,40 +95,76 @@ describe('Change Management', () => { describe('Basic change assessment (with filed Cat31 rules)', () => { it('calculates fare difference (upgrade)', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.assessment.fare_difference).toBe('100.00'); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(assertAssessment(result).fare_difference).toBe('100.00'); }); it('calculates additional collection on upgrade', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.assessment.additional_collection).toBe('100.00'); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(assertAssessment(result).additional_collection).toBe('100.00'); }); it('includes change fee for restricted fare per filed rule', async () => { const result = await agent.execute({ data: makeInput() }); - expect(Number(result.data.assessment.change_fee)).toBeGreaterThan(0); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(Number(assertAssessment(result).change_fee)).toBeGreaterThan(0); }); it('calculates total due (fee + additional + tax delta)', async () => { const result = await agent.execute({ data: makeInput() }); - const totalDue = Number(result.data.assessment.total_due); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + const totalDue = Number(assertAssessment(result).total_due); expect(totalDue).toBeGreaterThan(0); }); - it('calculates residual value', async () => { + it('calculates residual value as full ticketed base (not original − fee)', async () => { const result = await agent.execute({ data: makeInput() }); - const residual = Number(result.data.assessment.residual_value); - expect(residual).toBeGreaterThan(0); - expect(residual).toBeLessThanOrEqual(450); + expect(result.data).not.toHaveProperty('status', 'DOMAIN_INPUT_REQUIRED'); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + // Fully unused: residual = original base; change fee is separate (issue #150) + expect(assertAssessment(result).residual_value).toBe('450.00'); + expect(assertAssessment(result).residual_method).toBe('FULLY_UNUSED'); + expect(Number(assertAssessment(result).change_fee)).toBeGreaterThan(0); + }); + + it('fail-closed when partially used without residual valuation method', async () => { + const result = await agent.execute({ + data: makeInput({ ticket_usage: 'PARTIALLY_USED' }), + }); + expect(result.data).toMatchObject({ + status: 'DOMAIN_INPUT_REQUIRED', + }); + if (!('missing' in result.data)) throw new Error('expected domain sentinel'); + expect(result.data.missing).toContain('residual_valuation'); + expect(result.confidence).toBe(0); + }); + + it('uses CAT33_THB unused base when residual_valuation supplied', async () => { + const result = await agent.execute({ + data: makeInput({ + ticket_usage: 'PARTIALLY_USED', + residual_valuation: { + method: 'CAT33_THB', + unused_base_fare: '320.00', + flown_base_fare: '480.00', + unused_taxes: [{ code: 'GB', amount: '40.00', currency: 'USD' }], + }, + }), + }); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(assertAssessment(result).residual_value).toBe('320.00'); + expect(assertAssessment(result).residual_method).toBe('CAT33_THB'); }); it('sets action to REISSUE for fare change', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.assessment.action).toBe('REISSUE'); + expect(assertAssessment(result).action).toBe('REISSUE'); }); it('calculates tax difference', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.assessment.tax_difference).toBe('10.00'); + expect(assertAssessment(result).tax_difference).toBe('10.00'); }); }); @@ -127,26 +173,26 @@ describe('Change Management', () => { const result = await agent.execute({ data: makeInput({ cat31_rules: undefined }), }); - expect(result.data.assessment.change_fee).toBe('0.00'); - expect(result.data.assessment.fee_waived).toBe(false); - expect(result.data.assessment.summary).toContain('ATPCO default'); + expect(assertAssessment(result).change_fee).toBe('0.00'); + expect(assertAssessment(result).fee_waived).toBe(false); + expect(assertAssessment(result).summary).toContain('ATPCO default'); }); it('involuntary change with no rules: penalty = 0, fee_waived = true', async () => { const result = await agent.execute({ data: makeInput({ cat31_rules: undefined, is_involuntary: true }), }); - expect(result.data.assessment.change_fee).toBe('0.00'); - expect(result.data.assessment.fee_waived).toBe(true); - expect(result.data.assessment.summary).toContain('Involuntary'); + expect(assertAssessment(result).change_fee).toBe('0.00'); + expect(assertAssessment(result).fee_waived).toBe(true); + expect(assertAssessment(result).summary).toContain('Involuntary'); }); it('involuntary change with rules: filed penalty still waived to 0', async () => { const result = await agent.execute({ data: makeInput({ is_involuntary: true }), }); - expect(result.data.assessment.change_fee).toBe('0.00'); - expect(result.data.assessment.fee_waived).toBe(true); + expect(assertAssessment(result).change_fee).toBe('0.00'); + expect(assertAssessment(result).fee_waived).toBe(true); }); }); @@ -156,8 +202,8 @@ describe('Change Management', () => { requested_itinerary: makeRequested({ new_fare: '450.00', new_tax: '120.00' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.fare_difference).toBe('0.00'); - expect(result.data.assessment.additional_collection).toBe('0.00'); + expect(assertAssessment(result).fare_difference).toBe('0.00'); + expect(assertAssessment(result).additional_collection).toBe('0.00'); }); it('negative fare difference on downgrade', async () => { @@ -165,7 +211,7 @@ describe('Change Management', () => { requested_itinerary: makeRequested({ new_fare: '350.00', new_tax: '110.00' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.fare_difference).toBe('-100.00'); + expect(assertAssessment(result).fare_difference).toBe('-100.00'); }); it('forfeits difference on non-refundable downgrade per filed rule', async () => { @@ -174,7 +220,7 @@ describe('Change Management', () => { requested_itinerary: makeRequested({ new_fare: '350.00', new_tax: '110.00' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.forfeited_amount).toBe('100.00'); + expect(assertAssessment(result).forfeited_amount).toBe('100.00'); }); it('no forfeiture on refundable fare downgrade', async () => { @@ -183,7 +229,7 @@ describe('Change Management', () => { requested_itinerary: makeRequested({ new_fare: '350.00', new_tax: '110.00' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.forfeited_amount).toBe('0.00'); + expect(assertAssessment(result).forfeited_amount).toBe('0.00'); }); }); @@ -194,9 +240,9 @@ describe('Change Management', () => { current_datetime: '2026-03-15T20:00:00Z', }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.is_free_change).toBe(true); - expect(result.data.assessment.change_fee).toBe('0.00'); - expect(result.data.assessment.fee_waived).toBe(true); + expect(assertAssessment(result).is_free_change).toBe(true); + expect(assertAssessment(result).change_fee).toBe('0.00'); + expect(assertAssessment(result).fee_waived).toBe(true); }); it('not free after 24h window', async () => { @@ -205,7 +251,7 @@ describe('Change Management', () => { current_datetime: '2026-03-15T12:00:00Z', }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.is_free_change).toBe(false); + expect(assertAssessment(result).is_free_change).toBe(false); }); it('full-fare Y class has no change fee per filed rule', async () => { @@ -213,7 +259,7 @@ describe('Change Management', () => { original_ticket: makeOriginal({ fare_basis: 'YOWUS' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.change_fee).toBe('0.00'); + expect(assertAssessment(result).change_fee).toBe('0.00'); }); it('business class has no change fee per filed rule', async () => { @@ -221,7 +267,7 @@ describe('Change Management', () => { original_ticket: makeOriginal({ fare_basis: 'COWUS' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.change_fee).toBe('0.00'); + expect(assertAssessment(result).change_fee).toBe('0.00'); }); }); @@ -229,15 +275,15 @@ describe('Change Management', () => { it('waives penalty with waiver code', async () => { const input = makeInput({ waiver_code: 'WAIVER123' }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.fee_waived).toBe(true); - expect(result.data.assessment.change_fee).toBe('0.00'); - expect(result.data.assessment.waiver_code).toBe('WAIVER123'); + expect(assertAssessment(result).fee_waived).toBe(true); + expect(assertAssessment(result).change_fee).toBe('0.00'); + expect(assertAssessment(result).waiver_code).toBe('WAIVER123'); }); it('stores waiver code on assessment', async () => { const input = makeInput({ waiver_code: 'ABCDEF' }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.waiver_code).toBe('ABCDEF'); + expect(assertAssessment(result).waiver_code).toBe('ABCDEF'); }); }); @@ -247,7 +293,7 @@ describe('Change Management', () => { original_ticket: makeOriginal({ fare_basis: 'HOWBASIC' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.action).toBe('REJECT'); + expect(assertAssessment(result).action).toBe('REJECT'); }); it('rejects change for NR (non-rebookable) fares', async () => { @@ -255,7 +301,7 @@ describe('Change Management', () => { original_ticket: makeOriginal({ fare_basis: 'HOWNR' }), }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.action).toBe('REJECT'); + expect(assertAssessment(result).action).toBe('REJECT'); }); it('warns when change is rejected', async () => { @@ -273,26 +319,26 @@ describe('Change Management', () => { cat31_rules: undefined, }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.action).not.toBe('REJECT'); + expect(assertAssessment(result).action).not.toBe('REJECT'); }); }); describe('Summary', () => { it('generates human-readable summary', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.assessment.summary).toBeTruthy(); - expect(result.data.assessment.summary.length).toBeGreaterThan(10); + expect(assertAssessment(result).summary).toBeTruthy(); + expect(assertAssessment(result).summary.length).toBeGreaterThan(10); }); it('summary mentions waiver when applied', async () => { const input = makeInput({ waiver_code: 'WAIVER123' }); const result = await agent.execute({ data: input }); - expect(result.data.assessment.summary).toContain('Waiver'); + expect(assertAssessment(result).summary).toContain('Waiver'); }); it('summary includes total due', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.assessment.summary).toContain('Total due'); + expect(assertAssessment(result).summary).toContain('Total due'); }); }); diff --git a/packages/agents/exchange/src/change-management/change-engine.ts b/packages/agents/exchange/src/change-management/change-engine.ts index ded1385..aa525e9 100644 --- a/packages/agents/exchange/src/change-management/change-engine.ts +++ b/packages/agents/exchange/src/change-management/change-engine.ts @@ -10,16 +10,21 @@ * default per the project's domain spec: voluntary changes are * PERMITTED AT NO CHARGE; involuntary changes have the fee waived. * - * The previous "$200 default" fallback was a CLAUDE.md violation and - * has been removed. Carrier-specific rules MUST flow in via input. + * Residual value (issue #150 / KB partial-refund-residual-value.md): + * - FULLY_UNUSED → residual = ticketed base (change fee is separate) + * - PARTIALLY_USED → require CAT33_THB or CARRIER_SPECIFIC valuation + * - NEVER residual = original − change fee + * - MPA-P / haversine / coupon-ratio are not passenger residual methods * * // DOMAIN_QUESTION: per-carrier ATPCO Cat31 data ingestion pipeline. + * // DOMAIN_QUESTION: DQ-R5 Cat 31 residual on partially used changes. */ import Decimal from 'decimal.js'; +import { domainInputRequired } from '@otaip/core'; import type { ChangeManagementInput, - ChangeManagementOutput, + ChangeManagementResult, ChangeAssessment, ChangeFeeRule, ChangeAction, @@ -56,7 +61,64 @@ function isWithinFreeChangeWindow( return hoursSinceBooking <= freeChangeHours; } -export function assessChange(input: ChangeManagementInput): ChangeManagementOutput { +function resolveResidual( + input: ChangeManagementInput, + originalFare: Decimal, +): + | { ok: true; residual: Decimal; method: 'FULLY_UNUSED' | 'CAT33_THB' | 'CARRIER_SPECIFIC' } + | { ok: false; result: ReturnType } { + const usage = input.ticket_usage ?? 'FULLY_UNUSED'; + + if (usage === 'FULLY_UNUSED') { + // Residual is the full ticketed base. Change fee is collected separately. + return { ok: true, residual: originalFare, method: 'FULLY_UNUSED' }; + } + + // PARTIALLY_USED — passenger residual = Cat 33 + THB (or carrier-specific). + // Never invent original − used, MPA-P, or haversine. + const valuation = input.residual_valuation; + if (!valuation) { + return { + ok: false, + result: domainInputRequired({ + missing: [ + 'residual_valuation', + 'cat33_thb_flown_amounts_or_carrier_residual', + ], + description: + 'Partially used ticket: passenger residual requires Cat 33 Historical Ticket Based (THB) flown valuation or a carrier-specific residual amount. Cannot use original−used, MPA-P interline proration, haversine, or coupon-ratio splits.', + references: [ + 'docs/knowledge-base/partial-refund-residual-value.md', + 'ATPCO Category 33 Re-Price Indicator A (Historical Ticket Based)', + 'GitHub issue #150', + ], + }), + }; + } + + if (valuation.method !== 'CAT33_THB' && valuation.method !== 'CARRIER_SPECIFIC') { + return { + ok: false, + result: domainInputRequired({ + missing: ['residual_valuation.method'], + description: + 'Partially used residual method must be CAT33_THB or CARRIER_SPECIFIC. MPA-P is airline interline settlement, not passenger residual.', + references: [ + 'docs/knowledge-base/partial-refund-residual-value.md', + 'GitHub issue #150', + ], + }), + }; + } + + return { + ok: true, + residual: new Decimal(valuation.unused_base_fare), + method: valuation.method, + }; +} + +export function assessChange(input: ChangeManagementInput): ChangeManagementResult { const now = currentTime(input); const orig = input.original_ticket; const req = input.requested_itinerary; @@ -74,6 +136,7 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementOutp fare_difference: '0.00', additional_collection: '0.00', residual_value: '0.00', + residual_method: 'FULLY_UNUSED', forfeited_amount: orig.base_fare, tax_difference: '0.00', total_due: '0.00', @@ -84,6 +147,12 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementOutp return { assessment }; } + const originalFare = new Decimal(orig.base_fare); + const residualResolved = resolveResidual(input, originalFare); + if (!residualResolved.ok) { + return residualResolved.result; + } + const rule = input.cat31_rules ? findMatchingRule(input.cat31_rules.rules, orig.fare_basis) : undefined; @@ -107,9 +176,9 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementOutp const effectiveChangeFee = isFreeChange || hasWaiver || isInvoluntary ? new Decimal('0.00') : changeFeeAmount; - // Fare difference - const originalFare = new Decimal(orig.base_fare); + // Fare difference vs residual available for application toward new fare const newFare = new Decimal(req.new_fare); + const residualValue = residualResolved.residual; const fareDifference = newFare.minus(originalFare); // Tax difference @@ -117,9 +186,6 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementOutp const newTax = new Decimal(req.new_tax); const taxDifference = newTax.minus(originalTax); - // Residual value: original fare minus penalty - const residualValue = originalFare.minus(effectiveChangeFee); - // Additional collection and forfeiture let additionalCollection = new Decimal('0.00'); let forfeitedAmount = new Decimal('0.00'); @@ -160,6 +226,9 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementOutp if (forfeitedAmount.greaterThan(0)) summaryParts.push(`Forfeited on downgrade: ${currency} ${forfeitedAmount.toFixed(2)}.`); if (taxDue.greaterThan(0)) summaryParts.push(`Tax adjustment: ${currency} ${taxDue.toFixed(2)}.`); + summaryParts.push( + `Residual (${residualResolved.method}): ${currency} ${residualValue.toFixed(2)}.`, + ); summaryParts.push(`Total due: ${currency} ${totalDue.toFixed(2)}.`); const assessment: ChangeAssessment = { @@ -172,6 +241,7 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementOutp fare_difference: fareDifference.toFixed(2), additional_collection: additionalCollection.toFixed(2), residual_value: residualValue.toFixed(2), + residual_method: residualResolved.method, forfeited_amount: forfeitedAmount.toFixed(2), tax_difference: taxDifference.toFixed(2), total_due: totalDue.toFixed(2), diff --git a/packages/agents/exchange/src/change-management/index.ts b/packages/agents/exchange/src/change-management/index.ts index 01292eb..a5888fe 100644 --- a/packages/agents/exchange/src/change-management/index.ts +++ b/packages/agents/exchange/src/change-management/index.ts @@ -4,20 +4,32 @@ * ATPCO Category 31 voluntary change assessment: change fees, * fare difference, residual value, waiver codes. * + * Residual: see docs/knowledge-base/partial-refund-residual-value.md + * (issue #150). Never residual = original − change fee. + * * Implements the base Agent interface from @otaip/core. */ import type { Agent, AgentInput, AgentOutput, AgentHealthStatus } from '@otaip/core'; -import { AgentNotInitializedError, AgentInputValidationError } from '@otaip/core'; -import type { ChangeManagementInput, ChangeManagementOutput } from './types.js'; +import { + AgentNotInitializedError, + AgentInputValidationError, + isDomainInputRequired, +} from '@otaip/core'; +import type { + ChangeManagementInput, + ChangeManagementResult, +} from './types.js'; import { assessChange } from './change-engine.js'; const TICKET_NUMBER_RE = /^\d{13}$/; const CARRIER_RE = /^[A-Z0-9]{2}$/; const PASSENGER_NAME_RE = /^[A-Z][A-Z' -]+\/[A-Z][A-Z' -]+$/; const RECORD_LOCATOR_RE = /^[A-Z0-9]{6}$/; +const VALID_USAGE = new Set(['FULLY_UNUSED', 'PARTIALLY_USED']); +const VALID_RESIDUAL_METHODS = new Set(['CAT33_THB', 'CARRIER_SPECIFIC']); -export class ChangeManagement implements Agent { +export class ChangeManagement implements Agent { readonly id = '5.1'; readonly name = 'Change Management'; readonly version = '0.1.0'; @@ -30,7 +42,7 @@ export class ChangeManagement implements Agent, - ): Promise> { + ): Promise> { if (!this.initialized) { throw new AgentNotInitializedError(this.id); } @@ -39,6 +51,23 @@ export class ChangeManagement implements Agent `missing: ${m}`), + ], + metadata: { + agent_id: this.id, + agent_version: this.version, + original_ticket: input.data.original_ticket.ticket_number, + status: 'DOMAIN_INPUT_REQUIRED', + }, + }; + } + const warnings: string[] = []; if (result.assessment.action === 'REJECT') { warnings.push('Change not permitted for this fare type.'); @@ -60,6 +89,7 @@ export class ChangeManagement implements Agent) { + if (isDomainInputRequired(result.data)) { + throw new Error(`unexpected DOMAIN_INPUT_REQUIRED: ${result.data.description}`); + } + return result.data; +} let agent: ExchangeReissue; @@ -33,7 +42,9 @@ function makeInput(overrides: Partial = {}): ExchangeReiss { code: 'YQ', amount: '15.00', currency: 'USD' }, ], change_fee: '200.00', - residual_value: '250.00', // 450 - 200 = 250 + // Fully unused residual = ticketed base (NOT original − change fee). Issue #150. + residual_value: '450.00', + residual_method: 'FULLY_UNUSED', new_segments: [ { carrier: 'BA', @@ -57,7 +68,7 @@ function makeInput(overrides: Partial = {}): ExchangeReiss type: 'CREDIT_CARD', card_code: 'VI', card_last_four: '4242', - amount: '505.00', + amount: '305.00', currency: 'USD', }, same_origin_destination: true, @@ -70,41 +81,70 @@ describe('Exchange/Reissue', () => { describe('Residual value application', () => { it('applies residual value to new fare', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.exchange_audit.residual_applied).toBe('250.00'); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(result.data.reissue.exchange_audit.residual_applied).toBe('450.00'); + expect(result.data.reissue.exchange_audit.residual_method).toBe('FULLY_UNUSED'); }); it('calculates additional collection (new fare - residual + change fee + new taxes)', async () => { const result = await agent.execute({ data: makeInput() }); - // new fare 550 - residual 250 = 300, + change fee 200 = 500, + new tax delta = 5 (GB increased by 5) → 505 - expect(Number(result.data.additional_collection)).toBeGreaterThan(0); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + // new fare 550 - residual 450 = 100, + change fee 200 = 300, + GB tax delta 5 → 305 + expect(result.data.additional_collection).toBe('305.00'); }); it('credit when residual exceeds new fare', async () => { const input = makeInput({ new_fare: '200.00', - residual_value: '250.00', + residual_value: '450.00', + residual_method: 'FULLY_UNUSED', change_fee: '0.00', }); const result = await agent.execute({ data: input }); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); expect(Number(result.data.credit_amount)).toBeGreaterThan(0); }); it('no credit when new fare exceeds residual', async () => { const result = await agent.execute({ data: makeInput() }); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); expect(result.data.credit_amount).toBe('0.00'); }); + + it('rejects missing residual_method at validation', async () => { + await expect( + agent.execute({ + data: makeInput({ residual_method: undefined as unknown as 'FULLY_UNUSED' }), + }), + ).rejects.toThrow('Invalid input'); + }); + + it('applies CAT33_THB residual without inventing original − fee', async () => { + const result = await agent.execute({ + data: makeInput({ + residual_value: '320.00', + residual_method: 'CAT33_THB', + change_fee: '150.00', + }), + }); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(result.data.reissue.exchange_audit.residual_applied).toBe('320.00'); + expect(result.data.reissue.exchange_audit.residual_method).toBe('CAT33_THB'); + // 550 - 320 + 150 + 5 tax = 385 + expect(result.data.additional_collection).toBe('385.00'); + }); }); describe('Tax carryforward', () => { it('carries forward taxes when same origin/destination', async () => { const result = await agent.execute({ data: makeInput({ same_origin_destination: true }) }); - expect(result.data.reissue.exchange_audit.taxes_carried_forward.length).toBeGreaterThan(0); + expect(assertReissue(result).reissue.exchange_audit.taxes_carried_forward.length).toBeGreaterThan(0); }); it('collects only tax delta for matching codes', async () => { const result = await agent.execute({ data: makeInput() }); // GB went from 85 to 90, US stayed same, YQ stayed same - const newTaxes = result.data.reissue.exchange_audit.taxes_new; + const newTaxes = assertReissue(result).reissue.exchange_audit.taxes_new; const gbDelta = newTaxes.find((t) => t.code === 'GB'); expect(gbDelta).toBeDefined(); expect(gbDelta!.amount).toBe('5.00'); @@ -112,7 +152,7 @@ describe('Exchange/Reissue', () => { it('does not carry forward taxes on different origin/destination', async () => { const result = await agent.execute({ data: makeInput({ same_origin_destination: false }) }); - expect(result.data.reissue.exchange_audit.taxes_carried_forward).toHaveLength(0); + expect(assertReissue(result).reissue.exchange_audit.taxes_carried_forward).toHaveLength(0); }); it('collects new tax codes in full', async () => { @@ -125,7 +165,7 @@ describe('Exchange/Reissue', () => { ], }); const result = await agent.execute({ data: input }); - const newTaxes = result.data.reissue.exchange_audit.taxes_new; + const newTaxes = assertReissue(result).reissue.exchange_audit.taxes_new; const xa = newTaxes.find((t) => t.code === 'XA'); expect(xa).toBeDefined(); expect(xa!.amount).toBe('10.00'); @@ -135,34 +175,34 @@ describe('Exchange/Reissue', () => { describe('New ticket record', () => { it('generates 13-digit ticket number', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.ticket_number).toMatch(/^\d{13}$/); + expect(assertReissue(result).reissue.ticket_number).toMatch(/^\d{13}$/); }); it('uses BA prefix (125)', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.ticket_number.startsWith('125')).toBe(true); + expect(assertReissue(result).reissue.ticket_number.startsWith('125')).toBe(true); }); it('sets all coupons to Open status', async () => { const result = await agent.execute({ data: makeInput() }); - for (const c of result.data.reissue.coupons) { + for (const c of assertReissue(result).reissue.coupons) { expect(c.status).toBe('O'); } }); it('sets issue date', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.issue_date).toBe('2026-04-01'); + expect(assertReissue(result).reissue.issue_date).toBe('2026-04-01'); }); it('preserves passenger name', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.passenger_name).toBe('SMITH/JOHN'); + expect(assertReissue(result).reissue.passenger_name).toBe('SMITH/JOHN'); }); it('calculates total amount correctly', async () => { const result = await agent.execute({ data: makeInput() }); - const total = Number(result.data.reissue.total_amount); + const total = Number(assertReissue(result).reissue.total_amount); expect(total).toBeGreaterThan(0); }); }); @@ -170,28 +210,28 @@ describe('Exchange/Reissue', () => { describe('Exchange audit trail', () => { it('records original ticket number', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.exchange_audit.original_ticket_number).toBe('1251234567890'); + expect(assertReissue(result).reissue.exchange_audit.original_ticket_number).toBe('1251234567890'); }); it('sets exchange indicator to E', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.exchange_audit.exchange_indicator).toBe('E'); + expect(assertReissue(result).reissue.exchange_audit.exchange_indicator).toBe('E'); }); it('records change fee paid', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.exchange_audit.change_fee_paid).toBe('200.00'); + expect(assertReissue(result).reissue.exchange_audit.change_fee_paid).toBe('200.00'); }); it('records original issue date', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.exchange_audit.original_issue_date).toBe('2026-03-01'); + expect(assertReissue(result).reissue.exchange_audit.original_issue_date).toBe('2026-03-01'); }); it('records waiver code when present', async () => { const input = makeInput({ waiver_code: 'WAIVER456' }); const result = await agent.execute({ data: input }); - expect(result.data.reissue.exchange_audit.waiver_code).toBe('WAIVER456'); + expect(assertReissue(result).reissue.exchange_audit.waiver_code).toBe('WAIVER456'); }); }); @@ -199,8 +239,8 @@ describe('Exchange/Reissue', () => { it('generates Amadeus TKTXCH command', async () => { const input = makeInput({ gds: 'AMADEUS' }); const result = await agent.execute({ data: input }); - expect(result.data.reissue.exchange_commands).toBeDefined(); - const tktxch = result.data.reissue.exchange_commands!.find( + expect(assertReissue(result).reissue.exchange_commands).toBeDefined(); + const tktxch = assertReissue(result).reissue.exchange_commands!.find( (c) => c.command_name === 'TKTXCH', ); expect(tktxch).toBeDefined(); @@ -210,7 +250,7 @@ describe('Exchange/Reissue', () => { it('generates Sabre EXCHANGE_PNR command', async () => { const input = makeInput({ gds: 'SABRE' }); const result = await agent.execute({ data: input }); - const cmd = result.data.reissue.exchange_commands!.find( + const cmd = assertReissue(result).reissue.exchange_commands!.find( (c) => c.command_name === 'EXCHANGE_PNR', ); expect(cmd).toBeDefined(); @@ -219,7 +259,7 @@ describe('Exchange/Reissue', () => { it('generates Travelport UNIVERSAL_RECORD_EXCHANGE command', async () => { const input = makeInput({ gds: 'TRAVELPORT' }); const result = await agent.execute({ data: input }); - const cmd = result.data.reissue.exchange_commands!.find( + const cmd = assertReissue(result).reissue.exchange_commands!.find( (c) => c.command_name === 'UNIVERSAL_RECORD_EXCHANGE', ); expect(cmd).toBeDefined(); @@ -227,7 +267,7 @@ describe('Exchange/Reissue', () => { it('omits commands when no GDS specified', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.reissue.exchange_commands).toBeUndefined(); + expect(assertReissue(result).reissue.exchange_commands).toBeUndefined(); }); }); @@ -238,8 +278,8 @@ describe('Exchange/Reissue', () => { gds: 'AMADEUS', }); const result = await agent.execute({ data: input }); - expect(result.data.reissue.exchange_audit.conjunction_originals).toHaveLength(2); - const conjRef = result.data.reissue.exchange_commands!.find( + expect(assertReissue(result).reissue.exchange_audit.conjunction_originals).toHaveLength(2); + const conjRef = assertReissue(result).reissue.exchange_commands!.find( (c) => c.command_name === 'CONJUNCTION_REFERENCE', ); expect(conjRef).toBeDefined(); diff --git a/packages/agents/exchange/src/exchange-reissue/index.ts b/packages/agents/exchange/src/exchange-reissue/index.ts index 5214000..0408897 100644 --- a/packages/agents/exchange/src/exchange-reissue/index.ts +++ b/packages/agents/exchange/src/exchange-reissue/index.ts @@ -4,12 +4,19 @@ * Ticket reissue with residual value, tax carryforward, * GDS exchange command stubs, conjunction ticket handling. * + * Residual method required — never invent original − change fee + * (docs/knowledge-base/partial-refund-residual-value.md, issue #150). + * * Implements the base Agent interface from @otaip/core. */ import type { Agent, AgentInput, AgentOutput, AgentHealthStatus } from '@otaip/core'; -import { AgentNotInitializedError, AgentInputValidationError } from '@otaip/core'; -import type { ExchangeReissueInput, ExchangeReissueOutput } from './types.js'; +import { + AgentNotInitializedError, + AgentInputValidationError, + isDomainInputRequired, +} from '@otaip/core'; +import type { ExchangeReissueInput, ExchangeReissueResult } from './types.js'; import { processReissue } from './reissue-engine.js'; const TICKET_NUMBER_RE = /^\d{13}$/; @@ -18,8 +25,9 @@ const AIRPORT_RE = /^[A-Z]{3}$/; const PASSENGER_NAME_RE = /^[A-Z][A-Z' -]+\/[A-Z][A-Z' -]+$/; const RECORD_LOCATOR_RE = /^[A-Z0-9]{6}$/; const VALID_GDS = new Set(['AMADEUS', 'SABRE', 'TRAVELPORT']); +const VALID_RESIDUAL_METHODS = new Set(['FULLY_UNUSED', 'CAT33_THB', 'CARRIER_SPECIFIC']); -export class ExchangeReissue implements Agent { +export class ExchangeReissue implements Agent { readonly id = '5.2'; readonly name = 'Exchange/Reissue'; readonly version = '0.1.0'; @@ -32,7 +40,7 @@ export class ExchangeReissue implements Agent, - ): Promise> { + ): Promise> { if (!this.initialized) { throw new AgentNotInitializedError(this.id); } @@ -41,6 +49,23 @@ export class ExchangeReissue implements Agent `missing: ${m}`), + ], + metadata: { + agent_id: this.id, + agent_version: this.version, + original_ticket: input.data.original_ticket_number, + status: 'DOMAIN_INPUT_REQUIRED', + }, + }; + } + const warnings: string[] = []; if (result.credit_amount !== '0.00') { warnings.push( @@ -64,6 +89,7 @@ export class ExchangeReissue implements Agent(); for (const t of input.original_taxes) { origMap.set(t.code, t); @@ -86,26 +84,21 @@ function computeTaxes(input: ExchangeReissueInput): { if (orig) { const origAmt = new Decimal(orig.amount); const newAmt = new Decimal(nt.amount); - // Carry forward the original amount carriedForward.push(orig); if (newAmt.greaterThan(origAmt)) { - // Collect the delta const delta = newAmt.minus(origAmt); newTaxes.push({ code: nt.code, amount: delta.toFixed(2), currency: nt.currency }); finalTaxes.push({ code: nt.code, amount: newAmt.toFixed(2), currency: nt.currency }); } else { - // Already paid enough finalTaxes.push({ code: nt.code, amount: origAmt.toFixed(2), currency: nt.currency }); } origMap.delete(nt.code); } else { - // New tax code — collect fully newTaxes.push(nt); finalTaxes.push(nt); } } - // Original taxes not in new itinerary — still show on ticket (carried forward) for (const [, orig] of origMap) { carriedForward.push(orig); finalTaxes.push(orig); @@ -119,7 +112,6 @@ function computeTaxes(input: ExchangeReissueInput): { }; } - // Different O/D: use new taxes entirely return { taxes: input.new_taxes, carriedForward: [], @@ -128,10 +120,6 @@ function computeTaxes(input: ExchangeReissueInput): { }; } -// --------------------------------------------------------------------------- -// GDS exchange commands -// --------------------------------------------------------------------------- - function buildExchangeCommands( input: ExchangeReissueInput, additionalCollection: string, @@ -191,7 +179,6 @@ function buildExchangeCommands( break; } - // Conjunction ticket reference if (input.conjunction_originals && input.conjunction_originals.length > 0) { commands.push({ gds: input.gds, @@ -207,11 +194,35 @@ function buildExchangeCommands( return commands; } -// --------------------------------------------------------------------------- -// Main engine -// --------------------------------------------------------------------------- +export function processReissue(input: ExchangeReissueInput): ExchangeReissueResult { + if (!input.residual_method) { + return domainInputRequired({ + missing: ['residual_method'], + description: + 'Exchange/reissue requires residual_method (FULLY_UNUSED, CAT33_THB, or CARRIER_SPECIFIC). Residual must not be invented as original − change fee. MPA-P is not passenger residual.', + references: [ + 'docs/knowledge-base/partial-refund-residual-value.md', + 'GitHub issue #150', + ], + }); + } + + if ( + input.residual_method !== 'FULLY_UNUSED' && + input.residual_method !== 'CAT33_THB' && + input.residual_method !== 'CARRIER_SPECIFIC' + ) { + return domainInputRequired({ + missing: ['residual_method'], + description: + 'residual_method must be FULLY_UNUSED, CAT33_THB, or CARRIER_SPECIFIC. Rejected: original−change-fee, MPA-P, haversine, coupon-ratio.', + references: [ + 'docs/knowledge-base/partial-refund-residual-value.md', + 'GitHub issue #150', + ], + }); + } -export function processReissue(input: ExchangeReissueInput): ExchangeReissueOutput { const prefix = resolvePrefix(input); const issueDate = input.issue_date ?? new Date().toISOString().slice(0, 10); const serial = generateSerial(input.record_locator, input.passenger_name); @@ -221,7 +232,7 @@ export function processReissue(input: ExchangeReissueInput): ExchangeReissueOutp const residualValue = new Decimal(input.residual_value); const changeFee = new Decimal(input.change_fee); - // Apply residual value first + // Apply residual as supplied — do not recompute as base − fee const afterResidual = newFare.minus(residualValue); let additionalCollection = new Decimal(0); let creditAmount = new Decimal(0); @@ -229,21 +240,17 @@ export function processReissue(input: ExchangeReissueInput): ExchangeReissueOutp if (afterResidual.greaterThan(0)) { additionalCollection = afterResidual.plus(changeFee); } else { - // Residual covers the new fare — potential credit creditAmount = afterResidual.abs(); - additionalCollection = changeFee; // Still owe the change fee + additionalCollection = changeFee; } - // Tax computation const { taxes, carriedForward, newTaxes, totalTax } = computeTaxes(input); - // Add new tax collection to additional collection const newTaxTotal = sumTaxes(newTaxes); additionalCollection = additionalCollection.plus(newTaxTotal); const totalAmount = newFare.plus(totalTax); - // Build coupons const coupons: ReissuedCoupon[] = input.new_segments.map((seg, idx) => ({ coupon_number: idx + 1, carrier: seg.carrier, @@ -258,7 +265,6 @@ export function processReissue(input: ExchangeReissueInput): ExchangeReissueOutp status: 'O' as const, })); - // Audit trail const exchangeAudit: ExchangeAuditTrail = { original_ticket_number: input.original_ticket_number, conjunction_originals: input.conjunction_originals, @@ -266,13 +272,13 @@ export function processReissue(input: ExchangeReissueInput): ExchangeReissueOutp exchange_indicator: 'E', change_fee_paid: changeFee.toFixed(2), residual_applied: residualValue.toFixed(2), + residual_method: input.residual_method, additional_collection: additionalCollection.toFixed(2), taxes_carried_forward: carriedForward, taxes_new: newTaxes, waiver_code: input.waiver_code, }; - // GDS commands const exchangeCommands = buildExchangeCommands(input, additionalCollection.toFixed(2)); const reissue: ReissueRecord = { diff --git a/packages/agents/exchange/src/exchange-reissue/types.ts b/packages/agents/exchange/src/exchange-reissue/types.ts index bdfc539..c1abecb 100644 --- a/packages/agents/exchange/src/exchange-reissue/types.ts +++ b/packages/agents/exchange/src/exchange-reissue/types.ts @@ -3,8 +3,13 @@ * * Agent 5.2: Ticket reissue with residual value, tax carryforward, * GDS exchange command stubs. + * + * Residual is consumed from Agent 5.1 / caller — never recomputed as + * original − change fee. See docs/knowledge-base/partial-refund-residual-value.md. */ +import type { DomainInputRequired, PassengerResidualMethod } from '@otaip/core'; + export type ExchangeGdsSystem = 'AMADEUS' | 'SABRE' | 'TRAVELPORT'; export interface ExchangeSegment { @@ -63,6 +68,8 @@ export interface ExchangeAuditTrail { change_fee_paid: string; /** Residual value applied (decimal string) */ residual_applied: string; + /** Residual valuation method (from input) */ + residual_method: PassengerResidualMethod; /** Additional collection (decimal string) */ additional_collection: string; /** Taxes carried forward from original ticket */ @@ -161,8 +168,16 @@ export interface ExchangeReissueInput { original_taxes: TaxItem[]; /** Change fee (decimal string, from Agent 5.1) */ change_fee: string; - /** Residual value (decimal string, from Agent 5.1) */ + /** + * Residual value (decimal string, from Agent 5.1). + * Must be valued via residual_method — never invent original − change fee. + */ residual_value: string; + /** + * How residual_value was produced. Required. + * PARTIALLY_USED tickets must use CAT33_THB or CARRIER_SPECIFIC. + */ + residual_method: PassengerResidualMethod; /** Waiver code (if applied in Agent 5.1) */ waiver_code?: string; /** New segments */ @@ -195,3 +210,6 @@ export interface ExchangeReissueOutput { /** Credit note amount if refund due (decimal string) */ credit_amount: string; } + +/** Successful reissue or fail-closed domain sentinel. */ +export type ExchangeReissueResult = ExchangeReissueOutput | DomainInputRequired; diff --git a/packages/agents/exchange/src/index.ts b/packages/agents/exchange/src/index.ts index ad46779..8dd5567 100644 --- a/packages/agents/exchange/src/index.ts +++ b/packages/agents/exchange/src/index.ts @@ -8,17 +8,20 @@ export { ChangeManagement } from './change-management/index.js'; export type { ChangeManagementInput, ChangeManagementOutput, + ChangeManagementResult, ChangeAssessment, OriginalTicketSummary, RequestedItinerary, ChangeFeeRule, ChangeAction, + TicketUsage, } from './change-management/index.js'; export { ExchangeReissue } from './exchange-reissue/index.js'; export type { ExchangeReissueInput, ExchangeReissueOutput, + ExchangeReissueResult, ReissueRecord, ReissuedCoupon, ExchangeAuditTrail, diff --git a/packages/agents/exchange/src/self-service-rebooking/index.ts b/packages/agents/exchange/src/self-service-rebooking/index.ts index 172512c..59e7c29 100644 --- a/packages/agents/exchange/src/self-service-rebooking/index.ts +++ b/packages/agents/exchange/src/self-service-rebooking/index.ts @@ -15,7 +15,11 @@ import Decimal from 'decimal.js'; import type { Agent, AgentHealthStatus, AgentInput, AgentOutput, SearchOffer } from '@otaip/core'; -import { AgentInputValidationError, AgentNotInitializedError } from '@otaip/core'; +import { + AgentInputValidationError, + AgentNotInitializedError, + isDomainInputRequired, +} from '@otaip/core'; import { AvailabilitySearch } from '@otaip/agents-search'; import type { AvailabilitySearchInput } from '@otaip/agents-search'; import { ChangeManagement } from '../change-management/index.js'; @@ -148,7 +152,7 @@ export class SelfServiceRebookingAgent }) .catch(() => null); - if (!assessment) continue; + if (!assessment || isDomainInputRequired(assessment.data)) continue; const a = assessment.data.assessment; // Skip REJECTed candidates entirely (noted in count but not returned). diff --git a/packages/agents/settlement/src/index.ts b/packages/agents/settlement/src/index.ts index d977c5a..6a02f2e 100644 --- a/packages/agents/settlement/src/index.ts +++ b/packages/agents/settlement/src/index.ts @@ -8,6 +8,7 @@ export { RefundProcessing } from './refund-processing/index.js'; export type { RefundProcessingInput, RefundProcessingOutput, + RefundProcessingResult, RefundRecord, RefundAuditTrail, RefundType, diff --git a/packages/agents/settlement/src/refund-processing/__tests__/refund-processing.test.ts b/packages/agents/settlement/src/refund-processing/__tests__/refund-processing.test.ts index 52dd062..4eec342 100644 --- a/packages/agents/settlement/src/refund-processing/__tests__/refund-processing.test.ts +++ b/packages/agents/settlement/src/refund-processing/__tests__/refund-processing.test.ts @@ -9,10 +9,20 @@ import { createRequire } from 'node:module'; import { RefundProcessing } from '../index.js'; import type { RefundProcessingInput, + RefundProcessingResult, TaxItem, CouponRefundItem, Cat33Rules, } from '../types.js'; +import type { AgentOutput } from '@otaip/core'; +import { isDomainInputRequired } from '@otaip/core'; + +function assertRefund(result: AgentOutput) { + if (isDomainInputRequired(result.data)) { + throw new Error(`unexpected DOMAIN_INPUT_REQUIRED: ${result.data.description}`); + } + return result.data; +} const require = createRequire(import.meta.url); const TEST_CAT33_RULES = require('./fixtures/test-cat33-rules.json') as Cat33Rules; @@ -59,46 +69,46 @@ describe('Refund Processing', () => { describe('Full refund', () => { it('applies penalty for restricted fare', async () => { const result = await agent.execute({ data: makeInput() }); - expect(Number(result.data.refund.penalty_applied)).toBeGreaterThan(0); + expect(Number(assertRefund(result).refund.penalty_applied)).toBeGreaterThan(0); }); it('calculates base fare refund after penalty', async () => { const result = await agent.execute({ data: makeInput() }); - const base = Number(result.data.refund.base_fare_refund); + const base = Number(assertRefund(result).refund.base_fare_refund); expect(base).toBeLessThan(450); expect(base).toBeGreaterThan(0); }); it('refunds all taxes', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.refund.tax_refund).toBe('120.00'); + expect(assertRefund(result).refund.tax_refund).toBe('120.00'); }); it('calculates total refund', async () => { const result = await agent.execute({ data: makeInput() }); - const total = Number(result.data.refund.total_refund); + const total = Number(assertRefund(result).refund.total_refund); expect(total).toBeGreaterThan(0); }); it('no penalty for Y class (full fare)', async () => { const result = await agent.execute({ data: makeInput({ fare_basis: 'YOWUS' }) }); - expect(result.data.refund.penalty_applied).toBe('0.00'); - expect(result.data.refund.base_fare_refund).toBe('450.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); }); it('no penalty for business class', async () => { const result = await agent.execute({ data: makeInput({ fare_basis: 'COWUS' }) }); - expect(result.data.refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); }); it('higher penalty for deep discount (E/G)', async () => { const result = await agent.execute({ data: makeInput({ fare_basis: 'EOWUS' }) }); - expect(Number(result.data.refund.penalty_applied)).toBe(300); + expect(Number(assertRefund(result).refund.penalty_applied)).toBe(300); }); it('lists all coupons as refunded', async () => { const result = await agent.execute({ data: makeInput() }); - expect(result.data.refund.audit.coupons_refunded).toEqual([1, 2, 3, 4]); + expect(assertRefund(result).refund.audit.coupons_refunded).toEqual([1, 2, 3, 4]); }); }); @@ -107,8 +117,8 @@ describe('Refund Processing', () => { const result = await agent.execute({ data: makeInput({ cat33_rules: undefined }), }); - expect(result.data.refund.penalty_applied).toBe('0.00'); - expect(result.data.refund.base_fare_refund).toBe('450.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); }); it('involuntary refund with no rules: penalty = 0, full refund regardless of fare basis', async () => { @@ -120,16 +130,16 @@ describe('Refund Processing', () => { is_refundable: false, }), }); - expect(result.data.refund.penalty_applied).toBe('0.00'); - expect(result.data.refund.base_fare_refund).toBe('450.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); }); it('involuntary refund with rules: penalty still waived to 0', async () => { const result = await agent.execute({ data: makeInput({ is_involuntary: true, fare_basis: 'EOWUS' }), }); - expect(result.data.refund.penalty_applied).toBe('0.00'); - expect(result.data.refund.base_fare_refund).toBe('450.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); }); }); @@ -138,40 +148,40 @@ describe('Refund Processing', () => { const result = await agent.execute({ data: makeInput({ fare_basis: 'HOWBASIC', is_refundable: false }), }); - expect(result.data.refund.base_fare_refund).toBe('0.00'); - expect(result.data.refund.tax_refund).toBe('120.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('0.00'); + expect(assertRefund(result).refund.tax_refund).toBe('120.00'); }); it('forfeits base fare for NR fares', async () => { const result = await agent.execute({ data: makeInput({ fare_basis: 'HOWNR', is_refundable: false }), }); - expect(result.data.refund.base_fare_refund).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('0.00'); }); it('taxes still refundable on non-refundable fare', async () => { const result = await agent.execute({ data: makeInput({ fare_basis: 'HOWBASIC', is_refundable: false }), }); - expect(Number(result.data.refund.tax_refund)).toBeGreaterThan(0); + expect(Number(assertRefund(result).refund.tax_refund)).toBeGreaterThan(0); }); }); describe('Tax-only refund', () => { it('refunds only taxes', async () => { const result = await agent.execute({ data: makeInput({ refund_type: 'TAX_ONLY' }) }); - expect(result.data.refund.base_fare_refund).toBe('0.00'); - expect(result.data.refund.tax_refund).toBe('120.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('0.00'); + expect(assertRefund(result).refund.tax_refund).toBe('120.00'); }); it('no penalty on tax-only refund', async () => { const result = await agent.execute({ data: makeInput({ refund_type: 'TAX_ONLY' }) }); - expect(result.data.refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); }); }); describe('Partial refund', () => { - it('prorates fare for partial refund', async () => { + it('fail-closed without partial_valuation method', async () => { const coupons: CouponRefundItem[] = [ { coupon_number: 1, status: 'O', refundable: true }, { coupon_number: 2, status: 'O', refundable: true }, @@ -179,18 +189,63 @@ describe('Refund Processing', () => { const result = await agent.execute({ data: makeInput({ refund_type: 'PARTIAL', coupons_to_refund: coupons }), }); - // 2 of 4 coupons = 50% of base fare - expect(Number(result.data.refund.audit.original_base_fare)).toBe(450); - expect(result.data.refund.audit.coupons_refunded).toEqual([1, 2]); + expect(result.data).toMatchObject({ status: 'DOMAIN_INPUT_REQUIRED' }); + if (!('missing' in result.data)) throw new Error('expected domain sentinel'); + expect(result.data.missing).toContain('partial_valuation'); + expect(result.data.description).toMatch(/THB|Historical Ticket Based/i); + expect(result.data.description).toMatch(/MPA-P/); + expect(result.confidence).toBe(0); + }); + + it('applies Cat 33 penalty to CAT33_THB unused base (not coupon-ratio)', async () => { + // Made-up: ticketed 800, THB flown 480 → unused 320; fixture HOWUS penalty 200 + const coupons: CouponRefundItem[] = [ + { coupon_number: 2, status: 'O', refundable: true }, + ]; + const result = await agent.execute({ + data: makeInput({ + base_fare: '800.00', + refund_type: 'PARTIAL', + coupons_to_refund: coupons, + total_coupons: 2, + fare_basis: 'HOWUS', + partial_valuation: { + method: 'CAT33_THB', + unused_base_fare: '320.00', + flown_base_fare: '480.00', + unused_taxes: [{ code: 'GB', amount: '55.00', currency: 'USD' }], + }, + }), + }); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(assertRefund(result).refund.audit.residual_method).toBe('CAT33_THB'); + expect(assertRefund(result).refund.audit.flown_base_fare).toBe('480.00'); + expect(assertRefund(result).refund.tax_refund).toBe('55.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('200.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('120.00'); }); - it('prorates taxes for partial refund', async () => { + it('does not invent coupon-ratio tax when THB unused taxes are supplied', async () => { const coupons: CouponRefundItem[] = [{ coupon_number: 3, status: 'O', refundable: true }]; const result = await agent.execute({ - data: makeInput({ refund_type: 'PARTIAL', coupons_to_refund: coupons }), + data: makeInput({ + refund_type: 'PARTIAL', + coupons_to_refund: coupons, + waiver_code: 'W', + partial_valuation: { + method: 'CAT33_THB', + unused_base_fare: '112.50', + unused_taxes: [ + { code: 'GB', amount: '20.00', currency: 'USD' }, + { code: 'US', amount: '5.00', currency: 'USD' }, + ], + }, + }), }); - // 1 of 4 coupons = 25% of taxes - expect(result.data.refund.tax_refund).toBe('30.00'); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + // Must use supplied unused taxes (25.00), not 25% of 120 = 30.00 coupon ratio + expect(assertRefund(result).refund.tax_refund).toBe('25.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('112.50'); }); it('only refunds coupons marked as refundable', async () => { @@ -199,30 +254,41 @@ describe('Refund Processing', () => { { coupon_number: 2, status: 'L', refundable: false }, ]; const result = await agent.execute({ - data: makeInput({ refund_type: 'PARTIAL', coupons_to_refund: coupons }), + data: makeInput({ + refund_type: 'PARTIAL', + coupons_to_refund: coupons, + waiver_code: 'W', + partial_valuation: { + method: 'CARRIER_SPECIFIC', + unused_base_fare: '200.00', + unused_taxes: [{ code: 'GB', amount: '10.00', currency: 'USD' }], + }, + }), }); - expect(result.data.refund.audit.coupons_refunded).toEqual([1]); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(assertRefund(result).refund.audit.coupons_refunded).toEqual([1]); + expect(assertRefund(result).refund.audit.residual_method).toBe('CARRIER_SPECIFIC'); }); }); describe('Waiver code', () => { it('bypasses penalty with waiver code', async () => { const result = await agent.execute({ data: makeInput({ waiver_code: 'WAIVER123' }) }); - expect(result.data.refund.penalty_applied).toBe('0.00'); - expect(result.data.refund.base_fare_refund).toBe('450.00'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); }); it('stores waiver code on record', async () => { const result = await agent.execute({ data: makeInput({ waiver_code: 'WAIVER123' }) }); - expect(result.data.refund.waiver_code).toBe('WAIVER123'); - expect(result.data.refund.audit.waiver_code).toBe('WAIVER123'); + expect(assertRefund(result).refund.waiver_code).toBe('WAIVER123'); + expect(assertRefund(result).refund.audit.waiver_code).toBe('WAIVER123'); }); }); describe('Commission recall', () => { it('recalls proportional commission on full refund', async () => { const result = await agent.execute({ data: makeInput({ waiver_code: 'W' }) }); // waiver so full base refund - expect(result.data.commission_recalled).toBe('31.50'); // full commission + expect(assertRefund(result).commission_recalled).toBe('31.50'); // full commission }); it('recalls proportional commission on partial refund', async () => { @@ -232,41 +298,47 @@ describe('Refund Processing', () => { refund_type: 'PARTIAL', coupons_to_refund: coupons, waiver_code: 'W', + partial_valuation: { + method: 'CAT33_THB', + unused_base_fare: '112.50', + unused_taxes: [{ code: 'GB', amount: '10.00', currency: 'USD' }], + }, }), }); - // 1/4 = 25% of base = 112.50 refunded → commission recall = 31.50 * 112.50/450 = 7.875 → 7.88 - expect(Number(result.data.commission_recalled)).toBeGreaterThan(0); - expect(Number(result.data.commission_recalled)).toBeLessThan(31.5); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + // unused base 112.50 → commission recall = 31.50 * 112.50/450 = 7.875 → 7.88 + expect(Number(assertRefund(result).commission_recalled)).toBeGreaterThan(0); + expect(Number(assertRefund(result).commission_recalled)).toBeLessThan(31.5); }); it('no commission recall when no commission data', async () => { const result = await agent.execute({ data: makeInput({ commission: undefined }) }); - expect(result.data.commission_recalled).toBe('0.00'); + expect(assertRefund(result).commission_recalled).toBe('0.00'); }); it('no commission recall on tax-only refund', async () => { const result = await agent.execute({ data: makeInput({ refund_type: 'TAX_ONLY' }) }); - expect(result.data.commission_recalled).toBe('0.00'); + expect(assertRefund(result).commission_recalled).toBe('0.00'); }); }); describe('BSP/ARC reporting', () => { it('generates BSP fields for BSP settlement', async () => { const result = await agent.execute({ data: makeInput({ settlement_system: 'BSP' }) }); - expect(result.data.refund.bsp_fields).toBeDefined(); - expect(result.data.refund.bsp_fields!.refund_indicator).toBe('R'); - expect(result.data.refund.bsp_fields!.original_ticket_number).toBe('1251234567890'); + expect(assertRefund(result).refund.bsp_fields).toBeDefined(); + expect(assertRefund(result).refund.bsp_fields!.refund_indicator).toBe('R'); + expect(assertRefund(result).refund.bsp_fields!.original_ticket_number).toBe('1251234567890'); }); it('generates ARC fields for ARC settlement', async () => { const result = await agent.execute({ data: makeInput({ settlement_system: 'ARC' }) }); - expect(result.data.refund.arc_fields).toBeDefined(); - expect(result.data.refund.arc_fields!.refund_type_indicator).toBe('R'); + expect(assertRefund(result).refund.arc_fields).toBeDefined(); + expect(assertRefund(result).refund.arc_fields!.refund_type_indicator).toBe('R'); }); it('no ARC fields on BSP ticket', async () => { const result = await agent.execute({ data: makeInput({ settlement_system: 'BSP' }) }); - expect(result.data.refund.arc_fields).toBeUndefined(); + expect(assertRefund(result).refund.arc_fields).toBeUndefined(); }); }); @@ -274,7 +346,7 @@ describe('Refund Processing', () => { it('records conjunction tickets in audit', async () => { const input = makeInput({ conjunction_tickets: ['1251234567891', '1251234567892'] }); const result = await agent.execute({ data: input }); - expect(result.data.refund.audit.conjunction_tickets).toHaveLength(2); + expect(assertRefund(result).refund.audit.conjunction_tickets).toHaveLength(2); }); it('rejects partial refund for conjunction set', async () => { diff --git a/packages/agents/settlement/src/refund-processing/index.ts b/packages/agents/settlement/src/refund-processing/index.ts index 79777de..70c8a86 100644 --- a/packages/agents/settlement/src/refund-processing/index.ts +++ b/packages/agents/settlement/src/refund-processing/index.ts @@ -4,12 +4,19 @@ * ATPCO Category 33 refund processing: penalty application, * commission recall, BSP/ARC reporting, conjunction ticket handling. * + * Partial residual: Cat 33 + THB — see + * docs/knowledge-base/partial-refund-residual-value.md (issue #150). + * * Implements the base Agent interface from @otaip/core. */ import type { Agent, AgentInput, AgentOutput, AgentHealthStatus } from '@otaip/core'; -import { AgentNotInitializedError, AgentInputValidationError } from '@otaip/core'; -import type { RefundProcessingInput, RefundProcessingOutput } from './types.js'; +import { + AgentNotInitializedError, + AgentInputValidationError, + isDomainInputRequired, +} from '@otaip/core'; +import type { RefundProcessingInput, RefundProcessingResult } from './types.js'; import { processRefund } from './refund-engine.js'; const TICKET_NUMBER_RE = /^\d{13}$/; @@ -18,8 +25,9 @@ const PASSENGER_NAME_RE = /^[A-Z][A-Z' -]+\/[A-Z][A-Z' -]+$/; const RECORD_LOCATOR_RE = /^[A-Z0-9]{6}$/; const VALID_REFUND_TYPES = new Set(['FULL', 'PARTIAL', 'TAX_ONLY']); const VALID_SETTLEMENT = new Set(['BSP', 'ARC']); +const VALID_PARTIAL_METHODS = new Set(['CAT33_THB', 'CARRIER_SPECIFIC']); -export class RefundProcessing implements Agent { +export class RefundProcessing implements Agent { readonly id = '6.1'; readonly name = 'Refund Processing'; readonly version = '0.1.0'; @@ -32,7 +40,7 @@ export class RefundProcessing implements Agent, - ): Promise> { + ): Promise> { if (!this.initialized) { throw new AgentNotInitializedError(this.id); } @@ -41,6 +49,24 @@ export class RefundProcessing implements Agent `missing: ${m}`), + ], + metadata: { + agent_id: this.id, + agent_version: this.version, + ticket_number: input.data.ticket_number, + refund_type: input.data.refund_type, + status: 'DOMAIN_INPUT_REQUIRED', + }, + }; + } + const warnings: string[] = []; if (result.refund.penalty_applied !== '0.00') { warnings.push( @@ -73,6 +99,7 @@ export class RefundProcessing implements Agent i + 1); @@ -142,38 +155,53 @@ export function processRefund(input: RefundProcessingInput): RefundProcessingOut } case 'PARTIAL': { - // Partial refund — specific coupons only. - const refundableCoupons = (input.coupons_to_refund ?? []).filter((c) => c.refundable); - const couponRatio = - input.total_coupons > 0 - ? new Decimal(refundableCoupons.length).dividedBy(input.total_coupons) - : new Decimal(0); - - const proratedBase = originalBase.times(couponRatio).toDecimalPlaces(2); - - if (isInvoluntary || hasWaiver) { - baseFareRefund = proratedBase; - penalty = new Decimal(0); - } else if (rule?.forfeit_base_fare && !input.is_refundable) { - baseFareRefund = new Decimal(0); - penalty = new Decimal(0); - } else if (rule) { - const penaltyAmount = new Decimal(rule.penalty_amount); - penalty = Decimal.min(penaltyAmount, proratedBase); - baseFareRefund = proratedBase.minus(penalty); - } else { - // No rule supplied → ATPCO default: no penalty on prorated portion. - baseFareRefund = proratedBase; - penalty = new Decimal(0); + // Fail closed without an explicit passenger valuation method. + // Passenger residual = Cat 33 + THB (or carrier-specific). Not MPA-P. + const valuation = input.partial_valuation; + if (!valuation) { + return domainInputRequired({ + missing: [ + 'partial_valuation', + 'cat33_thb_unused_base_and_taxes_or_carrier_residual', + ], + description: + 'PARTIAL refund requires Cat 33 Historical Ticket Based (THB) unused base/taxes or a carrier-specific residual valuation. Rejected: original−used without method, coupon-count ratio, MPA-P interline proration, and haversine through-fare splits.', + references: [ + 'docs/knowledge-base/partial-refund-residual-value.md', + 'ATPCO Category 33 Re-Price Indicator A (Historical Ticket Based)', + 'GitHub issue #150', + ], + }); + } + + if (valuation.method !== 'CAT33_THB' && valuation.method !== 'CARRIER_SPECIFIC') { + return domainInputRequired({ + missing: ['partial_valuation.method'], + description: + 'partial_valuation.method must be CAT33_THB or CARRIER_SPECIFIC. MPA-P is airline interline settlement, not passenger residual.', + references: [ + 'docs/knowledge-base/partial-refund-residual-value.md', + 'GitHub issue #150', + ], + }); } - // Prorate taxes - taxRefund = originalTax.times(couponRatio).toDecimalPlaces(2); - taxBreakdown = input.taxes.map((t) => ({ - code: t.code, - amount: new Decimal(t.amount).times(couponRatio).toDecimalPlaces(2).toFixed(2), - currency: t.currency, - })); + const unusedBase = new Decimal(valuation.unused_base_fare); + const applied = applyBasePenalty( + unusedBase, + rule, + isInvoluntary, + hasWaiver, + input.is_refundable, + ); + baseFareRefund = applied.baseFareRefund; + penalty = applied.penalty; + taxBreakdown = valuation.unused_taxes; + taxRefund = sumTaxes(taxBreakdown); + residualMethod = valuation.method; + flownBaseFare = valuation.flown_base_fare; + + const refundableCoupons = (input.coupons_to_refund ?? []).filter((c) => c.refundable); couponsRefunded = refundableCoupons.map((c) => c.coupon_number); break; } @@ -199,7 +227,9 @@ export function processRefund(input: RefundProcessingInput): RefundProcessingOut // Audit trail const audit: RefundAuditTrail = { original_ticket_number: input.ticket_number, - ...(input.conjunction_tickets !== undefined ? { conjunction_tickets: input.conjunction_tickets } : {}), + ...(input.conjunction_tickets !== undefined + ? { conjunction_tickets: input.conjunction_tickets } + : {}), refund_type: input.refund_type, original_base_fare: originalBase.toFixed(2), original_total_tax: originalTax.toFixed(2), @@ -209,6 +239,8 @@ export function processRefund(input: RefundProcessingInput): RefundProcessingOut tax_refunded: taxRefund.toFixed(2), commission_recalled: commissionRecalled.toFixed(2), coupons_refunded: couponsRefunded, + ...(residualMethod !== undefined ? { residual_method: residualMethod } : {}), + ...(flownBaseFare !== undefined ? { flown_base_fare: flownBaseFare } : {}), }; // Settlement fields diff --git a/packages/agents/settlement/src/refund-processing/types.ts b/packages/agents/settlement/src/refund-processing/types.ts index 2d37a1b..4486960 100644 --- a/packages/agents/settlement/src/refund-processing/types.ts +++ b/packages/agents/settlement/src/refund-processing/types.ts @@ -3,8 +3,14 @@ * * Agent 6.1: ATPCO Category 33 refund processing with penalty application, * commission recall, BSP/ARC reporting, conjunction ticket handling. + * + * Partial refunds: Cat 33 + THB (Historical Ticket Based) or carrier-specific + * valuation — never original−used / coupon-ratio / MPA-P. See + * docs/knowledge-base/partial-refund-residual-value.md (issue #150). */ +import type { DomainInputRequired, PassengerPartialValuation } from '@otaip/core'; + export type RefundType = 'FULL' | 'PARTIAL' | 'TAX_ONLY'; export type SettlementSystem = 'BSP' | 'ARC'; @@ -121,6 +127,10 @@ export interface RefundAuditTrail { commission_recalled: string; /** Coupons refunded */ coupons_refunded: number[]; + /** Valuation method for PARTIAL (when applicable) */ + residual_method?: 'CAT33_THB' | 'CARRIER_SPECIFIC'; + /** Flown base used in valuation audit (when supplied) */ + flown_base_fare?: string; } export interface RefundRecord { @@ -198,6 +208,12 @@ export interface RefundProcessingInput { * full refund). The engine never invents a penalty amount. */ cat33_rules?: Cat33Rules; + /** + * Required for PARTIAL refunds. Caller-supplied unused base/taxes after + * Cat 33 THB (Historical Ticket Based) or carrier-specific valuation. + * Without this, the engine returns DOMAIN_INPUT_REQUIRED (fail closed). + */ + partial_valuation?: PassengerPartialValuation; } export interface RefundProcessingOutput { @@ -208,3 +224,6 @@ export interface RefundProcessingOutput { /** Commission recalled (decimal string) */ commission_recalled: string; } + +/** Successful refund or fail-closed domain sentinel. */ +export type RefundProcessingResult = RefundProcessingOutput | DomainInputRequired; diff --git a/packages/core/src/domain/index.ts b/packages/core/src/domain/index.ts index c6a9cc2..44f7c05 100644 --- a/packages/core/src/domain/index.ts +++ b/packages/core/src/domain/index.ts @@ -1,2 +1,11 @@ -export type { DomainInputRequired } from './types.js'; -export { domainInputRequired, isDomainInputRequired } from './types.js'; +export type { + DomainInputRequired, + PassengerResidualMethod, + PassengerPartialValuation, + RejectedPassengerResidualMethod, +} from './types.js'; +export { + domainInputRequired, + isDomainInputRequired, + REJECTED_PASSENGER_RESIDUAL_METHODS, +} from './types.js'; diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index 721baf5..c4da0e5 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -36,3 +36,46 @@ export function isDomainInputRequired( (value as { status?: unknown }).status === 'DOMAIN_INPUT_REQUIRED' ); } + +/** + * Passenger residual / partial-refund valuation method (Agents 5.1 / 5.2 / 6.1). + * + * See `docs/knowledge-base/partial-refund-residual-value.md`. + * + * - FULLY_UNUSED — no flown coupons; residual/refundable base = ticketed base + * - CAT33_THB — Historical Ticket Based (Cat 33 Re-Price Indicator A) flown valuation + * - CARRIER_SPECIFIC — carrier residual formula amounts supplied by caller + * + * MPA-P / TPM / haversine / coupon-ratio / original−used without method are + * explicitly rejected and must never appear as a successful method. + */ +export type PassengerResidualMethod = + | 'FULLY_UNUSED' + | 'CAT33_THB' + | 'CARRIER_SPECIFIC'; + +/** Methods that engines must refuse (issue #150). */ +export const REJECTED_PASSENGER_RESIDUAL_METHODS = [ + 'ORIGINAL_MINUS_USED', + 'ORIGINAL_MINUS_CHANGE_FEE', + 'MPA_P', + 'HAVERSINE_THROUGH_FARE_SPLIT', + 'COUPON_COUNT_RATIO', +] as const; + +export type RejectedPassengerResidualMethod = + (typeof REJECTED_PASSENGER_RESIDUAL_METHODS)[number]; + +/** + * Caller-supplied unused value after an explicit valuation method. + * Engines apply Cat 31/33 penalties to these amounts — they do not invent them. + */ +export interface PassengerPartialValuation { + method: Exclude; + /** Unused base fare after THB / carrier valuation (decimal string). */ + unused_base_fare: string; + /** Optional audit: flown base from THB / carrier valuation. */ + flown_base_fare?: string; + /** Unused taxes by code — required for partial money paths. */ + unused_taxes: Array<{ code: string; amount: string; currency: string }>; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2788d3b..acf1634 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,8 +52,17 @@ export { fetchWithRetry, fetchOnce } from './http/index.js'; export type { FetchOnceOptions } from './http/index.js'; export type { FetchWithRetryOptions } from './http/index.js'; -export type { DomainInputRequired } from './domain/index.js'; -export { domainInputRequired, isDomainInputRequired } from './domain/index.js'; +export type { + DomainInputRequired, + PassengerResidualMethod, + PassengerPartialValuation, + RejectedPassengerResidualMethod, +} from './domain/index.js'; +export { + domainInputRequired, + isDomainInputRequired, + REJECTED_PASSENGER_RESIDUAL_METHODS, +} from './domain/index.js'; export { EU261_BANDS, From f12e6df1805ad72360bdb5177bbf26729f83cb00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:05:58 +0000 Subject: [PATCH 2/5] test(5.1): fix assertAssessment helper recursion in residual tests Co-authored-by: telivity-otaip --- .../src/change-management/__tests__/change-management.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts b/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts index 8d85c89..e5a57a7 100644 --- a/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts +++ b/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts @@ -26,7 +26,7 @@ function assertAssessment(result: AgentOutput) { if (isDomainInputRequired(result.data)) { throw new Error(`unexpected DOMAIN_INPUT_REQUIRED: ${result.data.description}`); } - return assertAssessment(result); + return result.data.assessment; } const require = createRequire(import.meta.url); From 30b52dee8740b9b59d99c7c330790eeb07111a6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:13:35 +0000 Subject: [PATCH 3/5] fix(domain): THB = Ticketing Handbook; Cat 33 no-match = free (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct invented “Historical Ticket Based” expansion — THB is the IATA Ticketing Handbook (cite by name only). Rename residual method to PUBLISHED_FARE. Align with #153: no Cat 33 / unmatched provision → free penalty; bare waiver_code and missing proration method ≠ free (fail closed). Co-authored-by: telivity-otaip --- CHANGELOG.md | 6 +- CLAUDE.md | 4 +- docs/agents/stage-5-exchange.md | 7 +- docs/agents/stage-6-settlement.md | 6 +- .../partial-refund-residual-value.md | 119 ++++++++++++------ .../__tests__/change-management.test.ts | 30 ++--- .../src/change-management/change-engine.ts | 52 +++++--- .../exchange/src/change-management/index.ts | 4 +- .../exchange/src/change-management/types.ts | 10 +- .../__tests__/exchange-reissue.test.ts | 6 +- .../exchange/src/exchange-reissue/index.ts | 4 +- .../src/exchange-reissue/reissue-engine.ts | 6 +- .../exchange/src/exchange-reissue/types.ts | 2 +- .../__tests__/refund-processing.test.ts | 95 ++++++++++---- .../settlement/src/refund-processing/index.ts | 4 +- .../src/refund-processing/refund-engine.ts | 39 +++--- .../settlement/src/refund-processing/types.ts | 12 +- packages/core/src/domain/types.ts | 18 ++- 18 files changed, 270 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69dc8d9..fb04a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,10 @@ ### Domain — partial refund / residual value ([#150](https://github.com/TelivityAI/otaip/issues/150)) -- New KB: `docs/knowledge-base/partial-refund-residual-value.md` — passenger residual = **Cat 33 + THB** (Historical Ticket Based); **MPA-P is interline only**; reject original−used / original−change-fee / coupon-ratio / haversine; conjunction all-or-none; worked examples; fail-closed interfaces. -- Agents **5.1 / 5.2 / 6.1** require explicit residual/partial valuation methods; return `DOMAIN_INPUT_REQUIRED` when method unspecified. +- New KB: `docs/knowledge-base/partial-refund-residual-value.md` — passenger residual = **Cat 33 + IATA Ticketing Handbook (THB)**; **MPA-P is interline only**; reject original−used / original−change-fee / coupon-ratio / haversine; conjunction all-or-none; worked examples. +- **THB** = IATA Ticketing Handbook (cite by name only — never invent alternate acronym expansions). +- Same split as [#153](https://github.com/TelivityAI/otaip/pull/153): **no Cat 33 data / unmatched provision → free** refund; **bare waiver / unspecified proration method ≠ free** (fail closed). +- Agents **5.1 / 5.2 / 6.1**: explicit `PUBLISHED_FARE` | `CARRIER_SPECIFIC` valuation on partials; `DOMAIN_INPUT_REQUIRED` when method unspecified or bare `waiver_code` without typed effect. - Removed invented residual = original − change fee (5.1) and coupon-ratio partial proration (6.1). - `@otaip/core`: export `PassengerResidualMethod`, `PassengerPartialValuation`, `REJECTED_PASSENGER_RESIDUAL_METHODS`. diff --git a/CLAUDE.md b/CLAUDE.md index 212e40c..aa5c3e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | "The US DOT 24-hour rule means free cancellation within 24 hours of booking" | STOP. The rule requires EITHER 24hr free cancellation OR 24hr fare hold (carrier chooses). Only applies 7+ days before departure. Only applies to US flights. Implementation varies by carrier and booking channel. Check KB for specifics. | | "Waiver codes bypass the standard penalty — I'll just skip the fee calculation when a waiver is present" | STOP. Waivers have different types with different effects (reduce, eliminate, change rebooking class). Do not treat all waivers as "skip penalty." Surface as DOMAIN_QUESTION: what is the waiver type and its specific effect? | | "BASIC economy / non-refundable fares simply cannot be changed" | STOP. This varies by carrier, market, and regulation. Some carriers allow changes with penalty, some allow same-day standby. EU regulations may override carrier restrictions. Check KB before implementing blanket restrictions. | -| "Residual value for a partially flown ticket is the original fare minus the flown portion" | STOP. Passenger residual = Cat 33 + THB (Historical Ticket Based) when filed, else carrier-specific amounts. MPA-P is interline settlement — not pax residual. Never haversine-split a through fare. See `docs/knowledge-base/partial-refund-residual-value.md`. | +| "Residual value for a partially flown ticket is the original fare minus the flown portion" | STOP. Passenger residual = Cat 33 + IATA Ticketing Handbook (THB) practice; unused value via published fare for flown sectors or carrier-specific amounts. MPA-P is interline — not pax residual. Never haversine-split a through fare. See `docs/knowledge-base/partial-refund-residual-value.md`. | ### Agent 5.2 — Exchange/Reissue Agent @@ -161,7 +161,7 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | Rationalization | Required response | |---|---| | "ATPCO Category 33 refund rules follow the same penalty structure as Cat 31" | STOP. Cat 33 and Cat 31 are independent categories with separate penalty structures. A fare can be non-refundable but changeable, or vice versa. Check KB for Cat 33 rules specifically. | -| "Partial refunds are calculated by subtracting the used portion from the original fare" | STOP. Reject original−used without a method. Passenger path = Cat 33 + THB (Historical Ticket Based) unused valuation, or carrier-specific amounts. Never coupon-ratio, haversine, or MPA-P (interline). Fail closed when method unspecified. See `docs/knowledge-base/partial-refund-residual-value.md`. | +| "Partial refunds are calculated by subtracting the used portion from the original fare" | STOP. Reject original−used without a method. Passenger path = Cat 33 + IATA Ticketing Handbook (THB); unused via `PUBLISHED_FARE` or `CARRIER_SPECIFIC`. No Cat 33 data = free penalty; bare waiver / missing method ≠ free. Never coupon-ratio, haversine, or MPA-P. See `docs/knowledge-base/partial-refund-residual-value.md`. | | "Commission recall on refund is straightforward — reverse the original commission" | STOP. Commission recall rules vary by carrier agreement. Some allow retention, some recall 100%, some proportionally. Net remit tickets have different rules. Check KB for carrier-specific terms. | | "Conjunction tickets — I'll process the refund on the specific coupon that's being refunded" | STOP. Conjunction ticket refunds are ALL-or-NONE. Cannot refund individual coupons independently. If some segments cancelled, it becomes a partial refund across the full conjunction fare. Check KB for conjunction refund handling. | | "For BSP reporting, I'll use the refund transaction type with the refund amount" | STOP. BSP refund reporting requires specific fields: original ticket reference, refund amount, penalty deducted, commission recall, tax breakdown. ARC format differs from BSP. Check KB for the market-specific format. | diff --git a/docs/agents/stage-5-exchange.md b/docs/agents/stage-5-exchange.md index f7be8d5..3515119 100644 --- a/docs/agents/stage-5-exchange.md +++ b/docs/agents/stage-5-exchange.md @@ -20,11 +20,12 @@ ATPCO Category 31 voluntary change assessment: change fees, fare difference, res - `waiver_code?` -- airline-provided waiver code - `current_datetime?` -- ISO datetime - `ticket_usage?` -- `FULLY_UNUSED` (default) | `PARTIALLY_USED` -- `residual_valuation?` -- required when partially used: `CAT33_THB` or `CARRIER_SPECIFIC` unused base/taxes (see `docs/knowledge-base/partial-refund-residual-value.md`) +- `residual_valuation?` -- required when partially used: `PUBLISHED_FARE` or `CARRIER_SPECIFIC` unused base/taxes (see `docs/knowledge-base/partial-refund-residual-value.md`) +- Bare `waiver_code` without typed effect → fail closed (≠ free; see waiver typology / #138) **Output (`ChangeManagementResult`):** - Success: `assessment` -- action (`REISSUE | REBOOK | REJECT`), change fee, fare difference, additional collection, residual value + residual_method, forfeited amount, tax difference, total due, free change flag, summary -- Or `DOMAIN_INPUT_REQUIRED` when partially used without an explicit residual method (fail closed; never original − change fee / MPA-P) +- Or `DOMAIN_INPUT_REQUIRED` when partially used without an explicit residual method, or bare `waiver_code` (never original − change fee / MPA-P; Cat 31 no-match remains free) --- @@ -40,7 +41,7 @@ Ticket reissue with residual value application, tax carryforward, conjunction ti - `original_ticket_number`, `conjunction_originals?`, `original_issue_date` - `issuing_carrier`, `passenger_name`, `record_locator` - `original_base_fare`, `original_taxes` -- from original ticket -- `change_fee`, `residual_value`, `residual_method` -- from Agent 5.1 (`FULLY_UNUSED` | `CAT33_THB` | `CARRIER_SPECIFIC`) +- `change_fee`, `residual_value`, `residual_method` -- from Agent 5.1 (`FULLY_UNUSED` | `PUBLISHED_FARE` | `CARRIER_SPECIFIC`) - `waiver_code?` -- from Agent 5.1 - `new_segments` -- new flight segments - `new_fare`, `new_fare_currency`, `new_taxes`, `fare_calculation` diff --git a/docs/agents/stage-6-settlement.md b/docs/agents/stage-6-settlement.md index 2f51bf9..1f1b6cd 100644 --- a/docs/agents/stage-6-settlement.md +++ b/docs/agents/stage-6-settlement.md @@ -20,13 +20,13 @@ ATPCO Category 33 refund processing: penalty application, commission recall, BSP - `base_fare`, `base_fare_currency`, `taxes`, `commission?` - `refund_type` -- `'FULL' | 'PARTIAL' | 'TAX_ONLY'` - `coupons_to_refund?` -- specific coupons (for partial) -- `partial_valuation?` -- **required for PARTIAL**: `CAT33_THB` or `CARRIER_SPECIFIC` unused base + unused taxes (see `docs/knowledge-base/partial-refund-residual-value.md`) +- `partial_valuation?` -- **required for PARTIAL**: `PUBLISHED_FARE` or `CARRIER_SPECIFIC` unused base + unused taxes (see `docs/knowledge-base/partial-refund-residual-value.md`) - `total_coupons`, `waiver_code?`, `fare_basis`, `is_refundable` -- `settlement_system` -- `'BSP' | 'ARC'` +- Bare `waiver_code` without typed effect → fail closed (≠ free; #138/#153). No Cat 33 / unmatched → free penalty. **Output (`RefundProcessingResult`):** - Success: `refund` -- penalty applied, base fare refund, tax refund, tax breakdown, commission recalled, net refund, BSP/ARC reporting fields, audit trail; `net_refund_amount`, `commission_recalled` -- Or `DOMAIN_INPUT_REQUIRED` for PARTIAL without valuation method (fail closed; never original − used / coupon-ratio / MPA-P) +- Or `DOMAIN_INPUT_REQUIRED` for PARTIAL without valuation method, or bare `waiver_code` (never original − used / coupon-ratio / MPA-P) - Conjunction: PARTIAL rejected (all-or-none) --- diff --git a/docs/knowledge-base/partial-refund-residual-value.md b/docs/knowledge-base/partial-refund-residual-value.md index 2eef53b..2b7b768 100644 --- a/docs/knowledge-base/partial-refund-residual-value.md +++ b/docs/knowledge-base/partial-refund-residual-value.md @@ -2,30 +2,56 @@ Source: GitHub issue #150 (TMC / revenue-accounting domain input). Authoritative for Agents **5.1**, **5.2**, and **6.1**. Anything missing here is an open `DOMAIN_QUESTION` — never invent. +Related: Cat 31/33 no-match defaults and waiver typology — issue [#138](https://github.com/TelivityAI/otaip/issues/138) / PR [#153](https://github.com/TelivityAI/otaip/pull/153) (`docs/knowledge-base/waiver-typology.md` when merged). + ## Scope This document covers **passenger-facing** residual value and partial refunds on air tickets (voluntary change residual for reissue; Cat 33 voluntary refund of unused value after partial use). It does **not** cover airline-to-airline interline revenue allocation. +## Authoritative sources (cite; do not invent) + +| Source | Role for passenger residual | +| --- | --- | +| **ATPCO Category 33** (public category definition) | Conditions and applicable charges for voluntary refunds. **No Cat 33 data, or no applicable provision matched → refund permitted at no charge** (ATPCO public default). Not fail-closed. Not a DOMAIN_QUESTION. | +| **THB — IATA Ticketing Handbook** | Industry ticketing procedures for passenger documents / residual handling. **Cite by name only — never commit paid handbook text or extracts into this repository.** | +| **MPA-P / Prorate Manual** | **Out of scope** for passenger residual — airline interline settlement only. | + +Public Cat 33 wording (ATPCO): Category 33 defines the conditions and applicable charges under which voluntary refunds are permitted. In the absence of voluntary refunds data or when no applicable provision is matched, a refund is permitted at no charge and with no restrictions for that fare. + ## Forbidden arithmetic (reject as a general rule) | Invented formula | Why it is wrong | | --- | --- | | `residual = original − change fee` | Change fee is a **separate** Cat 31/16 collection. Residual is unused **ticketed fare value**, not fare-minus-penalty. | -| `partial refund = original − "used portion"` without a method | "Used portion" is undefined until a valuation method prices the flown sectors. | +| `partial refund = original − "used portion"` without a method | "Used portion" is undefined until an explicit valuation method prices the flown sectors. | | Coupon-count ratio (`base × refundable_coupons / total_coupons`) | Equal coupon split invents value; through fares are not linear in coupon count. | | Haversine / great-circle split of a through fare | Distance approximation is not a published fare and is not a filed residual method. | -| MPA-P / TPM / PFM tables applied to the passenger | **MPA-P is airline interline settlement**, not passenger residual. Do not reuse interline proration for pax refunds. | +| MPA-P / TPM / PFM tables applied to the passenger | **MPA-P is airline interline settlement**, not passenger residual. | ## What passenger residual actually is -**Passenger refund / residual = ATPCO Category 33 (penalty + eligibility) + THB valuation of the flown portion when the ticket is partially used.** +**Passenger refund / residual is governed by ATPCO Category 33 conditions/charges, applied with ticketing practice from the IATA Ticketing Handbook (THB).** -- **Cat 33** — voluntary refund rules: whether refund is permitted, penalty amount / forfeit flags, and the filed **re-price indicator** for how to value flown sectors. -- **THB** — **Historical Ticket Based** fares (ATPCO Cat 33 Re-Price Indicator **A**): re-price the **flown** sectors using fares in effect on the **original ticket issue date** (historical ticket date), subject to the filing’s tariff/rule/fare-class constraints. Indicator **B** (Historical Travel Commencement Based) is a different filed choice — do not silently substitute THB for B. +That means: -When the filing requires a carrier-specific formula instead of (or after) THB and that formula is not supplied as authoritative input → **fail closed** (`DOMAIN_INPUT_REQUIRED`). Do not invent TPM tables, MPA-P splits, or haversine. +1. **Penalty / eligibility** — from Cat 33 (filed provision, or ATPCO **no-match free** default). +2. **Unused fare value on partial use** — from an **explicit** valuation method supplied by the caller (`PUBLISHED_FARE` for flown sectors, or `CARRIER_SPECIFIC`). Engines do not invent flown amounts, MPA-P splits, or handbook extracts. + +**THB is the IATA Ticketing Handbook.** Cite it by name only. Do not invent alternate expansions of the acronym, and do not commit paid handbook text. + +## Fail-closed vs free — same split as #153 / waiver typology + +| Situation | Correct engine behavior | +| --- | --- | +| No Cat 33 data, or no provision matches | ATPCO public default: **free** refund (no Cat 33 charge). **Not** fail-closed. **Not** a DOMAIN_QUESTION. | +| Filed Cat 33 provision matches | Apply **filed** penalty / forfeit to the unused base | +| PARTIAL / partially used without `partial_valuation` / residual method | **Fail closed** (`DOMAIN_INPUT_REQUIRED`) — unspecified method ≠ free | +| `original − used` / coupon-ratio / haversine / MPA-P asserted as method | **Fail closed** — rejected methods | +| Bare `waiver_code` without typed `waiver_effect` | **≠ free**. Fail closed / DOMAIN_QUESTION — see issue #138 / PR #153 waiver typology. Code alone does not skip penalty | + +Do **not** collapse “missing Cat 33” into “missing proration method.” They are different branches. ## Decision tree @@ -34,38 +60,37 @@ Is the ticket fully unused (no flown coupons)? ├─ YES → Residual / refundable base = full ticketed base fare. │ Apply Cat 31 change fee (5.1) or Cat 33 refund penalty (6.1) SEPARATELY. │ Do NOT set residual = base − fee. +│ Cat 33 absent / unmatched → penalty 0 (free). Bare waiver ≠ free. │ └─ NO (partially used) - ├─ Cat 33 (or carrier residual filing) specifies THB (Re-Price Indicator A)? - │ ├─ YES, and caller supplies THB-priced flown / unused amounts - │ │ → unused_base = ticketed_base − THB_flown_base (amounts from historical pricing) - │ │ → apply Cat 33 penalty to unused_base (6.1) - │ │ → for reissue residual (5.1/5.2), unused residual is the unused fare value - │ │ after any Cat 31/33 interactions the filing requires - │ │ → taxes: value unused taxes per tax code rules (see Tax handling) - │ └─ YES, but THB amounts not supplied → DOMAIN_INPUT_REQUIRED + ├─ Explicit method PUBLISHED_FARE and caller supplies unused (+ flown) amounts? + │ ├─ YES → unused_base from caller; apply Cat 33 penalty to unused_base + │ │ (or free if no Cat 33 / no match) + │ └─ amounts missing → DOMAIN_INPUT_REQUIRED │ - ├─ Filing specifies CARRIER_SPECIFIC residual / proration method? - │ ├─ YES, and caller supplies the carrier-valued unused amounts → use as filed - │ └─ YES, but amounts / method details missing → DOMAIN_INPUT_REQUIRED + ├─ Explicit method CARRIER_SPECIFIC and caller supplies carrier-valued amounts? + │ ├─ YES → use as filed; apply Cat 33 penalty as above + │ └─ amounts / method details missing → DOMAIN_INPUT_REQUIRED │ └─ Method unspecified / only “original − used” asserted → DOMAIN_INPUT_REQUIRED (fail closed) + NOTE: this is NOT the Cat 33 no-match free path ``` ### Explicitly out of scope for passenger agents - **MPA-P** (Multilateral Proration Agreement – Passenger) and Prorate Manual / PFM data — **interline settlement between airlines**, not passenger residual. - Invented mileage tables, haversine splits, or equal coupon ratios. +- Committing paid THB / ATPCO DA text into the repo. ## Tax handling on partials Taxes are **not** the same as base fare residual. -1. Identify which tax amounts remain **unused** after the flown sectors (per tax code: airport vs enroute vs carrier-imposed YQ/YR, etc.). +1. Identify which tax amounts remain **unused** after the flown sectors (per tax code). 2. Cat 33 / regulatory rules may require full refund of some taxes even when base is reduced; others follow unused-sector valuation. -3. Do **not** invent a coupon-ratio tax split. Caller must supply unused tax breakdown (or a domain-approved tax valuation result) when method is THB or carrier-specific. -4. Open: per-code carryforward / refundability matrices remain `DOMAIN_QUESTION` until carrier/tax tables are ingested. +3. Do **not** invent a coupon-ratio tax split. Caller must supply unused tax breakdown with `PUBLISHED_FARE` or `CARRIER_SPECIFIC`. +4. Open: per-code matrices remain `DOMAIN_QUESTION` until carrier/tax tables are ingested. ## Conjunction tickets (Agent 6.1) @@ -75,21 +100,19 @@ Conjunction sets are **all-or-none** for refund: you cannot partially refund one Exchange (5.2) must reference **all** ticket numbers in a conjunction set when exchanging; residual spans the conjunction fare, not a single coupon. -## Proposed agent interfaces (fail closed) - -Callers must declare a method. Engines never invent amounts. +## Proposed agent interfaces (fail closed on method; free on Cat 33 no-match) ```ts type PassengerResidualMethod = | 'FULLY_UNUSED' // no flown coupons - | 'CAT33_THB' // Historical Ticket Based flown valuation supplied - | 'CARRIER_SPECIFIC'; // carrier residual amounts supplied + | 'PUBLISHED_FARE' // unused/flown amounts from published fare for flown sectors + | 'CARRIER_SPECIFIC'; // carrier residual amounts supplied (not MPA-P) interface PartialValuationInput { method: Exclude; - /** Unused base after THB / carrier valuation (decimal string). */ + /** Unused base after published-fare / carrier valuation (decimal string). */ unused_base_fare: string; - /** Optional audit: THB / carrier value of flown base. */ + /** Optional audit: flown base from that valuation. */ flown_base_fare?: string; /** Unused taxes by code — required for PARTIAL money paths. */ unused_taxes: Array<{ code: string; amount: string; currency: string }>; @@ -98,11 +121,11 @@ interface PartialValuationInput { - **5.1** — fully unused: `residual_value = original base`; change fee separate. Partially used: require `residual_valuation`; else `DOMAIN_INPUT_REQUIRED`. - **5.2** — applies caller/5.1 residual; requires `residual_method`; does not recompute residual from `original − change_fee`. -- **6.1** — `PARTIAL` requires `partial_valuation`; else `DOMAIN_INPUT_REQUIRED`. No coupon-ratio fallback. +- **6.1** — `PARTIAL` requires `partial_valuation`; else `DOMAIN_INPUT_REQUIRED`. Cat 33 absent/unmatched → **no penalty** on the unused base (free). No coupon-ratio fallback. Bare `waiver_code` ≠ free. ## Worked examples (made-up amounts) -Amounts below are **illustrative only** — not live tickets, not filed carrier data. +Amounts below are **illustrative only** — not live tickets, not filed carrier data, not handbook extracts. ### Example A — Fully unused one-way (reissue residual) @@ -117,51 +140,67 @@ Amounts below are **illustrative only** — not live tickets, not filed carrier | Fare difference | 550 − 450 = **100.00** | — | | Typical add-collect (fare + fee, taxes aside) | 100 + 200 = **300.00** | 550 − 250 + 200 = 500 (double-counts fee) | -### Example B — Round-trip partially flown, Cat 33 + THB +### Example B — Round-trip partially flown, published fare + Cat 33 penalty - RT ticketed base: **USD 800.00** (OUT + RTN as one through amount on the ticket) - Pax flew OUT only; requests refund of RTN -- THB (historical ticket date) published OW for flown OUT city pair: **USD 480.00** -- Cat 33 penalty: **USD 150.00** +- Published OW for flown OUT city pair (caller-supplied): **USD 480.00** +- Cat 33 filed penalty: **USD 150.00** - Unused taxes supplied after tax-code review: **USD 55.00** | Step | Amount | | --- | --- | -| Flown base (THB) | 480.00 | +| Flown base (published fare) | 480.00 | | Unused base before penalty | 800 − 480 = **320.00** | | Cat 33 penalty | 150.00 | | Refundable base | 320 − 150 = **170.00** | | Refundable tax | **55.00** (supplied — not 50% of original tax) | | Net before commission | **225.00** | +### Example B′ — Same itinerary, **no Cat 33 data** + +- Same unused base **320.00** via `PUBLISHED_FARE` +- No `cat33_rules` on input + +| Step | Amount | +| --- | --- | +| Unused base | 320.00 | +| Cat 33 penalty | **0.00** (ATPCO no-match free default) | +| Refundable base | **320.00** | + +This is **not** fail-closed. Contrast: omitting `partial_valuation` entirely **is** fail-closed. + Rejected alternatives for the same ticket: - `800 × 1/2 = 400` coupon split — invented - MPA-P / TPM split of the through fare — wrong domain (interline) - Haversine share of 800 — invented - `800 − "used"` with used undefined — fail closed +- Bare `waiver_code` ⇒ treat as free — wrong (see waiver typology) ### Example C — One-way multi-coupon partially flown, method missing - OW base **USD 600.00**, 2 coupons, coupon 1 flown -- No Cat 33 re-price indicator / no THB amounts / no carrier formula on input +- No `partial_valuation` on input -→ Engine returns `DOMAIN_INPUT_REQUIRED` with missing `partial_valuation` / `cat33_thb_flown_amounts`. **No** residual number is emitted. +→ Engine returns `DOMAIN_INPUT_REQUIRED` with missing `partial_valuation`. **No** residual number is emitted. (Having or lacking Cat 33 data does not fill in a proration method.) ## Data dependencies (do not invent) | Dependency | Role | Invented substitute (forbidden) | | --- | --- | --- | -| ATPCO Cat 33 filing (incl. Re-Price Indicator) | Penalty + whether THB vs travel-commencement vs other | Hardcoded “industry default” residual math | -| Historical fare quote (ticket date) for flown sectors | THB flown base | Current-day fare, haversine, coupon ratio | -| Carrier residual / SPA formula (when filed) | CARRIER_SPECIFIC path | MPA-P / TPM tables | +| ATPCO Cat 33 filing | Penalty / eligibility; no-match → free | Hardcoded “industry default” residual math; inventing penalties | +| IATA Ticketing Handbook (THB) | Cite procedures by name only | Committing paid handbook text; inventing “THB = …” acronyms | +| Published fare amounts for flown sectors | `PUBLISHED_FARE` unused/flown base | Current-day guess, haversine, coupon ratio | +| Carrier residual formula (when filed) | `CARRIER_SPECIFIC` path | MPA-P / TPM tables | | Per-tax-code unused amounts | Partial tax refund | Pro-rata by coupon count | | MPA-P / PFM / TPM | **Not used** for passenger residual | — | ## Open DOMAIN_QUESTIONs -- **DQ-R1** — Per-carrier ingestion of Cat 33 Re-Price Indicator (A=THB vs B=travel commencement) and calculation options (Method A/B per Cat 33 Data Application). -- **DQ-R2** — Authoritative source for historical ticket-date fare quotes used as THB flown valuation (GDS informative pricing vs ATPCO fare feed). +- **DQ-R1** — Per-carrier ingestion of Cat 33 structured data (re-price / calculation options per paid ATPCO DA — cite only, do not commit). +- **DQ-R2** — Operational source for published-fare flown valuation amounts (GDS informative pricing vs ATPCO fare feed). - **DQ-R3** — Tax-code matrix for unused-tax determination on partials (especially YQ/YR and non-refundable airport charges). - **DQ-R4** — Any documented market exception to conjunction all-or-none refunds (none known; keep fail closed). - **DQ-R5** — Cat 31 residual interactions on **partially used** voluntary changes beyond “unused fare value + separate change fee” (carrier-specific). +- Waiver identity → typed effect: see issue **#138** / PR **#153** (do not treat bare waiver as free). diff --git a/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts b/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts index e5a57a7..2871380 100644 --- a/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts +++ b/packages/agents/exchange/src/change-management/__tests__/change-management.test.ts @@ -140,12 +140,12 @@ describe('Change Management', () => { expect(result.confidence).toBe(0); }); - it('uses CAT33_THB unused base when residual_valuation supplied', async () => { + it('uses PUBLISHED_FARE unused base when residual_valuation supplied', async () => { const result = await agent.execute({ data: makeInput({ ticket_usage: 'PARTIALLY_USED', residual_valuation: { - method: 'CAT33_THB', + method: 'PUBLISHED_FARE', unused_base_fare: '320.00', flown_base_fare: '480.00', unused_taxes: [{ code: 'GB', amount: '40.00', currency: 'USD' }], @@ -154,7 +154,7 @@ describe('Change Management', () => { }); if ('status' in result.data) throw new Error('unexpected domain sentinel'); expect(assertAssessment(result).residual_value).toBe('320.00'); - expect(assertAssessment(result).residual_method).toBe('CAT33_THB'); + expect(assertAssessment(result).residual_method).toBe('PUBLISHED_FARE'); }); it('sets action to REISSUE for fare change', async () => { @@ -168,8 +168,8 @@ describe('Change Management', () => { }); }); - describe('ATPCO default — no Cat31 rules supplied', () => { - it('voluntary change with no rules: penalty = 0 (ATPCO default)', async () => { + describe('ATPCO default — no Cat31 conditions/charges matched', () => { + it('voluntary change with no Cat31 data: free change (not fail-closed)', async () => { const result = await agent.execute({ data: makeInput({ cat31_rules: undefined }), }); @@ -272,18 +272,12 @@ describe('Change Management', () => { }); describe('Waiver codes', () => { - it('waives penalty with waiver code', async () => { + it('bare waiver_code fails closed (≠ free; same split as #153)', async () => { const input = makeInput({ waiver_code: 'WAIVER123' }); const result = await agent.execute({ data: input }); - expect(assertAssessment(result).fee_waived).toBe(true); - expect(assertAssessment(result).change_fee).toBe('0.00'); - expect(assertAssessment(result).waiver_code).toBe('WAIVER123'); - }); - - it('stores waiver code on assessment', async () => { - const input = makeInput({ waiver_code: 'ABCDEF' }); - const result = await agent.execute({ data: input }); - expect(assertAssessment(result).waiver_code).toBe('ABCDEF'); + expect(result.data).toMatchObject({ status: 'DOMAIN_INPUT_REQUIRED' }); + if (!('missing' in result.data)) throw new Error('expected domain sentinel'); + expect(result.data.missing).toContain('waiver_effect'); }); }); @@ -330,12 +324,6 @@ describe('Change Management', () => { expect(assertAssessment(result).summary.length).toBeGreaterThan(10); }); - it('summary mentions waiver when applied', async () => { - const input = makeInput({ waiver_code: 'WAIVER123' }); - const result = await agent.execute({ data: input }); - expect(assertAssessment(result).summary).toContain('Waiver'); - }); - it('summary includes total due', async () => { const result = await agent.execute({ data: makeInput() }); expect(assertAssessment(result).summary).toContain('Total due'); diff --git a/packages/agents/exchange/src/change-management/change-engine.ts b/packages/agents/exchange/src/change-management/change-engine.ts index aa525e9..9322ea0 100644 --- a/packages/agents/exchange/src/change-management/change-engine.ts +++ b/packages/agents/exchange/src/change-management/change-engine.ts @@ -12,7 +12,7 @@ * * Residual value (issue #150 / KB partial-refund-residual-value.md): * - FULLY_UNUSED → residual = ticketed base (change fee is separate) - * - PARTIALLY_USED → require CAT33_THB or CARRIER_SPECIFIC valuation + * - PARTIALLY_USED → require PUBLISHED_FARE or CARRIER_SPECIFIC valuation * - NEVER residual = original − change fee * - MPA-P / haversine / coupon-ratio are not passenger residual methods * @@ -65,7 +65,7 @@ function resolveResidual( input: ChangeManagementInput, originalFare: Decimal, ): - | { ok: true; residual: Decimal; method: 'FULLY_UNUSED' | 'CAT33_THB' | 'CARRIER_SPECIFIC' } + | { ok: true; residual: Decimal; method: 'FULLY_UNUSED' | 'PUBLISHED_FARE' | 'CARRIER_SPECIFIC' } | { ok: false; result: ReturnType } { const usage = input.ticket_usage ?? 'FULLY_UNUSED'; @@ -74,8 +74,9 @@ function resolveResidual( return { ok: true, residual: originalFare, method: 'FULLY_UNUSED' }; } - // PARTIALLY_USED — passenger residual = Cat 33 + THB (or carrier-specific). - // Never invent original − used, MPA-P, or haversine. + // PARTIALLY_USED — require explicit published-fare or carrier residual amounts. + // Cat 33 + IATA Ticketing Handbook (THB) govern passenger path; MPA-P does not. + // Missing method is fail-closed. Missing Cat 33 data is a separate free-penalty path. const valuation = input.residual_valuation; if (!valuation) { return { @@ -83,26 +84,26 @@ function resolveResidual( result: domainInputRequired({ missing: [ 'residual_valuation', - 'cat33_thb_flown_amounts_or_carrier_residual', + 'published_fare_or_carrier_residual_amounts', ], description: - 'Partially used ticket: passenger residual requires Cat 33 Historical Ticket Based (THB) flown valuation or a carrier-specific residual amount. Cannot use original−used, MPA-P interline proration, haversine, or coupon-ratio splits.', + 'Partially used ticket: passenger residual requires explicit PUBLISHED_FARE or CARRIER_SPECIFIC unused amounts (Cat 33 + IATA Ticketing Handbook practice). Cannot use original−used, MPA-P interline proration, haversine, or coupon-ratio splits. Note: absence of Cat 33 data means free penalty — it does not invent a proration method.', references: [ 'docs/knowledge-base/partial-refund-residual-value.md', - 'ATPCO Category 33 Re-Price Indicator A (Historical Ticket Based)', + 'IATA Ticketing Handbook (THB) — cite by name only', 'GitHub issue #150', ], }), }; } - if (valuation.method !== 'CAT33_THB' && valuation.method !== 'CARRIER_SPECIFIC') { + if (valuation.method !== 'PUBLISHED_FARE' && valuation.method !== 'CARRIER_SPECIFIC') { return { ok: false, result: domainInputRequired({ missing: ['residual_valuation.method'], description: - 'Partially used residual method must be CAT33_THB or CARRIER_SPECIFIC. MPA-P is airline interline settlement, not passenger residual.', + 'Partially used residual method must be PUBLISHED_FARE or CARRIER_SPECIFIC. MPA-P is airline interline settlement, not passenger residual.', references: [ 'docs/knowledge-base/partial-refund-residual-value.md', 'GitHub issue #150', @@ -147,6 +148,21 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementResu return { assessment }; } + // Bare waiver_code ≠ free (same split as #153 / waiver-typology). Fail closed + // until typed waiver_effect is supplied — do not invent skip-penalty. + if (input.waiver_code) { + return domainInputRequired({ + missing: ['waiver_effect'], + description: + 'waiver_code is present without typed waiver_effect. Bare waiver identity does not skip Cat 31 penalty (issue #138 / PR #153). Contrast: no Cat 31 data / unmatched provision → free change (ATPCO public default) — that path does not apply to bare waivers.', + references: [ + 'docs/knowledge-base/partial-refund-residual-value.md', + 'GitHub issue #138', + 'GitHub issue #150', + ], + }); + } + const originalFare = new Decimal(orig.base_fare); const residualResolved = resolveResidual(input, originalFare); if (!residualResolved.ok) { @@ -160,8 +176,8 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementResu // Penalty source-of-truth: // 1. Filed Cat31 rule for this fare basis → rule.change_fee // 2. No rule + involuntary → 0 (carrier-initiated) - // 3. No rule + voluntary → 0 (ATPCO default) - // The previous "$200 default when no rule" path was an invention. + // 3. No rule / unmatched provision + voluntary → 0 (ATPCO public default — free) + // Bare waiver_code is NOT a free path (handled above). const changeFeeAmount = rule ? new Decimal(rule.change_fee) : new Decimal('0.00'); const freeChangeHours = rule?.free_change_hours ?? 0; const forfeitOnDowngrade = rule?.forfeit_difference_on_downgrade ?? false; @@ -169,12 +185,10 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementResu // Check free change window const isFreeChange = isWithinFreeChangeWindow(orig.booking_date, now, freeChangeHours); - // Check waiver code - const hasWaiver = !!input.waiver_code; - - // Effective change fee: 0 if free window, waiver, or involuntary; else the filed amount. + // Effective change fee: 0 if free window or involuntary; else the filed amount. + // Never treat bare waiver_code as fee waived. const effectiveChangeFee = - isFreeChange || hasWaiver || isInvoluntary ? new Decimal('0.00') : changeFeeAmount; + isFreeChange || isInvoluntary ? new Decimal('0.00') : changeFeeAmount; // Fare difference vs residual available for application toward new fare const newFare = new Decimal(req.new_fare); @@ -216,9 +230,10 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementResu const summaryParts: string[] = []; if (isInvoluntary) summaryParts.push('Involuntary change — fee waived per carrier/regulatory practice.'); if (isFreeChange) summaryParts.push('Free change (within booking window).'); - if (hasWaiver) summaryParts.push(`Waiver code ${input.waiver_code!} applied — penalty waived.`); if (!rule && !input.cat31_rules) summaryParts.push('No Cat31 rules supplied — applying ATPCO default (no charge).'); + else if (input.cat31_rules && !rule) + summaryParts.push('No matching Cat31 provision — applying ATPCO default (no charge).'); if (effectiveChangeFee.greaterThan(0)) summaryParts.push(`Change fee: ${currency} ${effectiveChangeFee.toFixed(2)}.`); if (additionalCollection.greaterThan(0)) @@ -236,8 +251,7 @@ export function assessChange(input: ChangeManagementInput): ChangeManagementResu action, change_fee: effectiveChangeFee.toFixed(2), change_fee_currency: currency, - fee_waived: isFreeChange || hasWaiver || isInvoluntary, - ...(input.waiver_code !== undefined ? { waiver_code: input.waiver_code } : {}), + fee_waived: isFreeChange || isInvoluntary, fare_difference: fareDifference.toFixed(2), additional_collection: additionalCollection.toFixed(2), residual_value: residualValue.toFixed(2), diff --git a/packages/agents/exchange/src/change-management/index.ts b/packages/agents/exchange/src/change-management/index.ts index a5888fe..057ed8c 100644 --- a/packages/agents/exchange/src/change-management/index.ts +++ b/packages/agents/exchange/src/change-management/index.ts @@ -27,7 +27,7 @@ const CARRIER_RE = /^[A-Z0-9]{2}$/; const PASSENGER_NAME_RE = /^[A-Z][A-Z' -]+\/[A-Z][A-Z' -]+$/; const RECORD_LOCATOR_RE = /^[A-Z0-9]{6}$/; const VALID_USAGE = new Set(['FULLY_UNUSED', 'PARTIALLY_USED']); -const VALID_RESIDUAL_METHODS = new Set(['CAT33_THB', 'CARRIER_SPECIFIC']); +const VALID_RESIDUAL_METHODS = new Set(['PUBLISHED_FARE', 'CARRIER_SPECIFIC']); export class ChangeManagement implements Agent { readonly id = '5.1'; @@ -165,7 +165,7 @@ export class ChangeManagement implements Agent { ).rejects.toThrow('Invalid input'); }); - it('applies CAT33_THB residual without inventing original − fee', async () => { + it('applies PUBLISHED_FARE residual without inventing original − fee', async () => { const result = await agent.execute({ data: makeInput({ residual_value: '320.00', - residual_method: 'CAT33_THB', + residual_method: 'PUBLISHED_FARE', change_fee: '150.00', }), }); if ('status' in result.data) throw new Error('unexpected domain sentinel'); expect(result.data.reissue.exchange_audit.residual_applied).toBe('320.00'); - expect(result.data.reissue.exchange_audit.residual_method).toBe('CAT33_THB'); + expect(result.data.reissue.exchange_audit.residual_method).toBe('PUBLISHED_FARE'); // 550 - 320 + 150 + 5 tax = 385 expect(result.data.additional_collection).toBe('385.00'); }); diff --git a/packages/agents/exchange/src/exchange-reissue/index.ts b/packages/agents/exchange/src/exchange-reissue/index.ts index 0408897..477b094 100644 --- a/packages/agents/exchange/src/exchange-reissue/index.ts +++ b/packages/agents/exchange/src/exchange-reissue/index.ts @@ -25,7 +25,7 @@ const AIRPORT_RE = /^[A-Z]{3}$/; const PASSENGER_NAME_RE = /^[A-Z][A-Z' -]+\/[A-Z][A-Z' -]+$/; const RECORD_LOCATOR_RE = /^[A-Z0-9]{6}$/; const VALID_GDS = new Set(['AMADEUS', 'SABRE', 'TRAVELPORT']); -const VALID_RESIDUAL_METHODS = new Set(['FULLY_UNUSED', 'CAT33_THB', 'CARRIER_SPECIFIC']); +const VALID_RESIDUAL_METHODS = new Set(['FULLY_UNUSED', 'PUBLISHED_FARE', 'CARRIER_SPECIFIC']); export class ExchangeReissue implements Agent { readonly id = '5.2'; @@ -170,7 +170,7 @@ export class ExchangeReissue implements Agent { }); }); - describe('ATPCO default — no Cat33 rules supplied', () => { - it('voluntary refund with no rules: penalty = 0, full base refund', async () => { + describe('ATPCO default — no Cat33 conditions/charges matched', () => { + it('voluntary refund with no Cat33 data: free refund (not fail-closed)', async () => { const result = await agent.execute({ data: makeInput({ cat33_rules: undefined }), }); @@ -121,6 +121,18 @@ describe('Refund Processing', () => { expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); }); + it('rules present but no provision match: free refund (not fail-closed)', async () => { + const result = await agent.execute({ + data: makeInput({ + fare_basis: 'ZZZNORULE', + cat33_rules: { rules: TEST_CAT33_RULES.rules }, + }), + }); + // ZZZNORULE matches none of the fixture patterns → free + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); + }); + it('involuntary refund with no rules: penalty = 0, full refund regardless of fare basis', async () => { const result = await agent.execute({ data: makeInput({ @@ -192,13 +204,13 @@ describe('Refund Processing', () => { expect(result.data).toMatchObject({ status: 'DOMAIN_INPUT_REQUIRED' }); if (!('missing' in result.data)) throw new Error('expected domain sentinel'); expect(result.data.missing).toContain('partial_valuation'); - expect(result.data.description).toMatch(/THB|Historical Ticket Based/i); + expect(result.data.description).toMatch(/THB|IATA Ticketing Handbook/i); expect(result.data.description).toMatch(/MPA-P/); expect(result.confidence).toBe(0); }); - it('applies Cat 33 penalty to CAT33_THB unused base (not coupon-ratio)', async () => { - // Made-up: ticketed 800, THB flown 480 → unused 320; fixture HOWUS penalty 200 + it('applies Cat 33 penalty to PUBLISHED_FARE unused base (not coupon-ratio)', async () => { + // Made-up: ticketed 800, published flown 480 → unused 320; fixture HOWUS penalty 200 const coupons: CouponRefundItem[] = [ { coupon_number: 2, status: 'O', refundable: true }, ]; @@ -210,7 +222,7 @@ describe('Refund Processing', () => { total_coupons: 2, fare_basis: 'HOWUS', partial_valuation: { - method: 'CAT33_THB', + method: 'PUBLISHED_FARE', unused_base_fare: '320.00', flown_base_fare: '480.00', unused_taxes: [{ code: 'GB', amount: '55.00', currency: 'USD' }], @@ -218,22 +230,46 @@ describe('Refund Processing', () => { }), }); if ('status' in result.data) throw new Error('unexpected domain sentinel'); - expect(assertRefund(result).refund.audit.residual_method).toBe('CAT33_THB'); + expect(assertRefund(result).refund.audit.residual_method).toBe('PUBLISHED_FARE'); expect(assertRefund(result).refund.audit.flown_base_fare).toBe('480.00'); expect(assertRefund(result).refund.tax_refund).toBe('55.00'); expect(assertRefund(result).refund.penalty_applied).toBe('200.00'); expect(assertRefund(result).refund.base_fare_refund).toBe('120.00'); }); - it('does not invent coupon-ratio tax when THB unused taxes are supplied', async () => { + it('no Cat 33 data + PUBLISHED_FARE: free penalty on unused base (not fail-closed)', async () => { + const coupons: CouponRefundItem[] = [ + { coupon_number: 2, status: 'O', refundable: true }, + ]; + const result = await agent.execute({ + data: makeInput({ + base_fare: '800.00', + refund_type: 'PARTIAL', + coupons_to_refund: coupons, + total_coupons: 2, + cat33_rules: undefined, + partial_valuation: { + method: 'PUBLISHED_FARE', + unused_base_fare: '320.00', + flown_base_fare: '480.00', + unused_taxes: [{ code: 'GB', amount: '55.00', currency: 'USD' }], + }, + }), + }); + if ('status' in result.data) throw new Error('unexpected domain sentinel'); + expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); + expect(assertRefund(result).refund.base_fare_refund).toBe('320.00'); + }); + + it('does not invent coupon-ratio tax when unused taxes are supplied', async () => { const coupons: CouponRefundItem[] = [{ coupon_number: 3, status: 'O', refundable: true }]; const result = await agent.execute({ data: makeInput({ refund_type: 'PARTIAL', coupons_to_refund: coupons, - waiver_code: 'W', + cat33_rules: undefined, partial_valuation: { - method: 'CAT33_THB', + method: 'PUBLISHED_FARE', unused_base_fare: '112.50', unused_taxes: [ { code: 'GB', amount: '20.00', currency: 'USD' }, @@ -243,7 +279,6 @@ describe('Refund Processing', () => { }), }); if ('status' in result.data) throw new Error('unexpected domain sentinel'); - // Must use supplied unused taxes (25.00), not 25% of 120 = 30.00 coupon ratio expect(assertRefund(result).refund.tax_refund).toBe('25.00'); expect(assertRefund(result).refund.base_fare_refund).toBe('112.50'); }); @@ -257,7 +292,7 @@ describe('Refund Processing', () => { data: makeInput({ refund_type: 'PARTIAL', coupons_to_refund: coupons, - waiver_code: 'W', + cat33_rules: undefined, partial_valuation: { method: 'CARRIER_SPECIFIC', unused_base_fare: '200.00', @@ -272,22 +307,38 @@ describe('Refund Processing', () => { }); describe('Waiver code', () => { - it('bypasses penalty with waiver code', async () => { + it('bare waiver_code fails closed (≠ free; same split as #153)', async () => { const result = await agent.execute({ data: makeInput({ waiver_code: 'WAIVER123' }) }); - expect(assertRefund(result).refund.penalty_applied).toBe('0.00'); - expect(assertRefund(result).refund.base_fare_refund).toBe('450.00'); + expect(result.data).toMatchObject({ status: 'DOMAIN_INPUT_REQUIRED' }); + if (!('missing' in result.data)) throw new Error('expected domain sentinel'); + expect(result.data.missing).toContain('waiver_effect'); + expect(result.data.description).toMatch(/waiver_effect|Bare waiver/i); }); - it('stores waiver code on record', async () => { - const result = await agent.execute({ data: makeInput({ waiver_code: 'WAIVER123' }) }); - expect(assertRefund(result).refund.waiver_code).toBe('WAIVER123'); - expect(assertRefund(result).refund.audit.waiver_code).toBe('WAIVER123'); + it('bare waiver on PARTIAL fails closed even when valuation supplied', async () => { + const result = await agent.execute({ + data: makeInput({ + refund_type: 'PARTIAL', + coupons_to_refund: [{ coupon_number: 1, status: 'O', refundable: true }], + waiver_code: 'W', + partial_valuation: { + method: 'PUBLISHED_FARE', + unused_base_fare: '100.00', + unused_taxes: [{ code: 'GB', amount: '10.00', currency: 'USD' }], + }, + }), + }); + expect(result.data).toMatchObject({ status: 'DOMAIN_INPUT_REQUIRED' }); + if (!('missing' in result.data)) throw new Error('expected domain sentinel'); + expect(result.data.missing).toContain('waiver_effect'); }); }); describe('Commission recall', () => { it('recalls proportional commission on full refund', async () => { - const result = await agent.execute({ data: makeInput({ waiver_code: 'W' }) }); // waiver so full base refund + const result = await agent.execute({ + data: makeInput({ cat33_rules: undefined }), // free — full base refund + }); expect(assertRefund(result).commission_recalled).toBe('31.50'); // full commission }); @@ -297,9 +348,9 @@ describe('Refund Processing', () => { data: makeInput({ refund_type: 'PARTIAL', coupons_to_refund: coupons, - waiver_code: 'W', + cat33_rules: undefined, partial_valuation: { - method: 'CAT33_THB', + method: 'PUBLISHED_FARE', unused_base_fare: '112.50', unused_taxes: [{ code: 'GB', amount: '10.00', currency: 'USD' }], }, diff --git a/packages/agents/settlement/src/refund-processing/index.ts b/packages/agents/settlement/src/refund-processing/index.ts index 70c8a86..58e8c84 100644 --- a/packages/agents/settlement/src/refund-processing/index.ts +++ b/packages/agents/settlement/src/refund-processing/index.ts @@ -25,7 +25,7 @@ const PASSENGER_NAME_RE = /^[A-Z][A-Z' -]+\/[A-Z][A-Z' -]+$/; const RECORD_LOCATOR_RE = /^[A-Z0-9]{6}$/; const VALID_REFUND_TYPES = new Set(['FULL', 'PARTIAL', 'TAX_ONLY']); const VALID_SETTLEMENT = new Set(['BSP', 'ARC']); -const VALID_PARTIAL_METHODS = new Set(['CAT33_THB', 'CARRIER_SPECIFIC']); +const VALID_PARTIAL_METHODS = new Set(['PUBLISHED_FARE', 'CARRIER_SPECIFIC']); export class RefundProcessing implements Agent { readonly id = '6.1'; @@ -173,7 +173,7 @@ export class RefundProcessing implements Agent; - /** Unused base fare after THB / carrier valuation (decimal string). */ + /** Unused base fare after published-fare / carrier valuation (decimal string). */ unused_base_fare: string; - /** Optional audit: flown base from THB / carrier valuation. */ + /** Optional audit: flown base from that valuation. */ flown_base_fare?: string; /** Unused taxes by code — required for partial money paths. */ unused_taxes: Array<{ code: string; amount: string; currency: string }>; From 9a5a9d48c4e8fc7cf4783f7a696f4515023bc745 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 09:45:29 +0000 Subject: [PATCH 4/5] merge: integrate main with #150 passenger residual + waiver typology Rebase PR #166 onto latest main (waiver typology #153, US DOT 24h, tax carryforward, BSP HOT, ADM prevention, etc.) while preserving issue #150 domain rules: - Passenger residual = PUBLISHED_FARE | CARRIER_SPECIFIC (THB cite-by-name) - Fail-closed without proration method; no coupon-ratio / MPA-P / haversine - Waiver typology from main: assertChangeWaiverInput / assertRefundWaiverInput - Exchange-reissue keeps per-tax carryforward + requires residual_method All 3587 tests pass. Co-authored-by: telivity-otaip --- .gitignore | 10 +- CLAUDE.md | 11 +- README.md | 2 +- agents.graph.json | 8 +- agents.manifest.json | 118 +++- data/reference/mct/README.md | 26 + data/reference/mct/airport-rules.json | 7 + data/reference/mct/carrier-overrides.json | 25 + docs/agent-map.html | 2 +- docs/agents.md | 4 +- docs/agents/stage-1-search.md | 24 +- docs/agents/stage-2-pricing.md | 26 +- docs/agents/stage-3-booking.md | 14 +- docs/agents/stage-5-exchange.md | 62 +- docs/agents/stage-6-settlement.md | 38 +- docs/agents/stage-7-reconciliation.md | 12 +- docs/agents/stage-8-tmc.md | 4 +- docs/knowledge-base/activities.md | 152 ++-- docs/knowledge-base/adm-prevention.md | 123 ++++ docs/knowledge-base/bsp-hot-reconciliation.md | 175 +++++ .../fare-construction-data-dependencies.md | 202 ++++++ .../gds-ndc-capability-matrix.csv | 36 + .../gds-ndc-capability-matrix.md | 243 +++++++ docs/knowledge-base/gds-pnr-name-commands.md | 120 ++++ .../involuntary-rebook-irrop.md | 146 ++++ docs/knowledge-base/mct.md | 53 ++ .../tax-carryforward-reissue.md | 140 ++++ .../tmc-mid-office-ttl-queues.md | 91 +++ docs/knowledge-base/transfers.md | 147 ++-- .../us-dot-24-hour-reservation.md | 110 +++ docs/knowledge-base/waiver-typology.md | 137 ++++ docs/operations/FAILURE_MODES.md | 2 +- ...es-availability-cancellation-policies.json | 73 ++ .../activities-booking-confirmed.json | 28 + .../activities-booking-preconfirmed.json | 11 + .../activities-cancel-simulation.json | 9 + ...rs-availability-cancellation-policies.json | 74 ++ .../transfers-booking-confirmed.json | 39 + .../unsupported-on-request-booking.json | 8 + .../src/__tests__/activities-adapter.test.ts | 37 +- .../__tests__/activities-integration.test.ts | 2 +- .../src/__tests__/dq-fixtures.test.ts | 152 ++++ .../src/__tests__/transfers-adapter.test.ts | 74 +- .../__tests__/transfers-integration.test.ts | 2 +- .../hotelbeds/src/activities-mapper.ts | 119 +++- .../hotelbeds/src/activities-types.ts | 86 ++- .../adapters/hotelbeds/src/capabilities.ts | 18 +- packages/adapters/hotelbeds/src/index.ts | 2 + .../hotelbeds/src/transfers-mapper.ts | 147 +++- .../adapters/hotelbeds/src/transfers-types.ts | 90 ++- .../agents-platform/src/knowledge/index.ts | 2 +- .../mid-office/__tests__/mid-office.test.ts | 71 +- packages/agents-tmc/src/mid-office/index.ts | 31 +- .../agents-tmc/src/mid-office/ttl-policy.ts | 94 +++ packages/agents-tmc/src/mid-office/types.ts | 9 + .../__tests__/capability-matrix.test.ts | 202 ++++++ .../src/gds-ndc-router/capability-matrix.ts | 298 ++++++++ .../data/capability-matrix.json | 357 ++++++++++ .../gds-ndc-router/data/carrier-channels.json | 4 +- .../data/gds-ndc-capability-matrix.csv | 36 + .../booking/src/gds-ndc-router/index.ts | 44 ++ .../src/gds-ndc-router/router-engine.ts | 143 +++- .../booking/src/gds-ndc-router/schema.ts | 42 ++ .../booking/src/gds-ndc-router/types.ts | 87 ++- packages/agents/booking/src/index.ts | 19 + .../pnr-builder/__tests__/pnr-builder.test.ts | 167 ++++- .../src/pnr-builder/command-builder.ts | 223 ++++-- .../__tests__/queue-management.test.ts | 64 +- .../booking/src/queue-management/index.ts | 10 + .../src/queue-management/queue-engine.ts | 154 +++- .../booking/src/queue-management/types.ts | 14 +- .../__tests__/change-management.test.ts | 383 ++++++++-- .../src/change-management/change-engine.ts | 260 +++++-- .../data/us-dot-24h-carrier-remedy.json | 79 +++ .../exchange/src/change-management/index.ts | 25 +- .../exchange/src/change-management/types.ts | 198 +++++- .../src/change-management/us-dot-24h.ts | 207 ++++++ .../__tests__/exchange-reissue.test.ts | 371 +++++++--- .../exchange/src/exchange-reissue/index.ts | 150 +++- .../src/exchange-reissue/reissue-engine.ts | 162 +++-- .../src/exchange-reissue/tax-carryforward.ts | 179 +++++ .../exchange/src/exchange-reissue/types.ts | 108 ++- packages/agents/exchange/src/index.ts | 39 +- .../fixtures/eu-arrive-non-eu-carrier.json | 52 ++ .../fixtures/eu-depart-any-carrier.json | 70 ++ .../fixtures/us-idb-non-oversale.json | 61 ++ .../__tests__/involuntary-rebook.test.ts | 380 ++++++++-- .../exchange/src/involuntary-rebook/index.ts | 15 +- .../src/involuntary-rebook/rebook-engine.ts | 342 ++++++--- .../exchange/src/involuntary-rebook/types.ts | 153 +++- .../__tests__/fare-construction.test.ts | 666 +++++++----------- .../fixtures/test-fare-construction-data.json | 27 + .../fare-construction/data/mileage-data.json | 56 -- .../src/fare-construction/data/roe-rates.json | 27 - .../data/rounding-rules.json | 27 - .../src/fare-construction/fare-engine.ts | 368 ++++++---- .../pricing/src/fare-construction/index.ts | 37 +- .../pricing/src/fare-construction/types.ts | 169 +++-- packages/agents/pricing/src/index.ts | 18 +- .../src/tax-calculation/data/tax-rates.json | 2 +- .../pricing/src/tax-calculation/tax-engine.ts | 13 +- .../__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 + .../__tests__/class-of-service-mapper.test.ts | 140 ++++ .../src/class-of-service-mapper/data.ts | 352 ++++++++- .../__tests__/connection-builder.test.ts | 97 ++- .../connection-builder/connection-scorer.ts | 14 +- .../search/src/connection-builder/index.ts | 25 +- .../search/src/connection-builder/mct-data.ts | 358 +++++----- .../search/src/connection-builder/types.ts | 15 +- .../__tests__/adm-prevention.test.ts | 293 +++++++- .../__tests__/fixtures/churn-all-hk-now.json | 88 +++ .../fixtures/travelport-dx-marriage.json | 41 ++ .../fixtures/ttl-deadline-day-tz.json | 32 + .../__tests__/fixtures/uc-hn-passive-pk.json | 47 ++ .../__tests__/fixtures/uncleared-tk.json | 29 + .../src/adm-prevention/audit-engine.ts | 201 +++++- .../settlement/src/adm-prevention/index.ts | 21 +- .../src/adm-prevention/status-codes.ts | 116 +++ .../settlement/src/adm-prevention/types.ts | 62 +- packages/agents/settlement/src/index.ts | 19 +- .../__tests__/refund-processing.test.ts | 269 ++++--- .../settlement/src/refund-processing/index.ts | 10 +- .../src/refund-processing/refund-engine.ts | 291 ++++++-- .../settlement/src/refund-processing/types.ts | 75 +- .../src/__tests__/sprint-a-e2e.test.ts | 1 + packages/core/src/regulations/eu261.ts | 4 +- 132 files changed, 11574 insertions(+), 2186 deletions(-) create mode 100644 data/reference/mct/README.md create mode 100644 data/reference/mct/airport-rules.json create mode 100644 data/reference/mct/carrier-overrides.json create mode 100644 docs/knowledge-base/adm-prevention.md create mode 100644 docs/knowledge-base/bsp-hot-reconciliation.md create mode 100644 docs/knowledge-base/fare-construction-data-dependencies.md create mode 100644 docs/knowledge-base/gds-ndc-capability-matrix.csv create mode 100644 docs/knowledge-base/gds-ndc-capability-matrix.md create mode 100644 docs/knowledge-base/gds-pnr-name-commands.md create mode 100644 docs/knowledge-base/involuntary-rebook-irrop.md create mode 100644 docs/knowledge-base/mct.md create mode 100644 docs/knowledge-base/tax-carryforward-reissue.md create mode 100644 docs/knowledge-base/tmc-mid-office-ttl-queues.md create mode 100644 docs/knowledge-base/us-dot-24-hour-reservation.md create mode 100644 docs/knowledge-base/waiver-typology.md create mode 100644 packages/adapters/hotelbeds/src/__fixtures__/activities-availability-cancellation-policies.json create mode 100644 packages/adapters/hotelbeds/src/__fixtures__/activities-booking-confirmed.json create mode 100644 packages/adapters/hotelbeds/src/__fixtures__/activities-booking-preconfirmed.json create mode 100644 packages/adapters/hotelbeds/src/__fixtures__/activities-cancel-simulation.json create mode 100644 packages/adapters/hotelbeds/src/__fixtures__/transfers-availability-cancellation-policies.json create mode 100644 packages/adapters/hotelbeds/src/__fixtures__/transfers-booking-confirmed.json create mode 100644 packages/adapters/hotelbeds/src/__fixtures__/unsupported-on-request-booking.json create mode 100644 packages/adapters/hotelbeds/src/__tests__/dq-fixtures.test.ts create mode 100644 packages/agents-tmc/src/mid-office/ttl-policy.ts create mode 100644 packages/agents/booking/src/gds-ndc-router/__tests__/capability-matrix.test.ts create mode 100644 packages/agents/booking/src/gds-ndc-router/capability-matrix.ts create mode 100644 packages/agents/booking/src/gds-ndc-router/data/capability-matrix.json create mode 100644 packages/agents/booking/src/gds-ndc-router/data/gds-ndc-capability-matrix.csv create mode 100644 packages/agents/exchange/src/change-management/data/us-dot-24h-carrier-remedy.json create mode 100644 packages/agents/exchange/src/change-management/us-dot-24h.ts create mode 100644 packages/agents/exchange/src/exchange-reissue/tax-carryforward.ts create mode 100644 packages/agents/exchange/src/involuntary-rebook/__tests__/fixtures/eu-arrive-non-eu-carrier.json create mode 100644 packages/agents/exchange/src/involuntary-rebook/__tests__/fixtures/eu-depart-any-carrier.json create mode 100644 packages/agents/exchange/src/involuntary-rebook/__tests__/fixtures/us-idb-non-oversale.json create mode 100644 packages/agents/pricing/src/fare-construction/__tests__/fixtures/test-fare-construction-data.json delete mode 100644 packages/agents/pricing/src/fare-construction/data/mileage-data.json delete mode 100644 packages/agents/pricing/src/fare-construction/data/roe-rates.json delete mode 100644 packages/agents/pricing/src/fare-construction/data/rounding-rules.json create mode 100644 packages/agents/reconciliation/src/bsp-reconciliation/__tests__/fixtures/hot-dish-rev23-synthetic.txt create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/churn-all-hk-now.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/travelport-dx-marriage.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/ttl-deadline-day-tz.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uc-hn-passive-pk.json create mode 100644 packages/agents/settlement/src/adm-prevention/__tests__/fixtures/uncleared-tk.json create mode 100644 packages/agents/settlement/src/adm-prevention/status-codes.ts diff --git a/.gitignore b/.gitignore index 77e6850..078442a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,13 @@ -# reference data +# reference data (downloaded airports, etc.) /data/* +# curated MCT starter dataset (checked in — see data/reference/mct/) +!/data/reference/ +!/data/reference/.gitkeep +!/data/reference/mct/ +!/data/reference/mct/** +/data/reference/airports.json +/data/reference/metro-areas.json +/data/reference/decommissioned.json # OTAIP run/gate traces (durable JSONL written by @otaip/integration) traces/ diff --git a/CLAUDE.md b/CLAUDE.md index aa5c3e4..d0db017 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 @@ -107,9 +108,9 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | Rationalization | Required response | |---|---| -| "NUC conversion uses a standard multiply-and-round pattern, I'll apply banker's rounding" | STOP. IATA rounding rules are currency-specific and are NOT standard banker's rounding. Check KB for the IATA rounding table. Do not implement any rounding without the exact rule for the currency in question. | -| "ROE rates change periodically, I'll use a hardcoded value for now as a placeholder" | STOP. ROE values are published by IATA monthly. Hardcoded ROE values will produce wrong fares immediately. Surface as DOMAIN_QUESTION: how will ROE data be ingested? | -| "TPM/MPM mileage data isn't available, I'll approximate the distance using haversine between airports" | STOP. TPM is NOT great-circle distance. TPM is published by IATA/SITA and includes routing-specific values that differ from haversine. HIP/BHC/CTM checks depend on exact TPM. Do not approximate. Surface as DOMAIN_QUESTION. | +| "NUC conversion uses a standard multiply-and-round pattern, I'll apply banker's rounding" | STOP. IATA Resolution **024d** uses per-currency units with **HX** (round up) or **NX** (nearest). Banker's / half-to-even is not a substitute. Check KB `docs/knowledge-base/fare-construction-data-dependencies.md`. | +| "ROE rates change periodically, I'll use a hardcoded value for now as a placeholder" | STOP. **IROE** values are published by IATA monthly for **fare construction** (Res 024c). Hardcoded rates produce wrong fares immediately. Pass licensed rates via `data_sources.iroe` or surface DOMAIN_INPUT_REQUIRED. Do not commit proprietary IROE files. Do **not** use IROE for ticket-tax / payment FX — that is **ICER** ([xrates](https://www.iata.org/en/services/finance/xrates/)). | +| "TPM/MPM mileage data isn't available, I'll approximate the distance using haversine between airports" | STOP. TPM is NOT great-circle distance. TPM is published by IATA ([TPM Manual](https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/)). HIP/BHC/CTM depend on exact TPM. Fail closed — never approximate. | | "The HIP check logic seems straightforward — just compare the fare to the sum of sector fares" | STOP. HIP/BHC/CTM each have different comparison rules, directionality requirements, and NUC-vs-local-currency considerations. Check KB for the exact ATPCO comparison logic before implementing. | | "For multi-sector itineraries, I'll prorate the fare evenly across segments" | STOP. IATA fare proration uses TPM-based proportional allocation, not equal division. Check KB for the exact proration formula. | | "I don't have the fare construction rules for this specific routing, I'll use the published fare directly" | STOP. Published fares and constructed fares are different pricing mechanisms. Do not conflate them. Surface as DOMAIN_QUESTION: which fare type applies? | @@ -119,8 +120,8 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | Rationalization | Required response | |---|---| -| "Airline X supports NDC, so I'll route all bookings through NDC" | STOP. Airlines have varying NDC adoption levels by transaction type and market. Some support NDC for shopping only. Some require NDC for certain fare types but GDS for others. Check KB for the carrier's NDC capability matrix. | -| "NDC version 21.3 is widely supported, I'll default to that" | STOP. NDC versions are NOT backward compatible in all cases. Carriers implement specific versions with carrier-specific extensions. Check KB for which version the specific carrier supports. | +| "Airline X supports NDC, so I'll route all bookings through NDC" | STOP. Airlines have varying NDC adoption levels by transaction type and vendor. Some support NDC for shopping only. Some require NDC for certain fare types but GDS for others. Check `docs/knowledge-base/gds-ndc-capability-matrix.md` for the carrier×vendor×transaction matrix. Res 787 is the Offer/Order process standard — not a channel parity checklist. | +| "NDC version 21.3 is widely supported, I'll default to that" | STOP. NDC versions are NOT backward compatible in all cases. Carriers implement specific versions with carrier-specific extensions. Check the matrix `ndc_version_notes` for the specific carrier×vendor. Never invent a version. | | "Codeshare flights route through the marketing carrier's channel" | STOP. Codeshare routing depends on ticketing arrangement, inventory control (free-sale vs blocked space), and plating carrier. Some require dual-channel booking. Check KB for the codeshare agreement's booking rules. | | "I'll map each airline to either GDS or NDC as their primary channel" | STOP. Most NDC airlines still require GDS for specific scenarios (groups, tours, corporate fares, post-booking servicing). The routing decision must be PER-TRANSACTION, not per-airline. Check KB for channel capability per transaction type. | diff --git a/README.md b/README.md index 95c388f..88abd96 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ See [docs/architecture.md](docs/architecture.md) for the full architecture overv ## Domain Expertise -OTAIP agents encode real industry logic: ATPCO fare rules (Categories 1-33), NUC/ROE fare construction with HIP/BHC/CTM checks, BSP HOT file reconciliation, ADM prevention (9 pre-ticketing checks), NDC/EDIFACT normalization, IRROPS rebooking with EU261 and US DOT compliance, void window enforcement, married segment integrity, and payment-to-ticketing state machines with BSP finality rules. +OTAIP agents encode real industry logic: ATPCO fare rules (Categories 1-33), NUC/ROE fare construction with HIP/BHC/CTM checks, BSP HOT file reconciliation, ADM prevention (10 pre-ticketing checks), NDC/EDIFACT normalization, IRROPS rebooking with EU261 and US DOT compliance, void window enforcement, married segment integrity, and payment-to-ticketing state machines with BSP finality rules. --- diff --git a/agents.graph.json b/agents.graph.json index 56d5270..ab5db37 100644 --- a/agents.graph.json +++ b/agents.graph.json @@ -160,7 +160,7 @@ "id": "2.2", "name": "Fare Construction", "stage": "pricing", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/pricing/src/fare-construction/index.ts" @@ -340,7 +340,7 @@ "id": "5.2", "name": "Exchange/Reissue", "stage": "exchange", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/exchange/src/exchange-reissue/index.ts" @@ -394,7 +394,7 @@ "id": "6.2", "name": "ADM Prevention", "stage": "settlement", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/settlement/src/adm-prevention/index.ts" @@ -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..e68ee95 100644 --- a/agents.manifest.json +++ b/agents.manifest.json @@ -2334,7 +2334,7 @@ "id": "2.2", "name": "Fare Construction", "stage": "pricing", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/pricing/src/fare-construction/index.ts" @@ -2806,6 +2806,106 @@ "additionalProperties": false } }, + "transaction_type": { + "type": "string", + "enum": [ + "shopping", + "booking", + "ticketing", + "servicing", + "group", + "corporate" + ] + }, + "vendor": { + "type": "string", + "enum": [ + "sabre", + "amadeus", + "duffel", + "navitaire", + "trippro", + "airline_direct", + "unknown" + ] + }, + "plating_carrier": { + "type": "string", + "minLength": 2, + "maxLength": 3 + }, + "capability_matrix": { + "type": "array", + "items": { + "type": "object", + "properties": { + "carrier": { + "type": "string", + "minLength": 1 + }, + "vendor": { + "type": "string", + "enum": [ + "sabre", + "amadeus", + "duffel", + "navitaire", + "trippro", + "airline_direct", + "unknown" + ] + }, + "transaction": { + "type": "string", + "enum": [ + "Shop", + "OrderCreate", + "OrderChange", + "OrderCancel", + "Servicing", + "Groups", + "Corporate" + ] + }, + "channel": { + "type": "string", + "enum": [ + "NDC", + "GDS", + "Direct/API", + "Either", + "unknown" + ] + }, + "ndc_version_notes": { + "type": "string" + }, + "fallback": { + "type": "string" + }, + "source": { + "type": "string" + }, + "confidence": { + "type": "string", + "enum": [ + "adapter_doc", + "vendor_public", + "unknown" + ] + } + }, + "required": [ + "carrier", + "vendor", + "transaction", + "channel", + "ndc_version_notes", + "fallback" + ], + "additionalProperties": false + } + }, "preferred_channel": { "type": "string", "enum": [ @@ -2828,6 +2928,7 @@ }, "required": [ "segments", + "transaction_type", "include_fallbacks" ], "additionalProperties": false @@ -2913,6 +3014,15 @@ "NDC_ORDER", "DIRECT_API" ] + }, + "domain_input_required": { + "type": "boolean" + }, + "missing_inputs": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4349,7 +4459,7 @@ "id": "5.2", "name": "Exchange/Reissue", "stage": "exchange", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/exchange/src/exchange-reissue/index.ts" @@ -5357,7 +5467,7 @@ "id": "6.2", "name": "ADM Prevention", "stage": "settlement", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/settlement/src/adm-prevention/index.ts" @@ -5402,7 +5512,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/data/reference/mct/README.md b/data/reference/mct/README.md new file mode 100644 index 0000000..aaf1c7a --- /dev/null +++ b/data/reference/mct/README.md @@ -0,0 +1,26 @@ +# MCT reference data (`data/reference/mct/`) + +Curated Minimum Connecting Time rows for Agent 1.3 Connection Builder. + +**Authority:** IATA SSIM Chapter 8 + PSC Resolution 765. See `docs/knowledge-base/mct.md`. + +## Hierarchy (most specific → fail-closed) + +| Priority | Level | File / field | When used | +| -------- | ------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| 1 | Carrier override | `carrier-overrides.json` | Matching arriving/departing carriers (+ connection status) at airport; optional terminals | +| 2 | Airport + terminal | `airport-rules.json` (`terminal_change` or terminal pair set) | No carrier match; terminal change known | +| 3 | Airport | `airport-rules.json` (station default for status) | No carrier/terminal match | +| 4 | Fail-closed | — | **No invented IATA global table.** MCT unresolved → connection invalid | + +## Rules for adding rows + +- Cite a source (`source` field): SSIM/aggregator extract, public doc URL, or `already-in-code:…`. +- Do **not** add airport-constant guesses or a global 60/90 table. +- Do **not** derive MCT from haversine / great-circle distance. +- Interline / unpublished carrier exceptions → leave as `DOMAIN_QUESTION` in the KB; do not invent. + +## Files + +- `carrier-overrides.json` — carrier@airport starter rows +- `airport-rules.json` — station/terminal rows (empty until a real extract is available) diff --git a/data/reference/mct/airport-rules.json b/data/reference/mct/airport-rules.json new file mode 100644 index 0000000..8f99907 --- /dev/null +++ b/data/reference/mct/airport-rules.json @@ -0,0 +1,7 @@ +{ + "version": "0.1.0", + "description": "Airport / airport+terminal MCT rules from SSIM station filings. Empty until a real extract is curated — do not invent airport constants.", + "authority": ["IATA SSIM Chapter 8", "IATA PSC Resolution 765"], + "hierarchy_levels": ["airport_terminal", "airport"], + "rules": [] +} diff --git a/data/reference/mct/carrier-overrides.json b/data/reference/mct/carrier-overrides.json new file mode 100644 index 0000000..020630b --- /dev/null +++ b/data/reference/mct/carrier-overrides.json @@ -0,0 +1,25 @@ +{ + "version": "0.1.0", + "description": "Carrier-specific MCT overrides (SSIM Ch.8 / PSC Res 765). Starter set only — not a full industry extract.", + "authority": [ + "IATA SSIM Chapter 8", + "IATA PSC Resolution 765", + "https://www.iata.org/contentassets/638f0938b3dd451b872a1d8357755421/minimum-connecting-time-user-guide_version-1.1.pdf" + ], + "hierarchy_level": "carrier_override", + "overrides": [ + { + "airport": "ORD", + "arriving_carrier": "UA", + "departing_carrier": "UA", + "connection_status": "DD", + "connection_type": "domestic", + "scope": "online", + "minutes": 50, + "arrival_terminal": null, + "departure_terminal": null, + "source": "already-in-code: packages/agents/search/src/connection-builder/mct-data.ts carrier_rules (pre-#141)", + "notes": "Online same-carrier domestic at ORD. Not validated against a public SSIM/OAG extract." + } + ] +} diff --git a/docs/agent-map.html b/docs/agent-map.html index db149b3..f56c4f6 100644 --- a/docs/agent-map.html +++ b/docs/agent-map.html @@ -1897,7 +1897,7 @@

Every agent, by stage.

- + +