From ec4697864cf9f9657d3d98346e27672f9dd6ebc7 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 17:26:41 -0400 Subject: [PATCH 1/3] feat(verification): rebuild payment verify as a typed multi-step pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route manual verify and the payment monitor through one ordered pipeline (fetch → destination → asset → amount → memo → attribute → persist PAID) with shared stage results, rejection-code mapping, and stage-aware pay-page errors. Closes #441. --- backend/src/routes/invoice.handlers.ts | 238 +++--- .../src/services/payment-monitor.service.ts | 80 +- .../services/payment-verification-pipeline.ts | 768 ++++++++++++++++++ backend/src/services/payment-verification.ts | 41 +- backend/src/types/api.ts | 26 +- .../payment-verification-pipeline.fixture.ts | 188 +++++ backend/tests/invoice-handlers.test.ts | 2 +- .../payment-verification-pipeline.test.ts | 347 ++++++++ .../shared-verification-contract.test.ts | 33 +- frontend/lib/payment-page-state.d.ts | 4 +- frontend/lib/payment-page-state.js | 18 +- frontend/lib/use-payment-page.ts | 3 +- frontend/lib/verification.d.ts | 13 + frontend/lib/verification.js | 10 +- frontend/tests/verification.test.js | 24 + shared/verification.ts | 83 ++ 16 files changed, 1678 insertions(+), 200 deletions(-) create mode 100644 backend/src/services/payment-verification-pipeline.ts create mode 100644 backend/tests/fixtures/payment-verification-pipeline.fixture.ts create mode 100644 backend/tests/payment-verification-pipeline.test.ts diff --git a/backend/src/routes/invoice.handlers.ts b/backend/src/routes/invoice.handlers.ts index 036bbdde..0966767d 100644 --- a/backend/src/routes/invoice.handlers.ts +++ b/backend/src/routes/invoice.handlers.ts @@ -25,16 +25,12 @@ 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 { - SettlementTimeUnavailableError, warningForLatePayment, } from '../domain/invoice-settlement'; import { cutoverDrainMode, simulationAllowed } from '../config/runtime'; @@ -48,7 +44,6 @@ import { type CachedVerificationBody, } from '../middleware/verify-cache'; import { verifySellerSignature } from '../utils/signature-verification'; -import { isHorizonUnavailable } from '../utils/horizon-client'; import { redactPaymentEventData } from '../utils/payment-event-redaction'; /** Kept explicit so clients can tune polling without duplicating backend policy. */ @@ -463,121 +458,123 @@ 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') } ); } - const hashCheck = checkTxHash(req.body?.txHash); - if (!hashCheck.ok) { - return sendVerificationFailure(res, 400, hashCheck.code, hashCheck.error); - } - - const payerCheck = checkPayerInfo(req.body); - if (!payerCheck.ok) { - return sendVerificationFailure(res, 400, payerCheck.code, payerCheck.error); - } - const invoice = await storage.getInvoiceById(id); - if (!invoice) { return sendFailure(res, 404, 'Invoice not found'); } - const statusCheck = checkInvoiceIsPayable(invoice.status); - if ( - !statusCheck.ok && - invoice.status !== 'CANCELLED' && - invoice.status !== 'EXPIRED' - ) { - return sendVerificationFailure(res, 400, statusCheck.code, statusCheck.error); - } - - let txDetails; - try { - txDetails = await stellar.getTransaction(hashCheck.value); - } catch (error: any) { - logError('Verify payment lookup error:', error); - if (isHorizonUnavailable(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. - 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. - 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, + const pipeline = await executePaymentVerificationPipeline({ + invoice, + txHash: req.body?.txHash, network, + expectedNetwork: STELLAR_NETWORK, + stellar, + storage, + payer: { + payerName: req.body?.payerName, + payerEmail: req.body?.payerEmail, + }, + requireSettledAt: true, + onAfterPersist: async () => { + options.paymentMonitor?.unregisterWatch(id); + }, }); - if (!verification.ok) { - // Semantic rejections are permanent facts about the transaction — - // safe to replay for the full expiry window. - 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', + 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) { + 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, + }); + + // 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) { 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); - - // 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', @@ -587,43 +584,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. - 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) { - return sendVerificationFailure( - res, - 503, - 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', - 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( - 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 15950bed..839dc11d 100644 --- a/backend/src/services/payment-monitor.service.ts +++ b/backend/src/services/payment-monitor.service.ts @@ -3,11 +3,12 @@ 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 { - parseSettlementTime, SettlementTimeUnavailableError, } from '../domain/invoice-settlement'; import { monitorBackoffMs } from '../utils/monitor-retry-backoff'; @@ -504,10 +505,12 @@ export class PaymentMonitorService { if (!payable.ok && invoice.status !== 'CANCELLED' && invoice.status !== 'EXPIRED') return; const isNative = payment.assetCode === 'XLM' && !payment.assetIssuer; - 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, @@ -517,24 +520,37 @@ 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(); + } 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, @@ -544,38 +560,8 @@ export class PaymentMonitorService { 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); - } 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', - { - code: 'TX_HASH_ALREADY_USED', - txHash: payment.txHash, - error: error.message, - } - ); - return; - } - throw error; - } + this.processedTxHashes.add(payment.txHash); + this.unregisterWatch(invoice.id); } 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..85c06fde --- /dev/null +++ b/backend/src/services/payment-verification-pipeline.ts @@ -0,0 +1,768 @@ +/** + * 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 { + 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 (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-handlers.test.ts b/backend/tests/invoice-handlers.test.ts index 177bafd8..b3d1c522 100644 --- a/backend/tests/invoice-handlers.test.ts +++ b/backend/tests/invoice-handlers.test.ts @@ -682,7 +682,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage it('keeps the verify response on the public shape', async () => { const created = await createInvoice({ customerEmail: 'pay@client.example' }); transaction = { - transaction: { memo: created.memo }, + transaction: { memo: created.memo, created_at: new Date().toISOString() }, operations: [ { type: 'payment', 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/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/use-payment-page.ts b/frontend/lib/use-payment-page.ts index 07114c80..de067866 100644 --- a/frontend/lib/use-payment-page.ts +++ b/frontend/lib/use-payment-page.ts @@ -180,8 +180,9 @@ export function usePaymentPage(id: string) { } const message = resolveVerificationError(error); + const data = (error && (error as any).response && (error as any).response.data) || {}; if (isApiUnavailableError(error)) setLoadError(apiErrorMessage(error)); - dispatch({ type: 'VERIFY_FAILED', error: message }); + dispatch({ type: 'VERIFY_FAILED', error: message, stage: data.stage ?? null, code: data.code ?? null }); toast.error(message); } }; 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 d14fcfd5..808af74c 100644 --- a/frontend/lib/verification.js +++ b/frontend/lib/verification.js @@ -12,7 +12,12 @@ const { rejectionLabel: _rejectionLabel } = require('./verify-rejection-label.ts'); -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@]+$/; @@ -103,6 +108,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/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 aaaae777..8fdff9dd 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', From 52636733b19ee0b6fa99967051c76a14fc33e27b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 01:58:20 -0400 Subject: [PATCH 2/3] test(verification): align controller rejection metadata expectation Preserve the accepted stage/code production contract and update the existing VERIFY_FAILED assertion to include its optional-stage fallback and rejection code. This resolves the single controller expectation mismatch reported by current-head CI run 37180881850. Source-only repair; no workflow dispatch or rerun and no local runtime execution. Operation: Q575-CI-CONTROLLER-CONTRACT-20261004-A3DEA --- frontend/tests/pay-verify-controller.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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', + }, ]); }); From 71284d1968d64dd06a3aad12bc6c9e57ccbc26fc Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 02:42:33 -0400 Subject: [PATCH 3/3] test: control the missing Horizon transaction response Supply HTTP 404 at the SDK call boundary while retaining the real service and Horizon wrapper. Assert the requested hash and single attempt, and restore the server factory after the case. Operation: Q575-HORIZON-404-BOUNDARY-20261004-A3DEA --- backend/tests/stellar-service.test.ts | 47 +++++++++++++++++++++------ 1 file changed, 37 insertions(+), 10 deletions(-) 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; } }); });