diff --git a/backend/src/utils/payment-event-redaction.ts b/backend/src/utils/payment-event-redaction.ts index 0c1bf95f..4d303b0f 100644 --- a/backend/src/utils/payment-event-redaction.ts +++ b/backend/src/utils/payment-event-redaction.ts @@ -5,9 +5,12 @@ * keys before rows leave the server so the audit read can never become a PII * side channel. Public keys, tx hashes, codes and amounts are kept — they are * on-chain data the seller can already see. + * + * Issue #559: the match list is shared with `IDENTITY_INVOICE_KEY_PATTERN` so + * a new identity key cannot leak through events while the pay DTO stay clean. */ -const REDACTED_KEY = /memo|email|customername|payername|sellername|description/i; +import { IDENTITY_INVOICE_KEY_PATTERN } from '../../../shared/invoice'; export function redactPaymentEventData( data: Record | null | undefined @@ -15,7 +18,7 @@ export function redactPaymentEventData( if (!data || typeof data !== 'object' || Array.isArray(data)) return null; const clean: Record = {}; for (const [key, value] of Object.entries(data)) { - if (REDACTED_KEY.test(key)) continue; + if (IDENTITY_INVOICE_KEY_PATTERN.test(key)) continue; clean[key] = value && typeof value === 'object' && !Array.isArray(value) ? redactPaymentEventData(value as Record) diff --git a/backend/tests/invoice-handlers.test.ts b/backend/tests/invoice-handlers.test.ts index 177bafd8..29cf33f5 100644 --- a/backend/tests/invoice-handlers.test.ts +++ b/backend/tests/invoice-handlers.test.ts @@ -8,7 +8,7 @@ import { PostgresInvoiceStorage } from '../src/storage/postgres-invoice-storage. import { InvoiceService } from '../src/services/invoice.service.ts'; import memoryStorage from '../src/storage/memory-storage.ts'; import type { InvoiceStorage } from '../src/storage/invoice-storage.ts'; -import { PUBLIC_INVOICE_FIELDS } from '../../shared/invoice.ts'; +import { PUBLIC_INVOICE_FIELDS, SELLER_ONLY_INVOICE_FIELDS } from '../../shared/invoice.ts'; const SELLER_A = 'GB3Q3VRHH3OQDYITTLONDLEHWQGKB27T2BEDSFHIUMOERULVXPDXRKG4'; const SELLER_B = 'GB6IHEZ4QNOHJZRYRFLOC45P4SK3KKL6KNPI5WEG6FNVSZ2K5FS2MNY7'; @@ -581,18 +581,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage }); describe('public pay DTO (issue #503)', () => { - const PII_KEYS = [ - 'customerName', - 'customerEmail', - 'sellerName', - 'sellerEmail', - 'payerPublicKey', - 'payerName', - 'payerEmail', - 'description', - 'metadata', - 'userId', - ]; + const PII_KEYS = [...SELLER_ONLY_INVOICE_FIELDS]; it('returns the public shape to an anonymous caller — no client or identity fields', async () => { const created = await createInvoice({ @@ -682,7 +671,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage it('keeps the verify response on the public shape', async () => { const created = await createInvoice({ customerEmail: 'pay@client.example' }); transaction = { - transaction: { memo: created.memo }, + transaction: { memo: created.memo, created_at: new Date().toISOString() }, operations: [ { type: 'payment', diff --git a/backend/tests/log-events.test.ts b/backend/tests/log-events.test.ts index 1fbc5e77..116174f3 100644 --- a/backend/tests/log-events.test.ts +++ b/backend/tests/log-events.test.ts @@ -6,6 +6,7 @@ import { logReference, requiredLogFields, } from '../src/observability/log-events'; +import { SELLER_ONLY_INVOICE_FIELDS } from '../../shared/invoice.ts'; describe('structured log event contract', () => { it('keeps the invoice loop taxonomy stable', () => { @@ -80,4 +81,28 @@ describe('structured log event contract', () => { } } }); + + it('drops every seller-only identity key even when stuffed into fields (#559)', () => { + const stuffed: Record = { + invoiceRef: 'inv-a1', + txRef: 'tx-b2', + errorCode: 'MEMO_MISMATCH', + network: 'TESTNET', + durationMs: 17, + }; + for (const key of SELLER_ONLY_INVOICE_FIELDS) { + stuffed[key] = `leak-${key}`; + } + const record = buildLogRecord( + 'warn', + 'payment.verify.rejected', + { requestId: 'req-559', service: 'api', environment: 'test' }, + stuffed, + new Date('2026-09-13T10:00:00.000Z') + ); + for (const key of SELLER_ONLY_INVOICE_FIELDS) { + assert.equal(record[key], undefined, `log record leaked ${key}`); + } + }); + }); diff --git a/backend/tests/public-invoice-whitelist.test.ts b/backend/tests/public-invoice-whitelist.test.ts new file mode 100644 index 00000000..354a6b3f --- /dev/null +++ b/backend/tests/public-invoice-whitelist.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + PUBLIC_INVOICE_FIELDS, + SELLER_ONLY_INVOICE_FIELDS, + isPublicInvoiceField, + isSellerOnlyInvoiceField, + omitSellerOnlyFields, + toPublicInvoiceDto, +} from '../../shared/invoice.ts'; + +/** + * Issue #559 — the public allowlist and seller-only denylist are the contract. + * Each key is named here so removing it from the shared constant fails this file. + */ +describe('public invoice whitelist (issue #559)', () => { + const EXPECTED_PUBLIC_FIELDS = [ + 'id', + 'sellerPublicKey', + 'amount', + 'assetCode', + 'assetIssuer', + 'memo', + 'status', + 'paymentTxHash', + 'latePaymentWarningCode', + 'settlementContext', + 'priorStatus', + 'createdAt', + 'paidAt', + 'cancelledAt', + 'settledAt', + 'expiresAt', + ] as const; + + const EXPECTED_SELLER_ONLY_FIELDS = [ + 'customerName', + 'customerEmail', + 'sellerName', + 'sellerEmail', + 'payerPublicKey', + 'payerName', + 'payerEmail', + 'description', + 'metadata', + 'userId', + ] as const; + + it('PUBLIC_INVOICE_FIELDS lists every expected public pay key', () => { + assert.deepEqual([...PUBLIC_INVOICE_FIELDS], [...EXPECTED_PUBLIC_FIELDS]); + }); + + for (const key of EXPECTED_PUBLIC_FIELDS) { + it(`whitelist includes public key: ${key}`, () => { + assert.equal(isPublicInvoiceField(key), true, `missing public key ${key}`); + assert.ok( + (PUBLIC_INVOICE_FIELDS as readonly string[]).includes(key), + `PUBLIC_INVOICE_FIELDS dropped ${key}` + ); + }); + } + + it('SELLER_ONLY_INVOICE_FIELDS lists every forbidden identity key', () => { + assert.deepEqual([...SELLER_ONLY_INVOICE_FIELDS], [...EXPECTED_SELLER_ONLY_FIELDS]); + }); + + for (const key of EXPECTED_SELLER_ONLY_FIELDS) { + it(`denylist includes seller-only key: ${key}`, () => { + assert.equal(isSellerOnlyInvoiceField(key), true, `missing seller-only key ${key}`); + assert.equal( + isPublicInvoiceField(key), + false, + `seller-only key ${key} must not be public` + ); + }); + } + + it('toPublicInvoiceDto drops every seller-only key even when present on input', () => { + const publicDto = toPublicInvoiceDto({ + id: 'inv_1', + sellerPublicKey: 'G'.padEnd(56, 'A'), + amount: 10, + assetCode: 'XLM', + memo: 'INV-TEST', + status: 'PENDING', + createdAt: '2026-09-01T00:00:00.000Z', + expiresAt: '2026-09-08T00:00:00.000Z', + customerName: 'Client Co', + customerEmail: 'pay@client.example', + sellerName: 'Studio', + sellerEmail: 'studio@example.com', + payerName: 'Percy', + payerEmail: 'percy@payer.example', + payerPublicKey: 'G'.padEnd(56, 'P'), + description: 'secret work', + metadata: { note: 'nope' }, + userId: 'user_1', + } as any); + + for (const key of EXPECTED_SELLER_ONLY_FIELDS) { + assert.equal( + (publicDto as Record)[key], + undefined, + `public DTO leaked ${key}` + ); + } + for (const key of Object.keys(publicDto)) { + assert.ok( + (PUBLIC_INVOICE_FIELDS as readonly string[]).includes(key), + `public DTO carries non-whitelisted key ${key}` + ); + } + }); + + it('omitSellerOnlyFields strips denylisted keys from an arbitrary payload', () => { + const cleaned = omitSellerOnlyFields({ + id: 'inv_1', + amount: 5, + customerEmail: 'leak@example.com', + payerName: 'Leak', + memo: 'INV-KEEP', + userId: 'u1', + }); + assert.equal('customerEmail' in cleaned, false); + assert.equal('payerName' in cleaned, false); + assert.equal('userId' in cleaned, false); + assert.equal(cleaned.memo, 'INV-KEEP'); + assert.equal(cleaned.amount, 5); + }); +}); diff --git a/docs/QUITTANCE_PROOF_SPEC.md b/docs/QUITTANCE_PROOF_SPEC.md index 154de8bc..f31fdb11 100644 --- a/docs/QUITTANCE_PROOF_SPEC.md +++ b/docs/QUITTANCE_PROOF_SPEC.md @@ -51,9 +51,11 @@ Given identical inputs and clock injection, the serialization output must be byt Settlement records link exactly one payer account (`G...`) to one recipient seller account (`G...`). Multi-party or list structures for payers are rejected. ### Invariant 6: Anti-Leak / Zero-PII -Proof documents and rendered exports must never contain: +Proof documents, mailto bodies, payment-event payloads, and structured logs must never contain: - Stellar secret keys (matching regex `S[A-Z2-7]{55}`). -- Personally Identifiable Information (such as payer email addresses or real names). +- Personally Identifiable Information (client email, payer email, payer name, seller profile fields). + +Issue #559: `PUBLIC_INVOICE_FIELDS` in `shared/invoice.ts` is the single allowlist for anonymous pay, payment-info, and verify responses. `SELLER_ONLY_INVOICE_FIELDS` names the identity keys that must stay off those surfaces, proof HTML, mailto bodies, and redacted event/log payloads. Seller workspace reads gated by the invoice Freighter wallet remain the only path that returns client contact. ### Invariant 7: No Inferred Ownership Unsettled invoices or invoices lacking explicit payer keys must have `payer: null`. The system must never guess, infer, or populate default payer addresses. diff --git a/frontend/components/PayAmountBlock.tsx b/frontend/components/PayAmountBlock.tsx index dc45bae9..c74cf233 100644 --- a/frontend/components/PayAmountBlock.tsx +++ b/frontend/components/PayAmountBlock.tsx @@ -21,14 +21,6 @@ export default function PayAmountBlock({ invoice }: { invoice: PayPageInvoice }) - {invoice.description &&

