From 7ff7266f58b0898c1e5319475c113a8d01f711b7 Mon Sep 17 00:00:00 2001 From: Iker Alustiza Date: Thu, 2 Jul 2026 19:03:52 +0200 Subject: [PATCH 1/2] feat: preserve method-specific receipt extension fields The core spec's Payment-Receipt section allows method specifications to define additional receipt fields. Parse receipts with a loose schema so unknown fields survive from/serialize/deserialize round trips instead of being stripped, keeping the exported Receipt type unchanged (base fields only, no index signature). --- src/Receipt.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++ src/Receipt.ts | 39 ++++++++++++++++++++++----------- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/Receipt.test.ts b/src/Receipt.test.ts index ea23b052..ec22c3a8 100644 --- a/src/Receipt.test.ts +++ b/src/Receipt.test.ts @@ -30,6 +30,42 @@ describe('from', () => { }), ).toThrow() }) + + test('behavior: preserves method-specific extension fields', () => { + const receipt = Receipt.from({ + method: 'nearintents', + reference: 'FtChYxxQh1k6vKjQ9wq5q1f8s2n3p4r5t6u7v8w9x0yz', + status: 'success', + timestamp: '2025-01-21T12:00:00.000Z', + challengeId: 'qB3wErTyU7iOpAsD9fGhJk', + originTxHash: '0x9bcff372aee89b648c922b850573b22387c31d693079f5e37cd255814e2d615a', + destinationNetwork: 'near:mainnet', + }) + + expect(receipt).toMatchInlineSnapshot(` + { + "challengeId": "qB3wErTyU7iOpAsD9fGhJk", + "destinationNetwork": "near:mainnet", + "method": "nearintents", + "originTxHash": "0x9bcff372aee89b648c922b850573b22387c31d693079f5e37cd255814e2d615a", + "reference": "FtChYxxQh1k6vKjQ9wq5q1f8s2n3p4r5t6u7v8w9x0yz", + "status": "success", + "timestamp": "2025-01-21T12:00:00.000Z", + } + `) + }) + + test('error: rejects invalid base field even with extension fields present', () => { + expect(() => + Receipt.from({ + method: 'nearintents', + reference: 'FtChYxxQh1k6vKjQ9wq5q1f8s2n3p4r5t6u7v8w9x0yz', + status: 'failed' as 'success', + timestamp: '2025-01-21T12:00:00.000Z', + originTxHash: '0x9bcff372aee89b648c922b850573b22387c31d693079f5e37cd255814e2d615a', + }), + ).toThrow() + }) }) describe('serialize', () => { @@ -67,6 +103,23 @@ describe('deserialize', () => { }) }) +describe('serialize + deserialize', () => { + test('behavior: round-trips method-specific extension fields', () => { + const receipt = Receipt.from({ + method: 'nearintents', + reference: 'FtChYxxQh1k6vKjQ9wq5q1f8s2n3p4r5t6u7v8w9x0yz', + status: 'success', + timestamp: '2025-01-21T12:00:00.000Z', + externalId: 'order_12345', + challengeId: 'qB3wErTyU7iOpAsD9fGhJk', + originTxHash: '0x9bcff372aee89b648c922b850573b22387c31d693079f5e37cd255814e2d615a', + destinationNetwork: 'near:mainnet', + }) + + expect(Receipt.deserialize(Receipt.serialize(receipt))).toEqual(receipt) + }) +}) + describe('fromResponse', () => { test('behavior: extracts receipt from Payment-Receipt header', () => { const encoded = diff --git a/src/Receipt.ts b/src/Receipt.ts index 975320f9..6211dfdd 100644 --- a/src/Receipt.ts +++ b/src/Receipt.ts @@ -3,17 +3,7 @@ import { Base64 } from 'ox' import * as Constants from './Constants.js' import * as z from './zod.js' -/** - * Schema for a payment receipt. - * - * @example - * ```ts - * import { Receipt } from 'mppx' - * - * const receipt = Receipt.Schema.parse(data) - * ``` - */ -export const Schema = z.object({ +const shape = { /** Payment method used (e.g., "tempo", "stripe"). */ method: z.string(), /** Method-specific reference (e.g., transaction hash). */ @@ -26,11 +16,34 @@ export const Schema = z.object({ status: z.literal('success'), /** RFC 3339 settlement timestamp. */ timestamp: z.datetime(), -}) +} + +/** Base-field schema used only to derive the {@link Receipt} type without an index signature. */ +const BaseSchema = z.object(shape) + +/** + * Schema for a payment receipt. + * + * Method specifications may define additional receipt fields beyond the + * base set (per the core spec's Payment-Receipt section); unknown fields + * are preserved through parse/serialize round-trips rather than stripped. + * + * @example + * ```ts + * import { Receipt } from 'mppx' + * + * const receipt = Receipt.Schema.parse(data) + * ``` + */ +export const Schema = z.looseObject(shape) /** * Payment receipt returned after verification. * + * Method-specific extension fields are preserved at runtime but not part of + * this base type; method packages can type them via intersection + * (e.g. `Receipt.Receipt & { originTxHash: string }`). + * * @example * ```ts * import { Receipt } from 'mppx' @@ -43,7 +56,7 @@ export const Schema = z.object({ * } * ``` */ -export type Receipt = z.infer +export type Receipt = z.infer /** * Deserializes a Payment-Receipt header value to a receipt. From acc4cca2ef30ab0fd5f3e19bda1aaf352e390b94 Mon Sep 17 00:00:00 2001 From: Iker <34474035+IkerAlus@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:34:07 +0200 Subject: [PATCH 2/2] Add changeset --- .changeset/receipt-extension-fields.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/receipt-extension-fields.md diff --git a/.changeset/receipt-extension-fields.md b/.changeset/receipt-extension-fields.md new file mode 100644 index 00000000..239020f3 --- /dev/null +++ b/.changeset/receipt-extension-fields.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Preserved method-specific extension fields on receipts. `Receipt.from`, `Receipt.deserialize`, and `Receipt.fromResponse` previously stripped fields outside the base set; they now pass unknown fields through, per the core spec's Payment-Receipt provision ("Payment method specifications MAY define additional fields for receipts").