From c3d37ad250c5cae9132f23fe39ddeb1898e8ced8 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Tue, 22 Sep 2026 09:31:10 -0400 Subject: [PATCH] Fix #507: enforce ledger close-time for invoice settlement classification --- backend/src/domain/invoice-settlement.ts | 88 +--- backend/src/routes/invoice.handlers.ts | 19 +- backend/src/services/invoice.service.ts | 10 +- .../src/services/payment-monitor.service.ts | 16 +- backend/src/storage/memory-storage.ts | 24 +- .../tests/invoice-cancel-payment-race.test.ts | 31 +- .../tests/invoice-expiry-settlement.test.ts | 435 ++++++++++++++++++ backend/tests/invoice-handlers.test.ts | 29 +- backend/tests/invoice-service.test.ts | 31 +- frontend/components/InvoiceTimeline.tsx | 9 +- frontend/components/PaymentReceipt.tsx | 22 +- frontend/lib/export.ts | 21 +- frontend/lib/invoice-timeline.d.ts | 2 + frontend/lib/invoice-timeline.js | 10 +- frontend/tests/invoice-workspace.test.js | 20 + shared/invoice.ts | 8 + shared/settlement.ts | 187 ++++++++ 17 files changed, 793 insertions(+), 169 deletions(-) create mode 100644 backend/tests/invoice-expiry-settlement.test.ts create mode 100644 shared/settlement.ts diff --git a/backend/src/domain/invoice-settlement.ts b/backend/src/domain/invoice-settlement.ts index 503754b8..fbe71d7d 100644 --- a/backend/src/domain/invoice-settlement.ts +++ b/backend/src/domain/invoice-settlement.ts @@ -1,80 +1,8 @@ -import type { InvoiceStatus } from '../storage/invoice-storage'; - -export type SettlementContext = 'ON_TIME' | 'AFTER_EXPIRY' | 'AFTER_CANCEL'; -export type LatePaymentWarningCode = - | 'PAYMENT_RECEIVED_AFTER_EXPIRY' - | 'PAYMENT_RECEIVED_AFTER_CANCEL'; - -export const LATE_PAYMENT_WARNINGS: Record = { - PAYMENT_RECEIVED_AFTER_EXPIRY: 'Payment was received after this invoice expired.', - PAYMENT_RECEIVED_AFTER_CANCEL: 'Payment was received after this invoice was cancelled.', -}; - -export interface SettlementFields { - settledAt: Date; - settlementContext: SettlementContext; - priorStatus?: InvoiceStatus; - latePaymentWarningCode?: LatePaymentWarningCode; -} - -export interface SettlementInvoiceState { - status: InvoiceStatus; - cancelledAt?: Date | string | null; -} - -export class SettlementTimeUnavailableError extends Error { - readonly code = 'TRANSACTION_CLOSE_TIME_UNAVAILABLE'; - - constructor(message = 'Transaction close time is unavailable; try verification again later') { - super(message); - this.name = 'SettlementTimeUnavailableError'; - } -} - -export function parseSettlementTime(value: unknown): Date | null { - if (value instanceof Date) { - return Number.isFinite(value.getTime()) ? value : null; - } - if (typeof value !== 'string' && typeof value !== 'number') { - return null; - } - - const parsed = new Date(value); - return Number.isFinite(parsed.getTime()) ? parsed : null; -} - -export function warningForLatePayment(code: LatePaymentWarningCode): string { - return LATE_PAYMENT_WARNINGS[code]; -} - -export function settlementFieldsForInvoice( - invoice: SettlementInvoiceState, - settledAtInput: unknown -): SettlementFields { - const settledAt = parseSettlementTime(settledAtInput); - if (!settledAt) { - throw new SettlementTimeUnavailableError(); - } - - if (invoice.status === 'CANCELLED') { - const cancelledAt = parseSettlementTime(invoice.cancelledAt); - if (!cancelledAt) { - throw new SettlementTimeUnavailableError( - 'Invoice cancellation time is unavailable; try verification again later' - ); - } - - const afterCancel = settledAt.getTime() >= cancelledAt.getTime(); - return { - settledAt, - settlementContext: afterCancel ? 'AFTER_CANCEL' : 'ON_TIME', - priorStatus: 'CANCELLED', - latePaymentWarningCode: afterCancel ? 'PAYMENT_RECEIVED_AFTER_CANCEL' : undefined, - }; - } - - return { - settledAt, - settlementContext: 'ON_TIME', - }; -} +/** + * Domain re-exports for invoice settlement. + * + * All settlement contracts, types, and logic are canonical in shared/settlement.ts. + * This module re-exports them for backend domain consumers. + */ + +export * from '../../../shared/settlement'; diff --git a/backend/src/routes/invoice.handlers.ts b/backend/src/routes/invoice.handlers.ts index 2d9c0f08..45813c6e 100644 --- a/backend/src/routes/invoice.handlers.ts +++ b/backend/src/routes/invoice.handlers.ts @@ -417,7 +417,12 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa } catch (error: any) { logError('Verify payment lookup error:', error); const notFound = failure('TRANSACTION_NOT_FOUND'); - // Cache the rejection to prevent repeated Horizon lookups for invalid hashes + await cacheVerificationResult(id, hashCheck.value, 'rejected', notFound.code); + return sendVerificationFailure(res, 404, notFound.code, notFound.error); + } + + if (!txDetails || !txDetails.transaction) { + const notFound = failure('TRANSACTION_NOT_FOUND'); await cacheVerificationResult(id, hashCheck.value, 'rejected', notFound.code); return sendVerificationFailure(res, 404, notFound.code, notFound.error); } @@ -438,12 +443,11 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa }); if (!verification.ok) { - // Cache verification failures to prevent repeated attempts await cacheVerificationResult(id, hashCheck.value, 'rejected', verification.code); return sendVerificationFailure(res, 400, verification.code, verification.error); } - if (invoice.status === 'CANCELLED' && !verification.value.settledAt) { + if (!verification.value.settledAt) { return sendVerificationFailure( res, 503, @@ -462,14 +466,9 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa { settledAt: verification.value.settledAt } ); options.paymentMonitor?.unregisterWatch(id); - - // Cache successful verification await cacheVerificationResult(id, hashCheck.value, 'verified'); } catch (error) { if (error instanceof PaymentClaimError) { - // A transaction that already settled another invoice must not settle - // this one as well. 409, not 400: the request is well formed and it - // is the server's recorded state that refuses it. return sendVerificationFailure(res, 409, error.code, messageForCode(error.code)); } if (error instanceof SettlementTimeUnavailableError) { @@ -480,11 +479,9 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa messageForCode('TRANSACTION_CLOSE_TIME_UNAVAILABLE') ); } - // The payment lookup can cross expiresAt after the first status read. - // Re-read so that race still returns the public expiry contract. const latest = await storage.getInvoiceById(id); const latestStatus = latest && checkInvoiceIsPayable(latest.status); - if (latestStatus && !latestStatus.ok) { + if (latestStatus && !latestStatus.ok && latest?.status !== 'CANCELLED' && latest?.status !== 'EXPIRED') { return sendVerificationFailure( res, 400, diff --git a/backend/src/services/invoice.service.ts b/backend/src/services/invoice.service.ts index 40f55357..cb5bb34b 100644 --- a/backend/src/services/invoice.service.ts +++ b/backend/src/services/invoice.service.ts @@ -157,19 +157,25 @@ export class InvoiceService { settled_at = COALESCE($6::timestamptz, NOW()), settlement_context = CASE WHEN status = 'CANCELLED' AND $6::timestamptz >= cancelled_at THEN 'AFTER_CANCEL' + WHEN status = 'CANCELLED' THEN 'ON_TIME' + WHEN $6::timestamptz IS NOT NULL AND $6::timestamptz >= expires_at THEN 'AFTER_EXPIRY' ELSE 'ON_TIME' END, prior_status = CASE - WHEN status = 'CANCELLED' THEN status + WHEN status = 'CANCELLED' THEN 'CANCELLED' + WHEN status = 'EXPIRED' THEN 'EXPIRED' ELSE NULL END, late_payment_warning_code = CASE WHEN status = 'CANCELLED' AND $6::timestamptz >= cancelled_at THEN 'PAYMENT_RECEIVED_AFTER_CANCEL' + WHEN $6::timestamptz IS NOT NULL AND $6::timestamptz >= expires_at THEN 'PAYMENT_RECEIVED_AFTER_EXPIRY' ELSE NULL END WHERE id = $1 AND ( (status = 'PENDING' AND expires_at > NOW()) + OR (status = 'PENDING' AND $6::timestamptz IS NOT NULL) + OR (status = 'EXPIRED' AND $6::timestamptz IS NOT NULL) OR (status = 'CANCELLED' AND cancelled_at IS NOT NULL AND $6::timestamptz IS NOT NULL) ) RETURNING * @@ -211,7 +217,7 @@ export class InvoiceService { throw new Error('Invoice not found, expired, or already processed'); } - console.log('✅ Invoice marked as paid:', invoiceId); + console.log('Invoice marked as paid:', invoiceId); return this.mapRowToInvoice(result.rows[0]); } catch (error: any) { diff --git a/backend/src/services/payment-monitor.service.ts b/backend/src/services/payment-monitor.service.ts index b910ceea..528be251 100644 --- a/backend/src/services/payment-monitor.service.ts +++ b/backend/src/services/payment-monitor.service.ts @@ -452,13 +452,23 @@ export class PaymentMonitorService { } const payable = checkInvoiceIsPayable(invoice.status); - if (!payable.ok && invoice.status !== 'CANCELLED') return; + if (!payable.ok && invoice.status !== 'CANCELLED' && invoice.status !== 'EXPIRED') return; const isNative = payment.assetCode === 'XLM' && !payment.assetIssuer; + const createdAtString = typeof payment.createdAt === 'string' + ? payment.createdAt + : (payment.createdAt as unknown) instanceof Date + ? ((payment.createdAt as unknown) as Date).toISOString() + : undefined; + const verification = verifyHorizonPayment({ txHash: payment.txHash, network: this.network, - transaction: { memo: payment.memo, memo_type: payment.memoType }, + transaction: { + memo: payment.memo, + memo_type: payment.memoType, + created_at: createdAtString, + }, operations: [{ type: 'payment', from: payment.from, @@ -496,7 +506,7 @@ export class PaymentMonitorService { } const settledAt = parseSettlementTime(payment.createdAt) ?? verification.value.settledAt; - if (invoice.status === 'CANCELLED' && !settledAt) { + if (!settledAt) { throw new SettlementTimeUnavailableError(); } diff --git a/backend/src/storage/memory-storage.ts b/backend/src/storage/memory-storage.ts index 7106d3f1..1b816965 100644 --- a/backend/src/storage/memory-storage.ts +++ b/backend/src/storage/memory-storage.ts @@ -2,7 +2,10 @@ import { v4 as uuidv4 } from 'uuid'; import { calculateInvoiceStats } from './invoice-stats'; import type { InvoiceStats } from './invoice-stats'; import { isPendingInvoiceExpired } from '../domain/invoice-expiry'; -import { settlementFieldsForInvoice } from '../domain/invoice-settlement'; +import { + settlementFieldsForInvoice, + SettlementTimeUnavailableError, +} from '../domain/invoice-settlement'; import { MemoCollisionError, PaymentClaimError, @@ -119,20 +122,27 @@ class MemoryStorage { const now = new Date(); const invoice = this.invoices.get(id); if (!invoice || invoice.status === 'PAID') return undefined; - if (invoice.status === 'PENDING' && new Date(invoice.expiresAt).getTime() <= now.getTime()) { + if (invoice.status !== 'PENDING' && invoice.status !== 'CANCELLED' && invoice.status !== 'EXPIRED') { + return undefined; + } + + if (invoice.status === 'CANCELLED' && !options.settledAt) { + throw new SettlementTimeUnavailableError(); + } + if (invoice.status === 'EXPIRED' && !options.settledAt) { + return undefined; + } + + if (invoice.status === 'PENDING' && !options.settledAt && new Date(invoice.expiresAt).getTime() <= now.getTime()) { + invoice.status = 'EXPIRED'; return undefined; } - if (invoice.status !== 'PENDING' && invoice.status !== 'CANCELLED') return undefined; const settlement = settlementFieldsForInvoice( invoice, options.settledAt ?? (invoice.status === 'PENDING' ? now : undefined) ); - // One transaction settles one invoice. The claim below reads and records in - // the same synchronous step, so a second caller holding the same hash gets a - // decision here rather than a second PAID transition. A replay against this - // same invoice falls back to the "already processed" contract above. const decision = this.paymentClaims.claim(txHash, id, now); if (decision.kind === 'conflict') { throw new PaymentClaimError(txHash, id, decision.claim.invoiceId); diff --git a/backend/tests/invoice-cancel-payment-race.test.ts b/backend/tests/invoice-cancel-payment-race.test.ts index e8a7d058..1a95e581 100644 --- a/backend/tests/invoice-cancel-payment-race.test.ts +++ b/backend/tests/invoice-cancel-payment-race.test.ts @@ -174,12 +174,17 @@ class FakePostgresDb implements Queryable { if (sql.startsWith("UPDATE invoices SET status = 'PAID'") || sql.startsWith('WITH settled AS')) { const row = this.rows.find((candidate) => candidate.id === params[0]); - const settledAt = params[5] ? new Date(params[5]) : new Date(); + const rawSettledAt = params[5]; + const hasSettledAt = rawSettledAt !== undefined && rawSettledAt !== null; + const settledAt = hasSettledAt ? new Date(rawSettledAt) : new Date(); + const validSettledAt = Number.isFinite(settledAt.getTime()); const canSettle = row && ( (row.status === 'PENDING' && new Date(row.expires_at).getTime() > Date.now()) || - (row.status === 'CANCELLED' && row.cancelled_at && Number.isFinite(settledAt.getTime())) + (row.status === 'PENDING' && hasSettledAt && validSettledAt) || + (row.status === 'EXPIRED' && hasSettledAt && validSettledAt) || + (row.status === 'CANCELLED' && row.cancelled_at && hasSettledAt && validSettledAt) ); if (!row || !canSettle) { @@ -190,6 +195,10 @@ class FakePostgresDb implements Queryable { const afterCancel = priorStatus === 'CANCELLED' && settledAt.getTime() >= new Date(row.cancelled_at).getTime(); + const afterExpiry = + priorStatus !== 'CANCELLED' && + hasSettledAt && + settledAt.getTime() >= new Date(row.expires_at).getTime(); Object.assign(row, { status: 'PAID', @@ -199,9 +208,21 @@ class FakePostgresDb implements Queryable { payer_email: params[4], paid_at: new Date(), settled_at: settledAt, - settlement_context: afterCancel ? 'AFTER_CANCEL' : 'ON_TIME', - prior_status: priorStatus === 'CANCELLED' ? 'CANCELLED' : null, - late_payment_warning_code: afterCancel ? 'PAYMENT_RECEIVED_AFTER_CANCEL' : null, + settlement_context: afterCancel + ? 'AFTER_CANCEL' + : afterExpiry + ? 'AFTER_EXPIRY' + : 'ON_TIME', + prior_status: priorStatus === 'CANCELLED' + ? 'CANCELLED' + : priorStatus === 'EXPIRED' + ? 'EXPIRED' + : null, + late_payment_warning_code: afterCancel + ? 'PAYMENT_RECEIVED_AFTER_CANCEL' + : afterExpiry + ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' + : null, }); this.events.push({ invoiceId: row.id, diff --git a/backend/tests/invoice-expiry-settlement.test.ts b/backend/tests/invoice-expiry-settlement.test.ts new file mode 100644 index 00000000..8c4a44bd --- /dev/null +++ b/backend/tests/invoice-expiry-settlement.test.ts @@ -0,0 +1,435 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import type { Request, Response } from 'express'; +import { createInvoiceHandlers } from '../src/routes/invoice.handlers.ts'; +import { InvoiceMemoryService } from '../src/services/invoice-memory.service.ts'; +import { InvoiceService } from '../src/services/invoice.service.ts'; +import type { Queryable } from '../src/services/invoice.service.ts'; +import { + PaymentMonitorService, + type PaymentPageSource, +} from '../src/services/payment-monitor.service.ts'; +import type { + PaymentMonitorCheckpoint, + PaymentMonitorCheckpointStore, +} from '../src/services/payment-monitor-checkpoint.ts'; +import { SettlementTimeUnavailableError } from '../src/domain/invoice-settlement.ts'; +import { MemoryInvoiceStorage } from '../src/storage/memory-invoice-storage.ts'; +import { MemoryStorage } from '../src/storage/memory-storage.ts'; +import { PostgresInvoiceStorage } from '../src/storage/postgres-invoice-storage.ts'; +import type { InvoiceStorage } from '../src/storage/invoice-storage.ts'; + +const SELLER = 'GB3Q3VRHH3OQDYITTLONDLEHWQGKB27T2BEDSFHIUMOERULVXPDXRKG4'; +const PAYER = 'GCBIBQVH2B3STCBIYSMTQH6DWKSB2XUGLXH7RGPIN3OXPCFCIQEICVZ6'; +const TX_HASH = 'd'.repeat(64); +const TX_HASH_2 = 'e'.repeat(64); + +interface FakeResponse { + statusCode: number; + body: any; + headers: Record; +} + +function createRes(): FakeResponse & Response { + const res: any = { + statusCode: 200, + body: undefined, + headers: {}, + set(name: string, value: string) { + res.headers[name] = value; + return res; + }, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: any) { + res.body = payload; + return res; + }, + }; + return res; +} + +function createReq(init: { body?: any; params?: any; query?: any } = {}): Request { + return { + body: init.body || {}, + params: init.params || {}, + query: init.query || {}, + } as unknown as Request; +} + +async function call( + handler: (req: Request, res: Response) => Promise, + req: Request +): Promise { + const res = createRes(); + await handler(req, res); + return res; +} + +function invoiceBody(overrides: Record = {}) { + return { + sellerPublicKey: SELLER, + amount: 42.5, + assetCode: 'XLM', + description: 'Expiry settlement fixture', + expiresInDays: 1, + ...overrides, + }; +} + +function isoOffset(baseIso: string | Date, deltaMs: number): string { + return new Date(new Date(baseIso).getTime() + deltaMs).toISOString(); +} + +function paymentTransaction(overrides: { + memo: string; + amount?: string; + createdAt?: string; +}) { + return { + transaction: { + memo: overrides.memo, + created_at: overrides.createdAt, + }, + operations: [ + { + type: 'payment', + from: PAYER, + to: SELLER, + amount: overrides.amount ?? '42.5000000', + asset_type: 'native', + }, + ], + }; +} + +class FakePostgresDb implements Queryable { + rows: any[] = []; + events: any[] = []; + + async query(sql: string, params: any[] = []): Promise<{ rows: any[]; rowCount: number }> { + const normalized = sql.replace(/\s+/g, ' ').trim(); + + if (normalized.startsWith('INSERT INTO invoices')) { + const row = { + id: params[0], + seller_public_key: params[1], + seller_name: params[2], + seller_email: params[3], + amount: String(params[4]), + asset_code: params[5], + asset_issuer: params[6], + memo: params[7], + description: params[8], + customer_name: params[9], + customer_email: params[10], + status: params[11], + expires_at: params[12], + payment_tx_hash: null, + payer_public_key: null, + payer_name: null, + payer_email: null, + created_at: new Date(), + paid_at: null, + cancelled_at: null, + settled_at: null, + settlement_context: null, + prior_status: null, + late_payment_warning_code: null, + metadata: null, + }; + this.rows.push(row); + return { rows: [{ ...row }], rowCount: 1 }; + } + + if (normalized.startsWith("UPDATE invoices SET status = 'EXPIRED'")) { + const now = new Date(params[0]).getTime(); + const expired = this.rows.filter( + (row) => row.status === 'PENDING' && new Date(row.expires_at).getTime() <= now + ); + expired.forEach((row) => { + row.status = 'EXPIRED'; + }); + return { rows: expired.map((row) => ({ id: row.id })), rowCount: expired.length }; + } + + if (normalized.startsWith("UPDATE invoices SET status = 'PAID'") || normalized.startsWith('WITH settled AS')) { + const row = this.rows.find((candidate) => candidate.id === params[0]); + const rawSettledAt = params[5]; + const hasSettledAt = rawSettledAt !== undefined && rawSettledAt !== null; + const settledAt = hasSettledAt ? new Date(rawSettledAt) : new Date(); + const validSettledAt = Number.isFinite(settledAt.getTime()); + const canSettle = + row && + ( + (row.status === 'PENDING' && new Date(row.expires_at).getTime() > Date.now()) || + (row.status === 'PENDING' && hasSettledAt && validSettledAt) || + (row.status === 'EXPIRED' && hasSettledAt && validSettledAt) || + (row.status === 'CANCELLED' && row.cancelled_at && hasSettledAt && validSettledAt) + ); + + if (!row || !canSettle) { + return { rows: [], rowCount: 0 }; + } + + const priorStatus = row.status; + const afterCancel = + priorStatus === 'CANCELLED' && + settledAt.getTime() >= new Date(row.cancelled_at).getTime(); + const afterExpiry = + priorStatus !== 'CANCELLED' && + hasSettledAt && + settledAt.getTime() >= new Date(row.expires_at).getTime(); + + Object.assign(row, { + status: 'PAID', + payment_tx_hash: params[1], + payer_public_key: params[2], + payer_name: params[3], + payer_email: params[4], + paid_at: new Date(), + settled_at: settledAt, + settlement_context: afterCancel + ? 'AFTER_CANCEL' + : afterExpiry + ? 'AFTER_EXPIRY' + : 'ON_TIME', + prior_status: priorStatus === 'CANCELLED' + ? 'CANCELLED' + : priorStatus === 'EXPIRED' + ? 'EXPIRED' + : null, + late_payment_warning_code: afterCancel + ? 'PAYMENT_RECEIVED_AFTER_CANCEL' + : afterExpiry + ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' + : null, + }); + + return { rows: [{ ...row }], rowCount: 1 }; + } + + if (normalized.startsWith('INSERT INTO payment_events')) { + this.events.push({ invoiceId: params[0], type: params[1], data: params[2] }); + return { rows: [], rowCount: 1 }; + } + + if (normalized.startsWith('SELECT * FROM invoices WHERE id =')) { + const found = this.rows.filter((row) => row.id === params[0]); + return { rows: found.map((row) => ({ ...row })), rowCount: found.length }; + } + + if (normalized.startsWith('SELECT * FROM invoices WHERE memo =')) { + const found = this.rows.filter((row) => row.memo === params[0]); + return { rows: found.map((row) => ({ ...row })), rowCount: found.length }; + } + + throw new Error(`Unhandled query in fake Postgres: ${sql}`); + } +} + +function runExpiryAttributionSuite(name: string, createStorage: () => InvoiceStorage) { + describe(`expiry settlement classification on ${name}`, () => { + let storage: InvoiceStorage; + let transaction: any; + + const handlers = () => + createInvoiceHandlers({ + storage, + frontendUrl: 'http://localhost:3000', + allowSimulate: false, + stellar: { + getTransaction: async () => transaction, + } as any, + }); + + const createInvoice = async () => { + const res = await call(handlers().createInvoice, createReq({ body: invoiceBody() })); + assert.equal(res.statusCode, 201, JSON.stringify(res.body)); + return res.body.data.invoice; + }; + + beforeEach(() => { + storage = createStorage(); + transaction = undefined; + }); + + it('settles a payment whose close_time is after expiresAt as AFTER_EXPIRY', async () => { + const invoice = await createInvoice(); + const settledAt = isoOffset(invoice.expiresAt, 5000); + transaction = paymentTransaction({ memo: invoice.memo, createdAt: settledAt }); + + const verified = await call( + handlers().verifyPayment, + createReq({ params: { id: invoice.id }, body: { txHash: TX_HASH } }) + ); + + assert.equal(verified.statusCode, 200, JSON.stringify(verified.body)); + assert.equal(verified.body.success, true); + assert.equal(verified.body.code, 'PAYMENT_RECEIVED_AFTER_EXPIRY'); + assert.equal(verified.body.warning, 'Payment was received after this invoice expired.'); + assert.equal(verified.body.data.status, 'PAID'); + assert.equal(verified.body.data.paymentTxHash, TX_HASH); + assert.equal(verified.body.data.settlementContext, 'AFTER_EXPIRY'); + assert.equal(new Date(verified.body.data.settledAt).toISOString(), settledAt); + assert.equal(verified.body.data.latePaymentWarningCode, 'PAYMENT_RECEIVED_AFTER_EXPIRY'); + }); + + it('settles a payment whose close_time is before expiresAt as ON_TIME even under clock skew', async () => { + const invoice = await createInvoice(); + const settledAt = isoOffset(invoice.expiresAt, -5000); + transaction = paymentTransaction({ memo: invoice.memo, createdAt: settledAt }); + + const verified = await call( + handlers().verifyPayment, + createReq({ params: { id: invoice.id }, body: { txHash: TX_HASH } }) + ); + + assert.equal(verified.statusCode, 200, JSON.stringify(verified.body)); + assert.equal(verified.body.code, undefined); + assert.equal(verified.body.warning, undefined); + assert.equal(verified.body.data.status, 'PAID'); + assert.equal(verified.body.data.settlementContext, 'ON_TIME'); + assert.equal(new Date(verified.body.data.settledAt).toISOString(), settledAt); + assert.equal(Boolean(verified.body.data.latePaymentWarningCode), false); + }); + + it('fails closed with 503 when Horizon transaction close_time is missing', async () => { + const invoice = await createInvoice(); + transaction = paymentTransaction({ memo: invoice.memo, createdAt: undefined }); + + const verified = await call( + handlers().verifyPayment, + createReq({ params: { id: invoice.id }, body: { txHash: TX_HASH } }) + ); + + assert.equal(verified.statusCode, 503, JSON.stringify(verified.body)); + assert.equal(verified.body.code, 'TRANSACTION_CLOSE_TIME_UNAVAILABLE'); + + const stored = await storage.getInvoiceById(invoice.id); + assert.equal(stored?.status, 'PENDING'); + assert.equal(Boolean(stored?.paymentTxHash), false); + }); + }); +} + +runExpiryAttributionSuite('in-memory storage', () => + new MemoryInvoiceStorage(new InvoiceMemoryService(new MemoryStorage())) +); + +runExpiryAttributionSuite('postgres storage double', () => + new PostgresInvoiceStorage(new InvoiceService(new FakePostgresDb())) +); + +class MemoryCheckpointStore implements PaymentMonitorCheckpointStore { + value: PaymentMonitorCheckpoint | null = { + account: SELLER, + network: 'TESTNET', + cursor: 'cursor-0', + updatedAt: new Date(), + }; + + async load() { + return this.value; + } + + async save(value: Omit) { + this.value = { ...value, updatedAt: new Date() }; + } +} + +function monitorSource(createdAt: string | undefined): PaymentPageSource { + return { + async getLatestPaymentCursor() { + return 'cursor-0'; + }, + async getPaymentsPage() { + return [ + { + pagingToken: 'cursor-1', + ledger: 123, + payment: { + id: 'payment-1', + txHash: TX_HASH_2, + from: PAYER, + to: SELLER, + amount: '42.5000000', + assetCode: 'XLM', + memo: 'INV-EXPIRY-MONITOR', + memoType: 'text', + ledger: 123, + createdAt, + }, + }, + ]; + }, + }; +} + +describe('payment monitor expiry settlement attribution', () => { + let rawStorage: MemoryStorage; + let invoiceService: InvoiceMemoryService; + + beforeEach(() => { + rawStorage = new MemoryStorage(); + invoiceService = new InvoiceMemoryService(rawStorage); + }); + + it('marks invoice PAID with AFTER_EXPIRY when monitor detects payment after expiresAt', async () => { + const created = rawStorage.createInvoice({ + sellerPublicKey: SELLER, + amount: 42.5, + memo: 'INV-EXPIRY-MONITOR', + expiresAt: new Date(Date.now() - 10000), + }); + + const settledAt = new Date(Date.now() - 5000).toISOString(); + const monitor = new PaymentMonitorService({ + account: SELLER, + network: 'TESTNET', + source: monitorSource(settledAt), + invoices: invoiceService, + checkpoints: new MemoryCheckpointStore(), + database: undefined, + }); + + await monitor.runOnce(); + + const stored = rawStorage.getInvoiceById(created.id); + assert.equal(stored?.status, 'PAID'); + assert.equal(stored?.paymentTxHash, TX_HASH_2); + assert.equal(stored?.settlementContext, 'AFTER_EXPIRY'); + assert.equal(stored?.latePaymentWarningCode, 'PAYMENT_RECEIVED_AFTER_EXPIRY'); + }); + + it('does not mark PAID when monitor encounters payment with missing close_time', async () => { + const created = rawStorage.createInvoice({ + sellerPublicKey: SELLER, + amount: 42.5, + memo: 'INV-EXPIRY-MONITOR', + expiresAt: new Date(Date.now() - 10000), + }); + + const monitor = new PaymentMonitorService({ + account: SELLER, + network: 'TESTNET', + source: monitorSource(undefined), + invoices: invoiceService, + checkpoints: new MemoryCheckpointStore(), + database: undefined, + }); + + await assert.rejects( + async () => { + await monitor.runOnce(); + }, + (error: any) => error.name === 'SettlementTimeUnavailableError' + ); + + const stored = rawStorage.getInvoiceById(created.id); + assert.notEqual(stored?.status, 'PAID'); + assert.equal(Boolean(stored?.paymentTxHash), false); + }); +}); diff --git a/backend/tests/invoice-handlers.test.ts b/backend/tests/invoice-handlers.test.ts index e56a0760..1d0bf354 100644 --- a/backend/tests/invoice-handlers.test.ts +++ b/backend/tests/invoice-handlers.test.ts @@ -140,8 +140,9 @@ function createFakePostgres() { const row = rows.find( candidate => candidate.id === params[0] && ( - (candidate.status === 'PENDING' && new Date(candidate.expires_at).getTime() > Date.now()) || - (candidate.status === 'CANCELLED' && candidate.cancelled_at && Number.isFinite(settledAt.getTime())) + (candidate.status === 'PENDING' && (params[5] || new Date(candidate.expires_at).getTime() > Date.now())) || + (candidate.status === 'CANCELLED' && candidate.cancelled_at && Number.isFinite(settledAt.getTime())) || + (candidate.status === 'EXPIRED' && Number.isFinite(settledAt.getTime())) ) ); if (!row) { @@ -151,6 +152,19 @@ function createFakePostgres() { const afterCancel = priorStatus === 'CANCELLED' && settledAt.getTime() >= new Date(row.cancelled_at).getTime(); + const afterExpiry = + priorStatus !== 'CANCELLED' && + settledAt.getTime() >= new Date(row.expires_at).getTime(); + const settlementContext = afterCancel + ? 'AFTER_CANCEL' + : afterExpiry + ? 'AFTER_EXPIRY' + : 'ON_TIME'; + const lateWarning = afterCancel + ? 'PAYMENT_RECEIVED_AFTER_CANCEL' + : afterExpiry + ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' + : null; Object.assign(row, { status: 'PAID', payment_tx_hash: params[1], @@ -159,9 +173,9 @@ function createFakePostgres() { payer_email: params[4], paid_at: new Date(), settled_at: settledAt, - settlement_context: afterCancel ? 'AFTER_CANCEL' : 'ON_TIME', - prior_status: priorStatus === 'CANCELLED' ? 'CANCELLED' : null, - late_payment_warning_code: afterCancel ? 'PAYMENT_RECEIVED_AFTER_CANCEL' : null, + settlement_context: settlementContext, + prior_status: priorStatus === 'CANCELLED' || priorStatus === 'EXPIRED' ? priorStatus : null, + late_payment_warning_code: lateWarning, }); return { rows: [clone(row)], rowCount: 1 }; } @@ -217,9 +231,10 @@ function paymentTransaction(overrides: { to: string; assetType?: string; assetCode?: string; + createdAt?: string; }) { return { - transaction: { memo: overrides.memo }, + transaction: { memo: overrides.memo, created_at: overrides.createdAt ?? new Date().toISOString() }, operations: [ { type: 'payment', @@ -310,7 +325,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage assert.equal(got.body.data.customerEmail, customerEmail); transaction = { - transaction: { memo: created.memo }, + transaction: { memo: created.memo, created_at: new Date().toISOString() }, operations: [ { type: 'payment', diff --git a/backend/tests/invoice-service.test.ts b/backend/tests/invoice-service.test.ts index abc04ba9..19cb482a 100644 --- a/backend/tests/invoice-service.test.ts +++ b/backend/tests/invoice-service.test.ts @@ -62,12 +62,17 @@ class FakeInvoiceDb implements Queryable { if (sql.startsWith("UPDATE invoices SET status = 'PAID'") || sql.startsWith('WITH settled AS')) { const now = Date.now(); - const settledAt = params[5] ? new Date(params[5]) : new Date(); + const rawSettledAt = params[5]; + const hasSettledAt = rawSettledAt !== undefined && rawSettledAt !== null; + const settledAt = hasSettledAt ? new Date(rawSettledAt) : new Date(); + const validSettledAt = Number.isFinite(settledAt.getTime()); const row = this.rows.find( candidate => candidate.id === params[0] && ( (candidate.status === 'PENDING' && new Date(candidate.expires_at).getTime() > now) || - (candidate.status === 'CANCELLED' && candidate.cancelled_at && Number.isFinite(settledAt.getTime())) + (candidate.status === 'PENDING' && hasSettledAt && validSettledAt) || + (candidate.status === 'EXPIRED' && hasSettledAt && validSettledAt) || + (candidate.status === 'CANCELLED' && candidate.cancelled_at && hasSettledAt && validSettledAt) ) ); if (!row) { @@ -77,6 +82,10 @@ class FakeInvoiceDb implements Queryable { const afterCancel = priorStatus === 'CANCELLED' && settledAt.getTime() >= new Date(row.cancelled_at).getTime(); + const afterExpiry = + priorStatus !== 'CANCELLED' && + hasSettledAt && + settledAt.getTime() >= new Date(row.expires_at).getTime(); row.status = 'PAID'; row.payment_tx_hash = params[1]; row.payer_public_key = params[2]; @@ -84,9 +93,21 @@ class FakeInvoiceDb implements Queryable { row.payer_email = params[4]; row.paid_at = new Date(); row.settled_at = settledAt; - row.settlement_context = afterCancel ? 'AFTER_CANCEL' : 'ON_TIME'; - row.prior_status = priorStatus === 'CANCELLED' ? 'CANCELLED' : null; - row.late_payment_warning_code = afterCancel ? 'PAYMENT_RECEIVED_AFTER_CANCEL' : null; + row.settlement_context = afterCancel + ? 'AFTER_CANCEL' + : afterExpiry + ? 'AFTER_EXPIRY' + : 'ON_TIME'; + row.prior_status = priorStatus === 'CANCELLED' + ? 'CANCELLED' + : priorStatus === 'EXPIRED' + ? 'EXPIRED' + : null; + row.late_payment_warning_code = afterCancel + ? 'PAYMENT_RECEIVED_AFTER_CANCEL' + : afterExpiry + ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' + : null; return { rows: [{ ...row }], rowCount: 1 }; } diff --git a/frontend/components/InvoiceTimeline.tsx b/frontend/components/InvoiceTimeline.tsx index bac8ae42..7810dc78 100644 --- a/frontend/components/InvoiceTimeline.tsx +++ b/frontend/components/InvoiceTimeline.tsx @@ -6,6 +6,7 @@ import { buildInvoiceTimelineEvents } from '@/lib/invoice-timeline'; import { getTimeRemaining } from '@/lib/utils'; import { buildHorizonTxUrl, resolveExplorerNetwork } from '@/lib/explorer-tx-link'; import { getExplorerTransactionUrl } from '@/lib/stellar'; +import { getLatePaymentTimelineCopy } from '@shared/settlement'; interface InvoiceTimelineInvoice { status?: string; @@ -16,6 +17,8 @@ interface InvoiceTimelineInvoice { cancelledAt?: string; payerPublicKey?: string; paymentTxHash?: string; + settlementContext?: 'ON_TIME' | 'AFTER_EXPIRY' | 'AFTER_CANCEL' | null; + priorStatus?: string | null; latePaymentWarningCode?: 'PAYMENT_RECEIVED_AFTER_EXPIRY' | 'PAYMENT_RECEIVED_AFTER_CANCEL' | null; network?: string; } @@ -25,10 +28,6 @@ interface InvoiceTimelineProps { now?: number; } -const LATE_WARNING_COPY: Record = { - PAYMENT_RECEIVED_AFTER_EXPIRY: 'This payment arrived after the invoice had expired.', - PAYMENT_RECEIVED_AFTER_CANCEL: 'This payment arrived after the invoice had been cancelled.', -}; function iconFor(type: string) { switch (type) { @@ -117,7 +116,7 @@ export default function InvoiceTimeline({ invoice, now }: InvoiceTimelineProps) className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" aria-hidden="true" /> - {LATE_WARNING_COPY[event.lateWarningCode]} + {getLatePaymentTimelineCopy(event.lateWarningCode)}

)} diff --git a/frontend/components/PaymentReceipt.tsx b/frontend/components/PaymentReceipt.tsx index 40e64494..0b0c9d19 100644 --- a/frontend/components/PaymentReceipt.tsx +++ b/frontend/components/PaymentReceipt.tsx @@ -9,32 +9,14 @@ import { canSendProofEmail, getProofMailtoRecipient } from '@/lib/mailto-deliver import { toast } from 'sonner'; import type { PayPageInvoice } from './pay-page.types'; import { buildHorizonTxUrl, resolveExplorerNetwork } from '@/lib/explorer-tx-link'; -// The receipt renders a settled (paid / expired / cancelled) record. It shares -// the same status vocabulary as PaymentStatus and the verification rejection -// table, so the proof view and the pay page never disagree on wording. +import { getLatePaymentWarning } from '@shared/settlement'; interface PaymentReceiptProps { invoice: PayPageInvoice; } -function latePaymentWarning(invoice: PayPageInvoice): { title: string; body: string } | null { - if (invoice.latePaymentWarningCode === 'PAYMENT_RECEIVED_AFTER_CANCEL') { - return { - title: 'Payment received after cancellation', - body: 'This transaction proves funds reached the seller. Contact the seller to reconcile the payment.', - }; - } - if (invoice.latePaymentWarningCode === 'PAYMENT_RECEIVED_AFTER_EXPIRY') { - return { - title: 'Payment received after invoice expiry', - body: 'This transaction proves funds reached the seller after the original payment window.', - }; - } - return null; -} - export default function PaymentReceipt({ invoice }: PaymentReceiptProps) { - const warning = latePaymentWarning(invoice); + const warning = getLatePaymentWarning(invoice.latePaymentWarningCode); const handleDownloadPDF = () => { openInvoicePDF(invoice as any); toast.success('Opening payment proof'); diff --git a/frontend/lib/export.ts b/frontend/lib/export.ts index da3f3cbc..346dda8f 100644 --- a/frontend/lib/export.ts +++ b/frontend/lib/export.ts @@ -14,9 +14,8 @@ import { openProofMailto, } from './mailto-delivery.js'; -// The explorer rule is shared with the receipt and the proof email, so the -// printed document cannot name a different network from the link it prints. import { buildHorizonTxUrl, resolveExplorerNetwork } from './explorer-tx-link.ts'; +import { getLatePaymentWarning } from '../../shared/settlement.ts'; import { buildQuittanceProof, @@ -86,22 +85,6 @@ interface Invoice { paymentTxHash?: string; } -function latePaymentWarning(invoice: Invoice): { title: string; body: string } | null { - if (invoice.latePaymentWarningCode === 'PAYMENT_RECEIVED_AFTER_CANCEL') { - return { - title: 'Payment received after cancellation', - body: 'This transaction proves funds reached the seller. Contact the seller to reconcile the payment.', - }; - } - if (invoice.latePaymentWarningCode === 'PAYMENT_RECEIVED_AFTER_EXPIRY') { - return { - title: 'Payment received after invoice expiry', - body: 'This transaction proves funds reached the seller after the original payment window.', - }; - } - return null; -} - export function generateInvoiceCSV(invoices: Invoice[]): string { const headers = [ 'Invoice ID', @@ -188,7 +171,7 @@ export function generateInvoicePDF(invoiceOrProof: Invoice | QuittanceProof): st * a URL that resolves to nothing. */ const explorerUrl = isPaid ? buildHorizonTxUrl(invoice.paymentTxHash, explorerNetwork) : null; - const warning = latePaymentWarning(invoice); + const warning = getLatePaymentWarning(invoice.latePaymentWarningCode); return ` diff --git a/frontend/lib/invoice-timeline.d.ts b/frontend/lib/invoice-timeline.d.ts index 9b2ed1ad..04684453 100644 --- a/frontend/lib/invoice-timeline.d.ts +++ b/frontend/lib/invoice-timeline.d.ts @@ -24,6 +24,8 @@ export interface InvoiceTimelineInput { cancelledAt?: string; payerPublicKey?: string; paymentTxHash?: string; + settlementContext?: 'ON_TIME' | 'AFTER_EXPIRY' | 'AFTER_CANCEL' | null; + priorStatus?: string | null; latePaymentWarningCode?: 'PAYMENT_RECEIVED_AFTER_EXPIRY' | 'PAYMENT_RECEIVED_AFTER_CANCEL' | null; } diff --git a/frontend/lib/invoice-timeline.js b/frontend/lib/invoice-timeline.js index 140da578..85094da4 100644 --- a/frontend/lib/invoice-timeline.js +++ b/frontend/lib/invoice-timeline.js @@ -42,11 +42,11 @@ function buildInvoiceTimelineEvents(invoice, now = Date.now()) { timestamp: invoice.createdAt ?? null, }); - // `cancelledAt` is never cleared even if a later payment settles the - // invoice anyway (see backend/src/domain/invoice-settlement.ts's - // AFTER_CANCEL path), so this checks the timestamp directly rather than - // `invoice.status === 'CANCELLED'`, which a late payment can overwrite. const wasCancelled = Boolean(invoice.cancelledAt); + const wasExpired = + invoice.priorStatus === 'EXPIRED' || + invoice.settlementContext === 'AFTER_EXPIRY' || + invoice.latePaymentWarningCode === 'PAYMENT_RECEIVED_AFTER_EXPIRY'; const paidAt = invoice.settledAt ?? invoice.paidAt ?? null; const isPaid = invoice.status === 'PAID' && Boolean(paidAt); @@ -56,7 +56,7 @@ function buildInvoiceTimelineEvents(invoice, now = Date.now()) { label: 'Invoice cancelled', timestamp: invoice.cancelledAt, }); - } else if (!isPaid && hasInvoiceExpired(invoice, now)) { + } else if (wasExpired || (!isPaid && hasInvoiceExpired(invoice, now))) { events.push({ type: 'expired', label: 'Invoice expired', diff --git a/frontend/tests/invoice-workspace.test.js b/frontend/tests/invoice-workspace.test.js index 8ee118fe..24684c7b 100644 --- a/frontend/tests/invoice-workspace.test.js +++ b/frontend/tests/invoice-workspace.test.js @@ -116,6 +116,26 @@ test('buildInvoiceTimelineEvents: a late payment after cancellation shows all th assert.equal(events[2].lateWarningCode, 'PAYMENT_RECEIVED_AFTER_CANCEL'); }); +test('buildInvoiceTimelineEvents: a late payment after expiry shows created, expired, paid in chronological order, flagged', () => { + const invoice = { + status: 'PAID', + createdAt: '2026-08-29T09:00:00.000Z', + expiresAt: '2026-08-30T09:00:00.000Z', + settledAt: '2026-08-30T11:00:00.000Z', + priorStatus: 'EXPIRED', + settlementContext: 'AFTER_EXPIRY', + latePaymentWarningCode: 'PAYMENT_RECEIVED_AFTER_EXPIRY', + paymentTxHash: 'ghi789', + }; + const events = buildInvoiceTimelineEvents(invoice, NOW); + assert.deepEqual( + events.map((e) => e.type), + ['created', 'expired', 'paid'] + ); + assert.equal(events[1].timestamp, invoice.expiresAt); + assert.equal(events[2].lateWarningCode, 'PAYMENT_RECEIVED_AFTER_EXPIRY'); +}); + test('buildInvoiceTimelineEvents: falls back to paidAt when settledAt is absent (legacy records)', () => { const invoice = { status: 'PAID', diff --git a/shared/invoice.ts b/shared/invoice.ts index d2b19353..44d4f6c1 100644 --- a/shared/invoice.ts +++ b/shared/invoice.ts @@ -12,6 +12,8 @@ export type InvoiceStatus = 'PENDING' | 'PAID' | 'EXPIRED' | 'CANCELLED'; +import type { LatePaymentWarningCode, SettlementContext } from './settlement'; + /** ISO-8601 timestamp, as produced by JSON serialisation of a Date. */ export type IsoTimestamp = string; @@ -35,6 +37,12 @@ export interface InvoiceDto { payerEmail?: string; createdAt: IsoTimestamp; paidAt?: IsoTimestamp; + cancelledAt?: IsoTimestamp; + settledAt?: IsoTimestamp; + settlementContext?: SettlementContext; + priorStatus?: string; + latePaymentWarningCode?: LatePaymentWarningCode; expiresAt: IsoTimestamp; metadata?: unknown; } + diff --git a/shared/settlement.ts b/shared/settlement.ts new file mode 100644 index 00000000..ae9124ff --- /dev/null +++ b/shared/settlement.ts @@ -0,0 +1,187 @@ +/** + * Canonical invoice settlement contract shared by backend and frontend. + * + * Classifies on-chain payment settlement relative to invoice expiry and + * cancellation timestamps, producing consistent settlement context, warning + * codes, and user-facing copy. + */ + +import type { InvoiceStatus } from './invoice'; + +export type SettlementContext = 'ON_TIME' | 'AFTER_EXPIRY' | 'AFTER_CANCEL'; + +export type LatePaymentWarningCode = + | 'PAYMENT_RECEIVED_AFTER_EXPIRY' + | 'PAYMENT_RECEIVED_AFTER_CANCEL'; + +export interface LatePaymentWarningDefinition { + code: LatePaymentWarningCode; + apiMessage: string; + title: string; + body: string; + timelineCopy: string; +} + +export const LATE_PAYMENT_WARNING_DEFINITIONS: Record< + LatePaymentWarningCode, + LatePaymentWarningDefinition +> = { + PAYMENT_RECEIVED_AFTER_EXPIRY: { + code: 'PAYMENT_RECEIVED_AFTER_EXPIRY', + apiMessage: 'Payment was received after this invoice expired.', + title: 'Payment received after invoice expiry', + body: 'This transaction proves funds reached the seller after the original payment window.', + timelineCopy: 'This payment arrived after the invoice had expired.', + }, + PAYMENT_RECEIVED_AFTER_CANCEL: { + code: 'PAYMENT_RECEIVED_AFTER_CANCEL', + apiMessage: 'Payment was received after this invoice was cancelled.', + title: 'Payment received after cancellation', + body: 'This transaction proves funds reached the seller. Contact the seller to reconcile the payment.', + timelineCopy: 'This payment arrived after the invoice had been cancelled.', + }, +}; + +/** + * Mapping of late payment warning codes to API messages. + */ +export const LATE_PAYMENT_WARNINGS: Record = { + PAYMENT_RECEIVED_AFTER_EXPIRY: + LATE_PAYMENT_WARNING_DEFINITIONS.PAYMENT_RECEIVED_AFTER_EXPIRY.apiMessage, + PAYMENT_RECEIVED_AFTER_CANCEL: + LATE_PAYMENT_WARNING_DEFINITIONS.PAYMENT_RECEIVED_AFTER_CANCEL.apiMessage, +}; + +export interface SettlementFields { + settledAt: Date; + settlementContext: SettlementContext; + priorStatus?: InvoiceStatus; + latePaymentWarningCode?: LatePaymentWarningCode; +} + +export interface SettlementInvoiceState { + status: InvoiceStatus | string; + expiresAt?: Date | string | null; + cancelledAt?: Date | string | null; +} + + +export class SettlementTimeUnavailableError extends Error { + readonly code = 'TRANSACTION_CLOSE_TIME_UNAVAILABLE'; + + constructor(message = 'Transaction close time is unavailable; try verification again later') { + super(message); + this.name = 'SettlementTimeUnavailableError'; + } +} + +/** + * Parses a candidate settlement timestamp into a valid Date instance or null. + * + * @param value - Candidate date, string, or number timestamp. + * @returns Date instance if valid and finite, otherwise null. + */ +export function parseSettlementTime(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value : null; + } + if (typeof value !== 'string' && typeof value !== 'number') { + return null; + } + + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) ? parsed : null; +} + +/** + * Retrieves the API warning message for a given late payment code. + * + * @param code - Late payment warning code. + * @returns User-facing warning message string. + */ +export function warningForLatePayment(code: LatePaymentWarningCode): string { + return LATE_PAYMENT_WARNINGS[code]; +} + +/** + * Returns title and body copy for receipt or PDF rendering. + * + * @param code - Optional late payment warning code. + * @returns Title and body strings, or null if code is absent or unrecognised. + */ +export function getLatePaymentWarning( + code?: string | null +): { title: string; body: string } | null { + if (!code || !(code in LATE_PAYMENT_WARNING_DEFINITIONS)) { + return null; + } + const def = LATE_PAYMENT_WARNING_DEFINITIONS[code as LatePaymentWarningCode]; + return { + title: def.title, + body: def.body, + }; +} + +/** + * Returns timeline event copy for a given late payment code. + * + * @param code - Optional late payment warning code. + * @returns Single-sentence copy, or null if code is absent or unrecognised. + */ +export function getLatePaymentTimelineCopy(code?: string | null): string | null { + if (!code || !(code in LATE_PAYMENT_WARNING_DEFINITIONS)) { + return null; + } + return LATE_PAYMENT_WARNING_DEFINITIONS[code as LatePaymentWarningCode].timelineCopy; +} + +/** + * Evaluates settlement context, prior status, and late payment warnings + * by comparing ledger close time against invoice lifecycle milestones. + * + * @param invoice - Invoice state containing status, expiresAt, and cancelledAt. + * @param settledAtInput - Ledger close time candidate. + * @returns Evaluated settlement fields. + * @throws SettlementTimeUnavailableError when close time or milestone time is missing. + */ +export function settlementFieldsForInvoice( + invoice: SettlementInvoiceState, + settledAtInput: unknown +): SettlementFields { + const settledAt = parseSettlementTime(settledAtInput); + if (!settledAt) { + throw new SettlementTimeUnavailableError(); + } + + if (invoice.status === 'CANCELLED') { + const cancelledAt = parseSettlementTime(invoice.cancelledAt); + if (!cancelledAt) { + throw new SettlementTimeUnavailableError( + 'Invoice cancellation time is unavailable; try verification again later' + ); + } + + const afterCancel = settledAt.getTime() >= cancelledAt.getTime(); + return { + settledAt, + settlementContext: afterCancel ? 'AFTER_CANCEL' : 'ON_TIME', + priorStatus: 'CANCELLED', + latePaymentWarningCode: afterCancel ? 'PAYMENT_RECEIVED_AFTER_CANCEL' : undefined, + }; + } + + const expiresAt = parseSettlementTime(invoice.expiresAt); + if (!expiresAt) { + throw new SettlementTimeUnavailableError( + 'Invoice expiry time is unavailable; try verification again later' + ); + } + + const afterExpiry = settledAt.getTime() >= expiresAt.getTime(); + return { + settledAt, + settlementContext: afterExpiry ? 'AFTER_EXPIRY' : 'ON_TIME', + priorStatus: invoice.status === 'EXPIRED' ? 'EXPIRED' : undefined, + latePaymentWarningCode: afterExpiry ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' : undefined, + }; +}