mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -769,7 +769,14 @@ export default function CompanyProfileForm({
|
||||
// themselves, so there is no delegation to evidence. Mirrors the API's own
|
||||
// waiver in `assertPoaDelegationSatisfied` — the two must agree, or this
|
||||
// demands a file the server would accept the submission without.
|
||||
const delegationRequired = (poaProvided || requirePoa) && !poaSameAsOwner;
|
||||
//
|
||||
// Split from `poaDue` — "there is a representative, so their details are
|
||||
// owed" — because a self-PoA keeps the second while dropping the first. The
|
||||
// API draws the same line (`poaDue` / `delegationDue` in
|
||||
// getOnboardingRequirements); anything that is about the *details* must key
|
||||
// on `poaDue`, only the paper keys on this.
|
||||
const poaDue = poaProvided || requirePoa;
|
||||
const delegationRequired = poaDue && !poaSameAsOwner;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
(() => {
|
||||
@@ -853,9 +860,15 @@ export default function CompanyProfileForm({
|
||||
// here.
|
||||
if (gmGaps.name) requiredKeys.push("generalManagerName");
|
||||
if (gmGaps.phone) requiredKeys.push("generalManagerPhone");
|
||||
} else if (step === "poa" && delegationRequired) {
|
||||
} else if (step === "poa" && poaDue) {
|
||||
// Only once a PoA is required or provided: an untouched optional PoA is
|
||||
// still a step the customer may walk straight past.
|
||||
//
|
||||
// `poaDue`, NOT `delegationRequired`: the paper is waived for a self-PoA
|
||||
// but `REQUIRED_POA_FIELDS` is not, and the API reports every one of them
|
||||
// missing (`missingPoaFields` keys on its own `poaDue`) — which fails the
|
||||
// submit and clamps the resume back here. Keying this on the paper let the
|
||||
// customer walk past an input this step had already put on screen.
|
||||
if (poaGaps.name) requiredKeys.push("poaName");
|
||||
if (poaGaps.email) requiredKeys.push("poaEmail");
|
||||
if (poaGaps.phone) requiredKeys.push("poaPhone");
|
||||
|
||||
@@ -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