recover: ( payment ) recover cancelation fix deleted by malware

This commit is contained in:
Abubeker Yasin
2026-08-10 14:25:14 +03:00
parent b91a7e8a8f
commit 18c47ca4a3
2 changed files with 107 additions and 8 deletions

View File

@@ -40,6 +40,7 @@ describe("PaymentsService", () => {
findUniqueOrThrow: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
create: jest.fn(),
},
paymentMethod: {
@@ -81,6 +82,7 @@ describe("PaymentsService", () => {
const mockPaymentClient = {
initiate: jest.fn(),
getIntentByReference: jest.fn(),
reconcileByReference: jest.fn(),
};
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
@@ -242,6 +244,14 @@ describe("PaymentsService", () => {
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
// syncIntentProjection applies the status in a separate guarded write (never demoting a
// SUCCEEDED row), then reads the projection back — so this is what it returns.
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
const result = await service.initiatePayment({
bookingId: "booking-1",
@@ -479,6 +489,14 @@ describe("PaymentsService", () => {
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
// See above: the projection is read back after the guarded status write.
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
const result = await service.getIntentByBookingId("booking-1");
@@ -499,4 +517,49 @@ describe("PaymentsService", () => {
);
});
});
// Regression: a booking confirmed between a sweep's candidate query and its turn in the loop
// used to have its SUCCEEDED projection demoted to PROCESSING by the re-sync, with nothing
// able to restore it (finalizePaymentSuccess only writes SUCCEEDED while the booking is still
// PENDING_PAYMENT). A later stale payment.failed from an abandoned sibling attempt could then
// push that same row to FAILED, because markPaymentFailed only shields SUCCEEDED/CANCELLED.
describe("confirmed-booking projection integrity", () => {
it("does not re-sync or cancel a booking confirmed since the caller's snapshot", async () => {
mockPrisma.booking.findUnique.mockResolvedValue({ status: "CONFIRMED" });
const result = await service.reconcileAndConfirmIfPaid("booking-1");
expect(result).toEqual({ paid: true, verified: true });
// Neither the payment service nor the projection is touched.
expect(mockPaymentClient.reconcileByReference).not.toHaveBeenCalled();
expect(mockPrisma.paymentIntent.upsert).not.toHaveBeenCalled();
});
it("writes the mirrored status only where the row is not already SUCCEEDED", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPaymentClient.getIntentByReference.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
});
await service.getIntentByBookingId("booking-1");
// The upsert must never carry a status on its update path...
const upsertArg = mockPrisma.paymentIntent.upsert.mock.calls[0][0];
expect(upsertArg.update).not.toHaveProperty("status");
// ...the status arrives through a write guarded on the row not being SUCCEEDED, which is
// what makes demoting the confirming payment structurally impossible.
expect(mockPrisma.paymentIntent.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: { not: PaymentIntentStatus.SUCCEEDED },
}),
}),
);
});
});
});

View File

@@ -616,12 +616,13 @@ export class PaymentsService {
bookingId: string,
snapshot: PaymentIntentSnapshot,
) {
// Writing SUCCEEDED is finalizePaymentSuccess's job alone — it is the only place that can
// enforce confirm-once atomically — so a SUCCEEDED snapshot syncs as PROCESSING here.
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? PaymentIntentStatus.PROCESSING
: (snapshot.status as unknown as PaymentIntentStatus);
const data = {
status,
method: snapshot.provider as unknown as PaymentMethodType,
merchantOrderId: snapshot.merchantOrderId,
clientAction: snapshot.clientAction
@@ -635,7 +636,7 @@ export class PaymentsService {
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
};
return this.prisma.paymentIntent.upsert({
await this.prisma.paymentIntent.upsert({
where: { bookingId },
// amountMinor/currency are refreshed on update too: a cross-currency method switch
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
@@ -649,9 +650,17 @@ export class PaymentsService {
bookingId,
amountMinor: snapshot.amountMinor,
currency: snapshot.currency,
status,
...data,
},
});
await this.prisma.paymentIntent.updateMany({
where: { bookingId, status: { not: PaymentIntentStatus.SUCCEEDED } },
data: { status },
});
return this.prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId } });
}
private async initiateWalletPayment(
@@ -1029,6 +1038,14 @@ export class PaymentsService {
async reconcileAndConfirmIfPaid(
bookingId: string,
): Promise<{ paid: boolean; verified: boolean }> {
const current = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: { status: true },
});
if (current?.status === "CONFIRMED") {
return { paid: true, verified: true };
}
const settlement = await this.paymentClient.reconcileByReference(
PaymentReferenceType.BOOKING,
bookingId,
@@ -1161,12 +1178,31 @@ export class PaymentsService {
});
if (confirmed === 0) {
// Booking already confirmed by another payment (or not payable and not forced). This capture
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
this.logger.error(
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
);
const recordsConfirmingCapture =
booking.status === "CONFIRMED" && intent.paidAt != null;
if (recordsConfirmingCapture) {
const { count } = await this.prisma.paymentIntent.updateMany({
where: { id: intent.id, status: { not: PaymentIntentStatus.SUCCEEDED } },
data: { status: PaymentIntentStatus.SUCCEEDED },
});
if (count > 0) {
this.logger.warn(
`Restored demoted payment projection for booking ${booking.id} ` +
`(intent ${intent.id}): ${intent.status} → SUCCEEDED`,
);
}
}
const duplicateCapture =
input.providerTxnId != null &&
intent.providerTxnId != null &&
input.providerTxnId !== intent.providerTxnId;
if (duplicateCapture || !recordsConfirmingCapture) {
this.logger.error(
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
);
}
return { alreadyFinalized: true };
}