Skip to content

Commit 22cb4b9

Browse files
feat(api): add hosted charge receipts
1 parent 1b3d00e commit 22cb4b9

6 files changed

Lines changed: 703 additions & 5 deletions

File tree

apps/api/src/__tests__/Charge.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
GetFixedTimestamp,
1313
} from './Setup';
1414

15+
const mockDashboardUrl = 'http://localhost:4200';
16+
1517
jest.mock('../modules/Database');
1618
jest.mock('../utils/IdGenerator', () => ({
1719
GenerateId: jest.fn((prefix: string) => DeterministicId(prefix)),
@@ -21,7 +23,7 @@ jest.mock('../utils/Timestamp', () => ({
2123
}));
2224
jest.mock('../modules/AppConfig', () => ({
2325
GetAppConfig: jest.fn(() => ({
24-
dashboardUrl: 'http://localhost:4200',
26+
dashboardUrl: mockDashboardUrl,
2527
livemode: false,
2628
appSecret: 'test-secret',
2729
})),
@@ -103,6 +105,10 @@ describe('ChargeModule', () => {
103105
transaction_hash: null,
104106
},
105107
});
108+
expect(charge.receipt_number).toBe(`rcpt_${charge.id}`);
109+
expect(charge.receipt_url).toBe(
110+
`${mockDashboardUrl}/v1/receipts/${charge.id}`
111+
);
106112
expect(charge.refunded).toBe(false);
107113
expect(charge.refunds).toEqual({
108114
object: 'list',
@@ -199,6 +205,11 @@ describe('ChargeModule', () => {
199205
expect(mockDb.Set).toHaveBeenCalledWith('Charges', charge.id, charge);
200206
expect(charge.status).toBe('succeeded');
201207
expect(charge.captured).toBe(true);
208+
expect(charge.receipt_number).toEqual(expect.any(String));
209+
expect(charge.receipt_number).toBe(`rcpt_${charge.id}`);
210+
expect(charge.receipt_url).toBe(
211+
`${mockDashboardUrl}/v1/receipts/${charge.id}`
212+
);
202213
expect(eventService.Emit).toHaveBeenCalledWith(
203214
'charge.succeeded',
204215
'acct_z_platform',
@@ -832,6 +843,10 @@ describe('ChargeModule', () => {
832843
expect(charge.status).toBe('succeeded');
833844
expect(charge.captured).toBe(true);
834845
expect(charge.payment_intent).toBe('pi_z_1');
846+
expect(charge.receipt_number).toBe(`rcpt_${charge.id}`);
847+
expect(charge.receipt_url).toBe(
848+
`${mockDashboardUrl}/v1/receipts/${charge.id}`
849+
);
835850
expect(charge.payment_method_details).toEqual({
836851
type: 'crypto',
837852
crypto: {
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import express from 'express';
2+
import { AddressInfo } from 'net';
3+
import { Server } from 'http';
4+
import receiptsRouter from '../routes/receipts.routes';
5+
import { db } from '../modules/Database';
6+
import { Charge } from '@zoneless/shared-types';
7+
8+
const TEST_DASHBOARD_URL = 'http://localhost:4200';
9+
10+
describe('Receipt routes', () => {
11+
let server: Server;
12+
let baseUrl: string;
13+
14+
beforeEach(async () => {
15+
const app = express();
16+
app.use('/v1/receipts', receiptsRouter);
17+
18+
server = await new Promise<Server>((resolve) => {
19+
const listeningServer = app.listen(0, () => resolve(listeningServer));
20+
});
21+
22+
const address = server.address() as AddressInfo;
23+
baseUrl = `http://127.0.0.1:${address.port}`;
24+
});
25+
26+
afterEach(async () => {
27+
jest.restoreAllMocks();
28+
await new Promise<void>((resolve, reject) => {
29+
server.close((error) => (error ? reject(error) : resolve()));
30+
});
31+
});
32+
33+
it('should return HTML for a valid Charge receipt', async () => {
34+
jest.spyOn(db, 'Get').mockResolvedValue(ChargeFixture());
35+
36+
const response = await fetch(`${baseUrl}/v1/receipts/ch_z_receipt`);
37+
const html = await response.text();
38+
39+
expect(response.status).toBe(200);
40+
expect(response.headers.get('content-type')).toContain('text/html');
41+
expect(html).toContain('Payment receipt');
42+
expect(html).toContain('12.34 USDC');
43+
expect(html).toContain('ch_z_receipt');
44+
expect(html).toContain('rcpt_ch_z_receipt');
45+
expect(html).toContain('Hosted checkout payment');
46+
expect(html).toContain('sig_123');
47+
expect(html).toContain('https://explorer.solana.com/tx/sig_123');
48+
});
49+
50+
it('should render pending, failed, and refunded receipt states', async () => {
51+
const cases: Array<{
52+
charge: Charge;
53+
expectedLabel: string;
54+
expectedMessage: string;
55+
}> = [
56+
{
57+
charge: ChargeFixture({
58+
captured: false,
59+
status: 'pending',
60+
amount_captured: 0,
61+
}),
62+
expectedLabel: 'Pending',
63+
expectedMessage: 'This payment is still processing.',
64+
},
65+
{
66+
charge: ChargeFixture({
67+
captured: false,
68+
paid: false,
69+
status: 'failed',
70+
amount_captured: 0,
71+
}),
72+
expectedLabel: 'Failed',
73+
expectedMessage: 'This payment could not be completed.',
74+
},
75+
{
76+
charge: ChargeFixture({
77+
refunded: true,
78+
amount_refunded: 1234,
79+
}),
80+
expectedLabel: 'Refunded',
81+
expectedMessage: 'This payment has been refunded.',
82+
},
83+
{
84+
charge: ChargeFixture({
85+
amount_refunded: 500,
86+
}),
87+
expectedLabel: 'Partially refunded',
88+
expectedMessage: 'Part of this payment has been refunded.',
89+
},
90+
];
91+
92+
for (const testCase of cases) {
93+
jest.spyOn(db, 'Get').mockResolvedValueOnce(testCase.charge);
94+
95+
const response = await fetch(`${baseUrl}/v1/receipts/ch_z_receipt`);
96+
const html = await response.text();
97+
98+
expect(response.status).toBe(200);
99+
expect(html).toContain(testCase.expectedLabel);
100+
expect(html).toContain(testCase.expectedMessage);
101+
}
102+
});
103+
104+
it('should return a clean 404 page when the Charge is missing', async () => {
105+
jest.spyOn(db, 'Get').mockResolvedValue(null);
106+
107+
const response = await fetch(`${baseUrl}/v1/receipts/ch_z_missing`);
108+
const html = await response.text();
109+
110+
expect(response.status).toBe(404);
111+
expect(response.headers.get('content-type')).toContain('text/html');
112+
expect(html).toContain('Receipt not found');
113+
expect(html).toContain('could not find a receipt');
114+
});
115+
});
116+
117+
function ChargeFixture(overrides: Partial<Charge> = {}): Charge {
118+
const charge: Charge = {
119+
id: 'ch_z_receipt',
120+
object: 'charge',
121+
amount: 1234,
122+
amount_captured: 1234,
123+
amount_refunded: 0,
124+
application: null,
125+
application_fee: null,
126+
application_fee_amount: null,
127+
balance_transaction: null,
128+
billing_details: {
129+
address: null,
130+
email: null,
131+
name: null,
132+
phone: null,
133+
tax_id: null,
134+
},
135+
calculated_statement_descriptor: null,
136+
captured: true,
137+
created: 1700000000,
138+
currency: 'usdc',
139+
customer: null,
140+
description: 'Hosted checkout payment',
141+
disputed: false,
142+
failure_balance_transaction: null,
143+
failure_code: null,
144+
failure_message: null,
145+
fraud_details: {},
146+
livemode: false,
147+
metadata: {},
148+
on_behalf_of: null,
149+
outcome: null,
150+
paid: true,
151+
payment_intent: 'pi_z_receipt',
152+
payment_method: 'PayerWallet111',
153+
payment_method_details: {
154+
type: 'crypto',
155+
crypto: {
156+
buyer_address: 'PayerWallet111',
157+
fingerprint: null,
158+
network: 'solana',
159+
token_currency: 'usdc',
160+
transaction_hash: 'sig_123',
161+
},
162+
},
163+
presentment_details: null,
164+
radar_options: null,
165+
receipt_email: null,
166+
receipt_number: 'rcpt_ch_z_receipt',
167+
receipt_url: `${TEST_DASHBOARD_URL}/v1/receipts/ch_z_receipt`,
168+
refunded: false,
169+
refunds: null,
170+
review: null,
171+
shipping: null,
172+
source_transfer: null,
173+
statement_descriptor: null,
174+
statement_descriptor_suffix: null,
175+
status: 'succeeded',
176+
transfer: null,
177+
transfer_data: null,
178+
transfer_group: null,
179+
platform_account: 'acct_z_platform',
180+
};
181+
return { ...charge, ...overrides };
182+
}

apps/api/src/modules/Charge.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { GetAppConfig } from './AppConfig';
3737
import { AppError } from '../utils/AppError';
3838
import { ERRORS } from '../utils/Errors';
3939
import { GetPlatformAccountId } from './PlatformAccess';
40+
import { BuildChargeReceiptNumber, BuildChargeReceiptUrl } from './Receipt';
4041

4142
type AddressInput = NonNullable<
4243
NonNullable<CreateChargeInput['shipping']>['address']
@@ -289,8 +290,8 @@ export class ChargeModule {
289290
presentment_details: null,
290291
radar_options: null,
291292
receipt_email: input.receipt_email ?? null,
292-
receipt_number: null,
293-
receipt_url: null,
293+
receipt_number: BuildChargeReceiptNumber(id),
294+
receipt_url: BuildChargeReceiptUrl(id),
294295
refunded: false,
295296
refunds: EmptyRefundsList(id),
296297
review: null,
@@ -412,8 +413,8 @@ export class ChargeModule {
412413
? { session: input.radar_options.session ?? null }
413414
: null,
414415
receipt_email: input.receipt_email ?? null,
415-
receipt_number: null,
416-
receipt_url: null,
416+
receipt_number: BuildChargeReceiptNumber(id),
417+
receipt_url: BuildChargeReceiptUrl(id),
417418
refunded: false,
418419
refunds: EmptyRefundsList(id),
419420
review: null,

0 commit comments

Comments
 (0)