mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: payemetn race condition
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user