mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
660 lines
22 KiB
TypeScript
660 lines
22 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;
|
||
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
|
||
payerName?: string;
|
||
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
|
||
expiresAt?: 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",
|
||
CBE_BILL: "cbe-bill",
|
||
};
|
||
|
||
/**
|
||
* 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 isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
||
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
|
||
throw new BadRequestException(
|
||
"CBE bill payment is only available for ETB invoices",
|
||
);
|
||
}
|
||
|
||
const snapshot = await this.paymentClient.initiate({
|
||
service: PaymentServiceEnum.FREIGHT,
|
||
referenceType: PaymentReferenceType.SHIPMENT,
|
||
referenceId: input.referenceId,
|
||
orderRef: input.orderRef,
|
||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||
// debited against the intent amount, so the dev shortcut would break it.
|
||
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
||
// shortcut floor is 10, not 1.
|
||
amountMinor: isCbeBill
|
||
? input.amountMinor
|
||
: input.method === ProviderMethod.CAC_BANK
|
||
? 10
|
||
: 1,
|
||
currency: input.currency,
|
||
provider: input.method as ProviderMethod,
|
||
platform: input.platform,
|
||
payerAccount: input.payerAccount,
|
||
payerName: input.payerName,
|
||
expiresAt: input.expiresAt,
|
||
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") {
|
||
// Still notify billing: a prior delivery may have flipped the intent to
|
||
// success and then died before the invoice settled (the two steps are not
|
||
// atomic). settleByPaymentId is idempotent — no open invoice, no-op.
|
||
if (opts.notify !== false) {
|
||
await this.billing.settleByPaymentId(
|
||
intent.id,
|
||
opts.providerTxnId ?? intent.transactionId ?? undefined,
|
||
opts.paidAt ?? intent.paidAt ?? undefined,
|
||
);
|
||
}
|
||
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);
|
||
}
|
||
}
|