mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1217 from Tria-plc/cancelation-fix
recover: ( payment ) recover cancelation fix deleted by malware
This commit is contained in:
@@ -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 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user