Skip to content
Merged
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
16 changes: 16 additions & 0 deletions __tests__/unit/services/refund.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client';
const { mockTransaction, mockTx } = vi.hoisted(() => ({
mockTransaction: vi.fn(),
mockTx: {
$queryRaw: vi.fn(),
invoice: { findUnique: vi.fn(), update: vi.fn() },
refund: { create: vi.fn() },
},
Expand Down Expand Up @@ -120,6 +121,21 @@ describe('refundService.issue — validation', () => {
});
});

describe('refundService.issue — concurrency safety', () => {
it('locks the invoice row FOR UPDATE so concurrent refunds cannot over-refund', async () => {
mockTx.invoice.findUnique.mockResolvedValue(makeInvoice());

await refundService.issue({ invoiceId: 'inv-1', amount: 10, reason: 'x' });

// The refundable-ceiling read + insert must run under a row lock; assert
// the FOR UPDATE was issued against the invoice id inside the transaction.
expect(mockTx.$queryRaw).toHaveBeenCalledTimes(1);
const sqlParts = mockTx.$queryRaw.mock.calls[0][0] as TemplateStringsArray;
expect(sqlParts.join('')).toMatch(/FOR UPDATE/i);
expect(mockTx.$queryRaw.mock.calls[0][1]).toBe('inv-1');
});
});

describe('refundService.issue — happy paths', () => {
it('creates the refund with denormalised customer/currency + the RF number + method', async () => {
mockTx.invoice.findUnique.mockResolvedValue(makeInvoice());
Expand Down
9 changes: 9 additions & 0 deletions lib/services/refund.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export const refundService = {
const refundNumber = await nextRefundNumber();

return prisma.$transaction(async (tx) => {
// Serialise concurrent refunds on the same invoice: take a row lock on
// the invoice up front so the refundable-ceiling read and the refund
// insert are atomic against other refund transactions. Without it, two
// overlapping requests could each read the same prior-refund total, both
// pass the ceiling check, and over-refund beyond the money actually
// received (Cursor review on #271). A concurrent refund blocks here until
// the first commits, then re-reads the updated total and is bounded.
await tx.$queryRaw`SELECT 1 FROM "Invoice" WHERE "id" = ${input.invoiceId} FOR UPDATE`;

const invoice = await tx.invoice.findUnique({
where: { id: input.invoiceId },
include: {
Expand Down
Loading