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,
+ };
+}