Merge pull request #1218 from Tria-plc/main

Main
This commit is contained in:
Abubeker Yasin
2026-08-10 14:27:38 +03:00
committed by GitHub
3 changed files with 122 additions and 10 deletions

View File

@@ -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");

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 };
}