Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/api/src/__tests__/Charge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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',
})),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down
182 changes: 182 additions & 0 deletions apps/api/src/__tests__/Receipt.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Server>((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<void>((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> = {}): 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 };
}
9 changes: 5 additions & 4 deletions apps/api/src/modules/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateChargeInput['shipping']>['address']
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading