Skip to content
105 changes: 105 additions & 0 deletions backend/src/domain/invoice-settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,108 @@ export function settlementFieldsForInvoice(
latePaymentWarningCode: afterExpiry ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' : undefined,
};
}

/** Outcomes cancel / verify / monitor may commit as the invoice's terminal status. */
export type TerminalOutcome = 'PAID' | 'CANCELLED';

/** Stable codes when a terminal commit loses the race or is otherwise illegal. */
export type TerminalConflictCode =
| 'INVOICE_ALREADY_PAID'
| 'INVOICE_ALREADY_CANCELLED'
| 'INVOICE_EXPIRED'
| 'INVOICE_NOT_PENDING';

const TERMINAL_CONFLICT_MESSAGES: Record<TerminalConflictCode, string> = {
INVOICE_ALREADY_PAID: 'Invoice has already been paid',
INVOICE_ALREADY_CANCELLED: 'Invoice has already been cancelled',
INVOICE_EXPIRED: 'Invoice has expired and can no longer accept payment',
INVOICE_NOT_PENDING: 'Invoice is not pending',
};

/**
* Legal status transitions that decide a terminal invoice outcome.
*
* PAID is hard-terminal: nothing leaves it (idempotent PAID replay is not a
* transition). CANCELLED and EXPIRED may still settle to PAID when an exact
* on-chain payment is attributed — that is the late-settlement path from
* LATE_PAYMENT_POLICY, not a concurrent cancel overwrite. Cancel itself is
* PENDING → CANCELLED only.
*/
export const LEGAL_TERMINAL_TRANSITIONS: ReadonlyArray<readonly [InvoiceStatus, InvoiceStatus]> = [
['PENDING', 'CANCELLED'],
['PENDING', 'PAID'],
['EXPIRED', 'PAID'],
['CANCELLED', 'PAID'],
];

export function isLegalTerminalTransition(from: InvoiceStatus, to: InvoiceStatus): boolean {
if (from === to && to === 'PAID') {
// Idempotent replay of the same paid invoice is allowed at the claim layer;
// it is not a storage status rewrite.
return false;
}
return LEGAL_TERMINAL_TRANSITIONS.some(([a, b]) => a === from && b === to);
}

export function messageForTerminalConflict(code: TerminalConflictCode): string {
return TERMINAL_CONFLICT_MESSAGES[code];
}

/**
* Raised when cancel or settlement refuses because another terminal outcome
* already won. Carries the stable code handlers return to clients.
*/
export class InvoiceTerminalConflictError extends Error {
readonly code: TerminalConflictCode;
readonly currentStatus: InvoiceStatus;
/** Present when the losing cancel must not clear an already-recorded hash. */
readonly paymentTxHash?: string;

constructor(code: TerminalConflictCode, currentStatus: InvoiceStatus, paymentTxHash?: string) {
super(messageForTerminalConflict(code));
this.name = 'InvoiceTerminalConflictError';
this.code = code;
this.currentStatus = currentStatus;
this.paymentTxHash = paymentTxHash;
}
}

/** Map a non-PENDING status onto the cancel-loser conflict code. */
export function cancelConflictForStatus(
status: InvoiceStatus,
paymentTxHash?: string | null
): InvoiceTerminalConflictError {
if (status === 'PAID') {
return new InvoiceTerminalConflictError('INVOICE_ALREADY_PAID', status, paymentTxHash ?? undefined);
}
if (status === 'CANCELLED') {
return new InvoiceTerminalConflictError('INVOICE_ALREADY_CANCELLED', status, paymentTxHash ?? undefined);
}
if (status === 'EXPIRED') {
return new InvoiceTerminalConflictError('INVOICE_EXPIRED', status, paymentTxHash ?? undefined);
}
return new InvoiceTerminalConflictError('INVOICE_NOT_PENDING', status, paymentTxHash ?? undefined);
}

/**
* Guard used by both storages before PENDING → CANCELLED. Throws a typed
* conflict when cancel lost the race (or the invoice was never pending).
*/
export function assertCancelTransitionAllowed(
status: InvoiceStatus,
paymentTxHash?: string | null
): void {
if (status === 'PENDING') return;
throw cancelConflictForStatus(status, paymentTxHash);
}

/**
* Guard used by both storages before writing PAID. Replay of an already-PAID
* invoice is handled by the caller (return existing / already-processed);
* every other illegal source status throws.
*/
export function assertPaidTransitionAllowed(status: InvoiceStatus): void {
if (status === 'PAID') return;
if (isLegalTerminalTransition(status, 'PAID')) return;
throw new InvoiceTerminalConflictError('INVOICE_NOT_PENDING', status);
}
47 changes: 23 additions & 24 deletions backend/src/routes/invoice.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { PaymentClaimError } from '../domain/payment-attribution';
import { IllegalStateTransitionError } from '../domain/invoice-lifecycle';
import {
SettlementTimeUnavailableError,
InvoiceTerminalConflictError,
warningForLatePayment,
} from '../domain/invoice-settlement';
import { cutoverDrainMode, simulationAllowed } from '../config/runtime';
Expand Down Expand Up @@ -422,17 +423,24 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa

