Skip to content

Commit fa4222d

Browse files
author
root
committed
fix: add idempotent retries and receipt queueing
Fixes #14 Fixes #15 Fixes #16
1 parent d5ef64c commit fa4222d

14 files changed

Lines changed: 273 additions & 61 deletions

apps/backend/prisma/schema.prisma

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,15 @@ model Expense {
9696
totalAmountCents BigInt
9797
currency String
9898
category String?
99+
idempotencyKey String? @unique
99100
createdAt DateTime @default(now())
100101
group Group @relation(fields: [groupId], references: [id])
101102
payer User @relation("expense_payer", fields: [payerId], references: [id])
102103
splits Split[]
103104
receipt Receipt?
104105
105106
@@index([groupId])
107+
@@index([payerId])
106108
@@index([createdAt])
107109
@@index([groupId, createdAt])
108110
@@map("expenses")
@@ -239,4 +241,3 @@ model Payment {
239241
@@map("payments")
240242
}
241243

242-

apps/backend/src/expenses/expenses.controller.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
1+
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
22
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
33
import { CurrentUser } from '../common/decorators/current-user.decorator';
44
import { JwtPayload } from '../auth/types/auth.types';
@@ -12,8 +12,13 @@ export class ExpensesController {
1212
constructor(private readonly expensesService: ExpensesService) {}
1313

1414
@Post('groups/:id/expenses')
15-
create(@Param('id') groupId: string, @CurrentUser() user: JwtPayload, @Body() dto: CreateExpenseDto) {
16-
return this.expensesService.create(groupId, user.sub, dto);
15+
create(
16+
@Param('id') groupId: string,
17+
@CurrentUser() user: JwtPayload,
18+
@Body() dto: CreateExpenseDto,
19+
@Headers('x-idempotency-key') idempotencyKey?: string,
20+
) {
21+
return this.expensesService.create(groupId, user.sub, dto, idempotencyKey);
1722
}
1823

1924
@Get('groups/:id/expenses')

apps/backend/src/expenses/expenses.service.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,119 @@ import { BadRequestException } from '@nestjs/common';
22
import { ExpensesService } from './expenses.service';
33

44
describe('ExpensesService', () => {
5+
it('returns the existing expense when the idempotency key already exists', async () => {
6+
const existingExpense = {
7+
id: 'expense-1',
8+
groupId: 'group-1',
9+
payerId: 'payer-1',
10+
description: 'Dinner',
11+
totalAmountCents: 1250n,
12+
currency: 'USD',
13+
category: null,
14+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
15+
splits: [{ id: 'split-1', userId: 'payer-1', owedAmountCents: 1250n, paidAmountCents: 1250n }],
16+
receipt: null,
17+
};
18+
const prisma: any = {
19+
expense: {
20+
findUnique: jest.fn().mockResolvedValue(existingExpense),
21+
},
22+
};
23+
24+
const service = new ExpensesService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
25+
26+
await expect(
27+
service.create(
28+
'group-1',
29+
'payer-1',
30+
{
31+
payerId: 'payer-1',
32+
description: 'Dinner',
33+
totalAmountCents: '1250',
34+
currency: 'USD',
35+
splits: [{ userId: 'payer-1', owedAmountCents: '1250', paidAmountCents: '1250' }],
36+
},
37+
'mobile:expense:existing',
38+
),
39+
).resolves.toEqual({
40+
id: 'expense-1',
41+
groupId: 'group-1',
42+
payerId: 'payer-1',
43+
description: 'Dinner',
44+
totalAmountCents: '1250',
45+
currency: 'USD',
46+
category: null,
47+
createdAt: '2026-01-01T00:00:00.000Z',
48+
receiptFileKey: null,
49+
splits: [{ id: 'split-1', userId: 'payer-1', owedAmountCents: '1250', paidAmountCents: '1250' }],
50+
});
51+
});
52+
53+
it('re-reads the existing expense after a unique-key race on idempotency', async () => {
54+
const existingExpense = {
55+
id: 'expense-2',
56+
groupId: 'group-1',
57+
payerId: 'payer-1',
58+
description: 'Lunch',
59+
totalAmountCents: 1500n,
60+
currency: 'USD',
61+
category: null,
62+
createdAt: new Date('2026-01-02T00:00:00.000Z'),
63+
splits: [
64+
{ id: 'split-1', userId: 'payer-1', owedAmountCents: 1000n, paidAmountCents: 1500n },
65+
{ id: 'split-2', userId: 'payer-2', owedAmountCents: 500n, paidAmountCents: 0n },
66+
],
67+
receipt: null,
68+
};
69+
const prisma: any = {
70+
expense: {
71+
findUnique: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(existingExpense),
72+
},
73+
groupMember: {
74+
findMany: jest.fn().mockResolvedValue([{ userId: 'payer-1' }, { userId: 'payer-2' }]),
75+
},
76+
$transaction: jest.fn().mockRejectedValue({ code: 'P2002' }),
77+
};
78+
const service = new ExpensesService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
79+
80+
await expect(
81+
service.create(
82+
'group-1',
83+
'payer-1',
84+
{
85+
payerId: 'payer-1',
86+
description: 'Lunch',
87+
totalAmountCents: '1500',
88+
currency: 'USD',
89+
splits: [
90+
{ userId: 'payer-1', owedAmountCents: '1000', paidAmountCents: '1500' },
91+
{ userId: 'payer-2', owedAmountCents: '500', paidAmountCents: '0' },
92+
],
93+
},
94+
'mobile:expense:race',
95+
),
96+
).resolves.toEqual({
97+
id: 'expense-2',
98+
groupId: 'group-1',
99+
payerId: 'payer-1',
100+
description: 'Lunch',
101+
totalAmountCents: '1500',
102+
currency: 'USD',
103+
category: null,
104+
createdAt: '2026-01-02T00:00:00.000Z',
105+
receiptFileKey: null,
106+
splits: [
107+
{ id: 'split-1', userId: 'payer-1', owedAmountCents: '1000', paidAmountCents: '1500' },
108+
{ id: 'split-2', userId: 'payer-2', owedAmountCents: '500', paidAmountCents: '0' },
109+
],
110+
});
111+
});
112+
5113
it('rejects create when split sum does not match total', async () => {
6114
const prisma: any = {
115+
expense: {
116+
findUnique: jest.fn(),
117+
},
7118
groupMember: {
8119
findMany: jest.fn().mockResolvedValue([{ userId: 'u1' }, { userId: 'u2' }]),
9120
},

apps/backend/src/expenses/expenses.service.ts

Lines changed: 78 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class ExpensesService {
2626
private readonly realtime: RealtimeService,
2727
) {}
2828

29-
async create(groupId: string, actorUserId: string, dto: CreateExpenseDto): Promise<ExpenseDto> {
29+
async create(groupId: string, actorUserId: string, dto: CreateExpenseDto, idempotencyKey?: string): Promise<ExpenseDto> {
3030
const totalAmount = BigInt(dto.totalAmountCents);
3131
if (totalAmount <= 0n) {
3232
throw new BadRequestException('Expense amount must be positive');
@@ -42,6 +42,16 @@ export class ExpensesService {
4242
throw new BadRequestException('Split sum must equal total amount');
4343
}
4444

45+
if (idempotencyKey) {
46+
const existingByKey = await this.prisma.expense.findUnique({
47+
where: { idempotencyKey },
48+
include: { splits: true, receipt: true },
49+
});
50+
if (existingByKey) {
51+
return this.toExpenseDto(existingByKey);
52+
}
53+
}
54+
4555
const memberIds = new Set<string>([actorUserId, dto.payerId, ...dto.splits.map((split) => split.userId)]);
4656
const memberships = await this.prisma.groupMember.findMany({
4757
where: {
@@ -65,58 +75,74 @@ export class ExpensesService {
6575
throw new BadRequestException('All split users must be group members');
6676
}
6777

68-
const expense = await this.prisma.$transaction(async (tx) => {
69-
const createdExpense = await tx.expense.create({
70-
data: {
71-
groupId,
72-
payerId: dto.payerId,
73-
description: dto.description,
74-
totalAmountCents: totalAmount,
75-
currency: dto.currency,
76-
category: dto.category ?? null,
77-
},
78-
});
79-
80-
await tx.split.createMany({
81-
data: dto.splits.map((split) => ({
82-
expenseId: createdExpense.id,
83-
userId: split.userId,
84-
owedAmountCents: BigInt(split.owedAmountCents),
85-
paidAmountCents: BigInt(split.paidAmountCents),
86-
})),
87-
});
88-
89-
const deltas = calculateBalanceDeltas(dto.payerId, dto.splits);
90-
for (const delta of deltas) {
91-
await this.balancesService.adjustBalance(
92-
tx as unknown as Prisma.TransactionClient,
93-
groupId,
94-
delta.userId,
95-
delta.counterpartyUserId,
96-
delta.delta,
97-
);
98-
}
99-
100-
await tx.activity.create({
101-
data: {
102-
groupId,
103-
actorUserId,
104-
type: 'expense_created',
105-
entityId: createdExpense.id,
106-
metadata: {
78+
let expense;
79+
try {
80+
expense = await this.prisma.$transaction(async (tx) => {
81+
const createdExpense = await tx.expense.create({
82+
data: {
83+
groupId,
10784
payerId: dto.payerId,
108-
totalAmountCents: totalAmount.toString(),
109-
category: dto.category ?? null,
85+
description: dto.description,
86+
totalAmountCents: totalAmount,
11087
currency: dto.currency,
88+
category: dto.category ?? null,
89+
idempotencyKey: idempotencyKey ?? null,
11190
},
112-
},
113-
});
91+
});
92+
93+
await tx.split.createMany({
94+
data: dto.splits.map((split) => ({
95+
expenseId: createdExpense.id,
96+
userId: split.userId,
97+
owedAmountCents: BigInt(split.owedAmountCents),
98+
paidAmountCents: BigInt(split.paidAmountCents),
99+
})),
100+
});
101+
102+
const deltas = calculateBalanceDeltas(dto.payerId, dto.splits);
103+
for (const delta of deltas) {
104+
await this.balancesService.adjustBalance(
105+
tx as unknown as Prisma.TransactionClient,
106+
groupId,
107+
delta.userId,
108+
delta.counterpartyUserId,
109+
delta.delta,
110+
);
111+
}
112+
113+
await tx.activity.create({
114+
data: {
115+
groupId,
116+
actorUserId,
117+
type: 'expense_created',
118+
entityId: createdExpense.id,
119+
metadata: {
120+
payerId: dto.payerId,
121+
totalAmountCents: totalAmount.toString(),
122+
category: dto.category ?? null,
123+
currency: dto.currency,
124+
},
125+
},
126+
});
114127

115-
return tx.expense.findUniqueOrThrow({
116-
where: { id: createdExpense.id },
117-
include: { splits: true, receipt: true },
128+
return tx.expense.findUniqueOrThrow({
129+
where: { id: createdExpense.id },
130+
include: { splits: true, receipt: true },
131+
});
118132
});
119-
});
133+
} catch (error) {
134+
if (idempotencyKey && this.isUniqueConstraintError(error)) {
135+
const existing = await this.prisma.expense.findUnique({
136+
where: { idempotencyKey },
137+
include: { splits: true, receipt: true },
138+
});
139+
if (existing) {
140+
return this.toExpenseDto(existing);
141+
}
142+
}
143+
144+
throw error;
145+
}
120146

121147
await this.redis.invalidateGroupCache(groupId);
122148

@@ -282,4 +308,8 @@ export class ExpensesService {
282308
})),
283309
};
284310
}
311+
312+
private isUniqueConstraintError(error: unknown): boolean {
313+
return Boolean(error && typeof error === 'object' && 'code' in error && (error as { code?: string }).code === 'P2002');
314+
}
285315
}

apps/backend/src/jobs/jobs-worker.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ export class JobsWorkerService implements OnModuleInit, OnModuleDestroy {
5858
const receiptWorker = new Worker(
5959
RECEIPT_PROCESSING_QUEUE,
6060
async (job) => {
61-
this.logger.log(`Receipt processing job queued for future OCR id=${job.id}`);
61+
const data = job.data as { receiptId?: string; expenseId?: string };
62+
this.logger.log(
63+
`Receipt processing job received id=${job.id} receiptId=${data.receiptId ?? 'unknown'} expenseId=${data.expenseId ?? 'unknown'}`,
64+
);
6265
},
6366
{ connection },
6467
);

apps/backend/src/receipts/receipts.integration.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing';
33
import { AddressInfo } from 'net';
44
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
55
import { PrismaService } from '../common/prisma.service';
6+
import { JobsQueueService } from '../jobs/jobs-queue.service';
67
import { S3Service } from '../s3/s3.service';
78
import { ReceiptsController } from './receipts.controller';
89
import { ReceiptsService } from './receipts.service';
@@ -28,10 +29,16 @@ describe('Receipt URL endpoint (integration-ish)', () => {
2829
findUnique: jest.fn().mockResolvedValue({ id: 'expense-1', groupId: 'group-1' }),
2930
},
3031
receipt: {
31-
upsert: jest.fn().mockResolvedValue(undefined),
32+
upsert: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
3233
},
3334
},
3435
},
36+
{
37+
provide: JobsQueueService,
38+
useValue: {
39+
enqueueReceiptProcessing: jest.fn().mockResolvedValue(undefined),
40+
},
41+
},
3542
{
3643
provide: S3Service,
3744
useValue: {

apps/backend/src/receipts/receipts.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Module } from '@nestjs/common';
2+
import { JobsModule } from '../jobs/jobs.module';
23
import { ReceiptsController } from './receipts.controller';
34
import { ReceiptsService } from './receipts.service';
45

56
@Module({
7+
imports: [JobsModule],
68
controllers: [ReceiptsController],
79
providers: [ReceiptsService],
810
exports: [ReceiptsService],

0 commit comments

Comments
 (0)