mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( payment ): verify-before-cancel never cancel a paid booking
This commit is contained in:
@@ -20,7 +20,10 @@ import {
|
||||
IntentReferenceQueryDto,
|
||||
} from "./dto/initiate-payment.dto";
|
||||
import { ConfirmPaymentDto } from "./dto/confirm-payment.dto";
|
||||
import { IntentsService } from "./intents.service";
|
||||
import {
|
||||
IntentsService,
|
||||
ReconcileReferenceResult,
|
||||
} from "./intents.service";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
|
||||
/**
|
||||
@@ -108,6 +111,26 @@ export class IntentsController {
|
||||
return this.intentsService.getByMerchantOrderId(merchantOrderId, provider);
|
||||
}
|
||||
|
||||
@Post("reconcile")
|
||||
@ApiOperation({
|
||||
summary: "Settlement check for a domain order (reconcile-before-cancel)",
|
||||
description:
|
||||
"Returns whether ANY intent for the reference is paid. Live-queries every non-failed intent " +
|
||||
"— including already-retired (expired/cancelled) ones — at the provider and registers any " +
|
||||
"late capture found (flips it to SUCCEEDED and emits payment.succeeded). " +
|
||||
"`unverifiable: true` means settlement could not be confirmed (a provider query errored or a " +
|
||||
"payment is still in flight) — the caller MUST NOT cancel the order in that case.",
|
||||
})
|
||||
async reconcile(
|
||||
@Body() dto: IntentReferenceQueryDto,
|
||||
): Promise<ReconcileReferenceResult> {
|
||||
return this.intentsService.reconcileReference(
|
||||
dto.service,
|
||||
dto.referenceType,
|
||||
dto.referenceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("intents/:id/confirm")
|
||||
@ApiOperation({
|
||||
summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)",
|
||||
|
||||
@@ -58,6 +58,18 @@ export class IntentsRepository extends BaseRepository<PaymentIntent> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Every intent for a domain order (newest first) — input for reconcile/settlement checks. */
|
||||
async findAllByReference(
|
||||
service: PaymentService,
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntent[]> {
|
||||
return this.repository.find({
|
||||
where: { service, referenceType, referenceId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
async findByMerchantOrderId(
|
||||
merchantOrderId: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
|
||||
@@ -44,6 +44,19 @@ export interface ProviderResultInput {
|
||||
rawResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
paid: boolean;
|
||||
/** 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".
|
||||
*/
|
||||
unverifiable: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IntentsService {
|
||||
private readonly logger = new Logger(IntentsService.name);
|
||||
@@ -301,6 +314,93 @@ export class IntentsService {
|
||||
return { db: intent ?? null, provider: providerStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement check for a domain order, tolerant of MANY intents. Used before the owning app
|
||||
* cancels a still-unpaid booking — a paid booking whose `payment.succeeded` event was lost (MQ
|
||||
* down, late/missing webhook) must NOT be cancelled. Resolution:
|
||||
* 1. any intent already SUCCEEDED → paid;
|
||||
* 2. else live-query every non-FAILED intent (incl. expired/cancelled — the session may have
|
||||
* been paid after we retired it) and feed the result through the state machine, so a paid
|
||||
* terminal intent is registered SUCCEEDED (and emits payment.succeeded) → paid;
|
||||
* 3. else not paid.
|
||||
* `unverifiable` is set when a candidate's provider query errored, or a payment is still in
|
||||
* flight (PROCESSING) — i.e. we could NOT confirm "not paid"; the caller must then NOT cancel.
|
||||
*/
|
||||
async reconcileReference(
|
||||
service: PaymentService,
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<ReconcileReferenceResult> {
|
||||
const intents = await this.intentsRepository.findAllByReference(
|
||||
service,
|
||||
referenceType,
|
||||
referenceId,
|
||||
);
|
||||
if (intents.length === 0) {
|
||||
return { paid: false, unverifiable: false };
|
||||
}
|
||||
|
||||
const alreadyPaid = intents.find(
|
||||
(i) => i.status === ProviderPaymentStatus.SUCCEEDED,
|
||||
);
|
||||
if (alreadyPaid) {
|
||||
return {
|
||||
paid: true,
|
||||
intent: this.toSnapshot(alreadyPaid),
|
||||
unverifiable: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Live-query every intent that could plausibly hold a payment (skip FAILED; SUCCEEDED handled
|
||||
// above). A CANCELLED/EXPIRED intent may still have been paid at the provider after we retired it.
|
||||
const candidates = intents.filter(
|
||||
(i) => i.status !== ProviderPaymentStatus.FAILED,
|
||||
);
|
||||
let providerErrors = 0;
|
||||
let inFlight = false;
|
||||
for (const intent of candidates) {
|
||||
let status: ProviderStatus;
|
||||
try {
|
||||
status = await this.queryProviderStatus(intent);
|
||||
} catch (err) {
|
||||
providerErrors++;
|
||||
this.logger.warn(
|
||||
`reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
status.status === ProviderPaymentStatus.SUCCEEDED ||
|
||||
status.status === ProviderPaymentStatus.PROCESSING
|
||||
) {
|
||||
// Register the (possibly late) result through the state machine — a terminal intent that
|
||||
// was paid flips to SUCCEEDED and emits payment.succeeded.
|
||||
await this.applyProviderResult(
|
||||
intent.id,
|
||||
this.fromProviderStatus(status),
|
||||
);
|
||||
}
|
||||
|
||||
if (status.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const refreshed =
|
||||
(await this.intentsRepository.findById(intent.id)) ?? intent;
|
||||
return {
|
||||
paid: true,
|
||||
intent: this.toSnapshot(refreshed),
|
||||
unverifiable: false,
|
||||
};
|
||||
}
|
||||
if (status.status === ProviderPaymentStatus.PROCESSING) {
|
||||
inFlight = true; // money in flight — not settled, but not safe to cancel either
|
||||
}
|
||||
}
|
||||
|
||||
return { paid: false, unverifiable: providerErrors > 0 || inFlight };
|
||||
}
|
||||
|
||||
/** Best-effort live provider status for a merchant order id; never throws (returns null). */
|
||||
private async queryProviderForMerchantOrder(
|
||||
merchantOrderId: string,
|
||||
|
||||
Reference in New Issue
Block a user