mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
merge conflict
This commit is contained in:
@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
|
||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
|
||||
type PaymentType = "booking"
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 255, name: "ref_id" })
|
||||
refId!: string
|
||||
|
||||
@Column({ type: "enum", enum: ["booking"] })
|
||||
@Column({ type: "varchar", length: 50 })
|
||||
type!: PaymentType;
|
||||
|
||||
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||
referenceType?: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
Body,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -20,14 +20,7 @@ import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
@@ -73,16 +66,6 @@ export class PaymentController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
||||
return this.paymentService.initiatePayment(dto);
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
@ApiOkResponse({ type: IntentStatusDto })
|
||||
@@ -97,54 +80,6 @@ export class PaymentController {
|
||||
return this.paymentService.refund(dto);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
@@ -153,76 +88,4 @@ export class PaymentController {
|
||||
const html = await this.paymentService.genReceiptHtml(orderId);
|
||||
return res.status(HttpStatus.OK).type("html").send(html);
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
|
||||
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
} from "@edr/types";
|
||||
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => BillingModule),
|
||||
TypeOrmModule.forFeature([
|
||||
PaymentEntity,
|
||||
PaymentWebhookEventEntity,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
|
||||
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";
|
||||
@@ -28,13 +29,44 @@ import {
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
|
||||
/** 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,
|
||||
@@ -45,6 +77,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"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);
|
||||
@@ -53,9 +102,8 @@ export class PaymentService {
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
async getAll(filters: {
|
||||
@@ -123,7 +171,6 @@ export class PaymentService {
|
||||
total += row.count;
|
||||
}
|
||||
|
||||
// Sum of successfully collected amounts.
|
||||
const paidAgg = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||
@@ -141,65 +188,74 @@ export class PaymentService {
|
||||
};
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.reference,
|
||||
amountMinor,
|
||||
currency: booking.paymentCurrency,
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
referenceType: input.referenceType,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
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 intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
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: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatIntentResponse(intent);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
booking: Booking,
|
||||
/** 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: bookingId, type: "booking" });
|
||||
const existing = await this.paymentRepo.findOneBy({
|
||||
refId: input.referenceId,
|
||||
});
|
||||
|
||||
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",
|
||||
};
|
||||
const method: PaymentEntity["method"] =
|
||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
const status =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const data = {
|
||||
status,
|
||||
method,
|
||||
@@ -216,30 +272,37 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
return this.paymentRepo.create({
|
||||
refId: bookingId,
|
||||
type: "booking",
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
reason: `Payment for booking ${booking.reference}`,
|
||||
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);
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
/**
|
||||
* 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(
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
bookingId,
|
||||
(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 booking ${bookingId}: ${message}; using local intent`,
|
||||
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,80 +310,59 @@ export class PaymentService {
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: bookingId });
|
||||
// Sync local projection with provider-reported status.
|
||||
const becameSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
|
||||
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
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: intent.id });
|
||||
return this.formatIntentStatus(refreshed ?? intent);
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
bookingId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
/**
|
||||
* 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 = input.paidAt ?? new Date();
|
||||
const paidAt = opts.paidAt ?? new Date();
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
|
||||
// Every booking is a real shipment now (contracts are a separate aggregate),
|
||||
// so payment always settles the booking to PAID and enters allocation.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (opts.notify !== false) {
|
||||
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
|
||||
}
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
@@ -335,6 +377,29 @@ export class PaymentService {
|
||||
{ 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 refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
// NOTE: refunding still mutates the booking directly — left intact pending
|
||||
// the refund redesign. TODO: route refunds through billing.refundPayable +
|
||||
// a `${source}.invoice.refunded` reaction, like settlement.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
@@ -363,7 +428,7 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
findBookingById(id: string) {
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
||||
return this.paymentRepo.findOneBy({ refId: id });
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
@@ -398,24 +463,37 @@ export class PaymentService {
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||
console.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||
if (event.eventType === "payment.succeeded") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||
}
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: event.referenceId,
|
||||
console.log(`Processing payment succeeded event for intent: }`,intent);
|
||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
|
||||
// When the intent references a booking, flip the booking itself paid.
|
||||
// refId holds the booking id (the domain reference the intent opened with).
|
||||
if (intent.referenceType === PaymentReferenceType.BOOKING) {
|
||||
await this.datasource.manager.update(
|
||||
Booking,
|
||||
{ id: intent.refId },
|
||||
{ status: "PAID", paymentStatus: "PAID" },
|
||||
);
|
||||
}
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||
}
|
||||
await this.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
|
||||
Reference in New Issue
Block a user