Payment for{invoice.description}

} - {(invoice.sellerName || invoice.sellerEmail) && ( -
- Seller - {invoice.sellerName &&

{invoice.sellerName}

} - {invoice.sellerEmail &&

{invoice.sellerEmail}

} -
- )} {invoice.status === 'PENDING' && invoice.expiresAt && (

Expires in{getTimeRemaining(invoice.expiresAt)}

diff --git a/frontend/components/PayProofPanel.tsx b/frontend/components/PayProofPanel.tsx index a0fff322..7ac1c85e 100644 --- a/frontend/components/PayProofPanel.tsx +++ b/frontend/components/PayProofPanel.tsx @@ -15,11 +15,26 @@ export default function PayProofPanel({ invoice, onDownload, onEmail }: PayProof
- -
diff --git a/frontend/components/PaymentReceipt.tsx b/frontend/components/PaymentReceipt.tsx index 17252222..d43cc405 100644 --- a/frontend/components/PaymentReceipt.tsx +++ b/frontend/components/PaymentReceipt.tsx @@ -67,9 +67,6 @@ PAYMENT DETAILS ─────────────────────────────────────── Amount Paid: ${canonicalAmount(invoice.amount) ?? formatAmount(invoice.amount, 7)} ${invoice.assetCode} -${invoice.description ? `Description: ${invoice.description}` : ''} -${invoice.customerName ? `Customer: ${invoice.customerName}` : ''} -${invoice.customerEmail ? `Email: ${invoice.customerEmail}` : ''} ─────────────────────────────────────── TRANSACTION DETAILS @@ -79,7 +76,7 @@ Transaction Hash: ${invoice.paymentTxHash} From (Payer): -${invoice.payerPublicKey || 'N/A'} +On-chain counterparty (see transaction) To (Recipient): ${invoice.sellerPublicKey} @@ -119,7 +116,7 @@ Stellar Blockchain Payment System invoice.paymentTxHash, resolveExplorerNetwork(invoice) ); - const canEmail = Boolean(invoice.customerEmail); + const canEmail = canSendProofEmail(invoice as any); const proofRecipient = getProofMailtoRecipient(invoice as any); const emailReasonId = 'receipt-email-reason'; @@ -177,12 +174,6 @@ Stellar Blockchain Payment System - {invoice.description && ( -
-

Payment For

-

{invoice.description}

-
- )}
@@ -198,45 +189,8 @@ Stellar Blockchain Payment System
- {(invoice.sellerName || invoice.sellerEmail) && ( -
-

Seller Information

- {invoice.sellerName && ( -
-

Name

-

{invoice.sellerName}

-
- )} - {invoice.sellerEmail && ( -
-

Email

-

{invoice.sellerEmail}

-
- )} -
- )} - {invoice.sellerName && ( -
-

Paid to

-

{invoice.sellerName}

- {invoice.sellerEmail && ( -

{invoice.sellerEmail}

- )} -
- )} - {(invoice.payerName || invoice.payerEmail) && ( -
-

Paid by

- {invoice.payerName && ( -

{invoice.payerName}

- )} - {invoice.payerEmail && ( -

{invoice.payerEmail}

- )} -
- )}
@@ -248,12 +202,6 @@ Stellar Blockchain Payment System

{invoice.paymentTxHash}

- {invoice.payerPublicKey && ( -
-

From (Payer Address)

-

{invoice.payerPublicKey}

-
- )}

To (Recipient Address)

@@ -293,7 +241,7 @@ Stellar Blockchain Payment System {!canEmail && (

- Unavailable: this invoice has no client email. + Unavailable: no client email on public invoice. Emailing proof is available from the seller workspace after connecting the invoice wallet.

)} diff --git a/frontend/components/pay-page.types.ts b/frontend/components/pay-page.types.ts index 68a139b7..65378f31 100644 --- a/frontend/components/pay-page.types.ts +++ b/frontend/components/pay-page.types.ts @@ -1,30 +1,21 @@ -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; +import type { PublicInvoiceDto } from '@shared/invoice'; + +/** + * Invoice shape the `/pay/[id]` page is allowed to read (issue #559). + * + * Mirrors `PublicInvoiceDto` with the pay-page settlement unions kept narrow. + * There is no index signature and no seller-only fields — accessing + * `customerEmail` (or any other workspace key) is a TypeScript error, so a + * leaked key fails the frontend typecheck rather than silently rendering. + */ +export type PayPageInvoice = Omit< + PublicInvoiceDto, + 'settlementContext' | 'priorStatus' | 'latePaymentWarningCode' +> & { 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; -} +}; export interface PayPagePaymentInfo { stellarQrCode?: string; diff --git a/frontend/lib/mailto-delivery.js b/frontend/lib/mailto-delivery.js index 16ac0cef..fae3a12f 100644 --- a/frontend/lib/mailto-delivery.js +++ b/frontend/lib/mailto-delivery.js @@ -143,12 +143,6 @@ function buildProofMailto(invoice, baseUrl) { if (invoice.payerPublicKey) { lines.push(`Payer Address: ${invoice.payerPublicKey}`); } - if (invoice.customerName) { - lines.push(`Client Name: ${invoice.customerName}`); - } - if (invoice.payerName) { - lines.push(`Payer Name: ${invoice.payerName}`); - } if (invoice.memo) { lines.push(`Memo: ${invoice.memo}`); } diff --git a/frontend/lib/payment-page-state.d.ts b/frontend/lib/payment-page-state.d.ts index 3106a23a..6bfc57b5 100644 --- a/frontend/lib/payment-page-state.d.ts +++ b/frontend/lib/payment-page-state.d.ts @@ -6,7 +6,11 @@ export interface PayInvoice { status: string; expiresAt?: string | Date; paymentTxHash?: string | null; - [key: string]: unknown; + amount?: number; + assetCode?: string; + memo?: string; + id?: string; + sellerPublicKey?: string; } export interface PaymentState { diff --git a/frontend/tests/mailto-delivery.test.js b/frontend/tests/mailto-delivery.test.js index a777b2f6..a3727806 100644 --- a/frontend/tests/mailto-delivery.test.js +++ b/frontend/tests/mailto-delivery.test.js @@ -128,8 +128,13 @@ test('buildProofMailto generates a properly encoded mailto URL with proof detail assert.ok(mailto.includes(encodeURIComponent(`Transaction Hash: ${'a'.repeat(64)}`))); assert.ok(mailto.includes(encodeURIComponent(`Seller Address: ${paidInvoice.sellerPublicKey}`))); assert.ok(mailto.includes(encodeURIComponent(`Payer Address: ${paidInvoice.payerPublicKey}`))); - assert.ok(mailto.includes(encodeURIComponent('Client Name: Alice Customer'))); - assert.ok(mailto.includes(encodeURIComponent('Payer Name: Alice Payer'))); + // Issue #559: proof mailto bodies omit client/payer identity. The To: + // address may still carry the recipient for delivery; only the body is checked. + const proofBody = decodeURIComponent(mailto.split('body=')[1] || ''); + assert.equal(proofBody.includes('Client Name:'), false); + assert.equal(proofBody.includes('Payer Name:'), false); + assert.equal(proofBody.includes('alice@example.com'), false); + assert.equal(proofBody.includes('alice.payer@example.com'), false); assert.ok(mailto.includes(encodeURIComponent('Verified on Stellar Blockchain'))); assert.ok(mailto.includes(encodeURIComponent('View Proof / Payment Details: https://quittance.example.com/pay/01234567-89ab-cdef-0123-456789abcdef'))); }); @@ -150,3 +155,24 @@ test('buildProofMailto throws when invoice is not paid or expired', () => { /Client or payer email is required to email payment proof/ ); }); + + +test('buildProofMailto body omits client email, payer email, and payer name (#559)', () => { + const mailto = buildProofMailto( + { + ...paidInvoice, + customerName: 'Should Not Appear', + customerEmail: 'client-secret@example.com', + payerName: 'Secret Payer', + payerEmail: 'payer-secret@example.com', + }, + 'https://quittance.example.com' + ); + const body = decodeURIComponent(mailto.split('body=')[1] || ''); + assert.equal(body.includes('Should Not Appear'), false); + assert.equal(body.includes('Secret Payer'), false); + assert.equal(body.includes('client-secret@example.com'), false); + assert.equal(body.includes('payer-secret@example.com'), false); + // Recipient To: still uses customerEmail for delivery — that is not the body. + assert.ok(mailto.startsWith('mailto:client-secret%40example.com?')); +}); diff --git a/frontend/tests/pay-page-public-dto.test.js b/frontend/tests/pay-page-public-dto.test.js new file mode 100644 index 00000000..56a744b5 --- /dev/null +++ b/frontend/tests/pay-page-public-dto.test.js @@ -0,0 +1,96 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * Issue #559 — pay page fixtures may only carry public fields. The lists are + * read from shared/invoice.ts as source text (no TS loader required) so each + * named key failing here means it was removed from the shared constant. + */ +function readSharedArray(constName) { + const source = fs.readFileSync( + path.join(__dirname, '../../shared/invoice.ts'), + 'utf8' + ); + const start = source.indexOf(`export const ${constName}`); + assert.ok(start >= 0, `shared/invoice.ts must export ${constName}`); + const bracket = source.indexOf('[', start); + const end = source.indexOf(']', bracket); + const block = source.slice(bracket, end + 1); + return [...block.matchAll(/'([^']+)'/g)].map((m) => m[1]); +} + +const PUBLIC_INVOICE_FIELDS = readSharedArray('PUBLIC_INVOICE_FIELDS'); +const SELLER_ONLY_INVOICE_FIELDS = readSharedArray('SELLER_ONLY_INVOICE_FIELDS'); + +test('pay page fixture rejects every seller-only key (#559)', () => { + /** @type {Record} */ + const payPageFixture = { + id: 'inv_pay', + sellerPublicKey: 'G'.padEnd(56, 'S'), + amount: 42.5, + assetCode: 'XLM', + memo: 'INV-PAY', + status: 'PENDING', + createdAt: '2026-09-01T00:00:00.000Z', + expiresAt: '2026-09-08T00:00:00.000Z', + }; + + for (const key of [ + 'id', + 'sellerPublicKey', + 'amount', + 'assetCode', + 'memo', + 'status', + 'createdAt', + 'expiresAt', + ]) { + assert.notEqual(payPageFixture[key], undefined, `fixture missing public key ${key}`); + } + + for (const key of SELLER_ONLY_INVOICE_FIELDS) { + assert.equal( + Object.prototype.hasOwnProperty.call(payPageFixture, key), + false, + `pay page fixture leaked seller-only key ${key}` + ); + assert.equal( + PUBLIC_INVOICE_FIELDS.includes(key), + false, + `seller-only key ${key} must stay off PUBLIC_INVOICE_FIELDS` + ); + } +}); + +for (const key of [ + 'id', + 'sellerPublicKey', + 'amount', + 'assetCode', + 'memo', + 'status', + 'expiresAt', + 'paymentTxHash', + 'createdAt', +]) { + test(`PUBLIC_INVOICE_FIELDS still names ${key}`, () => { + assert.ok(PUBLIC_INVOICE_FIELDS.includes(key), `whitelist dropped ${key}`); + }); +} + +for (const key of [ + 'customerEmail', + 'customerName', + 'payerEmail', + 'payerName', + 'sellerEmail', + 'sellerName', + 'description', + 'metadata', +]) { + test(`SELLER_ONLY_INVOICE_FIELDS still names ${key}`, () => { + assert.ok(SELLER_ONLY_INVOICE_FIELDS.includes(key), `denylist dropped ${key}`); + }); +} diff --git a/shared/invoice.ts b/shared/invoice.ts index 5c3a60c8..48ff466b 100644 --- a/shared/invoice.ts +++ b/shared/invoice.ts @@ -128,3 +128,56 @@ export function toPublicInvoiceDto(invoice: { expiresAt: iso(invoice.expiresAt) as IsoTimestamp, }; } + + +/** + * Keys that must never appear on a public pay / verify / payment-info / + * proof / mailto / log surface (issue #559). Seller workspace reads gated by + * the invoice's Freighter wallet remain the only path that returns them. + * + * `PUBLIC_INVOICE_FIELDS` is the allowlist; this is the denylist of + * identity-shaped keys the contract tests name so dropping one from the + * allowlist (or adding one here without covering a surface) fails loudly. + */ +export const SELLER_ONLY_INVOICE_FIELDS = [ + 'customerName', + 'customerEmail', + 'sellerName', + 'sellerEmail', + 'payerPublicKey', + 'payerName', + 'payerEmail', + 'description', + 'metadata', + 'userId', +] as const; + +export type SellerOnlyInvoiceField = (typeof SELLER_ONLY_INVOICE_FIELDS)[number]; + +/** Identity-shaped key names redacted from events, proof bodies, and logs. */ +export const IDENTITY_INVOICE_KEY_PATTERN = + /memo|email|customername|payername|sellername|description|metadata|userid/i; + +export function isSellerOnlyInvoiceField(key: string): boolean { + return (SELLER_ONLY_INVOICE_FIELDS as readonly string[]).includes(key); +} + +export function isPublicInvoiceField(key: string): boolean { + return (PUBLIC_INVOICE_FIELDS as readonly string[]).includes(key); +} + +/** + * Drop seller-only / identity-shaped keys from an arbitrary payload. + * Used by proof HTML, mailto bodies, and any surface that might be handed a + * full workspace invoice by mistake. + */ +export function omitSellerOnlyFields>( + input: T +): Omit { + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (isSellerOnlyInvoiceField(key)) continue; + out[key] = value; + } + return out as Omit; +}