fix: ( payments ) stop reconcile-before-cancel deferring bookings forever

This commit is contained in:
Abubeker Yasin
2026-08-11 11:19:39 +03:00
parent 8ce6483e26
commit dc2078dd28
5 changed files with 258 additions and 31 deletions

View File

@@ -155,4 +155,130 @@ describe("IntentsService CBE_BILL", () => {
expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
expect(applySpy).not.toHaveBeenCalled();
});
/**
* Regression: reconcileReference used to route every non-FAILED intent through
* queryProviderStatus, which THROWS "Unknown provider" for CBE_BILL (no map entry — D5). The
* throw was counted as a provider error, so the check returned `unverifiable` forever and the
* owning app could never auto-cancel the booking: seats stayed held and the sweep re-queried
* the same booking once a minute for days. With no outbound query to make, the stored status
* IS the answer.
*/
describe("reconcileReference (reconcile-before-cancel)", () => {
const cbeIntent = (status: ProviderPaymentStatus) =>
({
id: "intent-1",
service: PaymentService.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
referenceId: "booking-1",
merchantOrderId: "PSG-x",
provider: ProviderMethod.CBE_BILL,
status,
amountMinor: 1500,
currency: "ETB",
billReference: "000100000015",
}) as unknown as PaymentIntent;
it("reports an unpaid CBE_BILL intent as VERIFIED not paid, not unverifiable", async () => {
repository.findAllByReference.mockResolvedValue([
cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION),
] as never);
const result = await service.reconcileReference(
PaymentService.PASSENGER,
PaymentReferenceType.BOOKING,
"booking-1",
);
expect(result).toEqual({ paid: false, unverifiable: false });
expect(result.reason).toBeUndefined();
});
it("still reports a retired-but-settled CBE_BILL intent as paid", async () => {
// The inbound /cbe/payment already flipped it; step 2 of the resolution catches it.
repository.findAllByReference.mockResolvedValue([
cbeIntent(ProviderPaymentStatus.SUCCEEDED),
] as never);
const result = await service.reconcileReference(
PaymentService.PASSENGER,
PaymentReferenceType.BOOKING,
"booking-1",
);
expect(result.paid).toBe(true);
expect(result.unverifiable).toBe(false);
});
it("does not let an unqueryable sibling mask a real provider error", async () => {
const telebirr = {
...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION),
id: "intent-2",
provider: ProviderMethod.TELEBIRR,
} as unknown as PaymentIntent;
providers.set(ProviderMethod.TELEBIRR, {
method: ProviderMethod.TELEBIRR,
queryStatus: jest.fn().mockRejectedValue(new Error("ETIMEDOUT")),
});
repository.findAllByReference.mockResolvedValue([
cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION),
telebirr,
] as never);
const result = await service.reconcileReference(
PaymentService.PASSENGER,
PaymentReferenceType.BOOKING,
"booking-1",
);
expect(result).toEqual({
paid: false,
unverifiable: true,
reason: "PROVIDER_ERROR",
});
providers.delete(ProviderMethod.TELEBIRR);
});
it("reports IN_FLIGHT ahead of PROVIDER_ERROR so a caller never gives up on moving money", async () => {
const processing = {
...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION),
id: "intent-2",
provider: ProviderMethod.TELEBIRR,
} as unknown as PaymentIntent;
const failing = {
...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION),
id: "intent-3",
provider: ProviderMethod.WAAFI,
} as unknown as PaymentIntent;
providers.set(ProviderMethod.TELEBIRR, {
method: ProviderMethod.TELEBIRR,
queryStatus: jest
.fn()
.mockResolvedValue({ status: ProviderPaymentStatus.PROCESSING }),
});
providers.set(ProviderMethod.WAAFI, {
method: ProviderMethod.WAAFI,
queryStatus: jest.fn().mockRejectedValue(new Error("ETIMEDOUT")),
});
repository.findAllByReference.mockResolvedValue([
processing,
failing,
] as never);
repository.findById.mockResolvedValue(processing);
jest
.spyOn(service, "applyProviderResult")
.mockResolvedValue({ alreadyTerminal: false });
const result = await service.reconcileReference(
PaymentService.PASSENGER,
PaymentReferenceType.BOOKING,
"booking-1",
);
expect(result.unverifiable).toBe(true);
expect(result.reason).toBe("IN_FLIGHT");
providers.delete(ProviderMethod.TELEBIRR);
providers.delete(ProviderMethod.WAAFI);
});
});
});

