mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
feat: ( payment ) implement cbe payment
This commit is contained in:
@@ -4,11 +4,17 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
/**
|
||||
@@ -18,6 +24,9 @@ import { PaymentsService } from "./payments.service";
|
||||
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
// isPublic only skips the global IAM user-JWT guard — these routes stay protected by
|
||||
// ServiceAuthGuard's shared service token (the payment service is not an IAM user).
|
||||
@SetMetadata("isPublic", true)
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentsController {
|
||||
@@ -32,4 +41,16 @@ export class InternalPaymentsController {
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentsService.handlePaymentEvent(event);
|
||||
}
|
||||
|
||||
@Post("bill-query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
|
||||
})
|
||||
async billQuery(
|
||||
@Body() request: BillQueryRequestDto,
|
||||
): Promise<BillQueryResponseDto> {
|
||||
return this.paymentsService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +55,24 @@ export class MarkPaidResponseDto {
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
|
||||
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
}
|
||||
|
||||
export class BillQueryResponseDto {
|
||||
@ApiProperty() stillPayable!: boolean;
|
||||
@ApiPropertyOptional() payerName?: string | null;
|
||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||
@ApiPropertyOptional() currency?: string | null;
|
||||
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum PaymentMethodTypeEnum {
|
||||
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
|
||||
CARD = "CARD", // International
|
||||
WALLET = "WALLET", // Internal
|
||||
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
|
||||
}
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
@@ -115,8 +116,10 @@ export class SupportedPaymentMethodDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
})
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@ApiPropertyOptional({
|
||||
@@ -135,6 +138,14 @@ export class ClientActionDto {
|
||||
providerOrderId?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
|
||||
})
|
||||
billReference?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
instructions?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PaymentsService } from "./payments.service";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
import { TicketsService } from "../tickets/tickets.service";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
@@ -27,6 +28,7 @@ describe("PaymentsService", () => {
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
paymentIntent: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -81,6 +83,8 @@ describe("PaymentsService", () => {
|
||||
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||
Promise.resolve(minor),
|
||||
),
|
||||
displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100),
|
||||
convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100),
|
||||
getRateOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -109,6 +113,7 @@ describe("PaymentsService", () => {
|
||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||
{ provide: CurrencyService, useValue: mockCurrencyService },
|
||||
{ provide: AuditService, useValue: { log: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
PaymentRegionEnum,
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils";
|
||||
import {
|
||||
PaymentClientService,
|
||||
PaymentDiagnostic,
|
||||
@@ -222,6 +227,17 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
|
||||
// required — CBE identifies the payer at its own channel.
|
||||
if (
|
||||
method === PaymentMethodType.CBE_BILL &&
|
||||
(booking.currency ?? "ETB").toUpperCase() !== "ETB"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"CBE bill payment is only available for bookings charged in ETB",
|
||||
);
|
||||
}
|
||||
|
||||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||
|
||||
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
|
||||
@@ -259,15 +275,22 @@ export class PaymentsService {
|
||||
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||||
where: { type: method },
|
||||
});
|
||||
const chargeCurrency = (
|
||||
paymentMethod?.currency ?? booking.currency
|
||||
).toUpperCase();
|
||||
const chargeCurrency =
|
||||
method === PaymentMethodType.CBE_BILL
|
||||
? "ETB"
|
||||
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
|
||||
|
||||
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
|
||||
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
|
||||
|
||||
let chargeAmount: number;
|
||||
if (
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
// Force ETB, no conversion (D8) — eligibility was already checked above.
|
||||
chargeAmount = this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
);
|
||||
} else if (
|
||||
chargeCurrency === bookingDisplayCurrency &&
|
||||
chargeCurrency !== 'ETB' &&
|
||||
bookingDisplayTotalMinor != null
|
||||
@@ -285,6 +308,20 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE_BILL: the bill lives in CBE's system for as long as the booking is payable, so the
|
||||
// intent expiry is the booking's own payment deadline — never a provider-session TTL
|
||||
// (plan §6.4); payerName feeds the mandatory Full_Name of CBE's query response.
|
||||
let payerName: string | undefined;
|
||||
let expiresAt: string | undefined;
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
payerName =
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName;
|
||||
expiresAt = (
|
||||
await this.computeBookingPaymentDeadline(booking.id)
|
||||
)?.toISOString();
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
@@ -297,6 +334,8 @@ export class PaymentsService {
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
payerName,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
@@ -350,6 +389,97 @@ export class PaymentsService {
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check +
|
||||
* payer identity for a booking. Called by the payment service while a CBE teller/app is
|
||||
* waiting — read-only and fast. This is the double-payment guard: once the booking is
|
||||
* confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3).
|
||||
*/
|
||||
async billQuery(bookingId: string): Promise<BillQueryResponseDto> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, passenger: { include: { user: true } } },
|
||||
});
|
||||
if (!booking) return { stillPayable: false, reason: "CANCELLED" };
|
||||
|
||||
const base = {
|
||||
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
|
||||
payerName:
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName ??
|
||||
booking.passenger?.user?.fullName ??
|
||||
null,
|
||||
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
),
|
||||
currency: "ETB",
|
||||
};
|
||||
|
||||
if (booking.status === "CONFIRMED" || booking.paidAt) {
|
||||
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
||||
}
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
||||
}
|
||||
const deadline = await this.computeBookingPaymentDeadline(booking.id);
|
||||
if (deadline && deadline.getTime() < Date.now()) {
|
||||
return { ...base, stillPayable: false, reason: "EXPIRED" };
|
||||
}
|
||||
return { ...base, stillPayable: true, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
|
||||
* origin-segment time and that stop's own check-in window, falling back to the route default.
|
||||
*/
|
||||
private async computeBookingPaymentDeadline(
|
||||
bookingId: string,
|
||||
): Promise<Date | null> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
createdAt: true,
|
||||
originStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
departureAt: true,
|
||||
stopTimes: {
|
||||
select: {
|
||||
stationId: true,
|
||||
plannedArrivalAt: true,
|
||||
plannedDepartureAt: true,
|
||||
},
|
||||
},
|
||||
route: {
|
||||
select: {
|
||||
checkinMinutesBefore: true,
|
||||
stops: {
|
||||
select: { stationId: true, checkinMinutesBefore: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!booking?.schedule) return null;
|
||||
const originStop = booking.schedule.stopTimes?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedArrivalAt ??
|
||||
originStop?.plannedDepartureAt ??
|
||||
booking.schedule.departureAt) as Date;
|
||||
const originRouteStop = booking.schedule.route?.stops?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const checkinMinutes =
|
||||
originRouteStop?.checkinMinutesBefore ??
|
||||
booking.schedule.route?.checkinMinutesBefore ??
|
||||
undefined;
|
||||
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
|
||||
}
|
||||
|
||||
private resolveReturnUrls(
|
||||
method: PaymentMethodType,
|
||||
requestOrigin?: string | null,
|
||||
@@ -1117,15 +1247,17 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor`
|
||||
// carries the charge amount in MAJOR units (the intent's "real/major price" — what
|
||||
// initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so
|
||||
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
|
||||
// the display↔charge currency divergence is tracked separately under the USD/DJF
|
||||
// findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
|
||||
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
|
||||
if (event.amountMinor < expectedMajor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMajor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user