async cancelInvoice(req: Request, res: Response): Promise<void> {
try {
// Issue #517 — one proof path: the seller key, signature and message
// all travel in the JSON body. Query params and headers are legacy
// transports; when they carry a key that disagrees with the body the
// request is ambiguous and fails closed.
// Issue #558 / #517 — one proof path: seller key, signature, and
// `cancel:<invoiceId>` message all travel in the JSON body. Query and
// header seller keys are rejected even when they match the body.
const bodyKeyRaw = req.body?.sellerPublicKey;
const queryKeyRaw = req.query?.sellerPublicKey;
const headerKeyRaw = req.headers?.['x-seller-public-key'];
const alternates = [queryKeyRaw, headerKeyRaw]
.flat()
.filter((v): v is string => typeof v === 'string' && v.trim() !== '');

if (alternates.length > 0) {
return sendFailure(
res,
400,
'Send sellerPublicKey in the request body; query and header keys are not accepted'
);
}

let sellerPublicKey: string | undefined;
if (typeof bodyKeyRaw === 'string' && bodyKeyRaw.trim() !== '') {
const parsed = stellarPublicKeySchema.safeParse(bodyKeyRaw);
Expand All @@ -442,26 +450,6 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
sellerPublicKey = parsed.data;
}

if (alternates.length > 0) {
if (!sellerPublicKey) {
return sendFailure(
res,
400,
'Send sellerPublicKey in the request body; query and header keys are not accepted'
);
}
for (const alt of alternates) {
const parsed = stellarPublicKeySchema.safeParse(alt);
if (!parsed.success || parsed.data !== sellerPublicKey) {
return sendFailure(
res,
400,
'Conflicting sellerPublicKey values between body, query, and header'
);
}
}
}

if (!sellerPublicKey) {
return sendFailure(
res,
Expand Down Expand Up @@ -520,6 +508,17 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
options.paymentMonitor?.unregisterWatch(req.params.id);
sendSuccess(res, 200, invoice);
} catch (error: any) {
if (error instanceof InvoiceTerminalConflictError) {
// Stable code for the race loser; payment_tx_hash is left intact.
res.status(409).json({
success: false,
code: error.code,
error: error.message,
...(error.paymentTxHash ? { paymentTxHash: error.paymentTxHash } : {}),
...(error.currentStatus ? { status: error.currentStatus } : {}),
});
return;
}
if (error instanceof IllegalStateTransitionError) {
res.status(400).json({
success: false,
Expand Down
4 changes: 1 addition & 3 deletions backend/src/services/invoice-memory.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,7 @@ export class InvoiceMemoryService {
if (sellerPublicKey && existing.sellerPublicKey !== sellerPublicKey) {
throw new Error('Unauthorized: only the seller can cancel this invoice');
}
if (existing.status !== 'PENDING') {
throw new IllegalStateTransitionError(existing.status, 'CANCELLED');
}
// MemoryStorage throws InvoiceTerminalConflictError when cancel loses the race.
const updated = this.storage.cancelInvoice(invoiceId, sellerPublicKey);
if (!updated) {
throw new Error('Invoice not found or already processed');
Expand Down
18 changes: 8 additions & 10 deletions backend/src/services/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../domain/invoice-lifecycle';
import {
SettlementTimeUnavailableError,
cancelConflictForStatus,
type LatePaymentWarningCode,
type SettlementContext,
} from '../domain/invoice-settlement';
Expand Down Expand Up @@ -390,21 +391,18 @@ export class InvoiceService {
if (existing.rows.length === 0) {
throw new Error('Invoice not found');
}
const current = existing.rows[0];
const row = existing.rows[0];
if (
sellerPublicKey &&
current.status === 'PENDING' &&
current.seller_public_key !== sellerPublicKey
row.seller_public_key !== sellerPublicKey
) {
throw new Error('Unauthorized: only the seller can cancel this invoice');
}
if (current.status !== 'PENDING') {
throw new IllegalStateTransitionError(
current.status as 'PENDING' | 'PAID' | 'EXPIRED' | 'CANCELLED',
'CANCELLED'
);
}
throw new Error('Invoice not found or already processed');
// Another terminal outcome already won — do not clear payment_tx_hash.
throw cancelConflictForStatus(
row.status,
row.payment_tx_hash ?? undefined
);
}

return this.mapRowToInvoice(result.rows[0]);
Expand Down
26 changes: 15 additions & 11 deletions backend/src/storage/memory-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ 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 {
assertCancelTransitionAllowed,
assertPaidTransitionAllowed,
settlementFieldsForInvoice,
InvoiceTerminalConflictError,
} from '../domain/invoice-settlement';
import {
InvoiceIdCollisionError,
MemoCollisionError,
Expand Down Expand Up @@ -121,14 +126,17 @@ class MemoryStorage {
return updated;
}

// Cancel invoice
// Cancel invoice — PENDING → CANCELLED only; loser gets a typed terminal conflict.
cancelInvoice(id: string, sellerPublicKey?: string): Invoice | undefined {
this.markExpiredInvoices();
const invoice = this.invoices.get(id);
if (!invoice || invoice.status !== 'PENDING') return undefined;
if (!invoice) return undefined;
if (sellerPublicKey && invoice.sellerPublicKey !== sellerPublicKey) {
throw new Error('Unauthorized: only the seller can cancel this invoice');
}
// Throws InvoiceTerminalConflictError when PAID/CANCELLED/EXPIRED already won.
// Does not clear paymentTxHash on a lost race.
assertCancelTransitionAllowed(invoice.status, invoice.paymentTxHash);
return this.updateInvoice(id, { status: 'CANCELLED', cancelledAt: new Date() });
}

Expand All @@ -143,14 +151,10 @@ 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'
) {
return undefined;
}
if (!invoice) return undefined;
// Idempotent: already PAID is "already processed" for the caller, not a rewrite.
if (invoice.status === 'PAID') return undefined;
assertPaidTransitionAllowed(invoice.status);

// Settlement time must come from the ledger close time; never invent one.
const settlement = settlementFieldsForInvoice(invoice, options.settledAt);
Expand Down
Loading
Loading