mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
CAC Bank is an OTP debit with no redirect and no webhook: initiate SMSes a code to the payer's mobile, and the charge only settles when that code is confirmed. The payment service already spoke it (passenger uses it); the freight side had the enum values but none of the flow. API: - PaymentClientService.confirmOtp forwards the code to POST /payments/intents/:id/confirm, mapping 400/404 to BadRequest so a mistyped code stays retryable instead of surfacing as a gateway failure. - PaymentService.confirmOtp is keyed by the LOCAL intent id (the invoice's paymentId) rather than the domain reference, so the right invoice settles when several share a booking. On success billing settles the invoice. - payInvoice rejects CAC_BANK without payerAccount before calling the gateway, and no longer runs the demo auto-settle for a COLLECT_OTP intent (it is not paid until the payer confirms). - POST /billing/my-invoices/:id/confirm — ownership-checked, and since warehouse fee invoices are central invoices it covers those too. Portal: - useInvoicePayment owns the whole flow (initiate, redirect-or-OTP, confirm) and replaces the five near-identical pay mutations at the call sites. - PaymentMethodModal gains the CAC Bank option, the payer mobile field, and the OTP step. Click-outside is disabled there so a stray click cannot drop the payer out of a live OTP window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
629 lines
20 KiB
TypeScript
629 lines
20 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
forwardRef,
|
|
Inject,
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
Logger,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { PaymentEntity } from "./entities/payment.entity";
|
|
import { PaymentRepository } from "./payment.repository";
|
|
import { PaymentClientService } from "./payment-client.service";
|
|
import { BillingService } from "../billing/billing.service";
|
|
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import * as Handlebars from "handlebars";
|
|
|
|
import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers";
|
|
import {
|
|
PaymentService as PaymentServiceEnum,
|
|
PaymentReferenceType,
|
|
PaymentIntentSnapshot,
|
|
ProviderMethod,
|
|
} from "@edr/types";
|
|
import {
|
|
InitiateResponseDto,
|
|
IntentStatusDto,
|
|
PaymentPlatformDto,
|
|
} from "./payments.dto";
|
|
|
|
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
|
* the caller (billing) — this service never derives them from a domain record. */
|
|
export interface InitiateIntentInput {
|
|
/** Opaque domain reference (booking id, …). */
|
|
referenceId: string;
|
|
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
|
|
source: string;
|
|
/** Gateway reference type the intent is opened with (caller's domain decides it). */
|
|
referenceType: PaymentReferenceType;
|
|
/** Human-readable order ref shown on provider pages. */
|
|
orderRef: string;
|
|
/** Authoritative amount in minor units, computed by the caller. */
|
|
amountMinor: number;
|
|
currency: string;
|
|
/** Stored on the intent projection for receipts/dashboards. */
|
|
reason?: string;
|
|
/** Provider/method selector. */
|
|
method: ProviderMethod | string;
|
|
platform?: PaymentPlatformDto;
|
|
payerAccount?: string;
|
|
returnUrl?: string;
|
|
failureUrl?: string;
|
|
}
|
|
|
|
export interface InitiateIntentResult {
|
|
intentId: string;
|
|
response: InitiateResponseDto;
|
|
/** True when the provider settled the charge synchronously during initiate. */
|
|
immediateSuccess: boolean;
|
|
providerTxnId?: string;
|
|
paidAt?: Date;
|
|
}
|
|
|
|
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
|
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
|
processing: ProviderPaymentStatus.PROCESSING,
|
|
success: ProviderPaymentStatus.SUCCEEDED,
|
|
failed: ProviderPaymentStatus.FAILED,
|
|
canceled: ProviderPaymentStatus.CANCELLED,
|
|
refunded: ProviderPaymentStatus.CANCELLED,
|
|
};
|
|
|
|
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
|
TELEBIRR: "telebirr",
|
|
CBE_BIRR: "cbe-birr",
|
|
EBIRR: "ebirr",
|
|
WAAFI: "waafi",
|
|
CARD: "card",
|
|
DMONEY: "dmoney",
|
|
CAC_BANK: "cac-bank",
|
|
};
|
|
|
|
/**
|
|
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
|
|
* NOTHING domain-specific: it never loads a booking, computes an amount, or
|
|
* advances a domain record. On settlement it notifies billing directly
|
|
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
|
|
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
|
|
*/
|
|
@Injectable()
|
|
export class PaymentService {
|
|
private readonly logger = new Logger(PaymentService.name);
|
|
|
|
constructor(
|
|
private readonly paymentRepo: PaymentRepository,
|
|
private readonly paymentClient: PaymentClientService,
|
|
@Inject(forwardRef(() => BillingService))
|
|
private readonly billing: BillingService,
|
|
) { }
|
|
|
|
async getAll(filters: {
|
|
search?: string;
|
|
status?: string;
|
|
method?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}) {
|
|
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const qb = this.paymentRepo.createQueryBuilder("payment");
|
|
|
|
if (search) {
|
|
qb.andWhere(
|
|
"(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)",
|
|
{ search: `%${search}%` },
|
|
);
|
|
}
|
|
if (status) {
|
|
qb.andWhere("payment.status = :status", { status });
|
|
}
|
|
if (method) {
|
|
qb.andWhere("payment.method = :method", { method });
|
|
}
|
|
|
|
const [items, total] = await qb
|
|
.orderBy("payment.createdAt", "DESC")
|
|
.skip(skip)
|
|
.take(pageSize)
|
|
.getManyAndCount();
|
|
|
|
return {
|
|
items: items.map((p) => ({
|
|
id: p.id,
|
|
bookingId: p.refId,
|
|
amount: p.amount,
|
|
currency: p.currency,
|
|
method: p.method,
|
|
status: p.status,
|
|
merchantOrderId: p.merchantOrderId,
|
|
paidAt: p.paidAt,
|
|
createdAt: p.createdAt,
|
|
})),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
/** Aggregate counts across ALL payments for the dashboard summary cards. */
|
|
async getSummary() {
|
|
const rows = await this.paymentRepo
|
|
.createQueryBuilder("payment")
|
|
.select("payment.status", "status")
|
|
.addSelect("COUNT(*)::int", "count")
|
|
.groupBy("payment.status")
|
|
.getRawMany<{ status: string; count: number }>();
|
|
|
|
const byStatus: Record<string, number> = {};
|
|
let total = 0;
|
|
for (const row of rows) {
|
|
byStatus[row.status] = row.count;
|
|
total += row.count;
|
|
}
|
|
|
|
const paidAgg = await this.paymentRepo
|
|
.createQueryBuilder("payment")
|
|
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
|
.where("payment.status = :status", { status: "success" })
|
|
.getRawOne<{ sum: string }>();
|
|
|
|
return {
|
|
total,
|
|
success: byStatus["success"] ?? 0,
|
|
processing:
|
|
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
|
|
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
|
|
refunded: byStatus["refunded"] ?? 0,
|
|
paidAmount: Number(paidAgg?.sum ?? 0),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Open a gateway intent for a caller-supplied amount/reference and project it
|
|
* locally. Returns the intent id (so billing can correlate the invoice) plus
|
|
* the client action. When the provider settles synchronously, the intent is
|
|
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
|
* has stored the intent id, avoiding a settle-before-correlation race.
|
|
*/
|
|
/**
|
|
* Reconcile-before-cancel: ask the payment service whether ANY intent for
|
|
* this shipment actually settled at the provider (bank/gateway). A late
|
|
* capture found there is registered as SUCCEEDED and emits payment.succeeded,
|
|
* which drives the normal paid flow. A network/provider error reports
|
|
* `unverifiable` — the caller must not expire the order on unknown.
|
|
*/
|
|
async reconcileShipment(
|
|
referenceId: string,
|
|
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
|
try {
|
|
const result = await this.paymentClient.reconcileReference(
|
|
PaymentReferenceType.SHIPMENT,
|
|
referenceId,
|
|
);
|
|
return { paid: result.paid, unverifiable: result.unverifiable };
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
|
);
|
|
return { paid: false, unverifiable: true };
|
|
}
|
|
}
|
|
|
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
|
try {
|
|
|
|
|
|
|
|
const snapshot = await this.paymentClient.initiate({
|
|
service: PaymentServiceEnum.FREIGHT,
|
|
referenceType: PaymentReferenceType.SHIPMENT,
|
|
referenceId: input.referenceId,
|
|
orderRef: input.orderRef,
|
|
// amountMinor: input.amountMinor,
|
|
amountMinor:1,
|
|
currency: input.currency,
|
|
provider: input.method as ProviderMethod,
|
|
platform: input.platform,
|
|
payerAccount: input.payerAccount,
|
|
returnUrl:
|
|
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
|
failureUrl:
|
|
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
|
});
|
|
|
|
const immediateSuccess =
|
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
|
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
|
|
|
const intent = await this.upsertIntent(input, snapshot);
|
|
|
|
if (immediateSuccess) {
|
|
// Settle the projection but DO NOT notify billing — billing settles
|
|
// inline once it has stored intentId on the invoice (see payInvoice),
|
|
// avoiding a settle-before-correlation race.
|
|
await this.markIntentSucceeded(intent.id, {
|
|
providerTxnId: snapshot.providerTxnId,
|
|
paidAt,
|
|
notify: false,
|
|
});
|
|
}
|
|
|
|
return {
|
|
intentId: intent.id,
|
|
// `intent` still reflects the projection status ("processing" on immediate
|
|
// success — settlement is applied by the caller, not shown synchronously).
|
|
response: this.formatIntentResponse(intent),
|
|
immediateSuccess,
|
|
providerTxnId: snapshot.providerTxnId,
|
|
paidAt,
|
|
};
|
|
} catch (err) {
|
|
console.log(err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Create or update the local intent projection from a provider snapshot. */
|
|
private async upsertIntent(
|
|
input: InitiateIntentInput,
|
|
snapshot: PaymentIntentSnapshot,
|
|
): Promise<PaymentEntity> {
|
|
const existing = await this.paymentRepo.findOneBy({
|
|
refId: input.referenceId,
|
|
});
|
|
|
|
const method: PaymentEntity["method"] =
|
|
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
|
const status =
|
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
|
? "processing"
|
|
: this.toLocalStatus(snapshot.status);
|
|
|
|
const clientAction = (snapshot.clientAction ?? undefined) as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
const data = {
|
|
status,
|
|
method,
|
|
merchantOrderId:
|
|
snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "",
|
|
transactionId: snapshot.providerTxnId ?? existing?.transactionId,
|
|
expiresAt: snapshot.expiresAt
|
|
? new Date(snapshot.expiresAt)
|
|
: existing?.expiresAt,
|
|
failerCode: snapshot.failureCode ?? undefined,
|
|
failureMessage: snapshot.failureMessage ?? undefined,
|
|
};
|
|
|
|
if (existing) {
|
|
await this.paymentRepo.update({ id: existing.id }, {
|
|
...data,
|
|
clientAction,
|
|
} as any);
|
|
return { ...existing, ...data, clientAction } as PaymentEntity;
|
|
}
|
|
|
|
return this.paymentRepo.create({
|
|
refId: input.referenceId,
|
|
type: input.source,
|
|
referenceType: input.referenceType,
|
|
amount: input.amountMinor,
|
|
currency: input.currency as PaymentEntity["currency"],
|
|
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
|
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
|
clientAction: clientAction ?? {},
|
|
...data,
|
|
} as any);
|
|
}
|
|
|
|
/**
|
|
* Reconcile an intent's status with the gateway by reference. Read-only on the
|
|
* domain side: it syncs the local projection and, when the provider reports a
|
|
* newly-observed success, notifies billing to settle. `referenceId` is opaque
|
|
* (the booking id, but this service does not load it).
|
|
*/
|
|
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
|
|
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
|
|
|
|
let snapshot: PaymentIntentSnapshot | null = null;
|
|
try {
|
|
snapshot = await this.paymentClient.getIntentByReference(
|
|
(local?.referenceType as PaymentReferenceType) ??
|
|
PaymentReferenceType.SHIPMENT,
|
|
referenceId,
|
|
);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
this.logger.warn(
|
|
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
|
|
);
|
|
}
|
|
|
|
if (!snapshot) {
|
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
|
return this.formatIntentStatus(local);
|
|
}
|
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
|
|
|
// Sync local projection with provider-reported status.
|
|
const becameSuccess =
|
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED &&
|
|
local.status !== "success";
|
|
|
|
if (becameSuccess) {
|
|
await this.markIntentSucceeded(local.id, {
|
|
providerTxnId: snapshot.providerTxnId,
|
|
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
|
notify: true,
|
|
});
|
|
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
|
|
await this.paymentRepo.update(
|
|
{ id: local.id },
|
|
{
|
|
status: this.toLocalStatus(snapshot.status),
|
|
failerCode: snapshot.failureCode ?? undefined,
|
|
failureMessage: snapshot.failureMessage ?? undefined,
|
|
},
|
|
);
|
|
}
|
|
|
|
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
|
return this.formatIntentStatus(refreshed ?? local);
|
|
}
|
|
|
|
/**
|
|
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
|
|
* id (the invoice's `paymentId`) so the right invoice settles even when several
|
|
* invoices share a domain reference. The active gateway intent is looked up by
|
|
* reference, the OTP is forwarded, and the projection is refreshed. On success
|
|
* billing settles the linked invoice (idempotent — the outbox path converges too).
|
|
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
|
|
*/
|
|
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
|
|
const local = await this.paymentRepo.findOneBy({ id: intentId });
|
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
|
|
|
const snapshot = await this.paymentClient.getIntentByReference(
|
|
(local.referenceType as PaymentReferenceType) ??
|
|
PaymentReferenceType.SHIPMENT,
|
|
local.refId,
|
|
);
|
|
if (!snapshot) {
|
|
throw new NotFoundException("No active payment to confirm");
|
|
}
|
|
|
|
const confirmed = await this.paymentClient.confirmOtp(
|
|
snapshot.intentId,
|
|
otp,
|
|
);
|
|
|
|
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
await this.markIntentSucceeded(local.id, {
|
|
providerTxnId: confirmed.providerTxnId,
|
|
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
|
|
notify: true,
|
|
});
|
|
} else {
|
|
await this.paymentRepo.update(
|
|
{ id: local.id },
|
|
{
|
|
status: this.toLocalStatus(confirmed.status),
|
|
failerCode: confirmed.failureCode ?? undefined,
|
|
failureMessage: confirmed.failureMessage ?? undefined,
|
|
},
|
|
);
|
|
}
|
|
|
|
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
|
return this.formatIntentStatus(refreshed ?? local);
|
|
}
|
|
|
|
/**
|
|
* Mark a gateway intent paid and (by default) notify billing to settle the
|
|
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
|
* when the caller settles inline and will trigger settlement itself.
|
|
*/
|
|
async markIntentSucceeded(
|
|
intentId: string,
|
|
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
|
|
): Promise<{ alreadyFinalized: boolean }> {
|
|
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
if (intent.status === "success") return { alreadyFinalized: true };
|
|
|
|
const paidAt = opts.paidAt ?? new Date();
|
|
await this.paymentRepo.update(
|
|
{ id: intent.id },
|
|
{
|
|
status: "success",
|
|
paidAt,
|
|
transactionId: opts.providerTxnId ?? intent.transactionId,
|
|
},
|
|
);
|
|
|
|
if (opts.notify !== false) {
|
|
await this.billing.settleByPaymentId(
|
|
intent.id,
|
|
opts.providerTxnId,
|
|
paidAt,
|
|
);
|
|
}
|
|
|
|
return { alreadyFinalized: false };
|
|
}
|
|
|
|
async markPaymentFailed(input: {
|
|
intentId: string;
|
|
failureCode?: string;
|
|
failureMessage?: string;
|
|
}): Promise<void> {
|
|
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
if (intent.status === "success" || intent.status === "canceled") return;
|
|
|
|
await this.paymentRepo.update(
|
|
{ id: intent.id },
|
|
{
|
|
status: "failed",
|
|
failerCode: input.failureCode,
|
|
failureMessage: input.failureMessage,
|
|
},
|
|
);
|
|
|
|
// Invoice stays open for retry — nothing to settle. Logged only.
|
|
this.logger.warn(
|
|
`Payment ${intent.id} failed for ${intent.refId}` +
|
|
(input.failureMessage ? `: ${input.failureMessage}` : ""),
|
|
);
|
|
}
|
|
|
|
async getActivePaymentByOrderIdAndMethod(
|
|
orderId: string,
|
|
method: PaymentEntity["method"],
|
|
): Promise<PaymentEntity | null> {
|
|
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method);
|
|
}
|
|
|
|
async genReceiptHtml(orderId: string) {
|
|
const payment = await this.paymentRepo.findOneBy({
|
|
merchantOrderId: orderId,
|
|
status: "success",
|
|
});
|
|
if (!payment)
|
|
throw new BadRequestException(
|
|
"No successful payment found for this order",
|
|
);
|
|
|
|
const filePath = path.join(__dirname, "templates", "receipt.hbs");
|
|
if (!fs.existsSync(filePath)) throw new InternalServerErrorException();
|
|
|
|
const source = fs.readFileSync(filePath, "utf8");
|
|
const template = Handlebars.compile(source);
|
|
return template({
|
|
vendorName: "Ethio Djibouti Railway Freight Booking",
|
|
vendorAddress: "Addis Ababa",
|
|
receiptDate: payment.paidAt,
|
|
paymentMethod: payment.method,
|
|
subtotal: payment.amount.toString(),
|
|
total: payment.amount.toString(),
|
|
currency: payment.currency,
|
|
reason: payment.reason,
|
|
});
|
|
}
|
|
|
|
findBookingById(id: string) {
|
|
return this.paymentRepo.findOneBy({ refId: id });
|
|
}
|
|
|
|
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
|
const clientAction =
|
|
intent.clientAction && typeof intent.clientAction === "object"
|
|
? (intent.clientAction as unknown as ClientAction)
|
|
: undefined;
|
|
return {
|
|
intentId: intent.id,
|
|
status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
|
clientAction,
|
|
merchantOrderId: intent.merchantOrderId ?? undefined,
|
|
};
|
|
}
|
|
|
|
private formatIntentStatus(intent: PaymentEntity): IntentStatusDto {
|
|
return {
|
|
...this.formatIntentResponse(intent),
|
|
paidAt: intent.paidAt?.toISOString(),
|
|
failureCode: intent.failerCode ?? undefined,
|
|
failureMessage: intent.failureMessage ?? undefined,
|
|
};
|
|
}
|
|
|
|
async handlePaymentEvent(event: {
|
|
eventType: string;
|
|
eventId: string;
|
|
referenceId: string;
|
|
intentId: string;
|
|
providerTxnId?: string;
|
|
paidAt?: string;
|
|
failureCode?: string;
|
|
failureMessage?: string;
|
|
}): Promise<{
|
|
processed: boolean;
|
|
alreadyFinalized?: boolean;
|
|
reason?: string;
|
|
}> {
|
|
this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
|
|
if (event.eventType === "payment.succeeded") {
|
|
const intent = await this.paymentRepo.findOneBy({
|
|
refId: event.referenceId,
|
|
});
|
|
if (!intent) {
|
|
return {
|
|
processed: false,
|
|
reason: `No local intent for reference ${event.referenceId}`,
|
|
};
|
|
}
|
|
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
|
providerTxnId: event.providerTxnId,
|
|
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
|
notify: true,
|
|
});
|
|
this.logger.log(
|
|
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
|
);
|
|
|
|
// The payment service stays domain-agnostic: it settles the intent and
|
|
// lets billing settle the invoice (markIntentSucceeded → settleByPaymentId),
|
|
// which emits `${source}.invoice.paid`. Per-source advances (booking → PAID,
|
|
// warehouse → release, …) live in the domain services that listen for it.
|
|
return { processed: true, alreadyFinalized };
|
|
}
|
|
|
|
if (event.eventType === "payment.failed") {
|
|
const intent = await this.paymentRepo.findOneBy({
|
|
refId: event.referenceId,
|
|
});
|
|
if (!intent) {
|
|
return {
|
|
processed: false,
|
|
reason: `No local intent for reference ${event.referenceId}`,
|
|
};
|
|
}
|
|
await this.markPaymentFailed({
|
|
intentId: intent.id,
|
|
failureCode: event.failureCode,
|
|
failureMessage: event.failureMessage,
|
|
});
|
|
return { processed: true };
|
|
}
|
|
|
|
return {
|
|
processed: false,
|
|
reason: `Unknown event type: ${event.eventType}`,
|
|
};
|
|
}
|
|
|
|
private toLocalStatus(
|
|
status: ProviderPaymentStatus,
|
|
): PaymentEntity["status"] {
|
|
switch (status) {
|
|
case ProviderPaymentStatus.SUCCEEDED:
|
|
return "success";
|
|
case ProviderPaymentStatus.FAILED:
|
|
return "failed";
|
|
case ProviderPaymentStatus.CANCELLED:
|
|
return "canceled";
|
|
case ProviderPaymentStatus.PROCESSING:
|
|
return "processing";
|
|
default:
|
|
return "action-required";
|
|
}
|
|
}
|
|
|
|
async findByCompanyId(companyId: string) {
|
|
return this.paymentRepo.findByCompanyId(companyId);
|
|
}
|
|
}
|