@@ -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}
0 commit comments