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..3b46748a --- /dev/null +++ b/backend/tests/invoice-contract.test.ts @@ -0,0 +1,523 @@ +/** + * 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'; + +describe('Pay-link artifact response contract', () => { + const invoice = { + id: 'invoice-artifact-contract', + sellerPublicKey: `G${'A'.repeat(55)}`, + amount: 1, + assetCode: 'XLM', + memo: 'ARTIFACT', + status: 'PENDING', + createdAt: '2026-10-04T00:00:00.000Z', + expiresAt: '2026-10-05T00:00:00.000Z', + }; + const payload = { + invoice, + paymentAvailable: true, + paymentUrl: 'https://example.test/pay/invoice-artifact-contract', + copyValue: 'web+stellar:pay?destination=example', + networkPassphrase: 'Test SDF Network ; September 2015', + }; + + for (const [name, parse] of [ + ['create', parseCreateInvoiceResponse], + ['payment-info', parsePaymentInfoResponse], + ] as const) { + it(`${name} retains the copy value and network passphrase`, () => { + const parsed = parse({ success: true, data: payload }); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.equal(parsed.data.data.copyValue, payload.copyValue); + assert.equal(parsed.data.data.networkPassphrase, payload.networkPassphrase); + } + }); + + it(`${name} accepts absent or null optional artifact fields`, () => { + for (const value of [undefined, null]) { + const parsed = parse({ + success: true, + data: { ...payload, copyValue: value, networkPassphrase: value }, + }); + assert.equal(parsed.success, true); + if (parsed.success) { + assert.equal(parsed.data.data.copyValue, null); + assert.equal(parsed.data.data.networkPassphrase, null); + } + } + }); + } + + it('publishes the artifact fields in both response schemas', () => { + for (const schema of [ + INVOICE_OPENAPI_SPEC.components.schemas.CreateInvoiceResponse, + INVOICE_OPENAPI_SPEC.components.schemas.PaymentInfoResponse, + ]) { + assert.deepEqual(schema.properties.data.properties.copyValue, { type: 'string', nullable: true }); + assert.deepEqual(schema.properties.data.properties.networkPassphrase, { type: 'string', nullable: true }); + } + }); +}); + +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; + 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('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, + '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/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/backend/tests/invoice-response-success-contract.test.ts b/backend/tests/invoice-response-success-contract.test.ts new file mode 100644 index 00000000..9474aa1d --- /dev/null +++ b/backend/tests/invoice-response-success-contract.test.ts @@ -0,0 +1,63 @@ +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); + } + 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/docs/INVOICE_CONTRACT_REVIEW_20261004.md b/docs/INVOICE_CONTRACT_REVIEW_20261004.md new file mode 100644 index 00000000..4a4fe044 --- /dev/null +++ b/docs/INVOICE_CONTRACT_REVIEW_20261004.md @@ -0,0 +1,46 @@ +# 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. + +## 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. The follow-up records only its separately scoped execution, not upstream acceptance or payment. diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 16567bb2..e76c9a1a 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -132,7 +132,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 ebd7f9d8..062b1b86 100644 --- a/frontend/components/InvoiceCard.tsx +++ b/frontend/components/InvoiceCard.tsx @@ -16,30 +16,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; - assetIssuer?: 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 fd585f9e..a7ce57f9 100644 --- a/frontend/components/pay-page.types.ts +++ b/frontend/components/pay-page.types.ts @@ -1,61 +1,15 @@ -/** - * Shared types for the pay page modular architecture (issue #445). - */ - import type { PaymentState } from '@/lib/payment-page-state'; - -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'; /** - * Server pay-link artifact (issue #557). Create, the pay page, and the seller - * invoice page all render these same strings — they never rebuild the URI. + * 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 interface PayPagePaymentInfo { - stellarQrCode?: string | null; - /** The full SEP-0007 URI — always returned even when the QR falls back. */ - stellarUri?: string | null; - /** - * False when the URI outgrew the QR payload budget and the image encodes - * the HTTPS pay link instead (issue #510). - */ - stellarQrEncodesUri?: boolean | null; - paymentUrl?: string | null; - /** - * Whatever the QR encoded — the SEP-0007 URI or the HTTPS pay link. Copy - * actions on create, the pay page, and the seller page use this string. - */ - copyValue?: string | null; - /** Passphrase from the same resolver explorer links use. */ - networkPassphrase?: string | null; - statusPollingIntervalMs?: number; - paymentAvailable?: boolean; -} +export type PayPageInvoice = InvoiceDto; + +/** Payment instructions returned beside the invoice on the pay page. */ +export type PayPagePaymentInfo = PaymentInfoResult; export type PaymentSessionStatus = | 'loading' diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 83ef715d..216f5e0a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -9,6 +9,20 @@ import { resolveVerificationError } from './verification.js'; import { resolveStellarNetwork } from '@shared/network'; import { createBrowserRequestId } from './request-correlation-id.ts'; +import type { + ApiSuccess, + InvoiceStatsDto, + CancelInvoiceResponse, + CreateInvoiceRequest, + CreateInvoiceResponse, + GetInvoiceResponse, + GetStatsResponse, + ListInvoicesQuery, + ListInvoicesResponse, + PaymentInfoResponse, + VerifyPaymentResponse, +} from '../../shared/invoice-contract'; + /** * The API origin, resolved once per build. * @@ -61,57 +75,41 @@ api.interceptors.response.use( } ); +// Keep shared response types without making schema drift a runtime page gate. +// Contract tests validate the strict parsers; rolling client/server versions may +// omit optional fields or add new ones. Transport and server failures still +// reject through the interceptor above; request validation is unchanged. 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', { + const response = await api.post('/invoices', { ...data, assetCode: normalizedAssetCode, }); return response.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}`, { + const response = await api.get(`/invoices/${id}`, { params: sellerPublicKey ? { sellerPublicKey } : undefined, }); return response.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; - q?: string; - }) => { - const response = await api.get('/invoices', { params }); + getAll: async (params: ListInvoicesQuery): Promise => { + const response = await api.get('/invoices', { params }); return response.data; }, - - getPaymentInfo: async (id: string) => { - const response = await api.get(`/invoices/${id}/payment-info`); + getPaymentInfo: async (id: string): Promise => { + const response = await api.get(`/invoices/${id}/payment-info`); return response.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) => { @@ -123,29 +121,41 @@ 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 }); + cancel: async ( + id: string, + sellerPublicKey: string, + signature?: string + ): Promise => { + const response = await api.post(`/invoices/${id}/cancel`, { + sellerPublicKey, + signature, + }); return response.data; }, - - verify: async (id: string, txHash: string, payerInfo?: { payerName?: string; payerEmail?: string }) => { - const response = await api.post(`/invoices/${id}/verify`, { + 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; }, - - getStats: async (sellerPublicKey: string) => { - const response = await api.get('/invoices/stats', { + getStats: async (sellerPublicKey: string): Promise => { + const response = await api.get>('/invoices/stats', { params: { sellerPublicKey }, }); - return response.data; - }, + // Storage currently wraps stats in a one-row array. Keep the client's + // object shape without rejecting older partial responses or losing fields. + const body = response.data; + return Array.isArray(body.data) ? { ...body, data: body.data[0] } : body as GetStatsResponse; + } }; // Stellar APIs diff --git a/frontend/lib/export.ts b/frontend/lib/export.ts index f973cbe0..67afd00c 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'; @@ -63,34 +64,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', @@ -106,7 +81,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', @@ -153,7 +128,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'); @@ -176,7 +151,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); } @@ -463,7 +438,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) { let proof: QuittanceProof; if (isQuittanceProof(invoiceOrProof)) { proof = invoiceOrProof; @@ -492,12 +467,12 @@ 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( - invoiceOrProof: Invoice | QuittanceProof, + invoiceOrProof: InvoiceDto | QuittanceProof, baseUrl?: string, recipientOverride?: string ): string { 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/frontend/tests/wallet-session-surfaces.test.js b/frontend/tests/wallet-session-surfaces.test.js index c37b2124..f67d7f10 100644 --- a/frontend/tests/wallet-session-surfaces.test.js +++ b/frontend/tests/wallet-session-surfaces.test.js @@ -425,3 +425,23 @@ test('a disconnect mid-form keeps the draft and reconnects create without a relo getDom().window.sessionStorage.clear(); }); + +for (const shape of ['row array', 'object']) { + test(`dashboard preserves partial seller stats from a ${shape} response`, async () => { + walletOnTestnet(ALICE); + primeFor([invoice('inv_alice', ALICE, 11.11)]); + const stats = { total_invoices: 321, paid_invoices: 123, pending_invoices: 198 }; + bundle.setResponse('/invoices/stats', { data: shape === 'row array' ? [stats] : stats }); + + const { container, unmount } = await render(React.createElement(bundle.DashboardPage)); + try { + await settle(); + for (const value of [321, 123, 198]) { + assert.match(container.textContent, new RegExp(String(value))); + } + assert.doesNotMatch(container.textContent, /Quittance API is unreachable/); + } finally { + unmount(); + } + }); +} diff --git a/shared/invoice-contract.ts b/shared/invoice-contract.ts new file mode 100644 index 00000000..e3949216 --- /dev/null +++ b/shared/invoice-contract.ts @@ -0,0 +1,1212 @@ +/** + * 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'; + 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; + copyValue?: string | null; + networkPassphrase?: string | null; +} + +/** 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; + /** Search term supported by the current seller dashboard. */ + q?: 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; + copyValue?: string | null; + networkPassphrase?: string | null; +} + +/** 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); +} + +/** 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. + */ +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 (!isSuccessfulResponse(input)) { + 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, + copyValue: typeof raw.copyValue === 'string' ? raw.copyValue : null, + networkPassphrase: + typeof raw.networkPassphrase === 'string' ? raw.networkPassphrase : 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 (!isSuccessfulResponse(input)) { + 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. + * 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 +): 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 : 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 (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: input.pagination.limit as number, + offset: input.pagination.offset as number, + total: input.pagination.total as number, + }; + } + + 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 (!isSuccessfulResponse(input)) { + 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, + copyValue: typeof raw.copyValue === 'string' ? raw.copyValue : null, + networkPassphrase: + typeof raw.networkPassphrase === 'string' ? raw.networkPassphrase : 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 (!isSuccessfulResponse(input)) { + 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 (!isSuccessfulResponse(input)) { + 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 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)) { + return { success: false, error: 'Stats response must be an object' }; + } + + 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 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: { + ...counts, + revenue_by_asset: { ...revenue } as Record, + }, + }, + }; +} + +/** + * 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: 'q', + in: 'query', + required: false, + 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', 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', minimum: 0 }, + }, + }, + }, + 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' }, + copyValue: { type: 'string', nullable: true }, + networkPassphrase: { type: 'string', nullable: true }, + }, + }, + }, + }, + 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', + required: ['limit', 'offset', 'total'], + properties: { + 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 }, + }, + }, + }, + }, + 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' }, + copyValue: { type: 'string', nullable: true }, + networkPassphrase: { type: 'string', nullable: true }, + }, + }, + }, + }, + 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: { + oneOf: [ + { $ref: '#/components/schemas/InvoiceStatsDto' }, + { + type: 'array', + minItems: 1, + maxItems: 1, + items: { $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}`); + } + }); +});