mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +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(),
|
findUniqueOrThrow: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
},
|
},
|
||||||
paymentMethod: {
|
paymentMethod: {
|
||||||
@@ -81,6 +82,7 @@ describe("PaymentsService", () => {
|
|||||||
const mockPaymentClient = {
|
const mockPaymentClient = {
|
||||||
initiate: jest.fn(),
|
initiate: jest.fn(),
|
||||||
getIntentByReference: jest.fn(),
|
getIntentByReference: jest.fn(),
|
||||||
|
reconcileByReference: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
||||||
@@ -242,6 +244,14 @@ describe("PaymentsService", () => {
|
|||||||
merchantOrderId: "PSG-MERCH-123",
|
merchantOrderId: "PSG-MERCH-123",
|
||||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
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({
|
const result = await service.initiatePayment({
|
||||||
bookingId: "booking-1",
|
bookingId: "booking-1",
|
||||||
@@ -479,6 +489,14 @@ describe("PaymentsService", () => {
|
|||||||
merchantOrderId: "PSG-MERCH-123",
|
merchantOrderId: "PSG-MERCH-123",
|
||||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
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");
|
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,
|
bookingId: string,
|
||||||
snapshot: PaymentIntentSnapshot,
|
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 =
|
const status =
|
||||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||||
? PaymentIntentStatus.PROCESSING
|
? PaymentIntentStatus.PROCESSING
|
||||||
: (snapshot.status as unknown as PaymentIntentStatus);
|
: (snapshot.status as unknown as PaymentIntentStatus);
|
||||||
const data = {
|
const data = {
|
||||||
status,
|
|
||||||
method: snapshot.provider as unknown as PaymentMethodType,
|
method: snapshot.provider as unknown as PaymentMethodType,
|
||||||
merchantOrderId: snapshot.merchantOrderId,
|
merchantOrderId: snapshot.merchantOrderId,
|
||||||
clientAction: snapshot.clientAction
|
clientAction: snapshot.clientAction
|
||||||
@@ -635,7 +636,7 @@ export class PaymentsService {
|
|||||||
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
|
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
|
||||||
: Prisma.DbNull,
|
: Prisma.DbNull,
|
||||||
};
|
};
|
||||||
return this.prisma.paymentIntent.upsert({
|
await this.prisma.paymentIntent.upsert({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
// amountMinor/currency are refreshed on update too: a cross-currency method switch
|
// 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
|
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
|
||||||
@@ -649,9 +650,17 @@ export class PaymentsService {
|
|||||||
bookingId,
|
bookingId,
|
||||||
amountMinor: snapshot.amountMinor,
|
amountMinor: snapshot.amountMinor,
|
||||||
currency: snapshot.currency,
|
currency: snapshot.currency,
|
||||||
|
status,
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.prisma.paymentIntent.updateMany({
|
||||||
|
where: { bookingId, status: { not: PaymentIntentStatus.SUCCEEDED } },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initiateWalletPayment(
|
private async initiateWalletPayment(
|
||||||
@@ -1029,6 +1038,14 @@ export class PaymentsService {
|
|||||||
async reconcileAndConfirmIfPaid(
|
async reconcileAndConfirmIfPaid(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
): Promise<{ paid: boolean; verified: boolean }> {
|
): 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(
|
const settlement = await this.paymentClient.reconcileByReference(
|
||||||
PaymentReferenceType.BOOKING,
|
PaymentReferenceType.BOOKING,
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -1161,12 +1178,31 @@ export class PaymentsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (confirmed === 0) {
|
if (confirmed === 0) {
|
||||||
// Booking already confirmed by another payment (or not payable and not forced). This capture
|
const recordsConfirmingCapture =
|
||||||
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
|
booking.status === "CONFIRMED" && intent.paidAt != null;
|
||||||
this.logger.error(
|
if (recordsConfirmingCapture) {
|
||||||
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
|
const { count } = await this.prisma.paymentIntent.updateMany({
|
||||||
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
|
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 };
|
return { alreadyFinalized: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user