diff --git a/apps/api/src/__tests__/Charge.spec.ts b/apps/api/src/__tests__/Charge.spec.ts index 777d833..7ad3554 100644 --- a/apps/api/src/__tests__/Charge.spec.ts +++ b/apps/api/src/__tests__/Charge.spec.ts @@ -12,6 +12,8 @@ import { GetFixedTimestamp, } from './Setup'; +const mockDashboardUrl = 'http://localhost:4200'; + jest.mock('../modules/Database'); jest.mock('../utils/IdGenerator', () => ({ GenerateId: jest.fn((prefix: string) => DeterministicId(prefix)), @@ -21,7 +23,7 @@ jest.mock('../utils/Timestamp', () => ({ })); jest.mock('../modules/AppConfig', () => ({ GetAppConfig: jest.fn(() => ({ - dashboardUrl: 'http://localhost:4200', + dashboardUrl: mockDashboardUrl, livemode: false, appSecret: 'test-secret', })), @@ -103,6 +105,10 @@ describe('ChargeModule', () => { transaction_hash: null, }, }); + expect(charge.receipt_number).toBe(`rcpt_${charge.id}`); + expect(charge.receipt_url).toBe( + `${mockDashboardUrl}/v1/receipts/${charge.id}` + ); expect(charge.refunded).toBe(false); expect(charge.refunds).toEqual({ object: 'list', @@ -199,6 +205,11 @@ describe('ChargeModule', () => { expect(mockDb.Set).toHaveBeenCalledWith('Charges', charge.id, charge); expect(charge.status).toBe('succeeded'); expect(charge.captured).toBe(true); + expect(charge.receipt_number).toEqual(expect.any(String)); + expect(charge.receipt_number).toBe(`rcpt_${charge.id}`); + expect(charge.receipt_url).toBe( + `${mockDashboardUrl}/v1/receipts/${charge.id}` + ); expect(eventService.Emit).toHaveBeenCalledWith( 'charge.succeeded', 'acct_z_platform', @@ -832,6 +843,10 @@ describe('ChargeModule', () => { expect(charge.status).toBe('succeeded'); expect(charge.captured).toBe(true); expect(charge.payment_intent).toBe('pi_z_1'); + expect(charge.receipt_number).toBe(`rcpt_${charge.id}`); + expect(charge.receipt_url).toBe( + `${mockDashboardUrl}/v1/receipts/${charge.id}` + ); expect(charge.payment_method_details).toEqual({ type: 'crypto', crypto: { diff --git a/apps/api/src/__tests__/Receipt.spec.ts b/apps/api/src/__tests__/Receipt.spec.ts new file mode 100644 index 0000000..40f4af2 --- /dev/null +++ b/apps/api/src/__tests__/Receipt.spec.ts @@ -0,0 +1,182 @@ +import express from 'express'; +import { AddressInfo } from 'net'; +import { Server } from 'http'; +import receiptsRouter from '../routes/receipts.routes'; +import { db } from '../modules/Database'; +import { Charge } from '@zoneless/shared-types'; + +const TEST_DASHBOARD_URL = 'http://localhost:4200'; + +describe('Receipt routes', () => { + let server: Server; + let baseUrl: string; + + beforeEach(async () => { + const app = express(); + app.use('/v1/receipts', receiptsRouter); + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, () => resolve(listeningServer)); + }); + + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('should return HTML for a valid Charge receipt', async () => { + jest.spyOn(db, 'Get').mockResolvedValue(ChargeFixture()); + + const response = await fetch(`${baseUrl}/v1/receipts/ch_z_receipt`); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(html).toContain('Payment receipt'); + expect(html).toContain('12.34 USDC'); + expect(html).toContain('ch_z_receipt'); + expect(html).toContain('rcpt_ch_z_receipt'); + expect(html).toContain('Hosted checkout payment'); + expect(html).toContain('sig_123'); + expect(html).toContain('https://explorer.solana.com/tx/sig_123'); + }); + + it('should render pending, failed, and refunded receipt states', async () => { + const cases: Array<{ + charge: Charge; + expectedLabel: string; + expectedMessage: string; + }> = [ + { + charge: ChargeFixture({ + captured: false, + status: 'pending', + amount_captured: 0, + }), + expectedLabel: 'Pending', + expectedMessage: 'This payment is still processing.', + }, + { + charge: ChargeFixture({ + captured: false, + paid: false, + status: 'failed', + amount_captured: 0, + }), + expectedLabel: 'Failed', + expectedMessage: 'This payment could not be completed.', + }, + { + charge: ChargeFixture({ + refunded: true, + amount_refunded: 1234, + }), + expectedLabel: 'Refunded', + expectedMessage: 'This payment has been refunded.', + }, + { + charge: ChargeFixture({ + amount_refunded: 500, + }), + expectedLabel: 'Partially refunded', + expectedMessage: 'Part of this payment has been refunded.', + }, + ]; + + for (const testCase of cases) { + jest.spyOn(db, 'Get').mockResolvedValueOnce(testCase.charge); + + const response = await fetch(`${baseUrl}/v1/receipts/ch_z_receipt`); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain(testCase.expectedLabel); + expect(html).toContain(testCase.expectedMessage); + } + }); + + it('should return a clean 404 page when the Charge is missing', async () => { + jest.spyOn(db, 'Get').mockResolvedValue(null); + + const response = await fetch(`${baseUrl}/v1/receipts/ch_z_missing`); + const html = await response.text(); + + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(html).toContain('Receipt not found'); + expect(html).toContain('could not find a receipt'); + }); +}); + +function ChargeFixture(overrides: Partial = {}): Charge { + const charge: Charge = { + id: 'ch_z_receipt', + object: 'charge', + amount: 1234, + amount_captured: 1234, + amount_refunded: 0, + application: null, + application_fee: null, + application_fee_amount: null, + balance_transaction: null, + billing_details: { + address: null, + email: null, + name: null, + phone: null, + tax_id: null, + }, + calculated_statement_descriptor: null, + captured: true, + created: 1700000000, + currency: 'usdc', + customer: null, + description: 'Hosted checkout payment', + disputed: false, + failure_balance_transaction: null, + failure_code: null, + failure_message: null, + fraud_details: {}, + livemode: false, + metadata: {}, + on_behalf_of: null, + outcome: null, + paid: true, + payment_intent: 'pi_z_receipt', + payment_method: 'PayerWallet111', + payment_method_details: { + type: 'crypto', + crypto: { + buyer_address: 'PayerWallet111', + fingerprint: null, + network: 'solana', + token_currency: 'usdc', + transaction_hash: 'sig_123', + }, + }, + presentment_details: null, + radar_options: null, + receipt_email: null, + receipt_number: 'rcpt_ch_z_receipt', + receipt_url: `${TEST_DASHBOARD_URL}/v1/receipts/ch_z_receipt`, + refunded: false, + refunds: null, + review: null, + shipping: null, + source_transfer: null, + statement_descriptor: null, + statement_descriptor_suffix: null, + status: 'succeeded', + transfer: null, + transfer_data: null, + transfer_group: null, + platform_account: 'acct_z_platform', + }; + return { ...charge, ...overrides }; +} diff --git a/apps/api/src/modules/Charge.ts b/apps/api/src/modules/Charge.ts index 55d5115..a287cb7 100644 --- a/apps/api/src/modules/Charge.ts +++ b/apps/api/src/modules/Charge.ts @@ -37,6 +37,7 @@ import { GetAppConfig } from './AppConfig'; import { AppError } from '../utils/AppError'; import { ERRORS } from '../utils/Errors'; import { GetPlatformAccountId } from './PlatformAccess'; +import { BuildChargeReceiptNumber, BuildChargeReceiptUrl } from './Receipt'; type AddressInput = NonNullable< NonNullable['address'] @@ -289,8 +290,8 @@ export class ChargeModule { presentment_details: null, radar_options: null, receipt_email: input.receipt_email ?? null, - receipt_number: null, - receipt_url: null, + receipt_number: BuildChargeReceiptNumber(id), + receipt_url: BuildChargeReceiptUrl(id), refunded: false, refunds: EmptyRefundsList(id), review: null, @@ -412,8 +413,8 @@ export class ChargeModule { ? { session: input.radar_options.session ?? null } : null, receipt_email: input.receipt_email ?? null, - receipt_number: null, - receipt_url: null, + receipt_number: BuildChargeReceiptNumber(id), + receipt_url: BuildChargeReceiptUrl(id), refunded: false, refunds: EmptyRefundsList(id), review: null, diff --git a/apps/api/src/modules/Receipt.ts b/apps/api/src/modules/Receipt.ts new file mode 100644 index 0000000..5badfea --- /dev/null +++ b/apps/api/src/modules/Receipt.ts @@ -0,0 +1,463 @@ +import { Charge } from '@zoneless/shared-types'; +import { GetAppConfig } from './AppConfig'; +import { SolanaExplorerUrl } from './chains/Solana'; + +type ReceiptRow = { + label: string; + value: string; + href?: string; +}; + +type ReceiptStatusView = { + label: string; + className: string; + message: string; +}; + +/** + * Receipt numbers are derived from the already-stable Charge ID so idempotent + * charge creation paths do not need a second generated identifier. + */ +export function BuildChargeReceiptNumber(chargeId: string): string { + return `rcpt_${chargeId}`; +} + +export function BuildChargeReceiptUrl(chargeId: string): string { + return `${GetAppConfig().dashboardUrl}/v1/receipts/${encodeURIComponent( + chargeId + )}`; +} + +export function RenderReceiptNotFoundHtml(): string { + return PageHtml( + 'Receipt not found', + ` +
+
+
+ + Zoneless +
+

Receipt not found

+

We could not find a receipt for this charge.

+
+
+ ` + ); +} + +export function RenderChargeReceiptHtml(charge: Charge): string { + const status = ReceiptStatus(charge); + const receiptRows: ReceiptRow[] = [ + { label: 'Status', value: status.label }, + { label: 'Charge ID', value: charge.id }, + { + label: 'Receipt number', + value: charge.receipt_number ?? BuildChargeReceiptNumber(charge.id), + }, + { label: 'Created', value: FormatDate(charge.created) }, + ]; + + if (charge.description) { + receiptRows.unshift({ label: 'Description', value: charge.description }); + } + + const transactionRows = TransactionRows(charge); + + return PageHtml( + `Receipt ${charge.receipt_number ?? charge.id}`, + ` +
+
+
+
+ + Zoneless +
+ ${EscapeHtml( + status.label + )} +
+ +
+

Payment receipt

+
+ ${EscapeHtml( + FormatAmountValue(charge.amount) + )} + ${EscapeHtml( + charge.currency.toUpperCase() + )} +
+

${EscapeHtml(status.message)}

+
+ + ${RenderSection('Receipt details', receiptRows)} + ${ + transactionRows.length > 0 + ? RenderSection('Blockchain details', transactionRows) + : '' + } + +
+ Powered by Zoneless + ${EscapeHtml( + charge.livemode ? 'Live mode' : 'Test mode' + )} +
+
+
+ ` + ); +} + +function ReceiptStatus(charge: Charge): ReceiptStatusView { + if (charge.refunded || charge.amount_refunded >= charge.amount) { + return { + label: 'Refunded', + className: 'status-pill status-pill--refunded', + message: 'This payment has been refunded.', + }; + } + + if (charge.amount_refunded > 0) { + return { + label: 'Partially refunded', + className: 'status-pill status-pill--refunded', + message: 'Part of this payment has been refunded.', + }; + } + + if (charge.status === 'failed') { + return { + label: 'Failed', + className: 'status-pill status-pill--failed', + message: 'This payment could not be completed.', + }; + } + + if (charge.status === 'pending') { + return { + label: 'Pending', + className: 'status-pill status-pill--pending', + message: 'This payment is still processing.', + }; + } + + return { + label: 'Paid', + className: 'status-pill status-pill--paid', + message: 'Thanks, your payment was completed successfully.', + }; +} + +function TransactionRows(charge: Charge): ReceiptRow[] { + const crypto = charge.payment_method_details?.crypto; + const metadata = charge.metadata ?? {}; + const transactionHash = + crypto?.transaction_hash ?? metadata.blockchain_tx ?? null; + const explorerUrl = + metadata.explorer_url ?? + metadata.viewer_url ?? + (transactionHash && crypto?.network === 'solana' + ? SolanaExplorerUrl('tx', transactionHash) + : null); + + const rows: ReceiptRow[] = []; + + if (crypto?.network) { + rows.push({ label: 'Network', value: crypto.network }); + } + if (crypto?.token_currency) { + rows.push({ label: 'Token', value: crypto.token_currency.toUpperCase() }); + } + if (crypto?.buyer_address) { + rows.push({ label: 'Buyer wallet', value: crypto.buyer_address }); + } + if (transactionHash) { + rows.push({ label: 'Transaction', value: transactionHash }); + } + if (explorerUrl) { + rows.push({ + label: 'Explorer', + value: explorerUrl, + href: explorerUrl, + }); + } + + return rows; +} + +function RenderRow(row: ReceiptRow): string { + const value = row.href + ? `${EscapeHtml(row.value)}` + : EscapeHtml(row.value); + + return ` +
+
${EscapeHtml(row.label)}
+
${value}
+
+ `; +} + +function RenderSection(title: string, rows: ReceiptRow[]): string { + return ` +
+

${EscapeHtml(title)}

+
+ ${rows.map(RenderRow).join('')} +
+
+ `; +} + +function PageHtml(title: string, body: string): string { + return ` + + + + + ${EscapeHtml(title)} + + + + ${body} + +`; +} + +function FormatAmount(amount: number, currency: string): string { + return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`; +} + +function FormatAmountValue(amount: number): string { + return (amount / 100).toFixed(2); +} + +function FormatDate(timestamp: number): string { + return new Date(timestamp * 1000).toISOString(); +} + +function EscapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return char; + } + }); +} + +function EscapeAttribute(value: string): string { + return EscapeHtml(value); +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index a66b485..50a5ad7 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -29,6 +29,7 @@ import paymentPagesRouter from './paymentPages.routes'; import paymentIntentsRouter from './paymentIntents.routes'; import chargesRouter from './charges.routes'; import reportingRouter from './reporting.routes'; +import receiptsRouter from './receipts.routes'; const router = express.Router(); @@ -38,6 +39,7 @@ router.use('/config', configRouter); router.use('/setup', setupRouter); // Hosted checkout page bootstrap - the unguessable session ID is the credential router.use('/payment_pages', paymentPagesRouter); +router.use('/receipts', receiptsRouter); // --- Operator Routes --- // Guarded by the operator API key (managed hosting only) diff --git a/apps/api/src/routes/receipts.routes.ts b/apps/api/src/routes/receipts.routes.ts new file mode 100644 index 0000000..b868b26 --- /dev/null +++ b/apps/api/src/routes/receipts.routes.ts @@ -0,0 +1,35 @@ +import * as express from 'express'; +import { AsyncHandler } from '../utils/AsyncHandler'; +import { db } from '../modules/Database'; +import { ChargeModule } from '../modules/Charge'; +import { + RenderChargeReceiptHtml, + RenderReceiptNotFoundHtml, +} from '../modules/Receipt'; + +const router = express.Router(); +const chargeModule = new ChargeModule(db); + +/** + * GET /v1/receipts/:id + * Public hosted HTML receipt for a Charge. The receipt URL is returned on the + * authenticated Charge object; no email delivery is performed by this route. + */ +router.get( + '/:id', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const charge = await chargeModule.GetCharge(req.params.id); + + res.set('Cache-Control', 'no-store'); + res.type('html'); + + if (!charge) { + res.status(404).send(RenderReceiptNotFoundHtml()); + return; + } + + res.send(RenderChargeReceiptHtml(charge)); + }) +); + +export default router;