diff --git a/backend/src/routes/invoice.handlers.ts b/backend/src/routes/invoice.handlers.ts index e9d7e125..03a7d95a 100644 --- a/backend/src/routes/invoice.handlers.ts +++ b/backend/src/routes/invoice.handlers.ts @@ -28,17 +28,15 @@ import { import type { InvoiceStorage, StoredInvoice } from '../storage/invoice-storage'; import { STELLAR_NETWORK } from '../config/stellar'; import { - failure, checkInvoiceIsPayable, checkPayerInfo, checkTxHash, messageForCode, - verifyHorizonPayment, + stageForCode, + executePaymentVerificationPipeline, } from '../services/payment-verification'; -import { PaymentClaimError } from '../domain/payment-attribution'; import { IllegalStateTransitionError } from '../domain/invoice-lifecycle'; import { - SettlementTimeUnavailableError, warningForLatePayment, } from '../domain/invoice-settlement'; import { cutoverDrainMode, simulationAllowed } from '../config/runtime'; @@ -563,7 +561,8 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa res, 429, 'VERIFY_RATE_LIMIT_EXCEEDED', - messageForCode('VERIFY_RATE_LIMIT_EXCEEDED') + messageForCode('VERIFY_RATE_LIMIT_EXCEEDED'), + { stage: stageForCode('VERIFY_RATE_LIMIT_EXCEEDED') } ); } @@ -576,7 +575,9 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa network: STELLAR_NETWORK, durationMs: Date.now() - startedAt, }); - return sendVerificationFailure(res, 400, hashCheck.code, hashCheck.error); + return sendVerificationFailure(res, 400, hashCheck.code, hashCheck.error, { + stage: stageForCode(hashCheck.code), + }); } const payerCheck = checkPayerInfo(req.body); @@ -588,7 +589,9 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa network: STELLAR_NETWORK, durationMs: Date.now() - startedAt, }); - return sendVerificationFailure(res, 400, payerCheck.code, payerCheck.error); + return sendVerificationFailure(res, 400, payerCheck.code, payerCheck.error, { + stage: stageForCode(payerCheck.code), + }); } const invoice = await storage.getInvoiceById(id); @@ -617,7 +620,9 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa network: STELLAR_NETWORK, durationMs: Date.now() - startedAt, }); - return sendVerificationFailure(res, 400, statusCheck.code, statusCheck.error); + return sendVerificationFailure(res, 400, statusCheck.code, statusCheck.error, { + stage: stageForCode(statusCheck.code), + }); } emitEvent('info', 'payment.verify.started', context, { @@ -626,131 +631,146 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa network: STELLAR_NETWORK, }); - let txDetails; - try { - txDetails = await stellar.getTransaction(hashCheck.value); - } catch (error: any) { - emitOperationalFailure('invoice.verify'); - // Classify timeout/429/connection before memo/amount compare (#556). - if (classifyHorizonFailure(error)) { - // Horizon is overloaded or unreachable. A 503 invites the payer to - // retry; it is never cached — caching an outage as a rejection - // would poison the hash against later retries. - emitEvent('error', 'horizon.request.failed', context, { - operation: 'getTransaction', - errorCode: 'HORIZON_UNAVAILABLE', + const pipeline = await executePaymentVerificationPipeline({ + invoice, + txHash: hashCheck.value, + network, + expectedNetwork: STELLAR_NETWORK, + stellar: { + getTransaction: async (txHash) => { + try { + return await stellar.getTransaction(txHash); + } catch (error) { + emitOperationalFailure('invoice.verify'); + if (classifyHorizonFailure(error)) { + emitEvent('error', 'horizon.request.failed', context, { + operation: 'getTransaction', + errorCode: 'HORIZON_UNAVAILABLE', + network: STELLAR_NETWORK, + attempt: 1, + durationMs: Date.now() - startedAt, + }); + } + throw error; + } + }, + }, + storage, + payer: payerCheck.value, + requireSettledAt: true, + onAfterPersist: async ({ invoice: updatedInvoice, txHash }) => { + options.paymentMonitor?.unregisterWatch(id); + emitEvent('info', 'invoice.paid', context, { + invoiceRef: logReference(updatedInvoice.id), + sellerRef: logReference(updatedInvoice.sellerPublicKey), + txRef: logReference(txHash), + assetCode: updatedInvoice.assetCode || 'XLM', network: STELLAR_NETWORK, - attempt: 1, + storage: storageMode, durationMs: Date.now() - startedAt, }); - const unavailable = failure('VERIFY_UNAVAILABLE'); - return sendVerificationFailure(res, 503, unavailable.code, unavailable.error); - } - const notFound = failure('TRANSACTION_NOT_FOUND'); - // Cached with the short negative TTL: the hash may be ahead of - // Horizon indexing or the lookup may have failed transiently, and a - // long cache entry would turn a retry into a permanent block. - emitEvent('warn', 'payment.verify.rejected', context, { - invoiceRef: logReference(id), - txRef: logReference(req.body?.txHash), - errorCode: notFound.code, - network: STELLAR_NETWORK, - durationMs: Date.now() - startedAt, - }); - const body = verificationFailureBody(notFound.code, notFound.error); - await cacheResult(id, hashCheck.value, 404, body); - res.status(404).json(body); - return; - } - - const verification = verifyHorizonPayment({ - txHash: hashCheck.value, - expected: { - memo: invoice.memo, - amount: invoice.amount, - destination: invoice.sellerPublicKey, - assetCode: invoice.assetCode, - assetIssuer: invoice.assetIssuer, - network: STELLAR_NETWORK, }, - transaction: txDetails.transaction, - operations: txDetails.operations, - network, }); - if (!verification.ok) { - // Semantic rejections are permanent facts about the transaction — - // safe to replay for the full expiry window. - emitEvent('warn', 'payment.verify.rejected', context, { - invoiceRef: logReference(id), - txRef: logReference(req.body?.txHash), - errorCode: verification.code, - network: STELLAR_NETWORK, - durationMs: Date.now() - startedAt, + const rawHash = + typeof req.body?.txHash === 'string' ? req.body.txHash.trim() : undefined; + const cacheableHash = pipeline.ok + ? pipeline.txHash + : rawHash && /^[0-9a-f]{64}$/i.test(rawHash) + ? rawHash + : undefined; + + if (!pipeline.ok) { + if (pipeline.code !== 'VERIFY_UNAVAILABLE') { + emitEvent('warn', 'payment.verify.rejected', context, { + invoiceRef: logReference(id), + txRef: logReference(req.body?.txHash), + errorCode: pipeline.code, + network: STELLAR_NETWORK, + durationMs: Date.now() - startedAt, + }); + } + const httpStatus = + pipeline.code === 'VERIFY_RATE_LIMIT_EXCEEDED' + ? 429 + : pipeline.code === 'TRANSACTION_NOT_FOUND' + ? 404 + : pipeline.code === 'VERIFY_UNAVAILABLE' || + pipeline.code === 'TRANSACTION_CLOSE_TIME_UNAVAILABLE' + ? 503 + : pipeline.code === 'TX_HASH_ALREADY_USED' + ? 409 + : 400; + + const body = verificationFailureBody(pipeline.code, pipeline.error, { + stage: pipeline.stage, + details: pipeline.details, }); - const body = verificationFailureBody(verification.code, verification.error); - await cacheResult(id, hashCheck.value, 400, body); - // Issue #515: a rejected verify lands on the seller's audit feed with - // the same taxonomy the monitor uses, so "still PENDING" answers - // itself without the payer having to say so. - await storage.logPaymentEvent( - id, - verification.code === 'AMOUNT_TOO_LOW' || verification.code === 'AMOUNT_MISMATCH' - ? 'PARTIAL_PAYMENT' - : 'PAYMENT_REJECTED', - { - code: verification.code, - txHash: hashCheck.value, - source: 'manual-verify', + + // Do not cache outages or close-time gaps — both invite a retry. + const cacheable = + cacheableHash && + pipeline.code !== 'VERIFY_UNAVAILABLE' && + pipeline.code !== 'TRANSACTION_CLOSE_TIME_UNAVAILABLE' && + pipeline.code !== 'VERIFY_RATE_LIMIT_EXCEEDED' && + pipeline.code !== 'MISSING_TX_HASH' && + pipeline.code !== 'INVALID_TX_HASH' && + pipeline.code !== 'INVALID_PAYER_NAME' && + pipeline.code !== 'INVALID_PAYER_EMAIL' && + pipeline.code !== 'PAYER_INFO_TOO_LONG'; + + if (cacheable) { + await cacheResult(id, cacheableHash, httpStatus, body); + } + + if ( + pipeline.stage !== 'persist_paid' || + pipeline.code === 'TX_HASH_ALREADY_USED' + ) { + // Issue #515: rejected verify lands on the seller's audit feed. + // Skip pure input/status gates that never touched Horizon. + const skipEvent = + pipeline.code === 'MISSING_TX_HASH' || + pipeline.code === 'INVALID_TX_HASH' || + pipeline.code === 'INVALID_PAYER_NAME' || + pipeline.code === 'INVALID_PAYER_EMAIL' || + pipeline.code === 'PAYER_INFO_TOO_LONG' || + pipeline.code === 'INVOICE_ALREADY_PAID' || + pipeline.code === 'INVOICE_EXPIRED' || + pipeline.code === 'INVOICE_NOT_PENDING' || + pipeline.code === 'VERIFY_RATE_LIMIT_EXCEEDED'; + if (!skipEvent && cacheableHash) { + await storage + .logPaymentEvent( + id, + pipeline.code === 'AMOUNT_TOO_LOW' || pipeline.code === 'AMOUNT_MISMATCH' + ? 'PARTIAL_PAYMENT' + : 'PAYMENT_REJECTED', + { + code: pipeline.code, + stage: pipeline.stage, + txHash: cacheableHash, + source: 'manual-verify', + } + ) + .catch(() => undefined); } - ).catch(() => undefined); - return sendVerificationFailure(res, 400, verification.code, verification.error); - } + } - if (!verification.value.settledAt) { - emitEvent('warn', 'payment.verify.rejected', context, { - invoiceRef: logReference(id), - txRef: logReference(req.body?.txHash), - errorCode: 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', - network: STELLAR_NETWORK, - durationMs: Date.now() - startedAt, - }); return sendVerificationFailure( res, - 503, - 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', - messageForCode('TRANSACTION_CLOSE_TIME_UNAVAILABLE') + httpStatus, + pipeline.code, + pipeline.error, + { stage: pipeline.stage, details: pipeline.details } ); } - let updatedInvoice: StoredInvoice; - try { - updatedInvoice = await storage.markAsPaid( - id, - verification.value.txHash, - verification.value.from, - payerCheck.value, - { - settledAt: verification.value.settledAt, - destinationMuxedId: verification.value.toMuxedId, - } - ); - options.paymentMonitor?.unregisterWatch(id); - - emitEvent('info', 'invoice.paid', context, { - invoiceRef: logReference(updatedInvoice.id), - sellerRef: logReference(updatedInvoice.sellerPublicKey), - txRef: logReference(verification.value.txHash), - assetCode: updatedInvoice.assetCode || 'XLM', - network: STELLAR_NETWORK, - storage: storageMode, - durationMs: Date.now() - startedAt, - }); - - // PAID is terminal — the cached success replays for the full window. + const updatedInvoice = pipeline.invoice; + if (cacheableHash) { await cacheResult( id, - hashCheck.value, + cacheableHash, 200, apiSuccess(updatedInvoice, { message: 'Payment verified on Stellar', @@ -760,66 +780,6 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa : undefined, }) ); - } 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. The conflict is a - // stable fact about the tx hash, so it is safe to replay. - emitEvent('warn', 'payment.verify.rejected', context, { - invoiceRef: logReference(id), - txRef: logReference(req.body?.txHash), - errorCode: error.code, - network: STELLAR_NETWORK, - durationMs: Date.now() - startedAt, - }); - const body = verificationFailureBody(error.code, messageForCode(error.code)); - await cacheResult(id, hashCheck.value, 409, body); - res.status(409).json(body); - return; - } - if (error instanceof SettlementTimeUnavailableError) { - emitEvent('warn', 'payment.verify.rejected', context, { - invoiceRef: logReference(id), - txRef: logReference(req.body?.txHash), - errorCode: 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', - network: STELLAR_NETWORK, - durationMs: Date.now() - startedAt, - }); - return sendVerificationFailure( - res, - 503, - 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', - messageForCode('TRANSACTION_CLOSE_TIME_UNAVAILABLE') - ); - } - if (error instanceof IllegalStateTransitionError) { - const verificationCode = - error.code === 'INVOICE_ALREADY_PAID' - ? 'INVOICE_ALREADY_PAID' - : error.code === 'INVOICE_EXPIRED' - ? 'INVOICE_EXPIRED' - : 'INVOICE_NOT_PENDING'; - return sendVerificationFailure(res, 400, verificationCode, error.message); - } - // 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( - res, - 400, - latestStatus.code, - latestStatus.error - ); - } - throw error; } sendSuccess(res, 200, toPublicInvoiceDto(updatedInvoice), { diff --git a/backend/src/services/payment-monitor.service.ts b/backend/src/services/payment-monitor.service.ts index f8d4cf2f..e44b3a5c 100644 --- a/backend/src/services/payment-monitor.service.ts +++ b/backend/src/services/payment-monitor.service.ts @@ -3,12 +3,9 @@ import stellarService, { PaymentPageRecord, PaymentRecord } from './stellar.serv import invoiceService, { InvoiceService, Queryable } from './invoice.service'; import { SELLER_PUBLIC_KEY, STELLAR_NETWORK } from '../config/stellar'; import { pool } from '../config/database'; -import { checkInvoiceIsPayable, verifyHorizonPayment } from './payment-verification'; +import { checkInvoiceIsPayable, executePaymentVerificationPipeline } from './payment-verification'; import { canonicalAmount } from '../utils/safe-amount-compare'; -import { PaymentClaimError } from '../domain/payment-attribution'; -import { IllegalStateTransitionError } from '../domain/invoice-lifecycle'; import { - parseSettlementTime, SettlementTimeUnavailableError, } from '../domain/invoice-settlement'; import { monitorBackoffMs } from '../utils/monitor-retry-backoff'; @@ -543,10 +540,12 @@ export class PaymentMonitorService { network: this.network, }); - const verification = verifyHorizonPayment({ + const pipeline = await executePaymentVerificationPipeline({ + invoice, txHash: payment.txHash, network: this.network, - transaction: { memo: payment.memo, memo_type: payment.memoType }, + expectedNetwork: this.network, + transaction: { memo: payment.memo, memo_type: payment.memoType, created_at: payment.createdAt }, operations: [{ type: 'payment', from: payment.from, @@ -556,102 +555,77 @@ export class PaymentMonitorService { asset_code: isNative ? undefined : payment.assetCode, asset_issuer: payment.assetIssuer, }], - expected: { - memo: invoice.memo, - amount: invoice.amount, - destination: invoice.sellerPublicKey, - assetCode: invoice.assetCode, - assetIssuer: invoice.assetIssuer, - network: this.network, + storage: { + markAsPaid: async (id, txHash, payerPublicKey, payerInfo, options) => { + await this.saveTransaction(payment, id); + return this.invoices.markAsPaid( + id, + txHash, + payerPublicKey ?? payment.from, + payerInfo, + options + ); + }, + getInvoiceById: async (id) => { + const byMemo = await this.invoices.getInvoiceByMemo(invoice.memo); + return byMemo && byMemo.id === id ? byMemo : null; + }, }, + requireSettledAt: true, }); - if (!verification.ok) { + if (!pipeline.ok) { + if (pipeline.code === 'TRANSACTION_CLOSE_TIME_UNAVAILABLE') { + throw new SettlementTimeUnavailableError(); + } emitEvent('warn', 'payment.verify.rejected', context, { invoiceRef: logReference(invoice.id), txRef: logReference(payment.txHash), - errorCode: verification.code, + errorCode: pipeline.code, network: this.network, durationMs: Date.now() - startedAt, }); + const transition = pipeline.details; + const lifecycleAudit = + pipeline.stage === 'persist_paid' && + typeof transition?.lifecycleCode === 'string' && + typeof transition?.fromStatus === 'string' && + typeof transition?.toStatus === 'string' + ? { + code: transition.lifecycleCode, + fromStatus: transition.fromStatus, + toStatus: transition.toStatus, + } + : {}; await this.invoices.logPaymentEvent( invoice.id, - verification.code === 'AMOUNT_TOO_LOW' || verification.code === 'AMOUNT_MISMATCH' + pipeline.code === 'AMOUNT_TOO_LOW' || pipeline.code === 'AMOUNT_MISMATCH' ? 'PARTIAL_PAYMENT' : 'PAYMENT_REJECTED', { - code: verification.code, + code: pipeline.code, + stage: pipeline.stage, txHash: payment.txHash, expectedAmount: canonicalAmount(invoice.amount) ?? String(invoice.amount), receivedAmount: payment.amount, payerPublicKey: payment.from, + ...lifecycleAudit, } ); return; } - const settledAt = parseSettlementTime(payment.createdAt) ?? verification.value.settledAt; - if (!settledAt) { - throw new SettlementTimeUnavailableError(); - } - - try { - await this.saveTransaction(payment, invoice.id); - await this.invoices.markAsPaid( - invoice.id, - payment.txHash, - payment.from, - undefined, - { settledAt, destinationMuxedId: verification.value.toMuxedId } - ); - this.processedTxHashes.add(payment.txHash); - this.unregisterWatch(invoice.id); - emitEvent('info', 'invoice.paid', context, { - invoiceRef: logReference(invoice.id), - sellerRef: logReference(invoice.sellerPublicKey), - txRef: logReference(payment.txHash), - assetCode: invoice.assetCode || 'XLM', - network: this.network, - storage: this.database ? 'postgres' : 'memory', - durationMs: Date.now() - startedAt, - }); - } catch (error) { - if (error instanceof PaymentClaimError) { - // A transaction that already settled another invoice must not settle this one - emitEvent('warn', 'payment.verify.rejected', context, { - invoiceRef: logReference(invoice.id), - txRef: logReference(payment.txHash), - errorCode: error.code || 'TX_HASH_ALREADY_USED', - network: this.network, - durationMs: Date.now() - startedAt, - }); - await this.invoices.logPaymentEvent( - invoice.id, - 'PAYMENT_REJECTED', - { - code: 'TX_HASH_ALREADY_USED', - txHash: payment.txHash, - error: error.message, - } - ); - return; - } - if (error instanceof IllegalStateTransitionError) { - // Status raced (e.g. paid elsewhere); keep the stream healthy. - console.warn( - 'Payment monitor skipped settling invoice due to state conflict:', - error.message - ); - await this.invoices.logPaymentEvent(invoice.id, 'PAYMENT_REJECTED', { - code: error.code, - txHash: payment.txHash, - fromStatus: error.fromStatus, - toStatus: error.toStatus, - }); - return; - } - throw error; - } + this.processedTxHashes.add(payment.txHash); + this.unregisterWatch(invoice.id); + emitEvent('info', 'invoice.paid', context, { + invoiceRef: logReference(invoice.id), + sellerRef: logReference(invoice.sellerPublicKey), + txRef: logReference(payment.txHash), + assetCode: invoice.assetCode || 'XLM', + network: this.network, + storage: this.database ? 'postgres' : 'memory', + durationMs: Date.now() - startedAt, + }); } private async saveTransaction(payment: PaymentRecord, invoiceId: string) { diff --git a/backend/src/services/payment-verification-pipeline.ts b/backend/src/services/payment-verification-pipeline.ts new file mode 100644 index 00000000..d2ddcf7f --- /dev/null +++ b/backend/src/services/payment-verification-pipeline.ts @@ -0,0 +1,786 @@ +/** + * Multi-step payment verification pipeline (issue #441). + * + * Manual verify and the payment monitor both enter through + * `executePaymentVerificationPipeline` so stage order, rejection codes, and + * the PAID transition stay identical across entry points. + * + * Stages run in fixed order and short-circuit on hard failure: + * fetch_transaction → match_destination → match_asset → match_amount → + * match_memo → attribute → persist_paid. + */ + +import type { StoredInvoice, MarkAsPaidOptions, PayerInfo } from '../storage/invoice-storage'; +import type { + HorizonOperationLike, + HorizonTransactionLike, + NormalizedPaymentOperation, + ResolvedDestination, +} from './payment-verification'; +import { + checkTxHash, + checkPayerInfo, + checkInvoiceIsPayable, + selectInvoicePaymentOperation, + destinationMatches, + amountsMatch, + amountRejectionCode, + transactionSettlementTime, + resolveInvoiceAsset, + resolvePaymentAsset, + normalizePaymentOperation, +} from './payment-verification'; +import { PaymentClaimError } from '../domain/payment-attribution'; +import { IllegalStateTransitionError } from '../domain/invoice-lifecycle'; +import { + SettlementTimeUnavailableError, +} from '../domain/invoice-settlement'; +import { assetsMatch } from '../utils/asset-helpers'; +import { isHorizonUnavailable } from '../utils/horizon-client'; +import { + VERIFICATION_STAGES, + messageForCode, + stageForCode, +} from '../../../shared/verification'; +import type { + VerificationCode, + VerificationStage, +} from '../../../shared/verification'; + +export type { VerificationCode, VerificationStage }; + +export type StageResult = + | { ok: true; stage: VerificationStage; value: T } + | { + ok: false; + stage: VerificationStage; + code: VerificationCode; + error: string; + details?: Record; + }; + +function stageFailure( + stage: VerificationStage, + code: VerificationCode, + details?: Record +): StageResult { + return { + ok: false, + stage, + code, + error: messageForCode(code), + ...(details ? { details } : {}), + }; +} + +export function normalizeMemo(memo: unknown): string { + return typeof memo === 'string' ? memo : ''; +} + +function normalizeMemoType(memoType: unknown): string | undefined { + if (typeof memoType !== 'string' || memoType === '') return undefined; + return memoType.toLowerCase().replace(/^memo_/, ''); +} + +function asNormalizedOperation( + operation: NormalizedPaymentOperation | HorizonOperationLike +): NormalizedPaymentOperation { + if ('assetType' in operation && typeof (operation as NormalizedPaymentOperation).assetType === 'string') { + return operation as NormalizedPaymentOperation; + } + const normalized = normalizePaymentOperation(operation as HorizonOperationLike); + if (!normalized) { + return { + type: (operation as HorizonOperationLike).type || 'payment', + from: (operation as HorizonOperationLike).from ?? '', + to: (operation as HorizonOperationLike).to ?? '', + amount: (operation as HorizonOperationLike).amount ?? '', + assetType: (operation as HorizonOperationLike).asset_type ?? 'native', + assetCode: (operation as HorizonOperationLike).asset_code, + assetIssuer: (operation as HorizonOperationLike).asset_issuer, + }; + } + return normalized; +} + +export interface FetchTransactionStageInput { + txHash: unknown; + network?: string; + expectedNetwork?: string; + transaction?: HorizonTransactionLike; + operations?: HorizonOperationLike[]; + stellar?: { + getTransaction(txHash: string): Promise<{ + transaction: HorizonTransactionLike; + operations: HorizonOperationLike[]; + }>; + }; + expectedDestination?: string; +} + +export interface FetchTransactionOutput { + txHash: string; + transaction: HorizonTransactionLike; + operation: NormalizedPaymentOperation; + operations: HorizonOperationLike[]; + settledAt?: Date; + memo: string; + memoType?: string; +} + +export interface MatchDestinationStageInput { + operation: NormalizedPaymentOperation | HorizonOperationLike; + expectedDestination: string; +} + +export interface MatchDestinationOutput { + destination: string; + resolution: ResolvedDestination; +} + +export interface MatchAssetStageInput { + operation: NormalizedPaymentOperation | HorizonOperationLike; + expectedAssetCode?: string; + expectedAssetIssuer?: string; +} + +export interface MatchAssetOutput { + assetCode: string; + assetIssuer?: string; +} + +export interface MatchAmountStageInput { + operation: NormalizedPaymentOperation | HorizonOperationLike; + expectedAmount: number | string; +} + +export interface MatchAmountOutput { + amount: string; + expectedAmount: number | string; +} + +export interface MatchMemoStageInput { + transactionMemo?: unknown; + transactionMemoType?: unknown; + expectedMemo: string; +} + +export interface MatchMemoOutput { + memo: string; +} + +export interface AttributeStageInput { + payer?: { + payerName?: unknown; + payerEmail?: unknown; + }; + invoiceStatus?: string; + settledAt?: Date; + /** When true, missing close time is a hard failure (manual verify + monitor). */ + requireSettledAt?: boolean; +} + +export interface AttributeOutput { + payerName?: string; + payerEmail?: string; + settledAt?: Date; +} + +export interface PersistPaidStorage { + markAsPaid( + id: string, + txHash: string, + payerPublicKey?: string, + payer?: PayerInfo, + opts?: MarkAsPaidOptions + ): Promise; + getInvoiceById?(id: string): Promise; +} + +export interface PersistPaidStageInput { + invoiceId: string; + invoiceStatus: string; + storage: PersistPaidStorage; + txHash: string; + from?: string; + payer?: PayerInfo; + settledAt?: Date; + destinationMuxedId?: string; + onBeforePersist?: (ctx?: { + invoiceId: string; + txHash: string; + from?: string; + }) => Promise | void; + onAfterPersist?: (ctx: { + invoice: StoredInvoice; + txHash: string; + }) => Promise | void; +} + +export interface PersistPaidOutput { + invoice: StoredInvoice; + txHash?: string; + from?: string; + settledAt?: Date; +} + +export interface ExecutePipelineInput { + invoice: StoredInvoice; + txHash: unknown; + network?: string; + expectedNetwork?: string; + stellar?: { + getTransaction(txHash: string): Promise<{ + transaction: HorizonTransactionLike; + operations: HorizonOperationLike[]; + }>; + }; + storage: PersistPaidStorage; + transaction?: HorizonTransactionLike; + operations?: HorizonOperationLike[]; + payer?: { + payerName?: unknown; + payerEmail?: unknown; + }; + requireSettledAt?: boolean; + onBeforePersist?: (ctx?: { + invoiceId: string; + txHash: string; + from?: string; + }) => Promise | void; + onAfterPersist?: (context: { invoice: StoredInvoice; txHash: string }) => Promise | void; +} + +export type VerificationPipelineSuccess = { + ok: true; + stage: 'persist_paid'; + invoice: StoredInvoice; + txHash?: string; + from?: string; + settledAt?: Date; + toMuxedId?: string; + stagesCompleted: VerificationStage[]; +}; + +export type VerificationPipelineFailure = { + ok: false; + stage: VerificationStage; + code: VerificationCode; + error: string; + details?: Record; + stagesCompleted: VerificationStage[]; +}; + +export type VerificationPipelineResult = + | VerificationPipelineSuccess + | VerificationPipelineFailure; + +/** + * Stage 1: Validate hash/network, fetch Horizon resources, select payment op. + */ +export async function fetchTransactionStage( + input: FetchTransactionStageInput +): Promise> { + const hashCheck = checkTxHash(input.txHash); + if (!hashCheck.ok) { + return { + ok: false, + stage: 'fetch_transaction', + code: hashCheck.code, + error: hashCheck.error, + }; + } + + const txHash = hashCheck.value; + + if (input.expectedNetwork && input.network && input.expectedNetwork !== input.network) { + return stageFailure('fetch_transaction', 'NETWORK_MISMATCH', { + expected: input.expectedNetwork, + actual: input.network, + }); + } + + let transaction = input.transaction; + let operations = input.operations; + + if (!transaction || !operations) { + if (!input.stellar) { + return stageFailure('fetch_transaction', 'TRANSACTION_NOT_FOUND'); + } + try { + const fetched = await input.stellar.getTransaction(txHash); + transaction = fetched.transaction; + operations = fetched.operations; + } catch (err: any) { + if (isHorizonUnavailable(err)) { + return stageFailure('fetch_transaction', 'VERIFY_UNAVAILABLE'); + } + if ( + err?.code === 'VERIFY_RATE_LIMIT_EXCEEDED' || + err?.response?.status === 429 || + err?.status === 429 + ) { + return stageFailure('fetch_transaction', 'VERIFY_RATE_LIMIT_EXCEEDED'); + } + if ( + err?.code === 'TRANSACTION_NOT_FOUND' || + err?.response?.status === 404 || + err?.status === 404 || + /not found/i.test(err?.message || '') + ) { + return stageFailure('fetch_transaction', 'TRANSACTION_NOT_FOUND'); + } + return stageFailure('fetch_transaction', 'TRANSACTION_NOT_FOUND', { + message: err?.message, + }); + } + } + + const destination = input.expectedDestination || ''; + const selection = selectInvoicePaymentOperation(operations || [], destination); + + if (selection.kind === 'none') { + return stageFailure('fetch_transaction', 'NO_PAYMENT_OPERATION'); + } + if (selection.kind === 'ambiguous') { + return stageFailure('fetch_transaction', 'AMBIGUOUS_PAYMENT_OPERATION'); + } + + const paymentOp = selection.op; + const settledAt = transactionSettlementTime(transaction!); + + return { + ok: true, + stage: 'fetch_transaction', + value: { + txHash, + transaction: transaction!, + operation: paymentOp, + operations: operations || [], + settledAt, + memo: normalizeMemo(transaction?.memo), + memoType: normalizeMemoType(transaction?.memo_type), + }, + }; +} + +/** + * Stage 2: Payment destination must land on the invoice seller (incl. muxed). + */ +export function matchDestinationStage( + input: MatchDestinationStageInput +): StageResult { + const op = asNormalizedOperation(input.operation); + const resolution = destinationMatches(op.to, input.expectedDestination); + if (!resolution) { + return stageFailure('match_destination', 'DESTINATION_MISMATCH', { + expected: input.expectedDestination, + actual: op.to, + }); + } + return { + ok: true, + stage: 'match_destination', + value: { destination: op.to, resolution }, + }; +} + +/** + * Stage 3: Asset code and issuer must match the invoice. + */ +export function matchAssetStage( + input: MatchAssetStageInput +): StageResult { + const op = asNormalizedOperation(input.operation); + const invoiceAsset = resolveInvoiceAsset({ + assetCode: input.expectedAssetCode, + assetIssuer: input.expectedAssetIssuer, + }); + const paidAsset = resolvePaymentAsset({ + assetType: op.assetType, + assetCode: op.assetCode, + assetIssuer: op.assetIssuer, + }); + + if (!assetsMatch(invoiceAsset, paidAsset)) { + return stageFailure('match_asset', 'ASSET_MISMATCH', { + expectedAssetCode: input.expectedAssetCode, + expectedAssetIssuer: input.expectedAssetIssuer, + actualAssetCode: op.assetCode, + actualAssetIssuer: op.assetIssuer, + actualAssetType: op.assetType, + }); + } + + return { + ok: true, + stage: 'match_asset', + value: { + assetCode: op.assetType === 'native' ? 'XLM' : op.assetCode ?? '', + assetIssuer: op.assetType === 'native' ? undefined : op.assetIssuer, + }, + }; +} + +/** + * Stage 4: Received amount must equal the invoice amount (zero-stroop window). + */ +export function matchAmountStage( + input: MatchAmountStageInput +): StageResult { + const op = asNormalizedOperation(input.operation); + if (!amountsMatch(op.amount, input.expectedAmount)) { + const code = amountRejectionCode(op.amount, input.expectedAmount); + return stageFailure('match_amount', code, { + expected: String(input.expectedAmount), + actual: op.amount, + }); + } + return { + ok: true, + stage: 'match_amount', + value: { amount: op.amount || '', expectedAmount: input.expectedAmount }, + }; +} + +/** + * Stage 5: Text memo must match the invoice memo. + */ +export function matchMemoStage( + input: MatchMemoStageInput +): StageResult { + const txMemoType = normalizeMemoType(input.transactionMemoType); + if (txMemoType && txMemoType !== 'text' && txMemoType !== 'none') { + return stageFailure('match_memo', 'MEMO_TYPE_MISMATCH', { + actualType: txMemoType, + }); + } + + const actualMemo = normalizeMemo(input.transactionMemo); + const expectedMemo = normalizeMemo(input.expectedMemo); + if (actualMemo !== expectedMemo) { + return stageFailure('match_memo', 'MEMO_MISMATCH', { + expected: expectedMemo, + actual: actualMemo, + }); + } + return { + ok: true, + stage: 'match_memo', + value: { memo: actualMemo }, + }; +} + +/** + * Stage 6: Normalize payer fields and require ledger close time when needed. + */ +export function attributeStage( + input: AttributeStageInput +): StageResult { + const payerCheck = checkPayerInfo(input.payer ?? {}); + if (!payerCheck.ok) { + return { + ok: false, + stage: 'attribute', + code: payerCheck.code, + error: payerCheck.error, + }; + } + + const needsCloseTime = + input.requireSettledAt === true || input.invoiceStatus === 'CANCELLED'; + if (needsCloseTime && !input.settledAt) { + return stageFailure('attribute', 'TRANSACTION_CLOSE_TIME_UNAVAILABLE'); + } + + return { + ok: true, + stage: 'attribute', + value: { + payerName: payerCheck.value?.payerName, + payerEmail: payerCheck.value?.payerEmail, + settledAt: input.settledAt, + }, + }; +} + +/** + * Stage 7: Atomic PAID transition with claim / status conflict mapping. + */ +export async function persistPaidStage( + input: PersistPaidStageInput +): Promise> { + const statusCheck = checkInvoiceIsPayable(input.invoiceStatus); + if ( + !statusCheck.ok && + input.invoiceStatus !== 'CANCELLED' && + input.invoiceStatus !== 'EXPIRED' + ) { + return { + ok: false, + stage: 'persist_paid', + code: statusCheck.code, + error: statusCheck.error, + }; + } + + if (input.onBeforePersist) { + await input.onBeforePersist({ + invoiceId: input.invoiceId, + txHash: input.txHash, + from: input.from, + }); + } + + let updatedInvoice: StoredInvoice; + try { + updatedInvoice = await input.storage.markAsPaid( + input.invoiceId, + input.txHash, + input.from, + input.payer, + { + settledAt: input.settledAt, + ...(input.destinationMuxedId + ? { destinationMuxedId: input.destinationMuxedId } + : {}), + } + ); + } catch (err: any) { + if (err instanceof PaymentClaimError || err?.code === 'TX_HASH_ALREADY_USED') { + return stageFailure('persist_paid', 'TX_HASH_ALREADY_USED'); + } + if (err instanceof SettlementTimeUnavailableError) { + return stageFailure('persist_paid', 'TRANSACTION_CLOSE_TIME_UNAVAILABLE'); + } + if (err instanceof IllegalStateTransitionError) { + const code: VerificationCode = + err.code === 'INVOICE_ALREADY_PAID' || err.code === 'INVOICE_EXPIRED' + ? err.code + : 'INVOICE_NOT_PENDING'; + return { + ok: false, + stage: 'persist_paid', + code, + error: err.message, + details: { + lifecycleCode: err.code, + fromStatus: err.fromStatus, + toStatus: err.toStatus, + }, + }; + } + if (input.storage.getInvoiceById) { + try { + const latest = await input.storage.getInvoiceById(input.invoiceId); + const latestStatus = latest && checkInvoiceIsPayable(latest.status); + if ( + latestStatus && + !latestStatus.ok && + latest!.status !== 'CANCELLED' && + latest!.status !== 'EXPIRED' + ) { + return { + ok: false, + stage: 'persist_paid', + code: latestStatus.code, + error: latestStatus.error, + }; + } + } catch { + // ignore re-read errors + } + } + if (/already been paid/i.test(err?.message || '')) { + return stageFailure('persist_paid', 'INVOICE_ALREADY_PAID'); + } + if (/expired/i.test(err?.message || '')) { + return stageFailure('persist_paid', 'INVOICE_EXPIRED'); + } + throw err; + } + + if (input.onAfterPersist) { + await input.onAfterPersist({ invoice: updatedInvoice, txHash: input.txHash }); + } + + return { + ok: true, + stage: 'persist_paid', + value: { + invoice: updatedInvoice, + txHash: input.txHash, + from: input.from, + settledAt: input.settledAt, + }, + }; +} + +/** + * Runs the full verification pipeline with short-circuit on hard failure. + */ +export async function executePaymentVerificationPipeline( + input: ExecutePipelineInput +): Promise { + const stagesCompleted: VerificationStage[] = []; + + const payableCheck = checkInvoiceIsPayable(input.invoice.status); + if ( + !payableCheck.ok && + input.invoice.status !== 'CANCELLED' && + input.invoice.status !== 'EXPIRED' + ) { + return { + ok: false, + stage: 'persist_paid', + code: payableCheck.code, + error: payableCheck.error, + stagesCompleted, + }; + } + + const fetchRes = await fetchTransactionStage({ + txHash: input.txHash, + network: input.network, + expectedNetwork: input.expectedNetwork, + transaction: input.transaction, + operations: input.operations, + stellar: input.stellar, + expectedDestination: input.invoice.sellerPublicKey, + }); + if (!fetchRes.ok) { + return { + ok: false, + stage: 'fetch_transaction', + code: fetchRes.code, + error: fetchRes.error, + details: fetchRes.details, + stagesCompleted, + }; + } + stagesCompleted.push('fetch_transaction'); + + const destRes = matchDestinationStage({ + operation: fetchRes.value.operation, + expectedDestination: input.invoice.sellerPublicKey, + }); + if (!destRes.ok) { + return { + ok: false, + stage: 'match_destination', + code: destRes.code, + error: destRes.error, + details: destRes.details, + stagesCompleted, + }; + } + stagesCompleted.push('match_destination'); + + const assetRes = matchAssetStage({ + operation: fetchRes.value.operation, + expectedAssetCode: input.invoice.assetCode, + expectedAssetIssuer: input.invoice.assetIssuer, + }); + if (!assetRes.ok) { + return { + ok: false, + stage: 'match_asset', + code: assetRes.code, + error: assetRes.error, + details: assetRes.details, + stagesCompleted, + }; + } + stagesCompleted.push('match_asset'); + + const amountRes = matchAmountStage({ + operation: fetchRes.value.operation, + expectedAmount: input.invoice.amount, + }); + if (!amountRes.ok) { + return { + ok: false, + stage: 'match_amount', + code: amountRes.code, + error: amountRes.error, + details: amountRes.details, + stagesCompleted, + }; + } + stagesCompleted.push('match_amount'); + + const memoRes = matchMemoStage({ + transactionMemo: fetchRes.value.transaction?.memo ?? fetchRes.value.memo, + transactionMemoType: fetchRes.value.transaction?.memo_type ?? fetchRes.value.memoType, + expectedMemo: input.invoice.memo, + }); + if (!memoRes.ok) { + return { + ok: false, + stage: 'match_memo', + code: memoRes.code, + error: memoRes.error, + details: memoRes.details, + stagesCompleted, + }; + } + stagesCompleted.push('match_memo'); + + const attrRes = attributeStage({ + payer: input.payer, + invoiceStatus: input.invoice.status, + settledAt: fetchRes.value.settledAt, + requireSettledAt: input.requireSettledAt !== false, + }); + if (!attrRes.ok) { + return { + ok: false, + stage: 'attribute', + code: attrRes.code, + error: attrRes.error, + details: attrRes.details, + stagesCompleted, + }; + } + stagesCompleted.push('attribute'); + + const persistRes = await persistPaidStage({ + invoiceId: input.invoice.id, + invoiceStatus: input.invoice.status, + storage: input.storage, + txHash: fetchRes.value.txHash, + from: fetchRes.value.operation.from, + payer: { + payerName: attrRes.value.payerName, + payerEmail: attrRes.value.payerEmail, + }, + settledAt: attrRes.value.settledAt, + destinationMuxedId: destRes.value.resolution.muxedId, + onBeforePersist: input.onBeforePersist, + onAfterPersist: input.onAfterPersist, + }); + if (!persistRes.ok) { + return { + ok: false, + stage: 'persist_paid', + code: persistRes.code, + error: persistRes.error, + details: persistRes.details, + stagesCompleted, + }; + } + stagesCompleted.push('persist_paid'); + + return { + ok: true, + stage: 'persist_paid', + invoice: persistRes.value.invoice, + txHash: persistRes.value.txHash, + from: persistRes.value.from, + settledAt: persistRes.value.settledAt, + toMuxedId: destRes.value.resolution.muxedId, + stagesCompleted, + }; +} + +export { VERIFICATION_STAGES, stageForCode }; diff --git a/backend/src/services/payment-verification.ts b/backend/src/services/payment-verification.ts index 3827ef08..09e9ad8b 100644 --- a/backend/src/services/payment-verification.ts +++ b/backend/src/services/payment-verification.ts @@ -27,14 +27,53 @@ import { VERIFICATION_MESSAGES, } from '../../../shared/verification'; import type { VerificationCode } from '../../../shared/verification'; -export type { VerificationCode, VerificationFailureBody } from '../../../shared/verification'; +export type { + VerificationCode, + VerificationFailureBody, + VerificationStage, + StageResult, +} from '../../../shared/verification'; export { VERIFICATION_CHECKS, CHECK_REJECTION_CODES, + VERIFICATION_STAGES, + STAGE_REJECTION_CODES, VERIFICATION_MESSAGES, VERIFICATION_CODES, messageForCode, + stageForCode, } from '../../../shared/verification'; +export { + fetchTransactionStage, + matchDestinationStage, + matchAssetStage, + matchAmountStage, + matchMemoStage, + attributeStage, + persistPaidStage, + executePaymentVerificationPipeline, +} from './payment-verification-pipeline'; +export type { + FetchTransactionStageInput, + FetchTransactionOutput, + MatchDestinationStageInput, + MatchDestinationOutput, + MatchAssetStageInput, + MatchAssetOutput, + MatchAmountStageInput, + MatchAmountOutput, + MatchMemoStageInput, + MatchMemoOutput, + AttributeStageInput, + AttributeOutput, + PersistPaidStageInput, + PersistPaidOutput, + PersistPaidStorage, + ExecutePipelineInput, + VerificationPipelineSuccess, + VerificationPipelineFailure, + VerificationPipelineResult, +} from './payment-verification-pipeline'; export interface VerificationFailure { ok: false; diff --git a/backend/src/types/api.ts b/backend/src/types/api.ts index 6a402df5..d0832696 100644 --- a/backend/src/types/api.ts +++ b/backend/src/types/api.ts @@ -1,5 +1,5 @@ import { Response } from 'express'; -import type { VerificationCode } from '../services/payment-verification'; +import type { VerificationCode, VerificationStage } from '../services/payment-verification'; // Shared response envelope used by both the MVP and the Postgres server. // Both servers send the same success/failure shape so clients stay @@ -104,9 +104,16 @@ export function sendVerificationFailure( res: Response, status: number, code: VerificationCode, - error: string + error: string, + extra?: { stage?: VerificationStage; details?: Record } ): void { - res.status(status).json({ success: false, code, error }); + res.status(status).json({ + success: false, + code, + error, + ...(extra?.stage ? { stage: extra.stage } : {}), + ...(extra?.details ? { details: extra.details } : {}), + }); } /** @@ -119,14 +126,23 @@ export interface VerificationFailureBody { success: false; code: VerificationCode; error: string; + stage?: VerificationStage; + details?: Record; } /** Build a verification failure envelope with a stable code and its message. */ export function verificationFailureBody( code: VerificationCode, - error: string + error: string, + extra?: { stage?: VerificationStage; details?: Record } ): VerificationFailureBody { - return { success: false, code, error }; + return { + success: false, + code, + error, + ...(extra?.stage ? { stage: extra.stage } : {}), + ...(extra?.details ? { details: extra.details } : {}), + }; } export default { diff --git a/backend/tests/fixtures/payment-verification-pipeline.fixture.ts b/backend/tests/fixtures/payment-verification-pipeline.fixture.ts new file mode 100644 index 00000000..44100d61 --- /dev/null +++ b/backend/tests/fixtures/payment-verification-pipeline.fixture.ts @@ -0,0 +1,188 @@ +import type { StoredInvoice } from '../../src/storage/invoice-storage'; +import type { + HorizonOperationLike, + HorizonTransactionLike, +} from '../../src/services/payment-verification'; +import type { + VerificationCode, + VerificationStage, +} from '../../src/services/payment-verification'; + +export const SELLER_PUBLIC_KEY = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +export const BUYER_PUBLIC_KEY = 'GAYF33NNNMI2Z6VNRFXQ64D4E4SF77PM46NW3ZUZEEU5X7FCHAZCMHKU'; +export const OTHER_PUBLIC_KEY = 'GATTACKERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +export const VALID_TX_HASH = '2a4261f890848f037bd45bc9860d6065757f6d8389e030945b148a0e625cf7bd'; +export const ALREADY_USED_TX_HASH = 'ffa806b714bea1443ded88bff1f2b472905513deded3ba0b7cbd0f797a19854c'; + +export function createTestInvoice(overrides: Partial = {}): StoredInvoice { + return { + id: 'inv_pipeline_test_123', + sellerPublicKey: SELLER_PUBLIC_KEY, + amount: 25.5, + memo: 'order-101', + assetCode: 'USDC', + assetIssuer: SELLER_PUBLIC_KEY, + status: 'PENDING', + createdAt: new Date('2026-09-01T12:00:00.000Z'), + expiresAt: new Date('2026-09-01T13:00:00.000Z'), + ...overrides, + }; +} + +export function createTestTransaction( + overrides: Partial = {} +): HorizonTransactionLike { + return { + memo: 'order-101', + memo_type: 'text', + created_at: '2026-09-01T12:05:00.000Z', + ...overrides, + }; +} + +export function createTestOperation( + overrides: Partial = {} +): HorizonOperationLike { + return { + type: 'payment', + from: BUYER_PUBLIC_KEY, + to: SELLER_PUBLIC_KEY, + amount: '25.5000000', + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: SELLER_PUBLIC_KEY, + ...overrides, + }; +} + +export interface PipelineStageTestCase { + name: string; + stageToFail?: VerificationStage; + expectedCode?: VerificationCode; + expectedStagesCompleted: VerificationStage[]; + invoice: StoredInvoice; + txHash: unknown; + transaction?: HorizonTransactionLike; + operations?: HorizonOperationLike[]; + payer?: { payerName?: unknown; payerEmail?: unknown }; + mockStorageFail?: 'already_used' | 'already_paid'; +} + +export const PIPELINE_STAGE_CASES: Record = { + fetchTransactionFailHash: { + name: 'Fails at fetch_transaction stage with invalid transaction hash', + stageToFail: 'fetch_transaction', + expectedCode: 'INVALID_TX_HASH', + expectedStagesCompleted: [], + invoice: createTestInvoice(), + txHash: 'invalid-hash-string', + }, + fetchTransactionFailNotFound: { + name: 'Fails at fetch_transaction stage when transaction operations are missing', + stageToFail: 'fetch_transaction', + expectedCode: 'NO_PAYMENT_OPERATION', + expectedStagesCompleted: [], + invoice: createTestInvoice(), + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [], + }, + matchDestinationFail: { + name: 'Fails at match_destination stage when destination does not match seller', + stageToFail: 'match_destination', + expectedCode: 'DESTINATION_MISMATCH', + expectedStagesCompleted: ['fetch_transaction'], + invoice: createTestInvoice(), + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [createTestOperation({ to: OTHER_PUBLIC_KEY })], + }, + matchAssetFail: { + name: 'Fails at match_asset stage when asset code or issuer does not match', + stageToFail: 'match_asset', + expectedCode: 'ASSET_MISMATCH', + expectedStagesCompleted: ['fetch_transaction', 'match_destination'], + invoice: createTestInvoice(), + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [createTestOperation({ asset_code: 'EURC' })], + }, + matchAmountFail: { + name: 'Fails at match_amount stage when payment amount is below invoice amount', + stageToFail: 'match_amount', + expectedCode: 'AMOUNT_TOO_LOW', + expectedStagesCompleted: ['fetch_transaction', 'match_destination', 'match_asset'], + invoice: createTestInvoice({ amount: 50.0 }), + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [createTestOperation({ amount: '25.5000000' })], + }, + matchMemoFail: { + name: 'Fails at match_memo stage when transaction memo does not match invoice', + stageToFail: 'match_memo', + expectedCode: 'MEMO_MISMATCH', + expectedStagesCompleted: [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + ], + invoice: createTestInvoice({ memo: 'expected-memo' }), + txHash: VALID_TX_HASH, + transaction: createTestTransaction({ memo: 'different-memo' }), + operations: [createTestOperation()], + }, + attributeFail: { + name: 'Fails at attribute stage when payer email is malformed', + stageToFail: 'attribute', + expectedCode: 'INVALID_PAYER_EMAIL', + expectedStagesCompleted: [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + 'match_memo', + ], + invoice: createTestInvoice(), + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [createTestOperation()], + payer: { payerEmail: 'not-an-email' }, + }, + persistPaidFail: { + name: 'Fails at persist_paid stage when txHash was already claimed by another invoice', + stageToFail: 'persist_paid', + expectedCode: 'TX_HASH_ALREADY_USED', + expectedStagesCompleted: [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + 'match_memo', + 'attribute', + ], + invoice: createTestInvoice(), + txHash: ALREADY_USED_TX_HASH, + transaction: createTestTransaction(), + operations: [createTestOperation()], + mockStorageFail: 'already_used', + }, + fullSuccess: { + name: 'Succeeds through all 7 stages and marks invoice as PAID', + expectedStagesCompleted: [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + 'match_memo', + 'attribute', + 'persist_paid', + ], + invoice: createTestInvoice(), + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [createTestOperation()], + payer: { payerName: 'Alice', payerEmail: 'alice@example.com' }, + }, +}; diff --git a/backend/tests/invoice-payment-loop.test.ts b/backend/tests/invoice-payment-loop.test.ts index ca4345cd..cbb2f530 100644 --- a/backend/tests/invoice-payment-loop.test.ts +++ b/backend/tests/invoice-payment-loop.test.ts @@ -481,6 +481,7 @@ describe('invoice payment loop', () => { assert.equal(verified.status, 400); assert.equal(verified.body.code, 'INVALID_PAYER_EMAIL'); + assert.equal(verified.body.stage, 'attribute'); assert.match(verified.body.error, /email is invalid/i); }); diff --git a/backend/tests/payment-verification-pipeline.test.ts b/backend/tests/payment-verification-pipeline.test.ts new file mode 100644 index 00000000..341b76d1 --- /dev/null +++ b/backend/tests/payment-verification-pipeline.test.ts @@ -0,0 +1,347 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + VERIFICATION_STAGES, + STAGE_REJECTION_CODES, + stageForCode, + fetchTransactionStage, + matchDestinationStage, + matchAssetStage, + matchAmountStage, + matchMemoStage, + attributeStage, + persistPaidStage, + executePaymentVerificationPipeline, +} from '../src/services/payment-verification.ts'; +import type { StoredInvoice } from '../src/storage/invoice-storage.ts'; +import type { PersistPaidStorage } from '../src/services/payment-verification-pipeline.ts'; +import { PaymentClaimError } from '../src/domain/payment-attribution.ts'; +import { + SELLER_PUBLIC_KEY, + BUYER_PUBLIC_KEY, + OTHER_PUBLIC_KEY, + VALID_TX_HASH, + ALREADY_USED_TX_HASH, + createTestInvoice, + createTestTransaction, + createTestOperation, + PIPELINE_STAGE_CASES, +} from './fixtures/payment-verification-pipeline.fixture.ts'; + +function createMockStorage( + options: { + failMode?: 'already_used' | 'already_paid'; + existingInvoice?: StoredInvoice; + } = {} +): PersistPaidStorage { + const invoice = options.existingInvoice ?? createTestInvoice(); + return { + async markAsPaid(id, txHash, payerPublicKey, payer, opts) { + if (options.failMode === 'already_used' || txHash === ALREADY_USED_TX_HASH) { + throw new PaymentClaimError(String(txHash), id, 'inv_other'); + } + if (options.failMode === 'already_paid') { + throw new Error('Invoice has already been paid'); + } + return { + ...invoice, + id, + status: 'PAID', + paymentTxHash: txHash, + payerPublicKey, + payerName: payer?.payerName, + payerEmail: payer?.payerEmail, + paidAt: opts?.settledAt ?? new Date(), + settledAt: opts?.settledAt ?? new Date(), + }; + }, + async getInvoiceById(id) { + return { ...invoice, id }; + }, + }; +} + +describe('Payment Verification Pipeline Stages', () => { + it('defines the 7 required stages in exact execution order', () => { + assert.deepEqual([...VERIFICATION_STAGES], [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + 'match_memo', + 'attribute', + 'persist_paid', + ]); + }); + + it('correctly maps verification codes to their respective pipeline stage', () => { + for (const stage of VERIFICATION_STAGES) { + const codes = STAGE_REJECTION_CODES[stage]; + assert.ok(codes.length > 0, `Stage ${stage} must have mapped rejection codes`); + for (const code of codes) { + assert.equal(stageForCode(code), stage); + } + } + }); + + describe('Stage 1: fetchTransactionStage', () => { + it('fails when transaction hash is invalid', async () => { + const res = await fetchTransactionStage({ txHash: 'invalid-hash' }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.stage, 'fetch_transaction'); + assert.equal(res.code, 'INVALID_TX_HASH'); + }); + + it('fails when network mismatches', async () => { + const res = await fetchTransactionStage({ + txHash: VALID_TX_HASH, + network: 'PUBLIC', + expectedNetwork: 'TESTNET', + transaction: createTestTransaction(), + operations: [createTestOperation()], + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.stage, 'fetch_transaction'); + assert.equal(res.code, 'NETWORK_MISMATCH'); + }); + + it('fails when no payment operation exists', async () => { + const res = await fetchTransactionStage({ + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [ + { type: 'create_account', account: BUYER_PUBLIC_KEY, starting_balance: '10' } as any, + ], + expectedDestination: SELLER_PUBLIC_KEY, + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.stage, 'fetch_transaction'); + assert.equal(res.code, 'NO_PAYMENT_OPERATION'); + }); + + it('succeeds when valid transaction and operations are provided', async () => { + const op = createTestOperation(); + const res = await fetchTransactionStage({ + txHash: VALID_TX_HASH, + transaction: createTestTransaction(), + operations: [op], + expectedDestination: SELLER_PUBLIC_KEY, + }); + assert.equal(res.ok, true); + assert.equal(res.stage, 'fetch_transaction'); + if (res.ok) { + assert.equal(res.value.txHash, VALID_TX_HASH); + assert.equal(res.value.operation.to, SELLER_PUBLIC_KEY); + } + }); + }); + + describe('Stage 2: matchDestinationStage', () => { + it('fails when destination does not match expected seller', () => { + const op = createTestOperation({ to: OTHER_PUBLIC_KEY }); + const res = matchDestinationStage({ + operation: op, + expectedDestination: SELLER_PUBLIC_KEY, + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.stage, 'match_destination'); + assert.equal(res.code, 'DESTINATION_MISMATCH'); + }); + + it('succeeds when destination matches seller', () => { + const op = createTestOperation({ to: SELLER_PUBLIC_KEY }); + const res = matchDestinationStage({ + operation: op, + expectedDestination: SELLER_PUBLIC_KEY, + }); + assert.equal(res.ok, true); + if (res.ok) { + assert.equal(res.value.destination, SELLER_PUBLIC_KEY); + } + }); + }); + + describe('Stage 3: matchAssetStage', () => { + it('fails on asset mismatch', () => { + const res = matchAssetStage({ + operation: createTestOperation({ asset_code: 'EURC' }), + expectedAssetCode: 'USDC', + expectedAssetIssuer: SELLER_PUBLIC_KEY, + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.code, 'ASSET_MISMATCH'); + }); + + it('succeeds on matching asset', () => { + const res = matchAssetStage({ + operation: createTestOperation(), + expectedAssetCode: 'USDC', + expectedAssetIssuer: SELLER_PUBLIC_KEY, + }); + assert.equal(res.ok, true); + }); + }); + + describe('Stage 4: matchAmountStage', () => { + it('fails when amount is too low', () => { + const res = matchAmountStage({ + operation: createTestOperation({ amount: '10.0000000' }), + expectedAmount: 25.5, + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.code, 'AMOUNT_TOO_LOW'); + }); + + it('succeeds when amount matches', () => { + const res = matchAmountStage({ + operation: createTestOperation({ amount: '25.5000000' }), + expectedAmount: 25.5, + }); + assert.equal(res.ok, true); + }); + }); + + describe('Stage 5: matchMemoStage', () => { + it('fails when memo mismatches', () => { + const res = matchMemoStage({ + transactionMemo: 'wrong', + transactionMemoType: 'text', + expectedMemo: 'order-101', + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.code, 'MEMO_MISMATCH'); + }); + + it('fails when memo type is not text', () => { + const res = matchMemoStage({ + transactionMemo: 'order-101', + transactionMemoType: 'hash', + expectedMemo: 'order-101', + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.code, 'MEMO_TYPE_MISMATCH'); + }); + + it('succeeds when memo matches', () => { + const res = matchMemoStage({ + transactionMemo: 'order-101', + transactionMemoType: 'text', + expectedMemo: 'order-101', + }); + assert.equal(res.ok, true); + }); + }); + + describe('Stage 6: attributeStage', () => { + it('fails on invalid payer email', () => { + const res = attributeStage({ + payer: { payerEmail: 'nope' }, + invoiceStatus: 'PENDING', + settledAt: new Date(), + requireSettledAt: true, + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.code, 'INVALID_PAYER_EMAIL'); + }); + + it('fails when close time is required but missing', () => { + const res = attributeStage({ + invoiceStatus: 'PENDING', + requireSettledAt: true, + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.code, 'TRANSACTION_CLOSE_TIME_UNAVAILABLE'); + }); + + it('succeeds with valid payer and settledAt', () => { + const settledAt = new Date('2026-09-01T12:05:00.000Z'); + const res = attributeStage({ + payer: { payerName: 'Alice', payerEmail: 'alice@example.com' }, + invoiceStatus: 'PENDING', + settledAt, + requireSettledAt: true, + }); + assert.equal(res.ok, true); + if (res.ok) { + assert.equal(res.value.payerEmail, 'alice@example.com'); + assert.equal(res.value.settledAt, settledAt); + } + }); + }); + + describe('Stage 7: persistPaidStage', () => { + it('maps PaymentClaimError to TX_HASH_ALREADY_USED', async () => { + const res = await persistPaidStage({ + invoiceId: 'inv_pipeline_test_123', + invoiceStatus: 'PENDING', + storage: createMockStorage({ failMode: 'already_used' }), + txHash: VALID_TX_HASH, + from: BUYER_PUBLIC_KEY, + settledAt: new Date(), + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.code, 'TX_HASH_ALREADY_USED'); + }); + + it('persists PAID on success', async () => { + const res = await persistPaidStage({ + invoiceId: 'inv_pipeline_test_123', + invoiceStatus: 'PENDING', + storage: createMockStorage(), + txHash: VALID_TX_HASH, + from: BUYER_PUBLIC_KEY, + payer: { payerName: 'Alice' }, + settledAt: new Date(), + }); + assert.equal(res.ok, true); + if (res.ok) { + assert.equal(res.value.invoice.status, 'PAID'); + assert.equal(res.value.invoice.paymentTxHash, VALID_TX_HASH); + } + }); + }); + + describe('executePaymentVerificationPipeline short-circuit', () => { + for (const [key, testCase] of Object.entries(PIPELINE_STAGE_CASES)) { + it(testCase.name, async () => { + const result = await executePaymentVerificationPipeline({ + invoice: testCase.invoice, + txHash: testCase.txHash, + transaction: testCase.transaction, + operations: testCase.operations, + payer: testCase.payer, + storage: createMockStorage({ + failMode: testCase.mockStorageFail, + existingInvoice: testCase.invoice, + }), + requireSettledAt: true, + }); + + assert.deepEqual(result.stagesCompleted, testCase.expectedStagesCompleted); + + if (testCase.stageToFail) { + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.stage, testCase.stageToFail); + assert.equal(result.code, testCase.expectedCode); + } else { + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.invoice.status, 'PAID'); + assert.equal(result.txHash, VALID_TX_HASH); + } + }); + } + }); +}); diff --git a/backend/tests/shared-verification-contract.test.ts b/backend/tests/shared-verification-contract.test.ts index 5e8ec68e..bed3666a 100644 --- a/backend/tests/shared-verification-contract.test.ts +++ b/backend/tests/shared-verification-contract.test.ts @@ -15,12 +15,18 @@ import { VERIFICATION_CHECKS, VERIFICATION_CODES, VERIFICATION_MESSAGES, + VERIFICATION_STAGES, + STAGE_REJECTION_CODES, messageForCode, + stageForCode, } from '../../shared/verification'; import { VERIFICATION_CODES as BACKEND_CODES, VERIFICATION_MESSAGES as BACKEND_MESSAGES, + VERIFICATION_STAGES as BACKEND_STAGES, + STAGE_REJECTION_CODES as BACKEND_STAGE_CODES, messageForCode as backendMessageForCode, + stageForCode as backendStageForCode, } from '../src/services/payment-verification'; describe('shared verification contract', () => { @@ -47,7 +53,32 @@ describe('shared verification contract', () => { ]); }); - it('has a message for every declared code, with no extras', () => { + it('keeps the seven verification pipeline stages in their fixed order', () => { + assert.deepEqual([...VERIFICATION_STAGES], [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + 'match_memo', + 'attribute', + 'persist_paid', + ]); + assert.deepEqual([...BACKEND_STAGES], [...VERIFICATION_STAGES]); + }); + + it('maps each pipeline stage to shared rejection codes', () => { + assert.deepEqual(BACKEND_STAGE_CODES, STAGE_REJECTION_CODES); + for (const stage of VERIFICATION_STAGES) { + const codes = STAGE_REJECTION_CODES[stage]; + assert.ok(codes.length > 0); + for (const code of codes) { + assert.equal(stageForCode(code), stage); + assert.equal(backendStageForCode(code), stage); + } + } + }); + + it('has a message for every declared code, with no extras', () => { assert.deepEqual( Object.keys(VERIFICATION_MESSAGES).sort(), [...VERIFICATION_CODES].sort() diff --git a/backend/tests/stellar-service.test.ts b/backend/tests/stellar-service.test.ts index 772a74e5..78029210 100644 --- a/backend/tests/stellar-service.test.ts +++ b/backend/tests/stellar-service.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import stellarService from '../src/services/stellar.service'; +import { server } from '../src/config/stellar'; describe('StellarService - Horizon Read Consolidation', () => { it('exposes loadAccount, getBalance, verifyPayment, getTransaction, streamPayments, getRecentPayments, sendPayment', () => { @@ -27,18 +28,44 @@ describe('StellarService - Horizon Read Consolidation', () => { } }); - it('verifies payment with a non-existent 64-character hash returns TRANSACTION_NOT_FOUND', async () => { + it('returns TRANSACTION_NOT_FOUND when Horizon responds with HTTP 404 for a valid hash', async () => { const nonExistentHash = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - const result = await stellarService.verifyPayment(nonExistentHash, { - memo: 'INV-123', - amount: 10, - destination: 'GABC', - assetCode: 'XLM', - }); + const originalTransactions = server.transactions; + const transactionBuilder = originalTransactions.call(server); + const originalTransaction = transactionBuilder.transaction; + const requestedHashes: string[] = []; + let calls = 0; - assert.equal(result.ok, false); - if (!result.ok) { - assert.equal(result.code, 'TRANSACTION_NOT_FOUND'); + // Keep the service and Horizon wrapper real; control only the SDK response. + transactionBuilder.transaction = (txHash: string) => { + requestedHashes.push(txHash); + const transaction = originalTransaction.call(transactionBuilder, txHash); + transaction.call = async () => { + calls += 1; + throw Object.assign(new Error('Horizon 404'), { + response: { status: 404, statusText: '404', headers: {} }, + }); + }; + return transaction; + }; + server.transactions = () => transactionBuilder; + + try { + const result = await stellarService.verifyPayment(nonExistentHash, { + memo: 'INV-123', + amount: 10, + destination: 'GABC', + assetCode: 'XLM', + }); + + assert.deepEqual(requestedHashes, [nonExistentHash]); + assert.equal(calls, 1); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.code, 'TRANSACTION_NOT_FOUND'); + } + } finally { + server.transactions = originalTransactions; } }); }); diff --git a/frontend/lib/pay-verify-controller.d.ts b/frontend/lib/pay-verify-controller.d.ts index c3fb7444..fdaf3b2c 100644 --- a/frontend/lib/pay-verify-controller.d.ts +++ b/frontend/lib/pay-verify-controller.d.ts @@ -5,6 +5,7 @@ export interface VerifyClassification { isApiUnavailable: boolean; message: string; code?: string; + stage?: string; } export type VerifyPaymentResult = diff --git a/frontend/lib/pay-verify-controller.js b/frontend/lib/pay-verify-controller.js index e86038d9..adf5bb8d 100644 --- a/frontend/lib/pay-verify-controller.js +++ b/frontend/lib/pay-verify-controller.js @@ -48,7 +48,9 @@ function classifyVerifyError(error) { } const isApiUnavailable = isApiUnavailableError(error); - const code = error?.response?.data?.code; + const data = error?.response?.data ?? error?.cause?.response?.data ?? {}; + const code = data.code ?? error?.code; + const stage = data.stage; const message = resolveVerificationError( error, isApiUnavailable ? apiErrorMessage(error) : 'Verification failed' @@ -59,6 +61,7 @@ function classifyVerifyError(error) { isApiUnavailable, message, code, + stage, }; } @@ -122,7 +125,12 @@ async function executePaymentVerification({ } if (typeof dispatch === 'function') { - dispatch({ type: 'VERIFY_FAILED', error: classification.message }); + dispatch({ + type: 'VERIFY_FAILED', + error: classification.message, + stage: classification.stage ?? null, + code: classification.code ?? null, + }); } return { diff --git a/frontend/lib/payment-page-state.d.ts b/frontend/lib/payment-page-state.d.ts index 3106a23a..1cfde632 100644 --- a/frontend/lib/payment-page-state.d.ts +++ b/frontend/lib/payment-page-state.d.ts @@ -14,6 +14,8 @@ export interface PaymentState { invoice: PayInvoice | null; txHash: string | null; error: string | null; + stage?: string | null; + code?: string | null; } export interface WalletGateSession { @@ -39,7 +41,7 @@ export type PaymentEvent = | { type: 'PAY_FAILED'; error?: string } | { type: 'VERIFY_STARTED' } | { type: 'VERIFY_SUCCEEDED'; invoice?: PayInvoice | null; txHash?: string } - | { type: 'VERIFY_FAILED'; error?: string } + | { type: 'VERIFY_FAILED'; error?: string; stage?: string | null; code?: string | null } | { type: 'VERIFY_UNAVAILABLE' } | { type: 'COPIED'; key?: string } | { type: 'RESET' }; diff --git a/frontend/lib/payment-page-state.js b/frontend/lib/payment-page-state.js index 8a430d13..a5ac0c4f 100644 --- a/frontend/lib/payment-page-state.js +++ b/frontend/lib/payment-page-state.js @@ -103,6 +103,8 @@ function initialPaymentState(invoice) { invoice: invoice ?? null, txHash: invoice?.paymentTxHash ?? null, error: null, + stage: null, + code: null, }; } @@ -126,6 +128,8 @@ function paymentReducer(state, event) { invoice, txHash: invoice.paymentTxHash ?? state.txHash, error: null, + stage: null, + code: null, }; } @@ -134,7 +138,7 @@ function paymentReducer(state, event) { return { ...state, invoice }; } - return { ...state, status: PAY_STATES.IDLE, invoice, error: null }; + return { ...state, status: PAY_STATES.IDLE, invoice, error: null, stage: null, code: null }; } case 'PAY_STARTED': @@ -156,7 +160,7 @@ function paymentReducer(state, event) { case 'VERIFY_STARTED': if (isTerminalPayState(state.status)) return state; - return { ...state, status: PAY_STATES.VERIFYING, error: null }; + return { ...state, status: PAY_STATES.VERIFYING, error: null, stage: null, code: null }; case 'VERIFY_SUCCEEDED': return { @@ -164,11 +168,19 @@ function paymentReducer(state, event) { invoice: event.invoice ?? state.invoice, txHash: event.invoice?.paymentTxHash ?? event.txHash ?? state.txHash, error: null, + stage: null, + code: null, }; case 'VERIFY_FAILED': if (isTerminalPayState(state.status)) return state; - return { ...state, status: PAY_STATES.ERROR, error: event.error ?? 'Verification failed' }; + return { + ...state, + status: PAY_STATES.ERROR, + error: event.error ?? 'Verification failed', + stage: event.stage ?? null, + code: event.code ?? null, + }; // An outage is not a rejection. Return to idle so the payer keeps the // verify control and the rest of the session, instead of being told the diff --git a/frontend/lib/verification.d.ts b/frontend/lib/verification.d.ts index f6c230f5..fe7e1b74 100644 --- a/frontend/lib/verification.d.ts +++ b/frontend/lib/verification.d.ts @@ -54,3 +54,16 @@ export function checkTxHash(txHash: unknown): VerificationResult; export function checkPayerInfo(input: PayerInfo): VerificationResult; export function resolveVerificationError(error: unknown, fallback?: string): string; + +export type VerificationStage = + | 'fetch_transaction' + | 'match_destination' + | 'match_asset' + | 'match_amount' + | 'match_memo' + | 'attribute' + | 'persist_paid'; + +export const VERIFICATION_STAGES: readonly VerificationStage[]; +export const STAGE_REJECTION_CODES: Record; +export function stageForCode(code: VerificationCode): VerificationStage; diff --git a/frontend/lib/verification.js b/frontend/lib/verification.js index 84040afc..2aa32485 100644 --- a/frontend/lib/verification.js +++ b/frontend/lib/verification.js @@ -13,7 +13,12 @@ const { rejectionLabel: _rejectionLabel } = require('./verify-rejection-label.ts'); const { isEdgeLimitError, edgeLimitMessage } = require('./edge-limit.js'); -const { VERIFICATION_MESSAGES } = require('../../shared/verification.ts'); +const { + VERIFICATION_MESSAGES, + VERIFICATION_STAGES, + STAGE_REJECTION_CODES, + stageForCode, +} = require('../../shared/verification.ts'); const MAX_PAYER_FIELD_LENGTH = 255; const PAYER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -111,6 +116,9 @@ const resolveVerificationError = (error, fallback = 'Verification failed') => { module.exports = { VERIFICATION_MESSAGES, + VERIFICATION_STAGES, + STAGE_REJECTION_CODES, + stageForCode, messageForCode, failure, isValidTxHash, diff --git a/frontend/tests/pay-verify-controller.test.js b/frontend/tests/pay-verify-controller.test.js index 157c441f..22332ee6 100644 --- a/frontend/tests/pay-verify-controller.test.js +++ b/frontend/tests/pay-verify-controller.test.js @@ -178,6 +178,11 @@ test('executePaymentVerification handles backend rejection via VERIFY_FAILED', a assert.equal(result.message, 'Amount mismatch'); assert.deepEqual(events, [ { type: 'VERIFY_STARTED', txHash: validHash }, - { type: 'VERIFY_FAILED', error: 'Amount mismatch' }, + { + type: 'VERIFY_FAILED', + error: 'Amount mismatch', + stage: null, + code: 'AMOUNT_MISMATCH', + }, ]); }); diff --git a/frontend/tests/verification.test.js b/frontend/tests/verification.test.js index e600350c..2e6af29d 100644 --- a/frontend/tests/verification.test.js +++ b/frontend/tests/verification.test.js @@ -8,6 +8,9 @@ const { resolveVerificationError, normalizeTransactionHash, messageForCode, + VERIFICATION_STAGES, + STAGE_REJECTION_CODES, + stageForCode, } = require('../lib/verification'); const TX_HASH = 'a1b2c3d4'.repeat(8); // 64 hex characters @@ -121,3 +124,24 @@ test('messageForCode maps a known code and returns nothing for an unknown one', assert.equal(messageForCode('SOMETHING_NEW'), undefined); assert.equal(messageForCode(undefined), undefined); }); + +test('exports the 7 verification stages and maps all codes to stages', () => { + assert.deepEqual([...VERIFICATION_STAGES], [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + 'match_memo', + 'attribute', + 'persist_paid', + ]); + + for (const stage of VERIFICATION_STAGES) { + const codes = STAGE_REJECTION_CODES[stage]; + assert.ok(Array.isArray(codes)); + assert.ok(codes.length > 0); + for (const code of codes) { + assert.equal(stageForCode(code), stage); + } + } +}); diff --git a/shared/verification.ts b/shared/verification.ts index 48121856..e516d045 100644 --- a/shared/verification.ts +++ b/shared/verification.ts @@ -76,13 +76,96 @@ export const CHECK_REJECTION_CODES: Record asset: 'ASSET_MISMATCH', }; +/** + * Ordered stages of the payment verification pipeline (issue #441). + * + * fetch_transaction → match_destination → match_asset → match_amount → + * match_memo → attribute → persist_paid. A hard failure short-circuits. + */ +export const VERIFICATION_STAGES = [ + 'fetch_transaction', + 'match_destination', + 'match_asset', + 'match_amount', + 'match_memo', + 'attribute', + 'persist_paid', +] as const; + +export type VerificationStage = (typeof VERIFICATION_STAGES)[number]; + +/** + * Rejection codes each pipeline stage may produce. + * + * Existing codes are remapped onto stages; new codes are added only when a + * stage cannot reuse one already in VERIFICATION_MESSAGES. + */ +export const STAGE_REJECTION_CODES: Record = { + fetch_transaction: [ + 'MISSING_TX_HASH', + 'INVALID_TX_HASH', + 'TRANSACTION_NOT_FOUND', + 'VERIFY_UNAVAILABLE', + 'NO_PAYMENT_OPERATION', + 'AMBIGUOUS_PAYMENT_OPERATION', + 'NETWORK_MISMATCH', + 'VERIFY_RATE_LIMIT_EXCEEDED', + ], + match_destination: ['DESTINATION_MISMATCH'], + match_asset: ['ASSET_MISMATCH'], + match_amount: ['AMOUNT_MISMATCH', 'AMOUNT_TOO_LOW', 'AMOUNT_TOO_HIGH'], + match_memo: ['MEMO_TYPE_MISMATCH', 'MEMO_MISMATCH'], + attribute: [ + 'INVALID_PAYER_NAME', + 'INVALID_PAYER_EMAIL', + 'PAYER_INFO_TOO_LONG', + 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', + ], + persist_paid: [ + 'INVOICE_ALREADY_PAID', + 'INVOICE_EXPIRED', + 'INVOICE_NOT_PENDING', + 'TX_HASH_ALREADY_USED', + ], +}; + +const CODE_TO_STAGE = Object.fromEntries( + (Object.entries(STAGE_REJECTION_CODES) as [VerificationStage, readonly VerificationCode[]][]) + .flatMap(([stage, codes]) => codes.map((code) => [code, stage])) +) as Record; + +/** Which pipeline stage produces a given rejection code. */ +export function stageForCode(code: VerificationCode): VerificationStage { + return CODE_TO_STAGE[code] ?? 'fetch_transaction'; +} + /** The envelope a verification rejection is returned in. */ export interface VerificationFailureBody { success: false; code: VerificationCode; error: string; + stage?: VerificationStage; + details?: Record; +} + +/** Individual stage result when successful. */ +export interface StageSuccessResult { + ok: true; + stage: VerificationStage; + value: T; +} + +/** Individual stage result when failed. */ +export interface StageFailureResult { + ok: false; + stage: VerificationStage; + code: VerificationCode; + error: string; + details?: Record; } +export type StageResult = StageSuccessResult | StageFailureResult; + /** User-facing message for every rejection code. */ export const VERIFICATION_MESSAGES: Record = { MISSING_TX_HASH: 'Transaction hash is required',