Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 43 additions & 19 deletions backend/src/domain/invoice-settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<InvoiceStatus, readonly InvoiceStatus[]>> = 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;
Expand All @@ -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()
Expand All @@ -89,3 +112,4 @@ export function settlementFieldsForInvoice(
latePaymentWarningCode: afterExpiry ? 'PAYMENT_RECEIVED_AFTER_EXPIRY' : undefined,
};
}

13 changes: 9 additions & 4 deletions backend/src/routes/invoice.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}

Expand Down
23 changes: 11 additions & 12 deletions backend/src/services/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -251,19 +247,22 @@ 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');
}

console.log('✅ Invoice marked as paid:', invoiceId);

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 {
Expand Down
30 changes: 28 additions & 2 deletions backend/src/services/payment-monitor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand All @@ -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;
}
}
Expand Down
19 changes: 11 additions & 8 deletions backend/src/storage/memory-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions backend/tests/cross-seller-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading