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
1,365 changes: 1,325 additions & 40 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions services/api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"devDependencies": {
"@types/node": "^20.10.0",
"@types/pg": "^8.20.0",
"@types/tape": "^5.8.1",
"tape": "^5.10.2",
"typescript": "^5.3.3"
}
}
2 changes: 1 addition & 1 deletion services/api-gateway/src/authenticate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ test('authenticate decorator should return generic 401 on invalid JWT', async (t
await fastify.close();
t.end();
} catch (err) {
t.fail(err);
t.fail(String(err));
await fastify.close();
t.end();
}
Expand Down
57 changes: 55 additions & 2 deletions services/api-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,16 @@ import {
CreateSettlementBody,
AuthTokenBody,
UpdatePaymentStatusBody,
UpdateSettlementStatusBody,
UpdateMerchantSettingsBody,
createErrorResponse,
ErrorCodes
} from '@bettapay/validation';
import { PrismaClient } from '@prisma/client';
import prismaPkg from '@prisma/client';
import pg from 'pg';
import helmet from '@fastify/helmet';
import { PrismaPg } from '@prisma/adapter-pg';
const PrismaClient = ((prismaPkg as any).PrismaClient ?? (prismaPkg as any).default ?? prismaPkg) as new (options?: any) => any;

declare module 'fastify' {
export interface FastifyInstance {
Expand Down Expand Up @@ -89,6 +91,10 @@ interface UpdatePaymentStatusRouteBody {
status?: unknown;
}

interface UpdateSettlementStatusRouteBody {
status?: unknown;
}

// Allowed payment status transitions. `initiated` is the only non-terminal state;
// completed, failed, and cancelled are terminal and cannot transition further.
const PAYMENT_STATUS_TRANSITIONS: Record<string, readonly string[]> = {
Expand All @@ -98,6 +104,13 @@ const PAYMENT_STATUS_TRANSITIONS: Record<string, readonly string[]> = {
cancelled: [],
};

const SETTLEMENT_STATUS_TRANSITIONS: Record<string, readonly string[]> = {
pending: ['processing'],
processing: ['completed', 'failed'],
completed: [],
failed: [],
};

const isProduction = process.env.NODE_ENV === 'production';

const env = validateEnv(process.env);
Expand Down Expand Up @@ -260,7 +273,12 @@ fastify.register(fastifyJwt, {
fastify.register(rateLimit, {
max: 1000,
timeWindow: '1 minute',
addHeaders: true
addHeaders: {
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true,
'retry-after': true,
}
});

fastify.register(rateLimit, {
Expand Down Expand Up @@ -532,6 +550,41 @@ fastify.patch<{ Params: PaymentParams; Body: UpdatePaymentStatusRouteBody }>('/a
return reply.send(updated);
});

fastify.patch<{ Params: { id: string }; Body: UpdateSettlementStatusRouteBody }>('/api/settlements/:id/status', {
preValidation: [fastify.authenticate],
preHandler: [logRequestBody],
config: { rateLimit: { max: 30, timeWindow: '1 minute' } }
}, async (request, reply) => {
let d;
try {
d = UpdateSettlementStatusBody.parse(request.body);
} catch (error) {
return badRequest(reply, error);
}

const { id } = request.params;
const settlement = await prisma.settlement.findUnique({ where: { id } });
if (!settlement) return reply.code(404).send(createErrorResponse(ErrorCodes.NOT_FOUND, 'Settlement not found'));

const allowed = SETTLEMENT_STATUS_TRANSITIONS[settlement.status] ?? [];
if (!allowed.includes(d.status)) {
return reply.code(422).send({
error: 'Invalid status transition',
from: settlement.status,
to: d.status,
});
}

const updated = await prisma.settlement.update({
where: { id },
data: {
status: d.status,
...(d.status === 'completed' || d.status === 'failed' ? { completedAt: new Date() } : {}),
}
});
return reply.send(updated);
});

// Settlements
fastify.post<{ Body: CreateSettlementRouteBody }>('/api/settlements', {
preValidation: [fastify.authenticate],
Expand Down
8 changes: 4 additions & 4 deletions services/api-gateway/src/merchant-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ function buildApp(initial: Record<string, unknown> | 'missing') {
}

async function patch(app: ReturnType<typeof buildApp>, payload: unknown) {
return app.inject({ method: 'PATCH', url: '/api/merchants/m1/settings', payload });
return app.inject({ method: 'PATCH', url: '/api/merchants/m1/settings', payload: payload as any });
}

test('updating feeBps merges into existing settings', async (t) => {
const app = buildApp({ tier: 'silver', autoSettle: true });
const res = await patch(app, { feeBps: 75 });
t.equal(res.statusCode, 200, 'returns 200');
const settings = JSON.parse(res.body).merchant.settings;
const settings = JSON.parse(res.body as string).merchant.settings;
t.equal(settings.feeBps, 75, 'feeBps is set');
t.equal(settings.autoSettle, true, 'unrelated settings are preserved');
await app.close();
Expand All @@ -40,15 +40,15 @@ test('updating feeBps merges into existing settings', async (t) => {

test('updating a missing merchant returns 404', async (t) => {
const app = buildApp('missing');
const res = await patch(app, { feeBps: 75 });
const res = await patch(app, { feeBps: 75 }) as any;
t.equal(res.statusCode, 404, 'returns 404');
await app.close();
t.end();
});

test('an out-of-range feeBps is rejected', async (t) => {
const app = buildApp({});
const res = await patch(app, { feeBps: 20000 });
const res = await patch(app, { feeBps: 20000 }) as any;
t.equal(res.statusCode, 400, 'returns 400 for feeBps above 10000');
await app.close();
t.end();
Expand Down
2 changes: 1 addition & 1 deletion services/api-gateway/src/request-timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function buildApp(handlerTimeoutMs: number) {

test('Fastify uses the documented request and connection timeouts', async (t) => {
const app = buildApp(REQUEST_TIMEOUT_MS);
t.equal(app.initialConfig.requestTimeout, 30_000, 'requestTimeout is 30s');
t.equal((app.initialConfig as any).requestTimeout, 30_000, 'requestTimeout is 30s');
t.equal(app.initialConfig.connectionTimeout, 31_000, 'connectionTimeout is 31s (1s above requestTimeout)');
await app.close();
t.end();
Expand Down
84 changes: 84 additions & 0 deletions services/api-gateway/src/settlement-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from 'tape';
import Fastify from 'fastify';
import { UpdateSettlementStatusBody } from '@bettapay/validation';

const SETTLEMENT_STATUS_TRANSITIONS: Record<string, readonly string[]> = {
pending: ['processing'],
processing: ['completed', 'failed'],
completed: [],
failed: [],
};

function buildApp(initialStatus: string) {
const app = Fastify({ logger: false });
const settlement: { id: string; status: string; completedAt?: string | null } | null =
initialStatus === 'missing' ? null : { id: 'set_1', status: initialStatus, completedAt: null };

app.patch<{ Params: { id: string }; Body: { status?: unknown } }>(
'/api/settlements/:id/status',
async (request, reply) => {
let d;
try {
d = UpdateSettlementStatusBody.parse(request.body);
} catch {
return reply.code(400).send({ error: 'Invalid request payload' });
}

if (!settlement) return reply.code(404).send({ error: 'Settlement not found' });

const allowed = SETTLEMENT_STATUS_TRANSITIONS[settlement.status] ?? [];
if (!allowed.includes(d.status)) {
return reply.code(422).send({ error: 'Invalid status transition', from: settlement.status, to: d.status });
}

settlement.status = d.status;
if (d.status === 'completed' || d.status === 'failed') {
settlement.completedAt = new Date().toISOString();
}
return reply.send(settlement);
}
);

return app;
}

async function patch(app: ReturnType<typeof buildApp>, status: unknown) {
return app.inject({ method: 'PATCH', url: '/api/settlements/set_1/status', payload: { status } });
}

test('pending transitions to processing', async (t) => {
const app = buildApp('pending');
const res = await patch(app, 'processing');
t.equal(res.statusCode, 200, 'returns 200');
t.equal(JSON.parse(res.body).status, 'processing', 'updates to processing');
await app.close();
t.end();
});

test('processing transitions to completed and sets completedAt', async (t) => {
const app = buildApp('processing');
const res = await patch(app, 'completed');
t.equal(res.statusCode, 200, 'returns 200');
const body = JSON.parse(res.body);
t.equal(body.status, 'completed', 'updates to completed');
t.ok(body.completedAt, 'sets completedAt on terminal status');
await app.close();
t.end();
});

test('invalid transition returns 422', async (t) => {
const app = buildApp('pending');
const res = await patch(app, 'completed');
t.equal(res.statusCode, 422, 'returns 422');
t.equal(JSON.parse(res.body).from, 'pending', 'reports current status');
await app.close();
t.end();
});

test('missing settlement returns 404', async (t) => {
const app = buildApp('missing');
const res = await patch(app, 'processing');
t.equal(res.statusCode, 404, 'returns 404');
await app.close();
t.end();
});
5 changes: 5 additions & 0 deletions shared/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ export const UpdatePaymentStatusBody = z.object({
status: z.enum(['completed', 'failed', 'cancelled']),
});

export const UpdateSettlementStatusBody = z.object({
status: z.enum(['processing', 'completed', 'failed']),
});
export type UpdateSettlementStatusBody = z.infer<typeof UpdateSettlementStatusBody>;

// Per-merchant fee rule configuration. feeBps is basis points (1% = 100 bps),
// capped at 10000 (100%). Unknown keys are stripped; the route merges these into
// the merchant's existing settings rather than replacing them.
Expand Down