fix: payemetn race condition

This commit is contained in:
Nathnael
2026-08-02 18:28:40 +00:00
parent 7cebef8c39
commit 7762d6c2d3
2 changed files with 55 additions and 1 deletions

View File

@@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => {
); );
}); });
}); });
describe("PaymentService.markIntentSucceeded", () => {
const build = (rows: Record<string, unknown>[]) => {
const repo = makeRepo(rows);
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
const service = new PaymentService(
repo as never,
{} as never,
billing as never,
);
return { service, repo, billing };
};
it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => {
const paidAt = new Date("2026-08-01T09:00:00.000Z");
const { service, repo, billing } = build([
localIntent({ status: "success", transactionId: "txn-1", paidAt }),
]);
const result = await service.markIntentSucceeded("intent-1", {
notify: true,
});
expect(result.alreadyFinalized).toBe(true);
// No re-write of the intent row…
expect(repo.update).not.toHaveBeenCalled();
// …but billing still gets the (idempotent) settle call.
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
"intent-1",
"txn-1",
paidAt,
);
});
it("does not notify billing when notify is false, even when already success", async () => {
const { service, billing } = build([localIntent({ status: "success" })]);
await service.markIntentSucceeded("intent-1", { notify: false });
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
});
});

View File

@@ -451,7 +451,19 @@ export class PaymentService {
): Promise<{ alreadyFinalized: boolean }> { ): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId }); const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found"); if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true }; if (intent.status === "success") {
// Still notify billing: a prior delivery may have flipped the intent to
// success and then died before the invoice settled (the two steps are not
// atomic). settleByPaymentId is idempotent — no open invoice, no-op.
if (opts.notify !== false) {
await this.billing.settleByPaymentId(
intent.id,
opts.providerTxnId ?? intent.transactionId ?? undefined,
opts.paidAt ?? intent.paidAt ?? undefined,
);
}
return { alreadyFinalized: true };
}
const paidAt = opts.paidAt ?? new Date(); const paidAt = opts.paidAt ?? new Date();
await this.paymentRepo.update( await this.paymentRepo.update(