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
88 changes: 8 additions & 80 deletions backend/src/domain/invoice-settlement.ts
Original file line number Diff line number Diff line change
@@ -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<LatePaymentWarningCode, string> = {
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';
19 changes: 8 additions & 11 deletions backend/src/routes/invoice.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions backend/src/services/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 13 additions & 3 deletions backend/src/services/payment-monitor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}

Expand Down
24 changes: 17 additions & 7 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,
SettlementTimeUnavailableError,
} from '../domain/invoice-settlement';
import {
MemoCollisionError,
PaymentClaimError,
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 26 additions & 5 deletions backend/tests/invoice-cancel-payment-race.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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',
Expand All @@ -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,
Expand Down
Loading
Loading