View File

@@ -49,6 +49,14 @@ export interface ProviderResultInput {
rawResponse?: Record<string, unknown>;
}
/**
* Why a settlement check came back `unverifiable`. The two causes are NOT interchangeable:
* `IN_FLIGHT` is money actually moving and must be waited out indefinitely, while
* `PROVIDER_ERROR` can be a permanently unreachable gateway — a caller may eventually give up on
* that one rather than defer forever (see TasksService's reconcile grace window).
*/
export type ReconcileUnverifiableReason = "IN_FLIGHT" | "PROVIDER_ERROR";
/** Result of {@link IntentsService.reconcileReference} — a settlement check for a domain order. */
export interface ReconcileReferenceResult {
/** True when at least one intent for the order is settled (SUCCEEDED, incl. a just-registered late capture). */
@@ -56,10 +64,12 @@ export interface ReconcileReferenceResult {
/** Snapshot of the paying intent when `paid`. */
intent?: PaymentIntentSnapshot;
/**
* True when we could NOT confirm "not paid": at least one candidate intent's provider status
* query errored, so its settlement is unknown. Callers must treat this as "do not cancel".
* True when we could NOT confirm "not paid": a candidate intent's provider status query errored,
* or a payment is still in flight. Callers must treat this as "do not cancel".
*/
unverifiable: boolean;
/** Set whenever `unverifiable` — which of the two causes applies. */
reason?: ReconcileUnverifiableReason;
}
@Injectable()
@@ -486,19 +496,44 @@ export class IntentsService {
const candidates = intents.filter(
(i) => i.status !== ProviderPaymentStatus.FAILED,
);
// Inbound-only methods (CBE_BILL) have deliberately no PAYMENT_PROVIDER_MAP entry — plan D5,
// docs/cbe/CBE_IMPLEMENTATION_PLAN.md. There is NO outbound query to make, so their stored
// status is the best truth available and step 2 above already checked it. Counting them as
// provider errors made every CBE_BILL order permanently `unverifiable` and therefore
// impossible to auto-cancel — the caller deferred forever, once a minute, indefinitely.
const queryable = candidates.filter((i) => this.providers.has(i.provider));
const unqueryable = candidates.length - queryable.length;
if (unqueryable > 0) {
this.logger.log(
`reconcile: ${unqueryable}/${candidates.length} intent(s) for ${referenceType}/${referenceId} ` +
`have no outbound status query (inbound-only provider) — trusting the stored status`,
);
}
// Queried in parallel: a booking that accumulated several dead sessions used to serialise one
// 10s provider timeout per intent, so a single stuck order could hold the caller's sweep for
// 30s+. Results are still APPLIED in order, and we still stop at the first settled intent.
const probes = await Promise.all(
queryable.map(async (intent) => {
try {
return { intent, status: await this.queryProviderStatus(intent) };
} catch (err) {
this.logger.warn(
`reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${
err instanceof Error ? err.message : String(err)
}`,
);
return { intent, status: null };
}
}),
);
let providerErrors = 0;
let inFlight = false;
for (const intent of candidates) {
let status: ProviderStatus;
try {
status = await this.queryProviderStatus(intent);
} catch (err) {
for (const { intent, status } of probes) {
if (!status) {
providerErrors++;
this.logger.warn(
`reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${
err instanceof Error ? err.message : String(err)
}`,
);
continue;
}
@@ -528,7 +563,15 @@ export class IntentsService {
}
}
return { paid: false, unverifiable: providerErrors > 0 || inFlight };
// IN_FLIGHT outranks PROVIDER_ERROR: a caller that gives up after N minutes of gateway errors
// must NEVER apply that give-up to an order whose payment is actually moving.
if (inFlight) {
return { paid: false, unverifiable: true, reason: "IN_FLIGHT" };
}
if (providerErrors > 0) {
return { paid: false, unverifiable: true, reason: "PROVIDER_ERROR" };
}
return { paid: false, unverifiable: false };
}
/** Best-effort live provider status for a merchant order id; never throws (returns null). */