From 829ab7a59ceecbda909797ba108def460e71da32 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 16:59:58 -0400 Subject: [PATCH 1/9] Publish unified invoice API contract for FE and BE Closes #446. Share create/get/list/cancel/verify/stats request and response shapes plus the error envelope in shared/invoice-contract.ts, with runtime parsers and an OpenAPI 3.0.3 document. Express re-exports the envelopes; the Next.js client validates responses through the same parsers so invalid creates fail consistently on both sides and list/detail/verify/stats stay aligned. --- backend/src/types/api.ts | 54 +- backend/tests/invoice-contract.test.ts | 331 +++++++ frontend/app/dashboard/page.tsx | 2 +- frontend/components/InvoiceCard.tsx | 24 +- frontend/components/pay-page.types.ts | 49 +- frontend/lib/api.ts | 127 ++- frontend/lib/export.ts | 41 +- frontend/lib/payment-page-state.d.ts | 9 +- shared/invoice-contract.ts | 1130 ++++++++++++++++++++++++ shared/invoice.ts | 21 +- tests/invoice-contract.test.mjs | 35 + 11 files changed, 1639 insertions(+), 184 deletions(-) create mode 100644 backend/tests/invoice-contract.test.ts create mode 100644 shared/invoice-contract.ts create mode 100644 tests/invoice-contract.test.mjs diff --git a/backend/src/types/api.ts b/backend/src/types/api.ts index 6a402df5..bca77bcd 100644 --- a/backend/src/types/api.ts +++ b/backend/src/types/api.ts @@ -1,32 +1,19 @@ import { Response } from 'express'; import type { VerificationCode } 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 -// storage-agnostic: a frontend pointed at server-mvp.ts behaves exactly the -// same against server.ts (only persistence duration changes). -// sendSuccess / sendFailure wrap this envelope; they are shared helpers, so -// HTTP status codes and envelope keys are also pinned across backends. -export interface ApiPagination { - limit: number; - offset: number; - total: number; -} - -export interface ApiSuccess { - success: true; - data: T; - message?: string; - code?: string; - warning?: string; - pagination?: ApiPagination; -} - -export interface ApiFailure { - success: false; - error: string; - code?: VerificationCode; -} +import type { + ApiFailure as SharedApiFailure, + ApiPagination as SharedApiPagination, + ApiSuccess as SharedApiSuccess, + ValidationFailureBody as SharedValidationFailureBody, + VerificationFailureBody as SharedVerificationFailureBody, +} from '../../../shared/invoice-contract'; + +// Envelope types live in shared/invoice-contract.ts so Express handlers and the +// Next.js client agree on success/failure shapes (issue #446). HTTP helpers +// below stay backend-only because they need Express Response. +export type ApiPagination = SharedApiPagination; +export type ApiSuccess = SharedApiSuccess; +export type ApiFailure = SharedApiFailure; export interface CancelInvoiceInput { sellerPublicKey?: string; @@ -80,12 +67,7 @@ export function sendFailure(res: Response, status: number, error: string): void * fieldErrors is the same information keyed by payload field, so a form can * mark the inputs instead of showing one toast that names nothing. */ -export interface ValidationFailureBody { - success: false; - code: 'VALIDATION_ERROR'; - error: string; - fieldErrors: Record; -} +export type ValidationFailureBody = SharedValidationFailureBody; export function sendValidationFailure( res: Response, @@ -115,11 +97,7 @@ export function sendVerificationFailure( * `success` is the literal `false` rather than `boolean` so this discriminates * from a success envelope at the type level instead of only at runtime. */ -export interface VerificationFailureBody { - success: false; - code: VerificationCode; - error: string; -} +export type VerificationFailureBody = SharedVerificationFailureBody; /** Build a verification failure envelope with a stable code and its message. */ export function verificationFailureBody( diff --git a/backend/tests/invoice-contract.test.ts b/backend/tests/invoice-contract.test.ts new file mode 100644 index 00000000..e4e59210 --- /dev/null +++ b/backend/tests/invoice-contract.test.ts @@ -0,0 +1,331 @@ +/** + * Invoice API contract suite (issue #446). + * + * Pins shared request/response parsers and OpenAPI required fields, then + * exercises the in-memory MVP router so create/list/get/payment-info/stats + * envelopes stay aligned with shared/invoice-contract.ts. + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import express, { type Application } from 'express'; +import { Keypair } from '@stellar/stellar-sdk'; +import { + REQUIRED_INVOICE_DTO_FIELDS, + INVOICE_OPENAPI_SPEC, + parseInvoiceDto, + parseCreateInvoiceRequest, + parseCreateInvoiceResponse, + parseGetInvoiceResponse, + parseListInvoicesResponse, + parsePaymentInfoResponse, + parseCancelInvoiceResponse, + parseGetStatsResponse, + parseErrorEnvelope, +} from '../../shared/invoice-contract'; +import { createInvoiceRouter } from '../src/routes/invoice.routes'; +import { MemoryInvoiceStorage } from '../src/storage/memory-invoice-storage'; + +interface HttpResponse { + status: number; + headers: http.IncomingHttpHeaders; + body: any; +} + +function request( + port: number, + method: string, + path: string, + body?: unknown +): Promise { + return new Promise((resolve, reject) => { + let payload: Buffer | undefined; + const headers: Record = {}; + + if (body !== undefined) { + payload = Buffer.from(JSON.stringify(body)); + headers['content-type'] = 'application/json'; + headers['content-length'] = payload.length; + } + + const req = http.request( + { + host: '127.0.0.1', + port, + method, + path, + headers, + }, + (res) => { + let raw = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + raw += chunk; + }); + res.on('end', () => { + let parsed: any; + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; + } + resolve({ + status: res.statusCode || 0, + headers: res.headers, + body: parsed, + }); + }); + } + ); + + req.on('error', reject); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +describe('Invoice API contract (issue #446)', () => { + let server: http.Server; + let port: number; + + const sellerKeypair = Keypair.random(); + const sellerPublicKey = sellerKeypair.publicKey(); + + before(async () => { + const invoiceStorage = new MemoryInvoiceStorage(); + + const app: Application = express(); + app.use(express.json()); + + const mockStellar = { + getTransaction: async () => { + throw new Error('Transaction not found'); + }, + }; + + const router = createInvoiceRouter({ + storage: invoiceStorage, + stellar: mockStellar as any, + enableRateLimiting: false, + enableConcurrencyLock: false, + requireCancelSignature: false, + }); + + app.use('/api', router); + + server = http.createServer(app); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + port = (server.address() as AddressInfo).port; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + describe('schema invariants and divergence detection', () => { + it('accepts a compliant create invoice request', () => { + const parsed = parseCreateInvoiceRequest({ + amount: 25.5, + sellerPublicKey, + assetCode: 'XLM', + description: 'Design consultation', + }); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.equal(parsed.data.amount, 25.5); + assert.equal(parsed.data.sellerPublicKey, sellerPublicKey); + } + }); + + it('rejects create payloads when required fields diverge', () => { + const missingAmount = parseCreateInvoiceRequest({ + sellerPublicKey, + assetCode: 'XLM', + }); + assert.equal(missingAmount.success, false); + if (!missingAmount.success) { + assert.equal(missingAmount.code, 'VALIDATION_ERROR'); + assert.ok(missingAmount.fieldErrors?.amount); + } + + const missingSeller = parseCreateInvoiceRequest({ + amount: 10, + assetCode: 'XLM', + }); + assert.equal(missingSeller.success, false); + if (!missingSeller.success) { + assert.ok(missingSeller.fieldErrors?.sellerPublicKey); + } + + const negativeAmount = parseCreateInvoiceRequest({ + amount: -5, + sellerPublicKey, + assetCode: 'XLM', + }); + assert.equal(negativeAmount.success, false); + }); + + it('rejects an invoice DTO missing required fields', () => { + const result = parseInvoiceDto({ + sellerPublicKey, + amount: 10, + assetCode: 'XLM', + }); + assert.equal(result.success, false); + }); + + it('validates the shared error envelope', () => { + const parsed = parseErrorEnvelope({ + success: false, + error: 'Invoice not found', + code: 'NOT_FOUND', + }); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.equal(parsed.data.error, 'Invoice not found'); + assert.equal(parsed.data.code, 'NOT_FOUND'); + } + + assert.equal(parseErrorEnvelope({ success: true, data: {} }).success, false); + assert.equal(parseErrorEnvelope({ success: false }).success, false); + }); + + it('keeps OpenAPI InvoiceDto.required aligned with REQUIRED_INVOICE_DTO_FIELDS', () => { + const openApiRequired = (INVOICE_OPENAPI_SPEC as any).components.schemas + .InvoiceDto.required as string[]; + assert.deepEqual( + [...openApiRequired].sort(), + [...REQUIRED_INVOICE_DTO_FIELDS].sort() + ); + }); + + it('publishes OpenAPI paths for create, get, list, cancel, verify, and stats', () => { + const paths = (INVOICE_OPENAPI_SPEC as any).paths; + assert.ok(paths['/invoices']?.post); + assert.ok(paths['/invoices']?.get); + assert.ok(paths['/invoices/{id}']?.get); + assert.ok(paths['/invoices/{id}/payment-info']?.get); + assert.ok(paths['/invoices/{id}/cancel']?.post); + assert.ok(paths['/invoices/{id}/verify']?.post); + assert.ok(paths['/invoices/stats']?.get); + }); + }); + + describe('MVP router contract conformance', () => { + let createdInvoiceId: string; + + it('POST /api/invoices returns a create envelope that parses', async () => { + const res = await request(port, 'POST', '/api/invoices', { + amount: 100, + sellerPublicKey, + assetCode: 'XLM', + description: 'Contract test invoice', + }); + assert.equal(res.status, 201); + + const parsed = parseCreateInvoiceResponse(res.body); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.ok(parsed.data.data.invoice.id); + assert.equal(parsed.data.data.invoice.amount, 100); + assert.equal(parsed.data.data.invoice.status, 'PENDING'); + createdInvoiceId = parsed.data.data.invoice.id; + } + }); + + it('rejects an invalid create payload with the validation envelope', async () => { + const res = await request(port, 'POST', '/api/invoices', { + amount: 0, + sellerPublicKey: 'invalid-key', + }); + assert.equal(res.status, 400); + + const parsed = parseErrorEnvelope(res.body); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.equal(parsed.data.success, false); + assert.equal(parsed.data.code, 'VALIDATION_ERROR'); + assert.ok(parsed.data.fieldErrors); + } + }); + + it('GET /api/invoices/:id parses with the shared schema', async () => { + const res = await request(port, 'GET', `/api/invoices/${createdInvoiceId}`); + assert.equal(res.status, 200); + const parsed = parseGetInvoiceResponse(res.body); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.equal(parsed.data.data.id, createdInvoiceId); + } + }); + + it('GET /api/invoices list parses with the shared schema', async () => { + const res = await request( + port, + 'GET', + `/api/invoices?sellerPublicKey=${encodeURIComponent(sellerPublicKey)}` + ); + assert.equal(res.status, 200); + const parsed = parseListInvoicesResponse(res.body); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.ok(parsed.data.data.length >= 1); + assert.ok(parsed.data.pagination); + } + }); + + it('GET payment-info parses with the shared schema', async () => { + const res = await request( + port, + 'GET', + `/api/invoices/${createdInvoiceId}/payment-info` + ); + assert.equal(res.status, 200); + const parsed = parsePaymentInfoResponse(res.body); + assert.equal(parsed.success, true); + }); + + it('GET /api/invoices/stats parses with the shared schema', async () => { + const res = await request( + port, + 'GET', + `/api/invoices/stats?sellerPublicKey=${encodeURIComponent(sellerPublicKey)}` + ); + assert.equal(res.status, 200); + const parsed = parseGetStatsResponse(res.body); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.ok(parsed.data.data.total_invoices >= 1); + } + }); + + it('POST cancel returns a cancel envelope that parses', async () => { + const res = await request( + port, + 'POST', + `/api/invoices/${createdInvoiceId}/cancel`, + { sellerPublicKey } + ); + assert.ok([200, 400, 401, 403, 409].includes(res.status)); + if (res.status === 200) { + const parsed = parseCancelInvoiceResponse(res.body); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.equal(parsed.data.data.status, 'CANCELLED'); + } + } else { + const parsed = parseErrorEnvelope(res.body); + assert.equal(parsed.success, true); + } + }); + }); +}); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index fb564592..6ec3a9e7 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -123,7 +123,7 @@ export default function DashboardPage() { setLoaded({ owner: publicKey, invoices: invoicesResult.data, - stats: statsResult.data[0] || {}, + stats: statsResult.data || {}, }); } catch (error) { if (!active) return; diff --git a/frontend/components/InvoiceCard.tsx b/frontend/components/InvoiceCard.tsx index caa819d7..70331d31 100644 --- a/frontend/components/InvoiceCard.tsx +++ b/frontend/components/InvoiceCard.tsx @@ -15,29 +15,9 @@ import { describeAmount, statusBadgeLabel, statusText } from '@/lib/a11y'; import { invoiceApi } from '@/lib/api'; import { signInvoiceCancelMessage } from '@/lib/stellar'; -interface Invoice { - id: string; - amount: number; - assetCode: string; - description?: string; - customerName?: string; - customerEmail?: string; - status: string; - createdAt: string; - expiresAt: string; - memo: string; - sellerPublicKey?: string; - sellerName?: string; - sellerEmail?: string; - payerPublicKey?: string; - payerName?: string; - payerEmail?: string; - paymentTxHash?: string; - paidAt?: string; -} - +import type { InvoiceDto } from '../../shared/invoice'; interface InvoiceCardProps { - invoice: Invoice; + invoice: InvoiceDto; userWallet?: string | null; onCancel?: (id: string) => void; } diff --git a/frontend/components/pay-page.types.ts b/frontend/components/pay-page.types.ts index 68a139b7..924d940b 100644 --- a/frontend/components/pay-page.types.ts +++ b/frontend/components/pay-page.types.ts @@ -1,40 +1,11 @@ -export interface PayPageInvoice { - [key: string]: unknown; - id: string; - amount: number; - assetCode: string; - assetIssuer?: string; - description?: string; - customerName?: string; - customerEmail?: string; - sellerPublicKey: string; - sellerName?: string; - sellerEmail?: string; - memo: string; - status: 'PENDING' | 'PAID' | 'EXPIRED' | 'CANCELLED'; - createdAt: string; - expiresAt: string; - paidAt?: string; - cancelledAt?: string; - settledAt?: string; - settlementContext?: 'ON_TIME' | 'AFTER_EXPIRY' | 'AFTER_CANCEL'; - priorStatus?: 'PENDING' | 'PAID' | 'EXPIRED' | 'CANCELLED'; - latePaymentWarningCode?: 'PAYMENT_RECEIVED_AFTER_EXPIRY' | 'PAYMENT_RECEIVED_AFTER_CANCEL'; - paymentTxHash?: string; - payerName?: string; - payerEmail?: string; - payerPublicKey?: string; -} +import type { InvoiceDto } from '../../shared/invoice'; +import type { PaymentInfoResult } from '../../shared/invoice-contract'; -export interface PayPagePaymentInfo { - stellarQrCode?: string; - /** The full SEP-0007 URI the QR was built from — copyable payer text. */ - stellarUri?: string; - /** - * False when the URI outgrew the QR payload budget and the image encodes - * the HTTPS pay link instead (issue #510). - */ - stellarQrEncodesUri?: boolean; - paymentUrl?: string; - statusPollingIntervalMs?: number; -} +/** + * Pay-page invoice shape. One shared DTO with the API client (issue #446). + * Seller-only fields are optional and absent on anonymous pay responses. + */ +export type PayPageInvoice = InvoiceDto; + +/** Payment instructions returned beside the invoice on the pay page. */ +export type PayPagePaymentInfo = PaymentInfoResult; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 1e6ac6fd..b5750109 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -8,6 +8,27 @@ import { import { resolveVerificationError } from './verification.js'; import { resolveStellarNetwork } from '@shared/network'; +import type { + CancelInvoiceResponse, + CreateInvoiceRequest, + CreateInvoiceResponse, + GetInvoiceResponse, + GetStatsResponse, + ListInvoicesQuery, + ListInvoicesResponse, + PaymentInfoResponse, + VerifyPaymentResponse, +} from '../../shared/invoice-contract'; +import { + parseCancelInvoiceResponse, + parseCreateInvoiceResponse, + parseGetInvoiceResponse, + parseGetStatsResponse, + parseListInvoicesResponse, + parsePaymentInfoResponse, + parseVerifyPaymentResponse, +} from '../../shared/invoice-contract'; + /** * The API origin, resolved once per build. * @@ -44,56 +65,57 @@ api.interceptors.response.use( } ); + + export const invoiceApi = { - create: async (data: { - amount: number; - assetCode?: string; - assetIssuer?: string; - description?: string; - customerName?: string; - customerEmail?: string; - expiresInDays: number; - sellerPublicKey?: string; - sellerName?: string; - sellerEmail?: string; - network?: string; - idempotencyKey?: string; - }) => { + create: async (data: CreateInvoiceRequest): Promise => { const normalizedAssetCode = data.assetCode ? data.assetCode.toUpperCase() : 'XLM'; const response = await api.post('/invoices', { ...data, assetCode: normalizedAssetCode, }); - return response.data; + const parsed = parseCreateInvoiceResponse(response.data); + if (!parsed.success) { + throw new Error(`Create invoice contract parse failure: ${parsed.error}`); + } + return parsed.data; }, - - getById: async (id: string, sellerPublicKey?: string | null) => { + getById: async ( + id: string, + sellerPublicKey?: string | null + ): Promise => { // Workspace fields (client contact, payer identity) are only returned when // the caller presents the invoice's own seller key — issue #503. The pay // page calls this without a key and receives the public pay DTO. const response = await api.get(`/invoices/${id}`, { params: sellerPublicKey ? { sellerPublicKey } : undefined, }); - return response.data; + const parsed = parseGetInvoiceResponse(response.data); + if (!parsed.success) { + throw new Error(`Get invoice contract parse failure: ${parsed.error}`); + } + return parsed.data; }, - // Invoice history is scoped to the connected Freighter wallet, so the seller // key is required for list and stats calls. - getAll: async (params: { - sellerPublicKey: string; - status?: string; - limit?: number; - offset?: number; - }) => { + // Invoice history is scoped to the connected Freighter wallet, so the seller + // key is required for list and stats calls. + getAll: async (params: ListInvoicesQuery): Promise => { const response = await api.get('/invoices', { params }); - return response.data; + const parsed = parseListInvoicesResponse(response.data); + if (!parsed.success) { + throw new Error(`List invoices contract parse failure: ${parsed.error}`); + } + return parsed.data; }, - - getPaymentInfo: async (id: string) => { + getPaymentInfo: async (id: string): Promise => { const response = await api.get(`/invoices/${id}/payment-info`); - return response.data; + const parsed = parsePaymentInfoResponse(response.data); + if (!parsed.success) { + throw new Error(`Payment info contract parse failure: ${parsed.error}`); + } + return parsed.data; }, - // Seller-only audit feed (issue #515): rejected verifies and monitor // rejections for this invoice. Requires the invoice's own seller key. getPaymentEvents: async (id: string, sellerPublicKey: string) => { @@ -105,29 +127,52 @@ export const invoiceApi = { // One proof path (issue #517): the seller key and the Freighter signature // over `cancel:` travel in the request body — never in query or header. - cancel: async (id: string, sellerPublicKey: string, signature?: string) => { - const response = await api.post(`/invoices/${id}/cancel`, { sellerPublicKey, signature }); - return response.data; + // One proof path (issue #517): the seller key and the Freighter signature + // over `cancel:` travel in the request body — never in query or header. + cancel: async ( + id: string, + sellerPublicKey: string, + signature?: string + ): Promise => { + const response = await api.post(`/invoices/${id}/cancel`, { + sellerPublicKey, + signature, + }); + const parsed = parseCancelInvoiceResponse(response.data); + if (!parsed.success) { + throw new Error(`Cancel invoice contract parse failure: ${parsed.error}`); + } + return parsed.data; }, - - verify: async (id: string, txHash: string, payerInfo?: { payerName?: string; payerEmail?: string }) => { + verify: async ( + id: string, + txHash: string, + payerInfo?: { payerName?: string; payerEmail?: string } + ): Promise => { const response = await api.post(`/invoices/${id}/verify`, { txHash, // Lets the server reject a payment submitted from the wrong wallet network. // Resolved through the shared contract so the client sends the canonical // 'TESTNET' | 'PUBLIC' name rather than a raw env string (issue #511). network: resolveStellarNetwork(process.env.NEXT_PUBLIC_STELLAR_NETWORK), - ...payerInfo + ...payerInfo, }); - return response.data; + const parsed = parseVerifyPaymentResponse(response.data); + if (!parsed.success) { + throw new Error(`Verify payment contract parse failure: ${parsed.error}`); + } + return parsed.data; }, - - getStats: async (sellerPublicKey: string) => { + getStats: async (sellerPublicKey: string): Promise => { const response = await api.get('/invoices/stats', { params: { sellerPublicKey }, }); - return response.data; - }, + const parsed = parseGetStatsResponse(response.data); + if (!parsed.success) { + throw new Error(`Get stats contract parse failure: ${parsed.error}`); + } + return parsed.data; + } }; // Stellar APIs diff --git a/frontend/lib/export.ts b/frontend/lib/export.ts index 617363dd..59a3f7ea 100644 --- a/frontend/lib/export.ts +++ b/frontend/lib/export.ts @@ -1,3 +1,4 @@ +import type { InvoiceDto } from '../../shared/invoice'; import { format } from 'date-fns'; import { canonicalAmount } from './stroop-amount.js'; import { formatUtcDate, formatUtcDateTime } from './utc-format.js'; @@ -62,34 +63,8 @@ export function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (character) => HTML_ESCAPE_CHARACTERS[character]); } -interface Invoice { - id: string; - amount: number; - assetCode: string; - assetIssuer?: string; - description?: string; - customerName?: string; - customerEmail?: string; - sellerName?: string; - sellerEmail?: string; - payerName?: string; - payerEmail?: string; - status: string; - createdAt: string; - expiresAt: string; - paidAt?: string; - cancelledAt?: string; - settledAt?: string; - settlementContext?: 'ON_TIME' | 'AFTER_EXPIRY' | 'AFTER_CANCEL'; - priorStatus?: string; - latePaymentWarningCode?: 'PAYMENT_RECEIVED_AFTER_EXPIRY' | 'PAYMENT_RECEIVED_AFTER_CANCEL'; - memo: string; - sellerPublicKey: string; - payerPublicKey?: string; - paymentTxHash?: string; -} -function latePaymentWarning(invoice: Invoice): { title: string; body: string } | null { +function latePaymentWarning(invoice: InvoiceDto): { title: string; body: string } | null { if (invoice.latePaymentWarningCode === 'PAYMENT_RECEIVED_AFTER_CANCEL') { return { title: 'Payment received after cancellation', @@ -105,7 +80,7 @@ function latePaymentWarning(invoice: Invoice): { title: string; body: string } | return null; } -export function generateInvoiceCSV(invoices: Invoice[]): string { +export function generateInvoiceCSV(invoices: InvoiceDto[]): string { const headers = [ 'Invoice ID', 'Date', @@ -152,7 +127,7 @@ export function generateInvoiceCSV(invoices: Invoice[]): string { return csvContent; } -export function downloadInvoiceCSV(invoices: Invoice[], filename?: string) { +export function downloadInvoiceCSV(invoices: InvoiceDto[], filename?: string) { const csv = generateInvoiceCSV(invoices); const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); @@ -175,7 +150,7 @@ export function downloadInvoiceCSV(invoices: Invoice[], filename?: string) { * @param invoiceOrProof - Invoice record or canonical QuittanceProof model. * @returns HTML document string for display or PDF printing. */ -export function generateInvoicePDF(invoiceOrProof: Invoice | QuittanceProof): string { +export function generateInvoicePDF(invoiceOrProof: InvoiceDto | QuittanceProof): string { if (isQuittanceProof(invoiceOrProof)) { return renderQuittanceProofHtml(invoiceOrProof); } @@ -462,7 +437,7 @@ export function generateQuittanceProofPDF(proof: QuittanceProof): string { * * @param invoiceOrProof - Invoice record or canonical QuittanceProof model. */ -export function openInvoicePDF(invoiceOrProof: Invoice | QuittanceProof) { +export function openInvoicePDF(invoiceOrProof: InvoiceDto | QuittanceProof) { const pdfContent = generateInvoicePDF(invoiceOrProof); const printWindow = window.open('', '_blank', 'width=800,height=600'); if (printWindow) { @@ -476,10 +451,10 @@ export function openInvoicePDF(invoiceOrProof: Invoice | QuittanceProof) { } } -export function shareInvoiceByEmail(invoice: Invoice, baseUrl?: string): string { +export function shareInvoiceByEmail(invoice: InvoiceDto, baseUrl?: string): string { return openInvoiceMailto(invoice, baseUrl); } -export function emailPaymentProof(invoice: Invoice, baseUrl?: string): string { +export function emailPaymentProof(invoice: InvoiceDto, baseUrl?: string): string { return openProofMailto(invoice, baseUrl); } diff --git a/frontend/lib/payment-page-state.d.ts b/frontend/lib/payment-page-state.d.ts index 3106a23a..2a74019d 100644 --- a/frontend/lib/payment-page-state.d.ts +++ b/frontend/lib/payment-page-state.d.ts @@ -1,13 +1,10 @@ +import type { InvoiceDto } from '../../shared/invoice'; import type { WalletGateResult } from './freighter-availability'; export type PayStatus = 'idle' | 'paying' | 'verifying' | 'paid' | 'error' | 'expired'; -export interface PayInvoice { - status: string; - expiresAt?: string | Date; - paymentTxHash?: string | null; - [key: string]: unknown; -} +/** Pay-page invoice: the shared wire DTO (issue #446). */ +export type PayInvoice = InvoiceDto; export interface PaymentState { status: PayStatus; diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts new file mode 100644 index 00000000..9cf6673e --- /dev/null +++ b/shared/invoice-contract.ts @@ -0,0 +1,1130 @@ +/** + * Unified invoice API contract shared by Express handlers and the Next.js client. + * + * Request/response shapes, the shared error envelope, runtime parsers, and an + * OpenAPI 3.0.3 document for create / get / list / cancel / verify / stats. + * Parsers fail when a required field is missing so FE and BE cannot drift. + */ + +import type { + InvoiceDto, + InvoiceStatus, + IsoTimestamp, + LatePaymentWarningCode, + SettlementContext, +} from './invoice'; +import { + collectCreateInvoiceFieldErrors, + firstCreateInvoiceMessage, +} from './invoice-validation'; +import type { VerificationCode, VerificationFailureBody } from './verification'; + +export type { + InvoiceDto, + InvoiceStatus, + IsoTimestamp, + LatePaymentWarningCode, + SettlementContext, + VerificationCode, + VerificationFailureBody, +}; + +/** Standard success envelope for resource responses. */ +export interface ApiSuccess { + success: true; + data: T; + message?: string; + code?: string; + warning?: string; + pagination?: ApiPagination; +} + +/** Pagination metadata for collection responses. */ +export interface ApiPagination { + limit: number; + offset: number; + total: number; +} + +/** Standard success envelope for paginated collection responses. */ +export interface ApiPaginatedSuccess { + success: true; + data: T[]; + pagination: ApiPagination; +} + +/** Standard error envelope returned on API request failures. */ +export interface ApiFailure { + success: false; + error: string; + code?: string; + field?: string; + fieldErrors?: Record; +} + +/** Structured validation failure envelope naming field-level mistakes. */ +export interface ValidationFailureBody { + success: false; + code: 'VALIDATION_ERROR'; + error: string; + fieldErrors: Record; + field?: string; +} + +/** Request payload for creating a new invoice. */ +export interface CreateInvoiceRequest { + amount: number; + assetCode?: string; + assetIssuer?: string; + description?: string; + customerName?: string; + customerEmail?: string; + sellerPublicKey?: string; + sellerName?: string; + sellerEmail?: string; + expiresInDays?: number; + network?: 'TESTNET' | 'PUBLIC' | string; + idempotencyKey?: string; +} + +/** Payload returned within the success envelope after creating an invoice. */ +export interface CreateInvoiceResult { + invoice: InvoiceDto; + paymentAvailable: boolean; + paymentUrl: string; + statusPollingIntervalMs?: number; + qrCode?: string | null; + stellarQrCode?: string | null; + stellarUri?: string | null; + stellarQrEncodesUri?: boolean; +} + +/** Complete response envelope for invoice creation. */ +export type CreateInvoiceResponse = ApiSuccess; + +/** Route parameters for retrieving a single invoice. */ +export interface GetInvoiceParams { + id: string; +} + +/** Complete response envelope for retrieving an invoice. */ +export type GetInvoiceResponse = ApiSuccess; + +/** Query parameters for listing seller invoices. */ +export interface ListInvoicesQuery { + sellerPublicKey: string; + status?: InvoiceStatus | string; + limit?: number; + offset?: number; +} + +/** Complete response envelope for listing invoices. */ +export type ListInvoicesResponse = ApiPaginatedSuccess; + +/** Payload returned within the success envelope for invoice payment instructions. */ +export interface PaymentInfoResult { + invoice?: InvoiceDto; + paymentAvailable?: boolean; + paymentUrl?: string; + statusPollingIntervalMs?: number; + qrCode?: string | null; + stellarQrCode?: string | null; + stellarUri?: string | null; + stellarQrEncodesUri?: boolean; +} + +/** Complete response envelope for invoice payment instructions. */ +export type PaymentInfoResponse = ApiSuccess; + +/** Request payload for cancelling an invoice. */ +export interface CancelInvoiceRequest { + sellerPublicKey?: string; + signature?: string; + message?: string; +} + +/** Complete response envelope for invoice cancellation. */ +export type CancelInvoiceResponse = ApiSuccess; + +/** Request payload for verifying a submitted payment transaction. */ +export interface VerifyPaymentRequest { + txHash: string; + payerName?: string; + payerEmail?: string; + network?: string; +} + +/** Complete response envelope for payment verification. */ +export interface VerifyPaymentResponse { + success: true; + data: InvoiceDto; + message?: string; + code?: string; + warning?: string; +} + +/** Aggregate metrics and statistics for seller invoices. */ +export interface InvoiceStatsDto { + total_invoices: number; + paid_invoices: number; + pending_invoices: number; + actionable_invoices: number; + expired_invoices: number; + revenue_by_asset: Record; +} + +/** Query parameters for invoice statistics. */ +export interface GetStatsQuery { + sellerPublicKey: string; +} + +/** Complete response envelope for invoice statistics. */ +export type GetStatsResponse = ApiSuccess; + +/** Result returned by contract parsers. */ +export type ContractResult = + | { success: true; data: T } + | { + success: false; + error: string; + code?: string; + fieldErrors?: Record; + }; + +/** Required InvoiceDto keys — contract tests fail if this list loses a field. */ +export const REQUIRED_INVOICE_DTO_FIELDS = [ + 'id', + 'sellerPublicKey', + 'amount', + 'assetCode', + 'memo', + 'status', + 'createdAt', + 'expiresAt', +] as const; + +const VALID_INVOICE_STATUSES: readonly string[] = [ + 'PENDING', + 'PAID', + 'EXPIRED', + 'CANCELLED', +]; + +function isObject(val: unknown): val is Record { + return typeof val === 'object' && val !== null && !Array.isArray(val); +} + +/** + * Validates whether an unknown value conforms to the InvoiceDto contract. + */ +export function parseInvoiceDto(input: unknown): ContractResult { + if (!isObject(input)) { + return { success: false, error: 'Invoice must be an object' }; + } + + const { id, sellerPublicKey, amount, assetCode, memo, status, createdAt, expiresAt } = + input; + + if (typeof id !== 'string' || id.trim() === '') { + return { success: false, error: 'Invoice id is required and must be a string' }; + } + if (typeof sellerPublicKey !== 'string' || sellerPublicKey.trim() === '') { + return { + success: false, + error: 'Invoice sellerPublicKey is required and must be a string', + }; + } + if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { + return { + success: false, + error: 'Invoice amount must be a positive finite number', + }; + } + if (typeof assetCode !== 'string' || assetCode.trim() === '') { + return { + success: false, + error: 'Invoice assetCode is required and must be a string', + }; + } + if (typeof memo !== 'string' || memo.trim() === '') { + return { success: false, error: 'Invoice memo is required and must be a string' }; + } + if (typeof status !== 'string' || !VALID_INVOICE_STATUSES.includes(status)) { + return { + success: false, + error: 'Invoice status must be PENDING, PAID, EXPIRED, or CANCELLED', + }; + } + if (typeof createdAt !== 'string' || createdAt.trim() === '') { + return { + success: false, + error: 'Invoice createdAt is required and must be a string', + }; + } + if (typeof expiresAt !== 'string' || expiresAt.trim() === '') { + return { + success: false, + error: 'Invoice expiresAt is required and must be a string', + }; + } + + return { success: true, data: input as unknown as InvoiceDto }; +} + +/** + * Validates an invoice creation request payload against the shared domain rules. + */ +export function parseCreateInvoiceRequest( + input: unknown +): ContractResult { + const errors = collectCreateInvoiceFieldErrors(input); + if (Object.keys(errors).length > 0) { + const primary = firstCreateInvoiceMessage(errors) || 'Validation failed'; + return { + success: false, + code: 'VALIDATION_ERROR', + error: primary, + fieldErrors: errors, + }; + } + + return { success: true, data: input as CreateInvoiceRequest }; +} + +function unwrapDataObject(input: Record): Record { + if ('data' in input && isObject(input.data)) { + return input.data; + } + return input; +} + +function unwrapInvoiceTarget(raw: Record): Record { + if ('invoice' in raw && isObject(raw.invoice)) { + return raw.invoice; + } + return raw; +} + +/** + * Validates that an API response adheres to the CreateInvoiceResponse contract. + */ +export function parseCreateInvoiceResponse( + input: unknown +): ContractResult { + if (!isObject(input) || input.success === false) { + return { success: false, error: 'Create response must be an object' }; + } + + const raw = unwrapDataObject(input); + const invoiceParsed = parseInvoiceDto(unwrapInvoiceTarget(raw)); + if (!invoiceParsed.success) { + return invoiceParsed; + } + + const paymentAvailable = + typeof raw.paymentAvailable === 'boolean' + ? raw.paymentAvailable + : invoiceParsed.data.status === 'PENDING'; + const paymentUrl = typeof raw.paymentUrl === 'string' ? raw.paymentUrl : ''; + + return { + success: true, + data: { + success: true, + data: { + invoice: invoiceParsed.data, + paymentAvailable, + paymentUrl, + statusPollingIntervalMs: + typeof raw.statusPollingIntervalMs === 'number' + ? raw.statusPollingIntervalMs + : undefined, + qrCode: typeof raw.qrCode === 'string' ? raw.qrCode : null, + stellarQrCode: typeof raw.stellarQrCode === 'string' ? raw.stellarQrCode : null, + stellarUri: typeof raw.stellarUri === 'string' ? raw.stellarUri : null, + stellarQrEncodesUri: + typeof raw.stellarQrEncodesUri === 'boolean' + ? raw.stellarQrEncodesUri + : undefined, + }, + }, + }; +} + +/** + * Validates that an API response adheres to the GetInvoiceResponse contract. + */ +export function parseGetInvoiceResponse( + input: unknown +): ContractResult { + if (!isObject(input) || input.success === false) { + return { success: false, error: 'Get response must be an object' }; + } + + const invoiceParsed = parseInvoiceDto(unwrapInvoiceTarget(unwrapDataObject(input))); + if (!invoiceParsed.success) { + return invoiceParsed; + } + + return { + success: true, + data: { + success: true, + data: invoiceParsed.data, + }, + }; +} + +/** + * Validates that an API response adheres to the ListInvoicesResponse contract. + */ +export function parseListInvoicesResponse( + input: unknown +): ContractResult { + if (!isObject(input) || input.success === false) { + return { success: false, error: 'List response must be an object' }; + } + + const list = Array.isArray(input.data) + ? input.data + : Array.isArray(input) + ? input + : null; + + if (!list) { + return { success: false, error: 'List response must contain an array in data' }; + } + + const invoices: InvoiceDto[] = []; + for (const item of list) { + const itemParsed = parseInvoiceDto(item); + if (!itemParsed.success) { + return itemParsed; + } + invoices.push(itemParsed.data); + } + + let pagination: ApiPagination = { + limit: invoices.length, + offset: 0, + total: invoices.length, + }; + if (isObject(input.pagination)) { + pagination = { + limit: + typeof input.pagination.limit === 'number' + ? input.pagination.limit + : invoices.length, + offset: + typeof input.pagination.offset === 'number' ? input.pagination.offset : 0, + total: + typeof input.pagination.total === 'number' + ? input.pagination.total + : invoices.length, + }; + } + + return { + success: true, + data: { + success: true, + data: invoices, + pagination, + }, + }; +} + +/** + * Validates that an API response adheres to the PaymentInfoResponse contract. + */ +export function parsePaymentInfoResponse( + input: unknown +): ContractResult { + if (!isObject(input) || input.success === false) { + return { success: false, error: 'Payment info response must be an object' }; + } + + const raw = unwrapDataObject(input); + let invoiceParsed: InvoiceDto | undefined; + if (isObject(raw.invoice)) { + const result = parseInvoiceDto(raw.invoice); + if (!result.success) { + return result; + } + invoiceParsed = result.data; + } + + const paymentAvailable = + typeof raw.paymentAvailable === 'boolean' + ? raw.paymentAvailable + : invoiceParsed + ? invoiceParsed.status === 'PENDING' + : true; + const paymentUrl = typeof raw.paymentUrl === 'string' ? raw.paymentUrl : ''; + + return { + success: true, + data: { + success: true, + data: { + invoice: invoiceParsed, + paymentAvailable, + paymentUrl, + statusPollingIntervalMs: + typeof raw.statusPollingIntervalMs === 'number' + ? raw.statusPollingIntervalMs + : undefined, + qrCode: typeof raw.qrCode === 'string' ? raw.qrCode : null, + stellarQrCode: typeof raw.stellarQrCode === 'string' ? raw.stellarQrCode : null, + stellarUri: typeof raw.stellarUri === 'string' ? raw.stellarUri : null, + stellarQrEncodesUri: + typeof raw.stellarQrEncodesUri === 'boolean' + ? raw.stellarQrEncodesUri + : undefined, + }, + }, + }; +} + +/** + * Validates that an API response adheres to the CancelInvoiceResponse contract. + */ +export function parseCancelInvoiceResponse( + input: unknown +): ContractResult { + if (!isObject(input) || input.success === false) { + return { success: false, error: 'Cancel response must be an object' }; + } + + const invoiceParsed = parseInvoiceDto(unwrapInvoiceTarget(unwrapDataObject(input))); + if (!invoiceParsed.success) { + return invoiceParsed; + } + + return { + success: true, + data: { + success: true, + data: invoiceParsed.data, + }, + }; +} + +/** + * Validates that an API response adheres to the VerifyPaymentResponse contract. + */ +export function parseVerifyPaymentResponse( + input: unknown +): ContractResult { + if (!isObject(input) || input.success === false) { + return { success: false, error: 'Verify response must be an object' }; + } + + const invoiceParsed = parseInvoiceDto(unwrapInvoiceTarget(unwrapDataObject(input))); + if (!invoiceParsed.success) { + return invoiceParsed; + } + + return { + success: true, + data: { + success: true, + data: invoiceParsed.data, + message: typeof input.message === 'string' ? input.message : undefined, + code: typeof input.code === 'string' ? input.code : undefined, + warning: typeof input.warning === 'string' ? input.warning : undefined, + }, + }; +} + +/** + * Validates that an API response adheres to the GetStatsResponse contract. + * Accepts either a stats object or a one-element array (current storage shape). + */ +export function parseGetStatsResponse( + input: unknown +): ContractResult { + if (!isObject(input) || input.success === false) { + return { success: false, error: 'Stats response must be an object' }; + } + + const rawData = 'data' in input ? (input as { data: unknown }).data : input; + const targetObj = Array.isArray(rawData) ? rawData[0] : rawData; + if (!isObject(targetObj)) { + return { success: false, error: 'Stats data must be an object' }; + } + + const stats: InvoiceStatsDto = { + total_invoices: Number(targetObj.total_invoices || 0), + paid_invoices: Number(targetObj.paid_invoices || 0), + pending_invoices: Number(targetObj.pending_invoices || 0), + actionable_invoices: Number( + targetObj.actionable_invoices || targetObj.pending_invoices || 0 + ), + expired_invoices: Number(targetObj.expired_invoices || 0), + revenue_by_asset: isObject(targetObj.revenue_by_asset) + ? (targetObj.revenue_by_asset as Record) + : {}, + }; + + return { + success: true, + data: { + success: true, + data: stats, + }, + }; +} + +/** + * Validates an error response against the standard ApiFailure contract. + */ +export function parseErrorEnvelope(input: unknown): ContractResult { + if (!isObject(input) || input.success !== false || typeof input.error !== 'string') { + return { + success: false, + error: + 'Error response must be an object with success: false and an error string', + }; + } + + const fieldErrors: Record = {}; + if (isObject(input.fieldErrors)) { + for (const [k, v] of Object.entries(input.fieldErrors)) { + if (typeof v === 'string') { + fieldErrors[k] = v; + } + } + } + + return { + success: true, + data: { + success: false, + error: input.error, + code: typeof input.code === 'string' ? input.code : undefined, + field: typeof input.field === 'string' ? input.field : undefined, + fieldErrors: Object.keys(fieldErrors).length > 0 ? fieldErrors : undefined, + }, + }; +} + +/** Canonical OpenAPI 3.0.3 specification for the Quittance Invoice API. */ +export const INVOICE_OPENAPI_SPEC = { + openapi: '3.0.3', + info: { + title: 'Quittance Invoice API', + version: '1.0.0', + description: + 'Unified invoice API contract published for Express backend and Next.js frontend.', + }, + paths: { + '/invoices': { + post: { + summary: 'Create an invoice', + operationId: 'createInvoice', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateInvoiceRequest' }, + }, + }, + }, + responses: { + '201': { + description: 'Invoice created successfully', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateInvoiceResponse' }, + }, + }, + }, + '400': { + description: 'Validation failure', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ValidationFailureBody' }, + }, + }, + }, + }, + }, + get: { + summary: 'List invoices for a seller', + operationId: 'listInvoices', + parameters: [ + { + name: 'sellerPublicKey', + in: 'query', + required: true, + schema: { type: 'string' }, + }, + { + name: 'status', + in: 'query', + required: false, + schema: { $ref: '#/components/schemas/InvoiceStatus' }, + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100 }, + }, + { + name: 'offset', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 0 }, + }, + ], + responses: { + '200': { + description: 'List of invoices', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ListInvoicesResponse' }, + }, + }, + }, + '400': { + description: 'Invalid query parameters', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ApiFailure' }, + }, + }, + }, + }, + }, + }, + '/invoices/{id}': { + get: { + summary: 'Get invoice by ID', + operationId: 'getInvoice', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'Invoice details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/GetInvoiceResponse' }, + }, + }, + }, + '404': { + description: 'Invoice not found', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ApiFailure' }, + }, + }, + }, + }, + }, + }, + '/invoices/{id}/payment-info': { + get: { + summary: 'Get payment info for an invoice', + operationId: 'getPaymentInfo', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'Payment info and status payload', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PaymentInfoResponse' }, + }, + }, + }, + '404': { + description: 'Invoice not found', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ApiFailure' }, + }, + }, + }, + }, + }, + }, + '/invoices/{id}/cancel': { + post: { + summary: 'Cancel an invoice', + operationId: 'cancelInvoice', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CancelInvoiceRequest' }, + }, + }, + }, + responses: { + '200': { + description: 'Cancelled invoice details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CancelInvoiceResponse' }, + }, + }, + }, + '400': { + description: 'Cancel rejected', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ApiFailure' }, + }, + }, + }, + }, + }, + }, + '/invoices/{id}/verify': { + post: { + summary: 'Verify a payment transaction against an invoice', + operationId: 'verifyPayment', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/VerifyPaymentRequest' }, + }, + }, + }, + responses: { + '200': { + description: 'Payment verified', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/VerifyPaymentResponse' }, + }, + }, + }, + '400': { + description: 'Verification check failed', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/VerificationFailureBody' }, + }, + }, + }, + '404': { + description: 'Transaction or invoice not found', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/VerificationFailureBody' }, + }, + }, + }, + '429': { + description: 'Rate limit exceeded', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/VerificationFailureBody' }, + }, + }, + }, + }, + }, + }, + '/invoices/stats': { + get: { + summary: 'Get seller invoice statistics', + operationId: 'getStats', + parameters: [ + { + name: 'sellerPublicKey', + in: 'query', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'Invoice metrics and volume statistics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/GetStatsResponse' }, + }, + }, + }, + '400': { + description: 'Missing or invalid sellerPublicKey', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ApiFailure' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + InvoiceStatus: { + type: 'string', + enum: ['PENDING', 'PAID', 'EXPIRED', 'CANCELLED'], + }, + InvoiceDto: { + type: 'object', + required: [...REQUIRED_INVOICE_DTO_FIELDS], + properties: { + id: { type: 'string' }, + sellerPublicKey: { type: 'string' }, + sellerName: { type: 'string' }, + sellerEmail: { type: 'string' }, + amount: { type: 'number' }, + assetCode: { type: 'string' }, + assetIssuer: { type: 'string' }, + memo: { type: 'string' }, + description: { type: 'string' }, + customerName: { type: 'string' }, + customerEmail: { type: 'string' }, + status: { $ref: '#/components/schemas/InvoiceStatus' }, + paymentTxHash: { type: 'string' }, + payerPublicKey: { type: 'string' }, + payerName: { type: 'string' }, + payerEmail: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + paidAt: { type: 'string', format: 'date-time' }, + cancelledAt: { type: 'string', format: 'date-time' }, + settledAt: { type: 'string', format: 'date-time' }, + settlementContext: { + type: 'string', + enum: ['ON_TIME', 'AFTER_EXPIRY', 'AFTER_CANCEL'], + }, + priorStatus: { $ref: '#/components/schemas/InvoiceStatus' }, + latePaymentWarningCode: { + type: 'string', + enum: ['PAYMENT_RECEIVED_AFTER_EXPIRY', 'PAYMENT_RECEIVED_AFTER_CANCEL'], + }, + expiresAt: { type: 'string', format: 'date-time' }, + metadata: { type: 'object' }, + }, + }, + InvoiceStatsDto: { + type: 'object', + required: [ + 'total_invoices', + 'paid_invoices', + 'pending_invoices', + 'actionable_invoices', + 'expired_invoices', + 'revenue_by_asset', + ], + properties: { + total_invoices: { type: 'integer' }, + paid_invoices: { type: 'integer' }, + pending_invoices: { type: 'integer' }, + actionable_invoices: { type: 'integer' }, + expired_invoices: { type: 'integer' }, + revenue_by_asset: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + }, + }, + ApiFailure: { + type: 'object', + required: ['success', 'error'], + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + code: { type: 'string' }, + field: { type: 'string' }, + fieldErrors: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + ValidationFailureBody: { + type: 'object', + required: ['success', 'code', 'error', 'fieldErrors'], + properties: { + success: { type: 'boolean', enum: [false] }, + code: { type: 'string', enum: ['VALIDATION_ERROR'] }, + error: { type: 'string' }, + fieldErrors: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + field: { type: 'string' }, + }, + }, + VerificationFailureBody: { + type: 'object', + required: ['success', 'code', 'error'], + properties: { + success: { type: 'boolean', enum: [false] }, + code: { type: 'string' }, + error: { type: 'string' }, + }, + }, + CreateInvoiceRequest: { + type: 'object', + required: ['amount', 'sellerPublicKey'], + properties: { + amount: { type: 'number', minimum: 0.0000001, maximum: 1000000000 }, + assetCode: { type: 'string', default: 'XLM' }, + assetIssuer: { type: 'string' }, + description: { type: 'string', maxLength: 500 }, + customerName: { type: 'string', maxLength: 255 }, + customerEmail: { type: 'string', format: 'email' }, + sellerPublicKey: { type: 'string' }, + sellerName: { type: 'string', maxLength: 255 }, + sellerEmail: { type: 'string', format: 'email' }, + expiresInDays: { type: 'integer', minimum: 1, maximum: 30, default: 7 }, + network: { type: 'string', enum: ['TESTNET', 'PUBLIC'] }, + idempotencyKey: { type: 'string' }, + }, + }, + CreateInvoiceResponse: { + type: 'object', + required: ['success', 'data'], + properties: { + success: { type: 'boolean', enum: [true] }, + data: { + type: 'object', + required: ['invoice', 'paymentAvailable', 'paymentUrl'], + properties: { + invoice: { $ref: '#/components/schemas/InvoiceDto' }, + paymentAvailable: { type: 'boolean' }, + paymentUrl: { type: 'string' }, + statusPollingIntervalMs: { type: 'integer' }, + qrCode: { type: 'string', nullable: true }, + stellarQrCode: { type: 'string', nullable: true }, + stellarUri: { type: 'string', nullable: true }, + stellarQrEncodesUri: { type: 'boolean' }, + }, + }, + }, + }, + GetInvoiceResponse: { + type: 'object', + required: ['success', 'data'], + properties: { + success: { type: 'boolean', enum: [true] }, + data: { $ref: '#/components/schemas/InvoiceDto' }, + }, + }, + ListInvoicesResponse: { + type: 'object', + required: ['success', 'data'], + properties: { + success: { type: 'boolean', enum: [true] }, + data: { + type: 'array', + items: { $ref: '#/components/schemas/InvoiceDto' }, + }, + pagination: { + type: 'object', + properties: { + limit: { type: 'integer' }, + offset: { type: 'integer' }, + total: { type: 'integer' }, + }, + }, + }, + }, + PaymentInfoResponse: { + type: 'object', + required: ['success', 'data'], + properties: { + success: { type: 'boolean', enum: [true] }, + data: { + type: 'object', + properties: { + invoice: { $ref: '#/components/schemas/InvoiceDto' }, + paymentAvailable: { type: 'boolean' }, + paymentUrl: { type: 'string' }, + statusPollingIntervalMs: { type: 'integer' }, + qrCode: { type: 'string', nullable: true }, + stellarQrCode: { type: 'string', nullable: true }, + stellarUri: { type: 'string', nullable: true }, + stellarQrEncodesUri: { type: 'boolean' }, + }, + }, + }, + }, + CancelInvoiceRequest: { + type: 'object', + properties: { + sellerPublicKey: { type: 'string' }, + signature: { type: 'string' }, + message: { type: 'string' }, + }, + }, + CancelInvoiceResponse: { + type: 'object', + required: ['success', 'data'], + properties: { + success: { type: 'boolean', enum: [true] }, + data: { $ref: '#/components/schemas/InvoiceDto' }, + }, + }, + VerifyPaymentRequest: { + type: 'object', + required: ['txHash'], + properties: { + txHash: { type: 'string', minLength: 64, maxLength: 64 }, + payerName: { type: 'string' }, + payerEmail: { type: 'string' }, + network: { type: 'string' }, + }, + }, + VerifyPaymentResponse: { + type: 'object', + required: ['success', 'data'], + properties: { + success: { type: 'boolean', enum: [true] }, + data: { $ref: '#/components/schemas/InvoiceDto' }, + message: { type: 'string' }, + code: { type: 'string' }, + warning: { type: 'string' }, + }, + }, + GetStatsResponse: { + type: 'object', + required: ['success', 'data'], + properties: { + success: { type: 'boolean', enum: [true] }, + data: { $ref: '#/components/schemas/InvoiceStatsDto' }, + }, + }, + }, + }, +} as const; diff --git a/shared/invoice.ts b/shared/invoice.ts index 5c3a60c8..060ed7a2 100644 --- a/shared/invoice.ts +++ b/shared/invoice.ts @@ -15,6 +15,12 @@ export type InvoiceStatus = 'PENDING' | 'PAID' | 'EXPIRED' | 'CANCELLED'; /** ISO-8601 timestamp, as produced by JSON serialisation of a Date. */ export type IsoTimestamp = string; +export type SettlementContext = 'ON_TIME' | 'AFTER_EXPIRY' | 'AFTER_CANCEL'; + +export type LatePaymentWarningCode = + | 'PAYMENT_RECEIVED_AFTER_EXPIRY' + | 'PAYMENT_RECEIVED_AFTER_CANCEL'; + export interface InvoiceDto { id: string; sellerPublicKey: string; @@ -35,8 +41,15 @@ export interface InvoiceDto { payerEmail?: string; createdAt: IsoTimestamp; paidAt?: IsoTimestamp; + cancelledAt?: IsoTimestamp; + settledAt?: IsoTimestamp; + settlementContext?: SettlementContext; + priorStatus?: InvoiceStatus; + latePaymentWarningCode?: LatePaymentWarningCode; expiresAt: IsoTimestamp; metadata?: unknown; + /** Allow benign extra wire fields without a second parallel invoice type. */ + [key: string]: unknown; } /** @@ -59,8 +72,8 @@ export interface PublicInvoiceDto { memo: string; status: InvoiceStatus; paymentTxHash?: string; - latePaymentWarningCode?: string; - settlementContext?: string; + latePaymentWarningCode?: LatePaymentWarningCode; + settlementContext?: SettlementContext; priorStatus?: InvoiceStatus; createdAt: IsoTimestamp; paidAt?: IsoTimestamp; @@ -98,8 +111,8 @@ export function toPublicInvoiceDto(invoice: { memo: string; status: InvoiceStatus; paymentTxHash?: string; - latePaymentWarningCode?: string; - settlementContext?: string; + latePaymentWarningCode?: LatePaymentWarningCode; + settlementContext?: SettlementContext; priorStatus?: InvoiceStatus; createdAt: Date | IsoTimestamp; paidAt?: Date | IsoTimestamp; diff --git a/tests/invoice-contract.test.mjs b/tests/invoice-contract.test.mjs new file mode 100644 index 00000000..dac301f2 --- /dev/null +++ b/tests/invoice-contract.test.mjs @@ -0,0 +1,35 @@ +/** + * Shared invoice contract divergence guards (issue #446). + * Runs via the root `npm run test:shared` script used by CI. + */ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +describe('shared invoice contract package', () => { + it('ships invoice-contract.ts next to invoice.ts', () => { + assert.equal(existsSync(path.join(root, 'shared/invoice-contract.ts')), true); + assert.equal(existsSync(path.join(root, 'shared/invoice.ts')), true); + }); + + it('documents create/list/get/cancel/verify/stats in the OpenAPI constant', () => { + const source = readFileSync(path.join(root, 'shared/invoice-contract.ts'), 'utf8'); + for (const token of [ + "'/invoices'", + "'/invoices/{id}'", + "'/invoices/{id}/cancel'", + "'/invoices/{id}/verify'", + "'/invoices/stats'", + 'REQUIRED_INVOICE_DTO_FIELDS', + 'parseCreateInvoiceRequest', + 'parseListInvoicesResponse', + 'parseVerifyPaymentResponse', + ]) { + assert.ok(source.includes(token), `missing ${token}`); + } + }); +}); From b223d5798fe68c150a429e652f10990d3f632a7d Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 07:33:32 -0400 Subject: [PATCH 2/9] Fix invoice statistics response contract Require complete numeric statistics and preserve explicit zero counts. Reject malformed success envelopes, unsafe or negative counts, non-finite or nonnumeric revenue, and missing or ambiguous storage rows. Keep raw object and canonical one-row responses compatible; publish their numeric bounds and wire shapes in OpenAPI. Validation: 66 focused backend checks, 21 shared checks, and 15 frontend checks pass. Backend typecheck and TypeScript 5.9.3 scoped frontend API-client check pass. The same contract file on exact parent reports 16 pass / 19 fail. Actual invoiceApi.getStats with locked Axios 1.12.2 completes 18 local HTTP cases: parent has 13 mismatches, candidate none; real Express/memory empty, pending, and paid controls pass both. Boundary: no full frontend build/typecheck, browser, wallet, external Horizon, or PostgreSQL runtime rerun. No dependency or storage behavior changes. --- backend/tests/invoice-contract.test.ts | 134 +++++++++++++++++++++++++ shared/invoice-contract.ts | 89 +++++++++++----- 2 files changed, 200 insertions(+), 23 deletions(-) diff --git a/backend/tests/invoice-contract.test.ts b/backend/tests/invoice-contract.test.ts index e4e59210..ca6bcedc 100644 --- a/backend/tests/invoice-contract.test.ts +++ b/backend/tests/invoice-contract.test.ts @@ -27,6 +27,119 @@ import { import { createInvoiceRouter } from '../src/routes/invoice.routes'; import { MemoryInvoiceStorage } from '../src/storage/memory-invoice-storage'; +describe('Statistics response contract', () => { + const stats = { + total_invoices: 4, + paid_invoices: 1, + pending_invoices: 3, + actionable_invoices: 0, + expired_invoices: 0, + revenue_by_asset: { XLM: 0.0000001, USDC: 25.5 }, + }; + const shapes = { + bare: (data: unknown) => data, + object: (data: unknown) => ({ success: true, data }), + storage: (data: unknown) => ({ success: true, data: [data] }), + }; + const schema = INVOICE_OPENAPI_SPEC.components.schemas.InvoiceStatsDto; + + for (const [name, wrap] of Object.entries(shapes)) { + it(`preserves explicit zero counts in the ${name} shape`, () => { + const parsed = parseGetStatsResponse(wrap(stats)); + assert.equal(parsed.success, true); + if (parsed.success) assert.deepEqual(parsed.data.data, stats); + }); + } + + for (const field of schema.required) { + it(`rejects missing required statistic ${field}`, () => { + const incomplete: Record = { ...stats }; + delete incomplete[field]; + for (const wrap of Object.values(shapes)) { + assert.equal(parseGetStatsResponse(wrap(incomplete)).success, false); + } + }); + } + + for (const field of schema.required.filter((field) => field !== 'revenue_by_asset')) { + it(`rejects malformed ${field} instead of coercing it`, () => { + for (const value of [undefined, null, '', '0', 'invalid', false, true, [], {}, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const parsed = parseGetStatsResponse({ success: true, data: { ...stats, [field]: value } }); + assert.equal(parsed.success, false, `${field} accepted ${String(value)}`); + } + }); + } + + it('requires own statistic fields', () => { + const inherited = Object.create(stats); + assert.equal(parseGetStatsResponse({ success: true, data: inherited }).success, false); + }); + + it('rejects malformed revenue maps and values', () => { + for (const revenue of [null, [], '', 1, { XLM: '1' }, { XLM: false }, { XLM: null }, { XLM: -1 }, { XLM: NaN }, { XLM: Infinity }]) { + assert.equal( + parseGetStatsResponse({ success: true, data: { ...stats, revenue_by_asset: revenue } }).success, + false + ); + } + }); + + it('accepts complete zero statistics and finite fractional revenue', () => { + const empty = { + total_invoices: 0, + paid_invoices: 0, + pending_invoices: 0, + actionable_invoices: 0, + expired_invoices: 0, + revenue_by_asset: {}, + }; + const parsed = parseGetStatsResponse({ success: true, data: [empty] }); + assert.equal(parsed.success, true); + if (parsed.success) assert.deepEqual(parsed.data.data, empty); + assert.equal(parseGetStatsResponse({ ...stats, total_invoices: Number.MAX_SAFE_INTEGER }).success, true); + }); + + it('preserves own asset keys without altering the object prototype', () => { + const revenue = JSON.parse('{"__proto__":2,"constructor":3,"XLM":0}'); + const parsed = parseGetStatsResponse({ ...stats, revenue_by_asset: revenue }); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.deepEqual(parsed.data.data.revenue_by_asset, revenue); + assert.equal(Object.getPrototypeOf(parsed.data.data.revenue_by_asset), Object.prototype); + } + }); + + it('rejects incomplete or non-success envelopes', () => { + for (const success of [undefined, null, false, 0, 1, 'true']) { + assert.equal(parseGetStatsResponse({ success, data: stats }).success, false); + } + assert.equal(parseGetStatsResponse({ data: stats }).success, false); + assert.equal(parseGetStatsResponse({ ...stats, success: true }).success, false); + assert.equal(parseGetStatsResponse({ success: true, data: null }).success, false); + }); + + it('rejects missing or ambiguous storage rows', () => { + for (const data of [[], [stats, stats], [null], [1]]) { + assert.equal(parseGetStatsResponse({ success: true, data }).success, false); + } + }); + + it('publishes matching numeric bounds and the one-row storage response', () => { + for (const field of schema.required.filter((field) => field !== 'revenue_by_asset')) { + assert.deepEqual(schema.properties[field], { + type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER, + }); + } + assert.deepEqual(schema.properties.revenue_by_asset.additionalProperties, { type: 'number', minimum: 0 }); + assert.deepEqual(INVOICE_OPENAPI_SPEC.components.schemas.GetStatsResponse.properties.data, { + oneOf: [ + { $ref: '#/components/schemas/InvoiceStatsDto' }, + { type: 'array', minItems: 1, maxItems: 1, items: { $ref: '#/components/schemas/InvoiceStatsDto' } }, + ], + }); + }); +}); + interface HttpResponse { status: number; headers: http.IncomingHttpHeaders; @@ -308,6 +421,27 @@ describe('Invoice API contract (issue #446)', () => { } }); + it('GET /api/invoices/stats preserves a complete empty seller response', async () => { + const res = await request( + port, + 'GET', + `/api/invoices/stats?sellerPublicKey=${encodeURIComponent(Keypair.random().publicKey())}` + ); + assert.equal(res.status, 200); + const parsed = parseGetStatsResponse(res.body); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.deepEqual(parsed.data.data, { + total_invoices: 0, + paid_invoices: 0, + pending_invoices: 0, + actionable_invoices: 0, + expired_invoices: 0, + revenue_by_asset: {}, + }); + } + }); + it('POST cancel returns a cancel envelope that parses', async () => { const res = await request( port, diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts index 9cf6673e..20f8d417 100644 --- a/shared/invoice-contract.ts +++ b/shared/invoice-contract.ts @@ -539,39 +539,72 @@ export function parseVerifyPaymentResponse( /** * Validates that an API response adheres to the GetStatsResponse contract. - * Accepts either a stats object or a one-element array (current storage shape). + * Accepts a bare stats object or a success envelope containing an object or + * one-element array (the current storage response). The normalized result is + * always an object. Missing or malformed values must not become zero totals. */ export function parseGetStatsResponse( input: unknown ): ContractResult { - if (!isObject(input) || input.success === false) { + if (!isObject(input)) { return { success: false, error: 'Stats response must be an object' }; } - const rawData = 'data' in input ? (input as { data: unknown }).data : input; + const hasOwn = (object: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(object, key); + const isEnvelope = hasOwn(input, 'data') || hasOwn(input, 'success'); + if (isEnvelope && (!hasOwn(input, 'success') || input.success !== true || !hasOwn(input, 'data'))) { + return { success: false, error: 'Stats response must contain success: true and data' }; + } + + const rawData = isEnvelope ? input.data : input; + if (Array.isArray(rawData) && rawData.length !== 1) { + return { success: false, error: 'Stats data must contain exactly one stats object' }; + } const targetObj = Array.isArray(rawData) ? rawData[0] : rawData; if (!isObject(targetObj)) { return { success: false, error: 'Stats data must be an object' }; } - const stats: InvoiceStatsDto = { - total_invoices: Number(targetObj.total_invoices || 0), - paid_invoices: Number(targetObj.paid_invoices || 0), - pending_invoices: Number(targetObj.pending_invoices || 0), - actionable_invoices: Number( - targetObj.actionable_invoices || targetObj.pending_invoices || 0 - ), - expired_invoices: Number(targetObj.expired_invoices || 0), - revenue_by_asset: isObject(targetObj.revenue_by_asset) - ? (targetObj.revenue_by_asset as Record) - : {}, - }; + const countFields = [ + 'total_invoices', + 'paid_invoices', + 'pending_invoices', + 'actionable_invoices', + 'expired_invoices', + ] as const; + const counts = {} as Pick; + for (const field of countFields) { + const value = targetObj[field]; + if ( + !hasOwn(targetObj, field) || + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0 + ) { + return { success: false, error: `Stats ${field} must be a non-negative safe integer` }; + } + counts[field] = value; + } + + const revenue = targetObj.revenue_by_asset; + if (!hasOwn(targetObj, 'revenue_by_asset') || !isObject(revenue)) { + return { success: false, error: 'Stats revenue_by_asset must be an object' }; + } + for (const value of Object.values(revenue)) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return { success: false, error: 'Stats asset revenue must be a non-negative finite number' }; + } + } return { success: true, data: { success: true, - data: stats, + data: { + ...counts, + revenue_by_asset: { ...revenue } as Record, + }, }, }; } @@ -946,14 +979,14 @@ export const INVOICE_OPENAPI_SPEC = { 'revenue_by_asset', ], properties: { - total_invoices: { type: 'integer' }, - paid_invoices: { type: 'integer' }, - pending_invoices: { type: 'integer' }, - actionable_invoices: { type: 'integer' }, - expired_invoices: { type: 'integer' }, + total_invoices: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + paid_invoices: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + pending_invoices: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + actionable_invoices: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + expired_invoices: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, revenue_by_asset: { type: 'object', - additionalProperties: { type: 'number' }, + additionalProperties: { type: 'number', minimum: 0 }, }, }, }, @@ -1122,7 +1155,17 @@ export const INVOICE_OPENAPI_SPEC = { required: ['success', 'data'], properties: { success: { type: 'boolean', enum: [true] }, - data: { $ref: '#/components/schemas/InvoiceStatsDto' }, + data: { + oneOf: [ + { $ref: '#/components/schemas/InvoiceStatsDto' }, + { + type: 'array', + minItems: 1, + maxItems: 1, + items: { $ref: '#/components/schemas/InvoiceStatsDto' }, + }, + ], + }, }, }, }, From dc49a22dfa56705d77ea5b91fc5264daee1d2900 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:11:04 -0400 Subject: [PATCH 3/9] docs: record invoice contract review resolution and measured checks [skip ci] --- docs/INVOICE_CONTRACT_REVIEW_20261004.md | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/INVOICE_CONTRACT_REVIEW_20261004.md diff --git a/docs/INVOICE_CONTRACT_REVIEW_20261004.md b/docs/INVOICE_CONTRACT_REVIEW_20261004.md new file mode 100644 index 00000000..21c4a543 --- /dev/null +++ b/docs/INVOICE_CONTRACT_REVIEW_20261004.md @@ -0,0 +1,36 @@ +# Invoice contract review resolution — 2026-10-04 + +This records the response to [the October 3 review of PR 571](https://github.com/Quittance-Labs/Quittance0/pull/571#issuecomment-5970246093). The implementation revision is `f634e4f37945d40b0ed4024a88b380f5847fe87a`, tree `cc5b8f56e908f6e60e8bb683211a3d950fd959be`. + +## Changes addressing the review + +The existing branch now includes upstream `282c75ad933651a93b89a8412ab649b48714c739` without rewriting its original history. The invoice client retains shared return types while tolerating partial runtime payloads, rather than turning contract drift into an unavailable pay page or dashboard. Strict shared parsers and their regression assertions remain available for contract verification; malformed statistics are not silently accepted by the strict parser. Runtime statistics retain the existing one-row storage-response normalization. + +`copyValue` and `networkPassphrase` are carried through both creation/payment-info result types, their parsers, and the OpenAPI schemas. Conflict resolution preserves current-main request correlation, invoice search, canonical proof delivery and payment-session types. This is the same PR and original contribution, not a replacement submission. + +## Executed project checks + +[Run 37189795925](https://github.com/woahwhattheheck/Quittance0/actions/runs/37189795925), job `111399414070`, assembled and verified the exact implementation commit and tree. Node 24.21.0, npm 11.19.0, hosted Ubuntu; locked dependencies, test/loopback configuration. + +| Directory | Command | Exit | Result | +| --- | --- | --- | --- | +| frontend | `npm run lint` | 0 | Three anonymous-default-export warnings; no lint error | +| frontend | `npm run typecheck` | 0 | Passed | +| frontend | `npm test` | 0 | 756 passed, 0 failed | +| backend | `npm run typecheck` | 0 | Passed | +| backend | `node --import tsx --test tests/invoice-contract.test.ts tests/create-invoice-validation.test.ts tests/shared-verification-contract.test.ts` | 0 | 80 passed, 0 failed | +| repository root | `node --test tests/invoice-contract.test.mjs` | 0 | 2 passed, 0 failed | + +This is 838 selected passing tests, not the full backend or full shared suite. The original submitted head `b223d5798fe68c150a429e652f10990d3f632a7d` reproduced the review's six frontend failures in run `37188450363`: 594 passed out of 600. The newer candidate also incorporates upstream tests, so the different totals are not directly comparable test populations. + +**The combined workflow's overall conclusion is failure.** All six project commands succeeded, but an additional wrapper condition required the tracked worktree to remain unchanged. That condition failed; the initial runner did not retain its changed-file list. [Artifact 11298905270](https://github.com/woahwhattheheck/Quittance0/actions/runs/37189795925/artifacts/11298905270) contains every command log and the raw receipt. It records `all_checks_passed: true` separately from `source_worktree_unchanged: false`. + +## Independent original-source typecheck + +[Run 37190601163](https://github.com/woahwhattheheck/Quittance0/actions/runs/37190601163), job `111401771842`, checked out the same implementation and verified `frontend/next-env.d.ts` directly against its committed Git object before execution. Locked frontend install returned 0 with no tracked changes. The original-source frontend typecheck returned 0, without first regenerating or replacing the declaration. Its only tracked change was `frontend/tsconfig.tsbuildinfo`, a compiler-generated cache; application source and the declaration remained unchanged. + +This independent workflow also concluded failure because its extra clean-worktree condition rejected that generated cache. It is not a typecheck failure, and neither red workflow is represented here as green CI. [Artifact 11298820972](https://github.com/woahwhattheheck/Quittance0/actions/runs/37190601163/artifacts/11298820972) retains the original declaration, command logs, receipt and exact cache diff. Both archives were downloaded and inspected. + +## Limits + +The checks do not establish production deployment, live payment/settlement, browser/device wallet behavior, PostgreSQL integration, or full backend/shared-suite success. No generated cache or declaration changes were committed, no tests were removed, and validation-controller workflows remain off this product branch. Earlier failed diagnostic runs remain failed and are not publication evidence. This document adds no new execution or assertion of upstream acceptance or payment. From a8686a37022f7a714fcde217457cef56f333795c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 08:13:47 -0400 Subject: [PATCH 4/9] Validate supplied invoice pagination metadata Reject malformed pagination objects and require nonnegative safe-integer limit, offset, and total values in the strict shared parser. Reflect that contract in the OpenAPI schema. Continue normalizing legacy responses that omit pagination, and preserve the frontend client's tolerant runtime behavior. Two focused contract cases pass; the parent source reproduces acceptance of malformed supplied metadata. --- .../tests/invoice-pagination-contract.test.ts | 53 +++++++++++++++++++ shared/invoice-contract.ts | 41 +++++++++----- 2 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 backend/tests/invoice-pagination-contract.test.ts diff --git a/backend/tests/invoice-pagination-contract.test.ts b/backend/tests/invoice-pagination-contract.test.ts new file mode 100644 index 00000000..2b69fd7b --- /dev/null +++ b/backend/tests/invoice-pagination-contract.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { it } from 'node:test'; +import { parseListInvoicesResponse } from '../../shared/invoice-contract'; + +const invoice = { + id: 'invoice-pagination-contract', + sellerPublicKey: 'seller', + amount: 1, + assetCode: 'XLM', + memo: 'INVOICE', + status: 'PENDING', + createdAt: '2026-10-04T00:00:00.000Z', + expiresAt: '2026-10-05T00:00:00.000Z', +}; + +it('preserves valid list pagination and normalizes only omitted metadata', () => { + const pagination = { limit: 10, offset: 20, total: 31 }; + const explicit = parseListInvoicesResponse({ success: true, data: [invoice], pagination }); + assert.equal(explicit.success, true); + if (explicit.success) { + assert.deepEqual(explicit.data.pagination, pagination); + assert.deepEqual(explicit.data.data, [invoice]); + } + const legacy = parseListInvoicesResponse({ success: true, data: [invoice] }); + assert.equal(legacy.success, true); + if (legacy.success) assert.deepEqual(legacy.data.pagination, { limit: 1, offset: 0, total: 1 }); + const empty = parseListInvoicesResponse({ + success: true, data: [], pagination: { limit: 0, offset: 0, total: 0 }, + }); + assert.equal(empty.success, true); +}); + +it('rejects malformed supplied list pagination instead of inventing totals', () => { + const valid = { limit: 10, offset: 0, total: 31 }; + const malformed = [ + null, + [], + { ...valid, limit: -1 }, + { ...valid, offset: 0.5 }, + { ...valid, total: Infinity }, + { ...valid, total: NaN }, + { ...valid, total: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, total: '31' }, + { limit: 10, total: 31 }, + ]; + for (const pagination of malformed) { + assert.equal( + parseListInvoicesResponse({ success: true, data: [invoice], pagination }).success, + false, + `accepted malformed pagination: ${JSON.stringify(pagination)}`, + ); + } +}); diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts index 83af20fc..151de060 100644 --- a/shared/invoice-contract.ts +++ b/shared/invoice-contract.ts @@ -386,6 +386,8 @@ export function parseGetInvoiceResponse( /** * Validates that an API response adheres to the ListInvoicesResponse contract. + * Legacy responses may omit pagination. Supplied metadata must be complete + * and valid rather than silently replacing malformed values with page totals. */ export function parseListInvoicesResponse( input: unknown @@ -418,18 +420,28 @@ export function parseListInvoicesResponse( offset: 0, total: invoices.length, }; - if (isObject(input.pagination)) { + if (Object.prototype.hasOwnProperty.call(input, 'pagination')) { + if (!isObject(input.pagination)) { + return { success: false, error: 'List pagination must be an object' }; + } + for (const field of ['limit', 'offset', 'total'] as const) { + const value = input.pagination[field]; + if ( + !Object.prototype.hasOwnProperty.call(input.pagination, field) || + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0 + ) { + return { + success: false, + error: `List pagination.${field} must be a non-negative safe integer`, + }; + } + } pagination = { - limit: - typeof input.pagination.limit === 'number' - ? input.pagination.limit - : invoices.length, - offset: - typeof input.pagination.offset === 'number' ? input.pagination.offset : 0, - total: - typeof input.pagination.total === 'number' - ? input.pagination.total - : invoices.length, + limit: input.pagination.limit as number, + offset: input.pagination.offset as number, + total: input.pagination.total as number, }; } @@ -1105,10 +1117,11 @@ export const INVOICE_OPENAPI_SPEC = { }, pagination: { type: 'object', + required: ['limit', 'offset', 'total'], properties: { - limit: { type: 'integer' }, - offset: { type: 'integer' }, - total: { type: 'integer' }, + limit: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + offset: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + total: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, }, }, }, From 31650ab73508d70e1020b295298b2c499947447f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 09:55:44 -0400 Subject: [PATCH 5/9] Reject malformed success flags in invoice response contracts Use one shared discriminant guard for create, get, list, payment-info, cancel and verify responses. A supplied success value must be literal true; supported legacy payloads without the flag retain their existing normalization. Preserve the stats and pagination repairs, OpenAPI document and all payment/identity logic. Two focused regression tests execute against the complete production contract and its real invoice-validation dependency, transpiled with TypeScript 5.8.3 and run with Node 22.16.0. Original: 1 passed, 1 failed. Patched: 2 passed, 0 failed. This is not a full backend/frontend typecheck, HTTP integration or database run. --- .../invoice-response-success-contract.test.ts | 56 +++++++++++++++++++ shared/invoice-contract.ts | 17 ++++-- 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 backend/tests/invoice-response-success-contract.test.ts diff --git a/backend/tests/invoice-response-success-contract.test.ts b/backend/tests/invoice-response-success-contract.test.ts new file mode 100644 index 00000000..37879780 --- /dev/null +++ b/backend/tests/invoice-response-success-contract.test.ts @@ -0,0 +1,56 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import * as contract from '../../shared/invoice-contract'; + +const invoice = { + id: 'invoice-response-contract', + sellerPublicKey: 'G' + 'A'.repeat(55), + amount: 1, + assetCode: 'XLM', + memo: 'invoice-response-contract', + status: 'PENDING', + createdAt: '2026-10-04T00:00:00.000Z', + expiresAt: '2026-10-05T00:00:00.000Z', +}; +const payment = { invoice, paymentAvailable: true, paymentUrl: '/pay/example' }; +const stats = { + total_invoices: 1, + paid_invoices: 0, + pending_invoices: 1, + actionable_invoices: 1, + expired_invoices: 0, + revenue_by_asset: {}, +}; +const cases = [ + [contract.parseCreateInvoiceResponse, payment], + [contract.parseGetInvoiceResponse, invoice], + [contract.parseListInvoicesResponse, [invoice]], + [contract.parsePaymentInfoResponse, payment], + [contract.parseCancelInvoiceResponse, { ...invoice, status: 'CANCELLED' }], + [contract.parseVerifyPaymentResponse, { ...invoice, status: 'PAID' }], + [contract.parseGetStatsResponse, stats], +] as const; + +test('success response parsers reject a supplied non-true discriminant', () => { + const accepted: string[] = []; + for (const [parse, data] of cases) { + for (const success of [false, 'false', 'true', 0, 1, null, undefined, [], {}]) { + if (parse({ success, data }).success) { + accepted.push(`${parse.name}: ${String(success)} (${typeof success})`); + } + } + } + assert.deepEqual(accepted, []); +}); + +test('literal true and supported legacy payloads retain the same normalized data', () => { + for (const [parse, data] of cases) { + const envelope = parse({ success: true, data }); + assert.equal(envelope.success, true, parse.name); + const legacy = Array.isArray(data) ? { data } : data; + assert.deepEqual(parse(legacy), envelope, parse.name); + for (const input of [null, false, 'false', 0, []]) { + assert.equal(parse(input).success, false, parse.name); + } + } +}); diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts index 151de060..f889b4ee 100644 --- a/shared/invoice-contract.ts +++ b/shared/invoice-contract.ts @@ -220,6 +220,11 @@ function isObject(val: unknown): val is Record { return typeof val === 'object' && val !== null && !Array.isArray(val); } +/** Legacy payloads may omit success; a supplied discriminant must be true. */ +function isSuccessfulResponse(input: unknown): input is Record { + return isObject(input) && (!('success' in input) || input.success === true); +} + /** * Validates whether an unknown value conforms to the InvoiceDto contract. */ @@ -317,7 +322,7 @@ function unwrapInvoiceTarget(raw: Record): Record { - if (!isObject(input) || input.success === false) { + if (!isSuccessfulResponse(input)) { return { success: false, error: 'Create response must be an object' }; } @@ -366,7 +371,7 @@ export function parseCreateInvoiceResponse( export function parseGetInvoiceResponse( input: unknown ): ContractResult { - if (!isObject(input) || input.success === false) { + if (!isSuccessfulResponse(input)) { return { success: false, error: 'Get response must be an object' }; } @@ -392,7 +397,7 @@ export function parseGetInvoiceResponse( export function parseListInvoicesResponse( input: unknown ): ContractResult { - if (!isObject(input) || input.success === false) { + if (!isSuccessfulResponse(input)) { return { success: false, error: 'List response must be an object' }; } @@ -461,7 +466,7 @@ export function parseListInvoicesResponse( export function parsePaymentInfoResponse( input: unknown ): ContractResult { - if (!isObject(input) || input.success === false) { + if (!isSuccessfulResponse(input)) { return { success: false, error: 'Payment info response must be an object' }; } @@ -516,7 +521,7 @@ export function parsePaymentInfoResponse( export function parseCancelInvoiceResponse( input: unknown ): ContractResult { - if (!isObject(input) || input.success === false) { + if (!isSuccessfulResponse(input)) { return { success: false, error: 'Cancel response must be an object' }; } @@ -540,7 +545,7 @@ export function parseCancelInvoiceResponse( export function parseVerifyPaymentResponse( input: unknown ): ContractResult { - if (!isObject(input) || input.success === false) { + if (!isSuccessfulResponse(input)) { return { success: false, error: 'Verify response must be an object' }; } From 9577eebbac7e5a9c2361be9f8385a5f8e1203c81 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 09:57:40 -0400 Subject: [PATCH 6/9] Record scoped response-discriminant regression results --- docs/INVOICE_CONTRACT_REVIEW_20261004.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/INVOICE_CONTRACT_REVIEW_20261004.md b/docs/INVOICE_CONTRACT_REVIEW_20261004.md index 21c4a543..4a4fe044 100644 --- a/docs/INVOICE_CONTRACT_REVIEW_20261004.md +++ b/docs/INVOICE_CONTRACT_REVIEW_20261004.md @@ -31,6 +31,16 @@ This is 838 selected passing tests, not the full backend or full shared suite. T This independent workflow also concluded failure because its extra clean-worktree condition rejected that generated cache. It is not a typecheck failure, and neither red workflow is represented here as green CI. [Artifact 11298820972](https://github.com/woahwhattheheck/Quittance0/actions/runs/37190601163/artifacts/11298820972) retains the original declaration, command logs, receipt and exact cache diff. Both archives were downloaded and inspected. +## Response discriminant follow-up + +Source `31650ab73508d70e1020b295298b2c499947447f` is a direct successor of `a8686a37022f7a714fcde217457cef56f333795c`. The six strict success-response parsers now require a supplied `success` value to be literal `true`, instead of accepting malformed values such as `"false"`, `0`, or `null` and normalizing them into a success. Supported legacy payloads that omit the flag retain their existing normalization. The already-strict stats parser, earlier pagination repair, OpenAPI document, tolerant frontend runtime and payment/identity logic are unchanged. + +Two cases in `backend/tests/invoice-response-success-contract.test.ts` check discriminant rejection and compatibility. Both run against the complete production contract and its real `invoice-validation.ts` dependency, without a mock validator or copied parser. Before execution, the original source and dependency matched Git blobs `151de060f051a0ab604604cdd672779621680c61` and `541d7327556913cf8fbc7b240abbb41f31f40196`. The published correction matches executed source blob `f889b4eea91f3d74528ba24b4916cc5ec566d500`; the test blob is `3787978046ebbd1467bdeec30074a86ad5dc4c81`. + +The available cloud runtime had Node 22.16.0 and TypeScript 5.8.3, but not the project dependencies. `transpileModule` emitted the complete two production modules and the unchanged test into their corresponding CommonJS paths, with ES2022 target and no transpilation diagnostics. `node --test` then executed the emitted test. The original source produced **1 pass / 1 fail (exit 1)**; the corrected source produced **2 pass / 0 fail (exit 0)**. The compatibility case also covers valid stats and non-object rejection. Transpilation is not a full TypeScript typecheck. + +The repository-native command is `cd backend && node --import tsx --test tests/invoice-response-success-contract.test.ts`; the execution described above used its emitted JavaScript equivalent. No additional workflow was introduced. The earlier broader commands above were not rerun for this correction and must not be treated as fresh current-head results. + ## Limits -The checks do not establish production deployment, live payment/settlement, browser/device wallet behavior, PostgreSQL integration, or full backend/shared-suite success. No generated cache or declaration changes were committed, no tests were removed, and validation-controller workflows remain off this product branch. Earlier failed diagnostic runs remain failed and are not publication evidence. This document adds no new execution or assertion of upstream acceptance or payment. +The checks do not establish production deployment, live payment/settlement, browser/device wallet behavior, PostgreSQL integration, or full backend/shared-suite success. No generated cache or declaration changes were committed, no tests were removed, and validation-controller workflows remain off this product branch. Earlier failed diagnostic runs remain failed and are not publication evidence. The follow-up records only its separately scoped execution, not upstream acceptance or payment. From b72c1259a0a78b3442d6ffef57e5b47a155e2e59 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 10:02:47 -0400 Subject: [PATCH 7/9] fix: normalize legacy invoice lists before the object guard Make the existing bare-array normalization reachable while retaining the strict supplied-success discriminant and pagination validation. Empty and populated lists use the same envelope parser; invalid invoice items remain rejected. Extend the existing compatibility case with the list exception, preserving empty-array rejection for all other response parsers. No new test file, transport gate or workflow enters the product branch. Preserve the documentation-only successor 9577eeb as parent. Run https://github.com/woahwhattheheck/Quittance0/actions/runs/37207722383 executed both existing response-contract tests against the complete production contract and real validation dependency using native type stripping and temporary ESM import-path adaptation: 2 passed, 0 failed. Paired legacy-list cases improved 0/2 to 2/2, with five pagination/error/item controls retained. This is not a project typecheck, HTTP/database check or full repository suite. Checked source blob fc9ac682191b48c9748c39086c4e4bc6e257e905; test blob 9474aa1dc062e6ca5b1eaf8f8423cb0485c3f727. --- backend/tests/invoice-response-success-contract.test.ts | 9 ++++++++- shared/invoice-contract.ts | 9 ++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/backend/tests/invoice-response-success-contract.test.ts b/backend/tests/invoice-response-success-contract.test.ts index 37879780..9474aa1d 100644 --- a/backend/tests/invoice-response-success-contract.test.ts +++ b/backend/tests/invoice-response-success-contract.test.ts @@ -49,8 +49,15 @@ test('literal true and supported legacy payloads retain the same normalized data assert.equal(envelope.success, true, parse.name); const legacy = Array.isArray(data) ? { data } : data; assert.deepEqual(parse(legacy), envelope, parse.name); - for (const input of [null, false, 'false', 0, []]) { + for (const input of [null, false, 'false', 0]) { assert.equal(parse(input).success, false, parse.name); } + if (parse === contract.parseListInvoicesResponse) { + assert.deepEqual(parse([invoice]), envelope); + assert.deepEqual(parse([]), parse({ success: true, data: [] })); + assert.equal(parse([{ ...invoice, amount: 0 }]).success, false); + } else { + assert.equal(parse([]).success, false, parse.name); + } } }); diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts index f889b4ee..fc9ac682 100644 --- a/shared/invoice-contract.ts +++ b/shared/invoice-contract.ts @@ -397,15 +397,14 @@ export function parseGetInvoiceResponse( export function parseListInvoicesResponse( input: unknown ): ContractResult { + if (Array.isArray(input)) { + return parseListInvoicesResponse({ success: true, data: input }); + } if (!isSuccessfulResponse(input)) { return { success: false, error: 'List response must be an object' }; } - const list = Array.isArray(input.data) - ? input.data - : Array.isArray(input) - ? input - : null; + const list = Array.isArray(input.data) ? input.data : null; if (!list) { return { success: false, error: 'List response must contain an array in data' }; From 6f5c83054cdf616f055553922e9f52434f8c378e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 10:57:01 -0400 Subject: [PATCH 8/9] fix(contract): require seller public key in create type --- shared/invoice-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts index fc9ac682..c848ca01 100644 --- a/shared/invoice-contract.ts +++ b/shared/invoice-contract.ts @@ -79,7 +79,7 @@ export interface CreateInvoiceRequest { description?: string; customerName?: string; customerEmail?: string; - sellerPublicKey?: string; + sellerPublicKey: string; sellerName?: string; sellerEmail?: string; expiresInDays?: number; From 34c3c299a4e1ba46e986bc5b3e2e25aef9b62057 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 11:00:25 -0400 Subject: [PATCH 9/9] fix(contract): narrow invoice network type --- shared/invoice-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts index c848ca01..e3949216 100644 --- a/shared/invoice-contract.ts +++ b/shared/invoice-contract.ts @@ -83,7 +83,7 @@ export interface CreateInvoiceRequest { sellerName?: string; sellerEmail?: string; expiresInDays?: number; - network?: 'TESTNET' | 'PUBLIC' | string; + network?: 'TESTNET' | 'PUBLIC'; idempotencyKey?: string; }