diff --git a/backend/src/domain/invoice-settlement.ts b/backend/src/domain/invoice-settlement.ts index 6b92753..fc54ccd 100644 --- a/backend/src/domain/invoice-settlement.ts +++ b/backend/src/domain/invoice-settlement.ts @@ -32,6 +32,43 @@ export class SettlementTimeUnavailableError extends Error { } } +export class IllegalStatusTransitionError extends Error { + readonly code = 'ILLEGAL_STATUS_TRANSITION'; + readonly fromStatus: InvoiceStatus; + readonly toStatus: InvoiceStatus; + + constructor(fromStatus: InvoiceStatus, toStatus: InvoiceStatus) { + super(`Cannot transition invoice from ${fromStatus} to ${toStatus}`); + this.name = 'IllegalStatusTransitionError'; + this.fromStatus = fromStatus; + this.toStatus = toStatus; + } +} + +export const LEGAL_STATUS_TRANSITIONS: Readonly> = Object.freeze({ + PENDING: Object.freeze(['PAID', 'EXPIRED', 'CANCELLED'] as const), + EXPIRED: Object.freeze(['PAID'] as const), + PAID: Object.freeze([] as const), + CANCELLED: Object.freeze([] as const), +}); + +/** + * Validates whether a transition from one status to another is legal. + */ +export function isLegalStatusTransition(from: InvoiceStatus, to: InvoiceStatus): boolean { + const allowed = LEGAL_STATUS_TRANSITIONS[from]; + return allowed ? allowed.includes(to) : false; +} + +/** + * Asserts that a transition from one status to another is legal, throwing IllegalStatusTransitionError if not. + */ +export function assertLegalStatusTransition(from: InvoiceStatus, to: InvoiceStatus): void { + if (!isLegalStatusTransition(from, to)) { + throw new IllegalStatusTransitionError(from, to); + } +} + export function parseSettlementTime(value: unknown): Date | null { if (value instanceof Date) { return Number.isFinite(value.getTime()) ? value : null; @@ -48,34 +85,20 @@ export function warningForLatePayment(code: LatePaymentWarningCode): string { return LATE_PAYMENT_WARNINGS[code]; } +/** + * Computes settlement fields for settling an invoice, enforcing legal status transitions. + */ export function settlementFieldsForInvoice( invoice: SettlementInvoiceState, settledAtInput: unknown ): SettlementFields { + assertLegalStatusTransition(invoice.status, 'PAID'); + 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, - }; - } - - // PENDING or EXPIRED: the ledger close time, not the detection time, decides - // whether the payment landed inside the invoice's lifetime. const expiresAt = parseSettlementTime(invoice.expiresAt); const afterExpiry = expiresAt ? settledAt.getTime() >= expiresAt.getTime() @@ -89,3 +112,4 @@ export function settlementFieldsForInvoice( latePaymentWarningCode: afterExpiry ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' : undefined, }; } + diff --git a/backend/src/routes/invoice.handlers.ts b/backend/src/routes/invoice.handlers.ts index 036bbdd..b7d5ebc 100644 --- a/backend/src/routes/invoice.handlers.ts +++ b/backend/src/routes/invoice.handlers.ts @@ -35,6 +35,7 @@ import { import { PaymentClaimError } from '../domain/payment-attribution'; import { SettlementTimeUnavailableError, + IllegalStatusTransitionError, warningForLatePayment, } from '../domain/invoice-settlement'; import { cutoverDrainMode, simulationAllowed } from '../config/runtime'; @@ -486,7 +487,6 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa const statusCheck = checkInvoiceIsPayable(invoice.status); if ( !statusCheck.ok && - invoice.status !== 'CANCELLED' && invoice.status !== 'EXPIRED' ) { return sendVerificationFailure(res, 400, statusCheck.code, statusCheck.error); @@ -606,14 +606,11 @@ 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 && - latest!.status !== 'CANCELLED' && latest!.status !== 'EXPIRED' ) { return sendVerificationFailure( @@ -623,6 +620,14 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa latestStatus.error ); } + if (error instanceof IllegalStatusTransitionError) { + return sendVerificationFailure( + res, + 400, + 'INVOICE_NOT_PENDING', + 'Invoice is not pending' + ); + } throw error; } diff --git a/backend/src/services/invoice.service.ts b/backend/src/services/invoice.service.ts index 96b3cfe..9c9644b 100644 --- a/backend/src/services/invoice.service.ts +++ b/backend/src/services/invoice.service.ts @@ -8,6 +8,8 @@ import { calculateInvoiceExpiry } from '../domain/invoice-expiry'; import { PaymentClaimError } from '../domain/payment-attribution'; import { SettlementTimeUnavailableError, + IllegalStatusTransitionError, + assertLegalStatusTransition, type LatePaymentWarningCode, type SettlementContext, } from '../domain/invoice-settlement'; @@ -193,8 +195,6 @@ export class InvoiceService { payer_email = $5, settled_at = $6::timestamptz, settlement_context = CASE - WHEN status = 'CANCELLED' AND $6::timestamptz >= cancelled_at THEN 'AFTER_CANCEL' - WHEN status = 'CANCELLED' THEN 'ON_TIME' WHEN COALESCE($6::timestamptz >= expires_at, status = 'EXPIRED') THEN 'AFTER_EXPIRY' ELSE 'ON_TIME' END, @@ -203,16 +203,12 @@ export class InvoiceService { ELSE NULL END, late_payment_warning_code = CASE - WHEN status = 'CANCELLED' AND $6::timestamptz >= cancelled_at THEN 'PAYMENT_RECEIVED_AFTER_CANCEL' - WHEN status <> 'CANCELLED' AND COALESCE($6::timestamptz >= expires_at, status = 'EXPIRED') THEN 'PAYMENT_RECEIVED_AFTER_EXPIRY' + WHEN COALESCE($6::timestamptz >= expires_at, status = 'EXPIRED') THEN 'PAYMENT_RECEIVED_AFTER_EXPIRY' ELSE NULL END WHERE id = $1 AND $6::timestamptz IS NOT NULL - AND ( - status IN ('PENDING', 'EXPIRED') - OR (status = 'CANCELLED' AND cancelled_at IS NOT NULL) - ) + AND status IN ('PENDING', 'EXPIRED') RETURNING * ), payment_event AS ( @@ -251,6 +247,9 @@ export class InvoiceService { if (existing.rows.length > 0 && !settledAt) { throw new SettlementTimeUnavailableError(); } + if (existing.rows.length > 0 && existing.rows[0].status === 'CANCELLED') { + assertLegalStatusTransition(existing.rows[0].status, 'PAID'); + } throw new Error('Invoice not found, expired, or already processed'); } @@ -258,12 +257,12 @@ export class InvoiceService { return this.mapRowToInvoice(result.rows[0]); } catch (error: any) { - if (error instanceof SettlementTimeUnavailableError) { + if ( + error instanceof SettlementTimeUnavailableError || + error instanceof IllegalStatusTransitionError + ) { throw error; } - // Durable form of the payment claim lock (issue #501): the partial - // unique index on payment_tx_hash rejects a second settle with 23505, - // which maps to the same typed rejection the memory claim index raises. if (error?.code === '23505' && error?.constraint === 'uq_invoices_payment_tx_hash') { let holderId = 'unknown'; try { diff --git a/backend/src/services/payment-monitor.service.ts b/backend/src/services/payment-monitor.service.ts index 15950be..40db6a2 100644 --- a/backend/src/services/payment-monitor.service.ts +++ b/backend/src/services/payment-monitor.service.ts @@ -9,6 +9,7 @@ import { PaymentClaimError } from '../domain/payment-attribution'; import { parseSettlementTime, SettlementTimeUnavailableError, + IllegalStatusTransitionError, } from '../domain/invoice-settlement'; import { monitorBackoffMs } from '../utils/monitor-retry-backoff'; import { @@ -501,7 +502,18 @@ export class PaymentMonitorService { } const payable = checkInvoiceIsPayable(invoice.status); - if (!payable.ok && invoice.status !== 'CANCELLED' && invoice.status !== 'EXPIRED') return; + if (!payable.ok && invoice.status !== 'EXPIRED') { + await this.invoices.logPaymentEvent( + invoice.id, + 'PAYMENT_REJECTED', + { + code: payable.code, + txHash: payment.txHash, + status: invoice.status, + } + ); + return; + } const isNative = payment.assetCode === 'XLM' && !payment.assetIssuer; const verification = verifyHorizonPayment({ @@ -562,7 +574,6 @@ export class PaymentMonitorService { this.unregisterWatch(invoice.id); } catch (error) { if (error instanceof PaymentClaimError) { - // A transaction that already settled another invoice must not settle this one await this.invoices.logPaymentEvent( invoice.id, 'PAYMENT_REJECTED', @@ -574,6 +585,21 @@ export class PaymentMonitorService { ); return; } + if ( + error instanceof IllegalStatusTransitionError || + (error instanceof Error && error.message.includes('already processed')) + ) { + await this.invoices.logPaymentEvent( + invoice.id, + 'PAYMENT_REJECTED', + { + code: 'INVOICE_NOT_PENDING', + txHash: payment.txHash, + error: error.message, + } + ); + return; + } throw error; } } diff --git a/backend/src/storage/memory-storage.ts b/backend/src/storage/memory-storage.ts index 2ba8fb1..817308c 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, + assertLegalStatusTransition, +} from '../domain/invoice-settlement'; import { InvoiceIdCollisionError, MemoCollisionError, @@ -145,16 +148,16 @@ class MemoryStorage { this.markExpiredInvoices(); const now = new Date(); const invoice = this.invoices.get(id); - if (!invoice || invoice.status === 'PAID') return undefined; - if ( - invoice.status !== 'PENDING' && - invoice.status !== 'CANCELLED' && - invoice.status !== 'EXPIRED' - ) { + if (!invoice) { + return undefined; + } + if (invoice.status === 'CANCELLED') { + assertLegalStatusTransition(invoice.status, 'PAID'); + } + if (invoice.status !== 'PENDING' && invoice.status !== 'EXPIRED') { return undefined; } - // Settlement time must come from the ledger close time; never invent one. const settlement = settlementFieldsForInvoice(invoice, options.settledAt); // One transaction settles one invoice. The claim below reads and records in diff --git a/backend/tests/cross-seller-isolation.test.ts b/backend/tests/cross-seller-isolation.test.ts index 0313544..6e6a4e3 100644 --- a/backend/tests/cross-seller-isolation.test.ts +++ b/backend/tests/cross-seller-isolation.test.ts @@ -232,8 +232,7 @@ function runIsolationSuite(backendName: string, factory: () => InvoiceStorage) { }); it('Seller B stats are isolated from Seller A payment state', async () => { - // Pay Seller A invoice, then confirm Seller B stats are unaffected. - await storage.markAsPaid(invoiceA.id, TX_HASH, PAYER); + await storage.markAsPaid(invoiceA.id, TX_HASH, PAYER, undefined, { settledAt: new Date() }); const [statsA] = await storage.getInvoiceStats(SELLER_A); const [statsB] = await storage.getInvoiceStats(SELLER_B); @@ -282,7 +281,7 @@ function runIsolationSuite(backendName: string, factory: () => InvoiceStorage) { // Seller B's stats or list. it('paying Seller A invoice does not appear in Seller B list or stats', async () => { - await storage.markAsPaid(invoiceA.id, TX_HASH, PAYER); + await storage.markAsPaid(invoiceA.id, TX_HASH, PAYER, undefined, { settledAt: new Date() }); const bList = await storage.getInvoicesBySeller(SELLER_B); assert.equal(bList.some(r => r.id === invoiceA.id), false, diff --git a/backend/tests/invoice-cancel-payment-race.test.ts b/backend/tests/invoice-cancel-payment-race.test.ts index 45e56cb..848886c 100644 --- a/backend/tests/invoice-cancel-payment-race.test.ts +++ b/backend/tests/invoice-cancel-payment-race.test.ts @@ -198,8 +198,7 @@ class FakePostgresDb implements Queryable { Number.isFinite(settledAt.getTime()) && ( row.status === 'PENDING' || - row.status === 'EXPIRED' || - (row.status === 'CANCELLED' && row.cancelled_at) + row.status === 'EXPIRED' ); if (!row || !canSettle) { @@ -207,21 +206,12 @@ class FakePostgresDb implements Queryable { } const priorStatus = row.status; - const afterCancel = - priorStatus === 'CANCELLED' && - settledAt.getTime() >= new Date(row.cancelled_at).getTime(); const expiresAt = row.expires_at ? new Date(row.expires_at) : null; const afterExpiry = expiresAt ? settledAt.getTime() >= expiresAt.getTime() : priorStatus === 'EXPIRED'; - const settlementContext = - priorStatus === 'CANCELLED' - ? afterCancel ? 'AFTER_CANCEL' : 'ON_TIME' - : afterExpiry ? 'AFTER_EXPIRY' : 'ON_TIME'; - const warningCode = - priorStatus === 'CANCELLED' - ? afterCancel ? 'PAYMENT_RECEIVED_AFTER_CANCEL' : null - : afterExpiry ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' : null; + const settlementContext = afterExpiry ? 'AFTER_EXPIRY' : 'ON_TIME'; + const warningCode = afterExpiry ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' : null; Object.assign(row, { status: 'PAID', @@ -260,6 +250,20 @@ class FakePostgresDb implements Queryable { return { rows: [], rowCount: 1 }; } + if (sql.includes('FROM payment_events WHERE invoice_id =')) { + const found = this.events.filter((e) => e.invoiceId === params[0]); + return { + rows: found.map((e, idx) => ({ + id: `evt-${idx}`, + invoice_id: e.invoiceId, + event_type: e.eventType, + event_data: e.eventData, + created_at: new Date(), + })), + rowCount: found.length, + }; + } + if (sql.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 }; @@ -317,7 +321,7 @@ function runManualVerifySuite(name: string, createStorage: () => InvoiceStorage) transaction = undefined; }); - it('turns a matching payment detected after seller cancellation into PAID AFTER_CANCEL', async () => { + it('rejects payment verification when the invoice has already been cancelled', async () => { const invoice = await createInvoice(); const cancelled = await cancelInvoice(invoice.id); const settledAt = isoOffset(cancelled.cancelledAt, 1000); @@ -328,36 +332,13 @@ function runManualVerifySuite(name: string, createStorage: () => InvoiceStorage) 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_CANCEL'); - assert.equal(verified.body.warning, 'Payment was received after this invoice was cancelled.'); - assert.equal(verified.body.data.status, 'PAID'); - assert.equal(verified.body.data.paymentTxHash, TX_HASH); - assert.equal(verified.body.data.settlementContext, 'AFTER_CANCEL'); - assert.equal(new Date(verified.body.data.settledAt).toISOString(), settledAt); - assert.equal(verified.body.data.priorStatus, 'CANCELLED'); - assert.equal(verified.body.data.latePaymentWarningCode, 'PAYMENT_RECEIVED_AFTER_CANCEL'); - }); - - it('classifies a payment already on-chain before cancellation as ON_TIME when detected later', async () => { - const invoice = await createInvoice(); - const cancelled = await cancelInvoice(invoice.id); - const settledAt = isoOffset(cancelled.cancelledAt, -1000); - 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, 400, JSON.stringify(verified.body)); + assert.equal(verified.body.success, false); + assert.equal(verified.body.code, 'INVOICE_NOT_PENDING'); - 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(verified.body.data.priorStatus, 'CANCELLED'); + const stored = await storage.getInvoiceById(invoice.id); + assert.equal(stored?.status, 'CANCELLED'); + assert.equal(Boolean(stored?.paymentTxHash), false); }); it('keeps a cancelled invoice unchanged when the later transaction mismatches', async () => { @@ -375,7 +356,7 @@ function runManualVerifySuite(name: string, createStorage: () => InvoiceStorage) ); assert.equal(verified.statusCode, 400, JSON.stringify(verified.body)); - assert.equal(verified.body.code, 'AMOUNT_TOO_LOW'); + assert.equal(verified.body.code, 'INVOICE_NOT_PENDING'); const stored = await storage.getInvoiceById(invoice.id); assert.equal(stored?.status, 'CANCELLED'); @@ -502,7 +483,11 @@ class MemoryCheckpointStore implements PaymentMonitorCheckpointStore { } } -function monitorSource(createdAt: string, overrides: { amount?: string } = {}): PaymentPageSource { +function monitorSource( + memo: string, + createdAt: string, + overrides: { amount?: string } = {} +): PaymentPageSource { return { async getLatestPaymentCursor() { return 'cursor-0'; @@ -519,7 +504,7 @@ function monitorSource(createdAt: string, overrides: { amount?: string } = {}): to: SELLER, amount: overrides.amount ?? '42.5000000', assetCode: 'XLM', - memo: 'INV-MONITOR-CANCEL', + memo, memoType: 'text', ledger: 123, createdAt, @@ -530,75 +515,126 @@ function monitorSource(createdAt: string, overrides: { amount?: string } = {}): }; } -describe('cancel versus payment monitor attribution on memory storage', () => { - let rawStorage: MemoryStorage; - let invoiceService: InvoiceMemoryService; +interface MonitorTestHarness { + invoices: InvoiceService | InvoiceMemoryService; + database?: any; + createInvoice: () => Promise<{ id: string; memo: string }>; + cancelInvoice: (id: string) => Promise<{ cancelledAt: string | Date }>; + getInvoice: (id: string) => Promise<{ status: string; paymentTxHash?: string | null } | null | undefined>; + getPaymentEvents: (id: string) => Promise>; +} - beforeEach(() => { - rawStorage = new MemoryStorage(); - invoiceService = new InvoiceMemoryService(rawStorage); - }); +function createMemoryMonitorHarness(): MonitorTestHarness { + const rawStorage = new MemoryStorage(); + const invoiceService = new InvoiceMemoryService(rawStorage); + return { + invoices: invoiceService, + database: undefined, + createInvoice: async () => { + const inv = await invoiceService.createInvoice({ + sellerPublicKey: SELLER, + amount: 42.5, + assetCode: 'XLM', + }); + return { id: inv.id, memo: inv.memo }; + }, + cancelInvoice: async (id: string) => { + const cancelled = rawStorage.cancelInvoice(id, SELLER); + return { cancelledAt: cancelled!.cancelledAt! }; + }, + getInvoice: async (id: string) => rawStorage.getInvoiceById(id), + getPaymentEvents: async (id: string) => invoiceService.getPaymentEvents(id), + }; +} - it('settles an exact payment found after cancellation with AFTER_CANCEL context', async () => { - const invoice = rawStorage.createInvoice({ - sellerPublicKey: SELLER, - amount: 42.5, - assetCode: 'XLM', - memo: 'INV-MONITOR-CANCEL', - }); - const cancelled = rawStorage.cancelInvoice(invoice.id, SELLER); - assert.ok(cancelled?.cancelledAt, 'cancellation must record cancelledAt'); - const settledAt = isoOffset(cancelled.cancelledAt, 1000); - - const monitor = new PaymentMonitorService({ - account: SELLER, - network: 'TESTNET', - source: monitorSource(settledAt), - invoices: invoiceService, - checkpoints: new MemoryCheckpointStore(), - database: undefined, +function createPostgresMonitorHarness(): MonitorTestHarness { + const db = new FakePostgresDb(); + const invoiceService = new InvoiceService(db as any); + return { + invoices: invoiceService, + database: db as any, + createInvoice: async () => { + const inv = await invoiceService.createInvoice({ + sellerPublicKey: SELLER, + amount: 42.5, + assetCode: 'XLM', + }); + return { id: inv.id, memo: inv.memo }; + }, + cancelInvoice: async (id: string) => { + const cancelled = await invoiceService.cancelInvoice(id, SELLER); + return { cancelledAt: cancelled!.cancelledAt! }; + }, + getInvoice: async (id: string) => invoiceService.getInvoiceById(id), + getPaymentEvents: async (id: string) => invoiceService.getPaymentEvents(id), + }; +} + +function runMonitorCancelSuite(name: string, createHarness: () => MonitorTestHarness) { + describe(`cancel versus payment monitor attribution on ${name}`, () => { + let harness: MonitorTestHarness; + + beforeEach(() => { + harness = createHarness(); }); - await monitor.runOnce(); + it('rejects an exact payment found after cancellation and logs PAYMENT_REJECTED', async () => { + const invoice = await harness.createInvoice(); + const cancelled = await harness.cancelInvoice(invoice.id); + assert.ok(cancelled?.cancelledAt, 'cancellation must record cancelledAt'); + const settledAt = isoOffset(cancelled.cancelledAt, 1000); - const stored = rawStorage.getInvoiceById(invoice.id); - assert.equal(stored?.status, 'PAID'); - assert.equal(stored?.paymentTxHash, TX_HASH_2); - assert.equal(stored?.settlementContext, 'AFTER_CANCEL'); - assert.equal(stored?.latePaymentWarningCode, 'PAYMENT_RECEIVED_AFTER_CANCEL'); - }); + const monitor = new PaymentMonitorService({ + account: SELLER, + network: 'TESTNET', + source: monitorSource(invoice.memo, settledAt), + invoices: harness.invoices, + checkpoints: new MemoryCheckpointStore(), + database: harness.database, + }); - it('logs a mismatch after cancellation without changing the cancelled invoice', async () => { - const invoice = rawStorage.createInvoice({ - sellerPublicKey: SELLER, - amount: 42.5, - assetCode: 'XLM', - memo: 'INV-MONITOR-CANCEL', - }); - const cancelled = rawStorage.cancelInvoice(invoice.id, SELLER); - assert.ok(cancelled?.cancelledAt, 'cancellation must record cancelledAt'); - - const monitor = new PaymentMonitorService({ - account: SELLER, - network: 'TESTNET', - source: monitorSource(isoOffset(cancelled.cancelledAt, 1000), { amount: '41.0000000' }), - invoices: invoiceService, - checkpoints: new MemoryCheckpointStore(), - database: undefined, + await monitor.runOnce(); + + const stored = await harness.getInvoice(invoice.id); + assert.equal(stored?.status, 'CANCELLED'); + assert.equal(Boolean(stored?.paymentTxHash), false); + + const events = await harness.getPaymentEvents(invoice.id); + assert.equal(events.length, 1); + assert.equal(events[0].eventType, 'PAYMENT_REJECTED'); + assert.equal(events[0].eventData.code, 'INVOICE_NOT_PENDING'); }); - await monitor.runOnce(); + it('logs a mismatch after cancellation without changing the cancelled invoice', async () => { + const invoice = await harness.createInvoice(); + const cancelled = await harness.cancelInvoice(invoice.id); + assert.ok(cancelled?.cancelledAt, 'cancellation must record cancelledAt'); + + const monitor = new PaymentMonitorService({ + account: SELLER, + network: 'TESTNET', + source: monitorSource(invoice.memo, isoOffset(cancelled.cancelledAt, 1000), { amount: '41.0000000' }), + invoices: harness.invoices, + checkpoints: new MemoryCheckpointStore(), + database: harness.database, + }); + + await monitor.runOnce(); - const stored = rawStorage.getInvoiceById(invoice.id); - assert.equal(stored?.status, 'CANCELLED'); - assert.equal(stored?.paymentTxHash, undefined); + const stored = await harness.getInvoice(invoice.id); + assert.equal(stored?.status, 'CANCELLED'); + assert.equal(Boolean(stored?.paymentTxHash), false); - const events = await invoiceService.getPaymentEvents(invoice.id); - assert.equal(events.length, 1); - assert.equal(events[0].eventType, 'PARTIAL_PAYMENT'); - assert.equal(events[0].eventData.code, 'AMOUNT_TOO_LOW'); + const events = await harness.getPaymentEvents(invoice.id); + assert.equal(events.length, 1); + assert.equal(events[0].eventType, 'PAYMENT_REJECTED'); + assert.equal(events[0].eventData.code, 'INVOICE_NOT_PENDING'); + }); }); -}); +} + +runMonitorCancelSuite('in-memory storage', createMemoryMonitorHarness); +runMonitorCancelSuite('postgres storage double', createPostgresMonitorHarness); runManualVerifySuite('in-memory storage', createMemoryStorage); runManualVerifySuite('postgres storage double', createPostgresStorage); diff --git a/backend/tests/invoice-dual-backend.test.ts b/backend/tests/invoice-dual-backend.test.ts index c31bd90..842cfcf 100644 --- a/backend/tests/invoice-dual-backend.test.ts +++ b/backend/tests/invoice-dual-backend.test.ts @@ -411,10 +411,16 @@ function runDualBackendSuite( describe('markAsPaid (payment attribution)', () => { it('attributes payment to the correct invoice with all payer fields', async () => { const inv = await storage.createInvoice(baseInput(SELLER_A)); - const paid = await storage.markAsPaid(inv.id, TX_HASH_1, PAYER, { - payerName: 'Hal Finney', - payerEmail: 'hal@example.com', - }); + const paid = await storage.markAsPaid( + inv.id, + TX_HASH_1, + PAYER, + { + payerName: 'Hal Finney', + payerEmail: 'hal@example.com', + }, + { settledAt: new Date() } + ); assert.equal(paid.status, 'PAID'); assert.equal(paid.paymentTxHash, TX_HASH_1); @@ -426,24 +432,31 @@ function runDualBackendSuite( it('rejects attribution when the invoice has already been paid', async () => { const inv = await storage.createInvoice(baseInput(SELLER_A)); - await storage.markAsPaid(inv.id, TX_HASH_1, PAYER); + await storage.markAsPaid(inv.id, TX_HASH_1, PAYER, undefined, { settledAt: new Date() }); await assert.rejects( - () => storage.markAsPaid(inv.id, TX_HASH_2, PAYER), + () => storage.markAsPaid(inv.id, TX_HASH_2, PAYER, undefined, { settledAt: new Date() }), /Invoice not found|expired|already processed/ ); }); it('rejects attribution for a missing invoice', async () => { await assert.rejects( - () => storage.markAsPaid('00000000-0000-4000-8000-000000000000', TX_HASH_1, PAYER), + () => + storage.markAsPaid( + '00000000-0000-4000-8000-000000000000', + TX_HASH_1, + PAYER, + undefined, + { settledAt: new Date() } + ), /Invoice not found|expired|already processed/ ); }); it('getInvoiceById returns the paid state after successful attribution', async () => { const inv = await storage.createInvoice(baseInput(SELLER_A)); - await storage.markAsPaid(inv.id, TX_HASH_1, PAYER); + await storage.markAsPaid(inv.id, TX_HASH_1, PAYER, undefined, { settledAt: new Date() }); const fetched = await storage.getInvoiceById(inv.id); assert.equal(fetched?.status, 'PAID'); @@ -457,7 +470,7 @@ function runDualBackendSuite( it('returns counts scoped to the requesting seller', async () => { await storage.createInvoice(baseInput(SELLER_A)); const bInv = await storage.createInvoice(baseInput(SELLER_B, { amount: 100 })); - await storage.markAsPaid(bInv.id, TX_HASH_1, PAYER); + await storage.markAsPaid(bInv.id, TX_HASH_1, PAYER, undefined, { settledAt: new Date() }); const [statsA] = await storage.getInvoiceStats(SELLER_A); const [statsB] = await storage.getInvoiceStats(SELLER_B); diff --git a/backend/tests/invoice-handlers.test.ts b/backend/tests/invoice-handlers.test.ts index 177bafd..b3d1c52 100644 --- a/backend/tests/invoice-handlers.test.ts +++ b/backend/tests/invoice-handlers.test.ts @@ -682,7 +682,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage it('keeps the verify response on the public shape', async () => { const created = await createInvoice({ customerEmail: 'pay@client.example' }); transaction = { - transaction: { memo: created.memo }, + transaction: { memo: created.memo, created_at: new Date().toISOString() }, operations: [ { type: 'payment', diff --git a/backend/tests/payment-attribution.test.ts b/backend/tests/payment-attribution.test.ts index f3277f8..e0bdfea 100644 --- a/backend/tests/payment-attribution.test.ts +++ b/backend/tests/payment-attribution.test.ts @@ -17,7 +17,10 @@ import { PaymentClaimError, PaymentClaimIndex, } from '../src/domain/payment-attribution'; -import { SettlementTimeUnavailableError } from '../src/domain/invoice-settlement'; +import { + IllegalStatusTransitionError, + SettlementTimeUnavailableError, +} from '../src/domain/invoice-settlement'; import { MemoryStorage } from '../src/storage/memory-storage'; import { InvoiceMemoryService } from '../src/services/invoice-memory.service'; import { createInvoiceSchema } from '../src/utils/validation'; @@ -126,7 +129,7 @@ describe('one transaction settles one invoice', () => { assert.throws( () => storage.markAsPaid(cancelled.id, TX_A, SELLER), - SettlementTimeUnavailableError + IllegalStatusTransitionError ); assert.equal( storage.getPaymentClaim(TX_A), diff --git a/docs/EXPIRY-AND-LATE-PAYMENT.md b/docs/EXPIRY-AND-LATE-PAYMENT.md index e41e5af..edaa7af 100644 --- a/docs/EXPIRY-AND-LATE-PAYMENT.md +++ b/docs/EXPIRY-AND-LATE-PAYMENT.md @@ -44,7 +44,7 @@ stateDiagram-v2 | `PENDING` | `CANCELLED` | `POST /invoices/:id/cancel` | seller is the invoice owner | handler | | `PAID` | - | terminal | | | | `EXPIRED` | `PAID` | exact payment settles; ledger close time decides `ON_TIME` vs `AFTER_EXPIRY` | `settledAt >= expiresAt` flags `PAYMENT_RECEIVED_AFTER_EXPIRY` | handler / monitor | -| `CANCELLED` | `PAID` | exact payment settles; ledger close time decides `ON_TIME` vs `AFTER_CANCEL` | `settledAt >= cancelledAt` flags `PAYMENT_RECEIVED_AFTER_CANCEL` | handler / monitor | +| `CANCELLED` | - | terminal | | | ## The late-payment edge case diff --git a/frontend/app/invoice/[id]/page.tsx b/frontend/app/invoice/[id]/page.tsx index 786d119..391c7fd 100644 --- a/frontend/app/invoice/[id]/page.tsx +++ b/frontend/app/invoice/[id]/page.tsx @@ -156,6 +156,7 @@ export default function InvoiceDetailPage() { const message = apiErrorMessage(error, 'Failed to cancel invoice'); if (isApiUnavailableError(error)) setLoadError(message); toast.error(message); + await loadInvoice().catch(() => undefined); } }; diff --git a/frontend/lib/payment-page-state.d.ts b/frontend/lib/payment-page-state.d.ts index 3106a23..7a4af18 100644 --- a/frontend/lib/payment-page-state.d.ts +++ b/frontend/lib/payment-page-state.d.ts @@ -1,6 +1,6 @@ import type { WalletGateResult } from './freighter-availability'; -export type PayStatus = 'idle' | 'paying' | 'verifying' | 'paid' | 'error' | 'expired'; +export type PayStatus = 'idle' | 'paying' | 'verifying' | 'paid' | 'error' | 'expired' | 'cancelled'; export interface PayInvoice { status: string; @@ -60,6 +60,7 @@ export declare const PAY_STATES: { readonly PAID: 'paid'; readonly ERROR: 'error'; readonly EXPIRED: 'expired'; + readonly CANCELLED: 'cancelled'; }; export declare const TERMINAL_STATES: readonly PayStatus[]; diff --git a/frontend/lib/payment-page-state.js b/frontend/lib/payment-page-state.js index 8a430d1..66ec8b6 100644 --- a/frontend/lib/payment-page-state.js +++ b/frontend/lib/payment-page-state.js @@ -23,7 +23,6 @@ * moves the page back out of them. */ -/** Every state the pay page can be in. */ const PAY_STATES = Object.freeze({ IDLE: 'idle', PAYING: 'paying', @@ -31,9 +30,10 @@ const PAY_STATES = Object.freeze({ PAID: 'paid', ERROR: 'error', EXPIRED: 'expired', + CANCELLED: 'cancelled', }); -const TERMINAL_STATES = Object.freeze([PAY_STATES.PAID, PAY_STATES.EXPIRED]); +const TERMINAL_STATES = Object.freeze([PAY_STATES.PAID, PAY_STATES.EXPIRED, PAY_STATES.CANCELLED]); const { isTerminalPayState } = require('./pay-terminal-guard.ts'); const { effectiveInvoiceStatus, hasInvoiceExpired } = require('./invoice-lifecycle'); const { walletSessionChanged, walletSessionGate } = require('./wallet-session'); @@ -89,11 +89,12 @@ function getPayPageWalletGate(invoice, session, expectedNetwork, now) { return walletSessionGate(session, expectedNetwork); } -/** Maps an invoice status onto the state it forces, or null if it forces none. */ function stateForStatus(statusOrInvoice, now) { - const status = effectiveInvoiceStatus(asInvoice(statusOrInvoice), now); + const invoice = asInvoice(statusOrInvoice); + if (invoice?.status === 'CANCELLED') return PAY_STATES.CANCELLED; + const status = effectiveInvoiceStatus(invoice, now); if (status === 'PAID') return PAY_STATES.PAID; - if (isExpiredInvoice(status)) return PAY_STATES.EXPIRED; + if (isExpiredInvoice(status, now)) return PAY_STATES.EXPIRED; return null; } @@ -299,35 +300,16 @@ function isResultState(state) { ); } -/** - * Whether a result should interrupt the screen reader. - * - * Only a failure does: the payer is blocked on it. A confirmed payment is good - * news that can wait for a gap in the announcement queue. - */ function paymentStateKind(state) { return state?.status === PAY_STATES.ERROR ? 'error' : 'status'; } -/** - * Ends a clause with a full stop. - * - * Backend rejections arrive as fragments — "Memo mismatch", "Amount mismatch" - * — and a live region reads its content straight through, so two announcements - * run together into one breathless sentence without this. - */ function asSentence(text) { const trimmed = (text ?? '').trim(); if (!trimmed) return ''; return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; } -/** - * The sentence a live region reads when the pay page changes state. - * - * Every branch returns English prose rather than a state name, because this - * text is read aloud verbatim — "verifying" on its own is not a sentence. - */ function describePaymentState(state) { switch (state?.status) { case PAY_STATES.PAYING: @@ -338,6 +320,8 @@ function describePaymentState(state) { return 'Payment confirmed. Your payment proof is ready to download.'; case PAY_STATES.EXPIRED: return 'This invoice has expired and can no longer be paid.'; + case PAY_STATES.CANCELLED: + return 'This invoice has been cancelled by the seller.'; case PAY_STATES.ERROR: return state?.error ? `Payment could not be completed. ${asSentence(state.error)}` diff --git a/frontend/tests/payment-page-state.test.js b/frontend/tests/payment-page-state.test.js index 63e2326..6860468 100644 --- a/frontend/tests/payment-page-state.test.js +++ b/frontend/tests/payment-page-state.test.js @@ -131,7 +131,7 @@ test('stateForStatus only forces a state for settled invoices', () => { assert.equal(stateForStatus('PAID'), PAY_STATES.PAID); assert.equal(stateForStatus('EXPIRED'), PAY_STATES.EXPIRED); assert.equal(stateForStatus('PENDING'), null); - assert.equal(stateForStatus('CANCELLED'), null); + assert.equal(stateForStatus('CANCELLED'), PAY_STATES.CANCELLED); }); // ------------------------------------------------------------- happy paths