diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f21d6b..4eb6a57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,8 +285,14 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@fastify/rate-limit@10.3.0': - resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/rate-limit@11.0.0': + resolution: {integrity: sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==} + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 '@fastify/rate-limit@11.0.0': resolution: {integrity: sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==} @@ -1812,7 +1818,7 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 - '@fastify/rate-limit@10.3.0': + '@fastify/rate-limit@11.0.0': dependencies: '@lukeed/ms': 2.0.2 fastify-plugin: 5.1.0 diff --git a/services/api-gateway/src/index.ts b/services/api-gateway/src/index.ts index e5958f2..ae93494 100644 --- a/services/api-gateway/src/index.ts +++ b/services/api-gateway/src/index.ts @@ -41,6 +41,7 @@ import { CreateSettlementBody, AuthTokenBody, UpdatePaymentStatusBody, + UpdateSettlementStatusBody, UpdateMerchantSettingsBody, createErrorResponse, ErrorCodes, @@ -106,6 +107,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 = { @@ -645,6 +650,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.get('/api/settlements', { preValidation: [fastify.authenticate], diff --git a/services/api-gateway/src/merchant-settings.test.ts b/services/api-gateway/src/merchant-settings.test.ts index 429c32d..7feb055 100644 --- a/services/api-gateway/src/merchant-settings.test.ts +++ b/services/api-gateway/src/merchant-settings.test.ts @@ -31,7 +31,7 @@ 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(); @@ -40,7 +40,7 @@ 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(); @@ -48,7 +48,7 @@ test('updating a missing merchant returns 404', async (t) => { 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(); diff --git a/services/api-gateway/src/settlement-status.test.ts b/services/api-gateway/src/settlement-status.test.ts new file mode 100644 index 0000000..4c2843b --- /dev/null +++ b/services/api-gateway/src/settlement-status.test.ts @@ -0,0 +1,84 @@ +import test from 'tape'; +import Fastify from 'fastify'; +import { UpdateSettlementStatusBody } from '@bettapay/validation'; + +const SETTLEMENT_STATUS_TRANSITIONS: Record = { + 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, 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(); +}); diff --git a/shared/validation/schemas.ts b/shared/validation/schemas.ts index 82fae9d..46e7b18 100644 --- a/shared/validation/schemas.ts +++ b/shared/validation/schemas.ts @@ -299,6 +299,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; + // 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.