mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge remote-tracking branch 'origin/staging' into freight/fix/pay
# Conflicts: # apps/edr-freight-api/src/modules/billing/billing.service.ts # apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx # apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx # pnpm-lock.yaml
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface {
|
||||
name = "AddCbeBillPaymentMethod3050000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen
|
||||
// per the local convention (see 2460000000000-AddCacBankPaymentMethod).
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values directly.
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/**
|
||||
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
|
||||
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
|
||||
* so "cancelled" must not stand in for "already paid" or "refunded".
|
||||
*/
|
||||
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
|
||||
switch (status) {
|
||||
case Freight.InvoiceStatus.Paid:
|
||||
return "ALREADY_PAID";
|
||||
case Freight.InvoiceStatus.Refunded:
|
||||
return "REFUNDED";
|
||||
case Freight.InvoiceStatus.Cancelled:
|
||||
return "CANCELLED";
|
||||
case Freight.InvoiceStatus.Expired:
|
||||
return "EXPIRED";
|
||||
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
|
||||
default:
|
||||
return "NOT_PAYABLE";
|
||||
}
|
||||
}
|
||||
|
||||
/** A single line to bill on a generated invoice. */
|
||||
export interface InvoiceLineInput {
|
||||
chargeType: string;
|
||||
@@ -1034,6 +1055,7 @@ export class BillingService {
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(
|
||||
@@ -1090,6 +1112,10 @@ export class BillingService {
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
|
||||
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
|
||||
payerName: invoice.company?.name,
|
||||
expiresAt: invoice.dueAt?.toISOString(),
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
@@ -1100,12 +1126,14 @@ export class BillingService {
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
// billing must not simulate it. Kept for local demos only.
|
||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||
// code — so the demo shortcut must never fire for it.
|
||||
// code — so the demo shortcut must never fire for it. Same for CBE_BILL: its
|
||||
// bill must stay open until CBE actually settles it via /cbe/payment.
|
||||
if (
|
||||
!result.immediateSuccess &&
|
||||
result.response.clientAction?.type !== "COLLECT_OTP"
|
||||
result.response.clientAction?.type !== "COLLECT_OTP" &&
|
||||
opts.method !== "CBE_BILL"
|
||||
) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
@@ -1151,4 +1179,67 @@ export class BillingService {
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
|
||||
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
|
||||
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
|
||||
*/
|
||||
async billQuery(referenceId: string): Promise<{
|
||||
stillPayable: boolean;
|
||||
payerName?: string | null;
|
||||
currentAmountMinor?: number | null;
|
||||
currency?: string | null;
|
||||
reason?: string | null;
|
||||
paymentReason?: string | null;
|
||||
}> {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const open = await repo.findOne({
|
||||
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
|
||||
if (open) {
|
||||
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
payerName: open.company?.name ?? null,
|
||||
currentAmountMinor: balance,
|
||||
currency: open.currency,
|
||||
// CBE shows this beside the amount on the confirmation screen — the invoice number
|
||||
// the payer is holding, not our internal reference.
|
||||
paymentReason: `Freight invoice ${open.invoiceNumber}`,
|
||||
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
|
||||
// expired, and that is what the payer at the CBE counter must be told.
|
||||
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
|
||||
};
|
||||
}
|
||||
|
||||
const latest = await repo.findOne({
|
||||
where: { sourceId: referenceId },
|
||||
relations: { company: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
// A bill reference whose invoice no longer exists at all — a data problem, not a
|
||||
// cancellation the payer did anything to cause.
|
||||
if (!latest) {
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: null,
|
||||
currentAmountMinor: null,
|
||||
currency: null,
|
||||
reason: "NOT_FOUND",
|
||||
};
|
||||
}
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: latest.company?.name ?? null,
|
||||
currentAmountMinor: Math.round(Number(latest.totalAmount)),
|
||||
currency: latest.currency,
|
||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||
reason: closedInvoiceReason(latest.status),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
/** 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 PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
|
||||
@@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@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"] })
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
forwardRef,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* WARNING: currently unauthenticated — anyone who can reach the API can mark
|
||||
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
|
||||
* Consumer side of the payment microservice's outbox relay. Only the payment service may
|
||||
* call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@Public()
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
private readonly logger = new Logger(InternalPaymentController.name);
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
constructor(
|
||||
private readonly paymentService: PaymentService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -36,4 +48,16 @@ export class InternalPaymentController {
|
||||
this.logger.log(`Marking payment ${event} as PAID`);
|
||||
return this.paymentService.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.billingService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,30 @@ 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 invoice still payable, by whom, for how much" while a CBE channel is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
|
||||
// property to a relative require() into packages/types, which does not exist inside the
|
||||
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
|
||||
// decorators below still give us enum docs + runtime validation.
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@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: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
|
||||
@ApiPropertyOptional() paymentReason?: string | null;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
|
||||
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 {
|
||||
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
CBE_BILL: "cbe-bill",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -214,8 +219,13 @@ export class PaymentService {
|
||||
|
||||
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,
|
||||
@@ -223,11 +233,15 @@ export class PaymentService {
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
// amountMinor: input.amountMinor,
|
||||
amountMinor:1,
|
||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||
// debited against the intent amount, so the 1-birr dev shortcut would break it.
|
||||
amountMinor: isCbeBill ? input.amountMinor : 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:
|
||||
|
||||
@@ -60,8 +60,10 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -80,6 +82,17 @@ export class ClientActionDto {
|
||||
|
||||
@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 {
|
||||
|
||||
@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||
{ value: "card", label: "Card" },
|
||||
{ value: "dmoney", label: "D-Money" },
|
||||
{ value: "cac-bank", label: "CAC Bank" },
|
||||
{ value: "cbe-bill", label: "CBE Bill" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
@@ -19,7 +19,8 @@ export type PaymentMethod =
|
||||
| "waafi"
|
||||
| "card"
|
||||
| "dmoney"
|
||||
| "cac-bank";
|
||||
| "cac-bank"
|
||||
| "cbe-bill";
|
||||
|
||||
export interface PaymentRow {
|
||||
id: string;
|
||||
|
||||
@@ -34,9 +34,17 @@ function apiMessage(err: unknown, fallback: string): string {
|
||||
* charge through a different endpoint (warehouse fee invoices); OTP
|
||||
* confirmation always goes through billing, which owns the intent either way.
|
||||
*/
|
||||
/** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */
|
||||
interface BillAction {
|
||||
billReference: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
||||
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
|
||||
const [otpMessage, setOtpMessage] = useState<string | undefined>();
|
||||
const [billAction, setBillAction] = useState<BillAction | null>(null);
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (vars: {
|
||||
@@ -52,6 +60,16 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
||||
setOtpInvoiceId(vars.invoiceId);
|
||||
return;
|
||||
}
|
||||
// CBE_BILL settles asynchronously via CBE, not the browser — show the
|
||||
// bill reference instead of redirecting to a (nonexistent) checkout page.
|
||||
if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") {
|
||||
setBillAction({
|
||||
billReference: data.clientAction.billReference ?? "",
|
||||
instructions: data.clientAction.instructions,
|
||||
expiresAt: data.clientAction.expiresAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.location.href =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
@@ -76,6 +94,7 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
||||
payMutation.reset();
|
||||
otpMutation.reset();
|
||||
setOtpInvoiceId(null);
|
||||
setBillAction(null);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -109,6 +128,14 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
||||
setOtpInvoiceId(null);
|
||||
},
|
||||
},
|
||||
/** Drives the modal's "pay at CBE" step; `open` only for CBE_BILL. */
|
||||
bill: {
|
||||
open: billAction !== null,
|
||||
billReference: billAction?.billReference,
|
||||
instructions: billAction?.instructions,
|
||||
expiresAt: billAction?.expiresAt,
|
||||
close: () => setBillAction(null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +33,7 @@ import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/component
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
import {
|
||||
billedTo,
|
||||
fmtDate,
|
||||
InvoiceStatusBadge,
|
||||
isPayable,
|
||||
titleCase,
|
||||
} from "./invoice-ui";
|
||||
import { billedTo, fmtDate, InvoiceStatusBadge, isPayable, titleCase } from "./invoice-ui";
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
@@ -361,7 +355,7 @@ export default function InvoiceDetailPage() {
|
||||
</Paper>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
opened={payModalOpen || pay.bill.open}
|
||||
onClose={() => {
|
||||
if (!pay.processing) {
|
||||
setPayModalOpen(false);
|
||||
@@ -373,6 +367,7 @@ export default function InvoiceDetailPage() {
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
bill={pay.bill}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
pay.pay(id, method, payerAccount)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ interface ProviderOption {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// Only Telebirr, Waafi and CAC Bank are enabled for now.
|
||||
// Only Telebirr, Waafi, CAC Bank and CBE bill payment are enabled for now.
|
||||
const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
@@ -51,10 +51,20 @@ const PROVIDERS: ProviderOption[] = [
|
||||
currencies: ["USD"],
|
||||
accent: "#8A5A17",
|
||||
},
|
||||
{
|
||||
method: "CBE_BILL",
|
||||
label: "CBE bill payment",
|
||||
description: "Pay at any CBE branch, app or USSD · ETB",
|
||||
logo: "/assets/edr-logo.png",
|
||||
currencies: ["ETB"],
|
||||
accent: "#5B2D8C",
|
||||
},
|
||||
];
|
||||
|
||||
/** Providers that debit against an SMS OTP instead of redirecting to a page. */
|
||||
const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK";
|
||||
/** Providers that settle asynchronously via a bill reference instead of a redirect. */
|
||||
const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL";
|
||||
|
||||
/**
|
||||
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||
@@ -172,6 +182,7 @@ export function PaymentMethodModal({
|
||||
processing,
|
||||
error,
|
||||
otp,
|
||||
bill,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -184,14 +195,21 @@ export function PaymentMethodModal({
|
||||
error?: string | null;
|
||||
/** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */
|
||||
otp?: InvoicePaymentFlow["otp"];
|
||||
/** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */
|
||||
bill?: InvoicePaymentFlow["bill"];
|
||||
}) {
|
||||
const providers = useMemo(
|
||||
() => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)),
|
||||
[currency, otp],
|
||||
() =>
|
||||
providersForCurrency(currency).filter(
|
||||
(p) =>
|
||||
(otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)),
|
||||
),
|
||||
[currency, otp, bill],
|
||||
);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||
useEffect(() => {
|
||||
@@ -209,6 +227,101 @@ export function PaymentMethodModal({
|
||||
const needsMobile = isOtpMethod(method);
|
||||
const canSubmit = !needsMobile || mobile.trim().length > 0;
|
||||
|
||||
if (bill?.open) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={bill.close}
|
||||
centered
|
||||
radius={18}
|
||||
size={440}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||
>
|
||||
<Box px={24} py={24}>
|
||||
<Text fw={800} fz="18px" c="#10202F">
|
||||
Pay at CBE
|
||||
</Text>
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
{bill.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</Text>
|
||||
|
||||
<Group
|
||||
mt={18}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{ borderRadius: 12, border: "1.5px solid #E6ECF1" }}
|
||||
>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fz="22px"
|
||||
fw={800}
|
||||
c="#10202F"
|
||||
style={{ letterSpacing: 2 }}
|
||||
>
|
||||
{bill.billReference}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={copied ? <Check size={14} /> : undefined}
|
||||
onClick={() => {
|
||||
if (bill.billReference) {
|
||||
navigator.clipboard?.writeText(bill.billReference);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{amountLabel && (
|
||||
<Text mt={12} fz="13px" c="#7A8794">
|
||||
Amount due: <Text span fw={700} c="#10202F">{amountLabel}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{bill.expiresAt && (
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
Pay before:{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{new Date(bill.expiresAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text mt={14} fz="11.5px" c="#9AA8B5">
|
||||
This page updates automatically once CBE confirms your payment.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
mt={18}
|
||||
variant="default"
|
||||
radius={12}
|
||||
onClick={bill.close}
|
||||
styles={{
|
||||
root: { height: 44 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#475569" },
|
||||
}}
|
||||
>
|
||||
Close (pay later)
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
if (otp?.open) {
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -12,7 +12,8 @@ export type PaymentMethod =
|
||||
| "WAAFI"
|
||||
| "CARD"
|
||||
| "DMONEY"
|
||||
| "CAC_BANK";
|
||||
| "CAC_BANK"
|
||||
| "CBE_BILL";
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
|
||||
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
|
||||
}
|
||||
|
||||
export interface ClientAction {
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
url?: string;
|
||||
appId?: string;
|
||||
receiveCode?: string;
|
||||
shortCode?: string;
|
||||
providerOrderId?: string;
|
||||
message?: string;
|
||||
/** SHOW_BILL_REFERENCE (CBE bill payment) */
|
||||
billReference?: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface InitiateResponse {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3): new inbound biller
|
||||
-- method. Value must stay byte-identical to @edr/types ProviderMethod.CBE_BILL — the
|
||||
-- passenger payments service casts between the two enums directly.
|
||||
ALTER TYPE "passenger"."PaymentMethodType" ADD VALUE IF NOT EXISTS 'CBE_BILL';
|
||||
@@ -147,6 +147,7 @@ enum PaymentMethodType {
|
||||
WAAFI
|
||||
DMONEY
|
||||
CAC_BANK
|
||||
CBE_BILL
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -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,30 @@ 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 {
|
||||
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
|
||||
// property to a relative require() into packages/types, which does not exist inside the
|
||||
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
|
||||
// decorators below still give us enum docs + runtime validation.
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@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: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
|
||||
@ApiPropertyOptional() paymentReason?: 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,111 @@ 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 } } },
|
||||
});
|
||||
// Distinct from CANCELLED: the payment service issued a bill reference for a booking that
|
||||
// no longer exists at all, which is a data problem, not a customer-facing cancellation.
|
||||
if (!booking) return { stillPayable: false, reason: "NOT_FOUND" };
|
||||
|
||||
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",
|
||||
// CBE shows this beside the amount on the confirmation screen. bookingRef is the same
|
||||
// code on the customer's ticket, so they can match the two before confirming.
|
||||
paymentReason: `Train ticket booking ${booking.bookingRef}`,
|
||||
};
|
||||
|
||||
// Paid first: a booking that was paid and then boarded/refunded must never be reported as
|
||||
// merely "not payable" — the payer needs to hear that their money already went through.
|
||||
if (booking.status === "CONFIRMED" || booking.paidAt) {
|
||||
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
||||
}
|
||||
if (booking.status === "REFUNDED") {
|
||||
return { ...base, stillPayable: false, reason: "REFUNDED" };
|
||||
}
|
||||
if (booking.status === "CANCELLED") {
|
||||
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
||||
}
|
||||
// DRAFT / BOARDED / NO_SHOW without a payment: no honest specific wording exists.
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
|
||||
}
|
||||
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 +1261,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" };
|
||||
}
|
||||
|
||||
@@ -69,4 +69,9 @@ export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [
|
||||
name: { en: 'EDR Passenger Finance Manager' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.financeManager],
|
||||
},
|
||||
{
|
||||
key: 'edr_passenger_director',
|
||||
name: { en: 'EDR Passenger Director' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.director],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -166,9 +166,18 @@ export const PASSENGER_PERMS = {
|
||||
admin: 'edr_passenger_app:admin',
|
||||
} as const;
|
||||
|
||||
// Every view-only permission (`:view` and `:view_all`), derived from the registry
|
||||
// so newly-added view permissions are automatically included.
|
||||
export const PASSENGER_VIEW_PERMISSION_KEYS = PASSENGER_PERMISSION_KEYS.filter(
|
||||
(key) => key.endsWith(':view') || key.endsWith(':view_all'),
|
||||
);
|
||||
|
||||
export const ROLE_PERMISSION_PRESETS = {
|
||||
backofficeAdmin: [...PASSENGER_PERMISSION_KEYS],
|
||||
|
||||
// Director: read-only across the whole app — can view every resource, cannot manage/mutate anything.
|
||||
director: [...PASSENGER_VIEW_PERMISSION_KEYS],
|
||||
|
||||
stationMaster: [
|
||||
PASSENGER_PERMS.bookings.view,
|
||||
PASSENGER_PERMS.bookings.manage,
|
||||
|
||||
@@ -20,6 +20,8 @@ const STAFF_USERS = [
|
||||
{ email: 'passenger.staff@edr.local', username: 'passenger_staff', roleKey: 'edr_passenger_backoffice_staff' },
|
||||
{ email: 'passenger.agent@edr.local', username: 'passenger_agent', roleKey: 'edr_passenger_agent' },
|
||||
{ email: 'passenger.finance@edr.local', username: 'passenger_finance', roleKey: 'edr_passenger_finance' },
|
||||
{ email: 'passenger.director@edr.local', username: 'passenger_director', roleKey: 'edr_passenger_director' },
|
||||
{ email: 'passenger.ticketofficer@edr.local', username: 'passenger_ticket_officer', roleKey: 'edr_passenger_ticket_officer' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -200,6 +200,7 @@ export default function PaymentMethodsPage() {
|
||||
const paymentTypes = [
|
||||
{ value: 'TELEBIRR', label: 'Telebirr' },
|
||||
{ value: 'CBE_BIRR', label: 'CBE Birr' },
|
||||
{ value: 'CBE_BILL', label: 'CBE Bill Payment' },
|
||||
{ value: 'EBIRR', label: 'eBirr' },
|
||||
{ value: 'WAAFI', label: 'Waafi' },
|
||||
{ value: 'DMONEY', label: 'dMoney' },
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
ChevronLeft,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
const getIconForMethod = (methodId: string) => {
|
||||
@@ -45,6 +47,13 @@ export default function PaymentPage() {
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpMessage, setOtpMessage] = useState<string | null>(null);
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
// CBE bill payment: the bill reference the customer pays at any CBE channel.
|
||||
const [billAction, setBillAction] = useState<{
|
||||
billReference: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
} | null>(null);
|
||||
const [billCopied, setBillCopied] = useState(false);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
@@ -144,6 +153,16 @@ export default function PaymentPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// CBE bill: no redirect — show the bill reference and wait for the customer to pay
|
||||
// at a CBE channel. Confirmation only ever comes from polling the intent status.
|
||||
if (data?.clientAction?.type === 'SHOW_BILL_REFERENCE') {
|
||||
setPaymentIntent(data.intentId);
|
||||
updateStatus("REQUIRES_ACTION");
|
||||
setBillAction(data.clientAction);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
|
||||
setPaymentIntent(data.intentId);
|
||||
updateStatus("REQUIRES_ACTION");
|
||||
@@ -190,6 +209,34 @@ export default function PaymentPage() {
|
||||
|
||||
|
||||
|
||||
// While the CBE bill dialog is open, poll the intent; the booking confirms server-side
|
||||
// once CBE settles the bill. Never claim success on any client-side signal.
|
||||
const { data: billIntentStatus } = useQuery<any>({
|
||||
queryKey: ["cbe-bill-intent-status", bookingId],
|
||||
queryFn: () => apiClient.get(`/payments/intents/${bookingId}`),
|
||||
enabled: !!billAction && !!bookingId,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (billAction && billIntentStatus?.status === "SUCCEEDED") {
|
||||
updateStatus("SUCCEEDED");
|
||||
router.push("/booking/confirmation");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [billAction, billIntentStatus?.status]);
|
||||
|
||||
const copyBillReference = async () => {
|
||||
if (!billAction) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(billAction.billReference);
|
||||
setBillCopied(true);
|
||||
setTimeout(() => setBillCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable — the number is still shown on screen */
|
||||
}
|
||||
};
|
||||
|
||||
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
|
||||
const startPayment = (mobile?: string) => {
|
||||
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
|
||||
@@ -569,6 +616,62 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CBE bill payment — show the bill reference; confirmation comes from polling */}
|
||||
{billAction && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Pay at CBE</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{billAction.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3">
|
||||
<span className="font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100 select-all">
|
||||
{billAction.billReference}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyBillReference}
|
||||
className="btn-secondary px-3 py-2 flex items-center gap-1 text-sm"
|
||||
title="Copy bill number"
|
||||
>
|
||||
{billCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
{billCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-3 space-y-1">
|
||||
<p>
|
||||
Amount:{" "}
|
||||
<span className="font-semibold">
|
||||
ETB {(totalAmountDisplay ?? 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
{billAction.expiresAt && (
|
||||
<p>
|
||||
Pay before:{" "}
|
||||
<span className="font-semibold">
|
||||
{format(new Date(billAction.expiresAt), "MMM d, yyyy HH:mm")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||
Waiting for payment confirmation — this page updates automatically once CBE
|
||||
confirms your payment.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBillAction(null)}
|
||||
className="btn-secondary w-full py-2.5 mt-4"
|
||||
>
|
||||
Close (pay later)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-column grid */}
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
|
||||
@@ -13,6 +13,8 @@ import ebirrConfig from "./config/ebirr.config";
|
||||
import cardConfig from "./config/card.config";
|
||||
import dmoneyConfig from "./config/dmoney.config";
|
||||
import cacConfig from "./config/cac.config";
|
||||
import cbeBillConfig from "./config/cbe-bill.config";
|
||||
import { CbeBillModule } from "./modules/cbe-bill/cbe-bill.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { IntentsModule } from "./modules/intents/intents.module";
|
||||
import { OutboxModule } from "./modules/outbox/outbox.module";
|
||||
@@ -36,6 +38,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
||||
cardConfig,
|
||||
dmoneyConfig,
|
||||
cacConfig,
|
||||
cbeBillConfig,
|
||||
],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
@@ -47,6 +50,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
||||
HealthModule,
|
||||
ProvidersModule,
|
||||
IntentsModule,
|
||||
CbeBillModule,
|
||||
WebhooksModule,
|
||||
OutboxModule,
|
||||
ReconciliationModule,
|
||||
|
||||
26
apps/edr-payment-api/src/config/cbe-bill.config.ts
Normal file
26
apps/edr-payment-api/src/config/cbe-bill.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment — the INBOUND biller integration (docs/cbe/). Deliberately separate
|
||||
* from cbe.config.ts, which belongs to the outbound CBE_BIRR wallet gateway: two disjoint
|
||||
* credential/auth domains that rotate independently (plan D7).
|
||||
*/
|
||||
export default registerAs("cbeBill", () => ({
|
||||
/** Kill switch — all /cbe/* endpoints answer 503 while false. */
|
||||
enabled: process.env.CBE_BILL_ENABLED === "true",
|
||||
/** Credentials CBE presents to /cbe/oauth/token. */
|
||||
clientId: process.env.CBE_BILL_CLIENT_ID || "",
|
||||
clientSecret: process.env.CBE_BILL_CLIENT_SECRET || "",
|
||||
/** Signs/verifies the bearer tokens WE issue to CBE — never shared with ServiceAuthGuard. */
|
||||
jwtSecret: process.env.CBE_BILL_JWT_SECRET || "",
|
||||
tokenExpiresIn: Number(process.env.CBE_BILL_TOKEN_EXPIRES_IN || 3600),
|
||||
scope: process.env.CBE_BILL_SCOPE || "Unified_Outgoing",
|
||||
/** bill-query hop to the owning domain app. Short — CBE holds its own timeout over ours. */
|
||||
domainTimeoutMs: Number(process.env.CBE_BILL_DOMAIN_TIMEOUT_MS || 3000),
|
||||
passengerApiBaseUrl: (
|
||||
process.env.PASSENGER_API_BASE_URL || "http://localhost:3002"
|
||||
).replace(/\/$/, ""),
|
||||
freightApiBaseUrl: (
|
||||
process.env.FREIGHT_API_BASE_URL || "http://localhost:3001"
|
||||
).replace(/\/$/, ""),
|
||||
}));
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.1, §5):
|
||||
* - `bill_reference` — the short numeric Bill_Id CBE presents back to us; unique, null for
|
||||
* every non-CBE intent.
|
||||
* - `payer_name` — payer snapshot captured at initiate; fallback for /cbe/query Full_Name.
|
||||
* - `cbe_bill_reference_seq` — backs the 11-digit sequence body of the bill reference.
|
||||
*
|
||||
* DATA SAFETY: purely additive — new nullable columns and a new sequence; no existing rows
|
||||
* or values are touched.
|
||||
*/
|
||||
export class AddCbeBillReference1782300000000 implements MigrationInterface {
|
||||
name = "AddCbeBillReference1782300000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "bill_reference" varchar(32)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "payer_name" varchar(128)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_payment_intent_bill_reference" ON "edr_payment"."payment_intent" ("bill_reference") WHERE "bill_reference" IS NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS "edr_payment"."cbe_bill_reference_seq" START 10000001`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS "edr_payment"."cbe_bill_reference_seq"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "edr_payment"."uq_payment_intent_bill_reference"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "payer_name"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "bill_reference"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* CBE-protocol audit/idempotency ledger for the Unified Bill Payment integration
|
||||
* (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2).
|
||||
*
|
||||
* - UNIQUE (end_to_end_txn_id, operation): DB-level backstop for the application idempotency
|
||||
* checks — a concurrent duplicate of the same CBE attempt cannot create two rows.
|
||||
* - Partial UNIQUE (cbe_txn_ref) on settled PAYMENTs: blocks Cbe_Txn_Ref replay across bills.
|
||||
*
|
||||
* DATA SAFETY: new table only; nothing existing is touched.
|
||||
*/
|
||||
export class CreateCbeBillOperation1782400000000 implements MigrationInterface {
|
||||
name = "CreateCbeBillOperation1782400000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "edr_payment"."cbe_bill_operation" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"operation" varchar(16) NOT NULL,
|
||||
"bill_id" varchar(32) NOT NULL,
|
||||
"end_to_end_txn_id" varchar(128) NOT NULL,
|
||||
"cbe_txn_ref" varchar(128),
|
||||
"destination_api_name" varchar(64),
|
||||
"intent_id" uuid,
|
||||
"trade_status" varchar(16) NOT NULL,
|
||||
"failure_class" varchar(16),
|
||||
"response_code" varchar(8),
|
||||
"response_description" text,
|
||||
"request_payload" jsonb NOT NULL,
|
||||
"response_payload" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_e2e" ON "edr_payment"."cbe_bill_operation" ("end_to_end_txn_id", "operation")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_txn_ref" ON "edr_payment"."cbe_bill_operation" ("cbe_txn_ref") WHERE "operation" = 'PAYMENT' AND "trade_status" = 'SUCCESS'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_cbe_bill_operation_bill" ON "edr_payment"."cbe_bill_operation" ("bill_id", "operation")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "edr_payment"."cbe_bill_operation"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { PaymentReferenceType, PaymentService } from "@edr/types";
|
||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||
import { CbeBillError } from "./mappers/cbe-error.mapper";
|
||||
|
||||
/**
|
||||
* Why a bill is no longer payable. Shared vocabulary between both domain apps and the local
|
||||
* intent check, so one mapper produces every Response_Description CBE sees.
|
||||
* `NOT_PAYABLE` is the catch-all for domain states with no better name (booking DRAFT/BOARDED,
|
||||
* invoice DRAFT) — it must stay last-resort, never a substitute for a specific reason.
|
||||
*/
|
||||
export type BillNotPayableReason =
|
||||
| "ALREADY_PAID"
|
||||
| "CANCELLED"
|
||||
| "REFUNDED"
|
||||
| "EXPIRED"
|
||||
| "NOT_FOUND"
|
||||
| "NOT_PAYABLE";
|
||||
|
||||
/** Contract of POST /internal/payments/bill-query on the domain apps (plan Phase 4). */
|
||||
export interface BillQueryResult {
|
||||
stillPayable: boolean;
|
||||
payerName?: string | null;
|
||||
currentAmountMinor?: number | null;
|
||||
currency?: string | null;
|
||||
/** When stillPayable=false — see {@link BillNotPayableReason}. */
|
||||
reason?: BillNotPayableReason | string | null;
|
||||
/**
|
||||
* What the payer is paying for, shown on CBE's confirmation screen next to the amount —
|
||||
* the domain's own human reference (booking ref / invoice number), not our internal ids.
|
||||
*/
|
||||
paymentReason?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment_Reason when the domain app sends none (older build, or an order with no human
|
||||
* reference). Generic but never blank: CBE renders this field to the payer, and a bill with
|
||||
* an amount and no stated purpose is what a customer refuses to confirm.
|
||||
*/
|
||||
export function defaultPaymentReason(
|
||||
referenceType: PaymentReferenceType,
|
||||
): string {
|
||||
return referenceType === PaymentReferenceType.BOOKING
|
||||
? "Train ticket booking"
|
||||
: "Freight invoice";
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
|
||||
* has to name the thing they are actually holding — a passenger booking or a freight invoice —
|
||||
* rather than our internal "bill" abstraction (plan §6.6).
|
||||
*/
|
||||
function subjectOf(referenceType: PaymentReferenceType): string {
|
||||
return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice";
|
||||
}
|
||||
|
||||
export function reasonToDescription(
|
||||
reason: string | null | undefined,
|
||||
referenceType: PaymentReferenceType,
|
||||
): string {
|
||||
const subject = subjectOf(referenceType);
|
||||
switch (reason) {
|
||||
case "ALREADY_PAID":
|
||||
return `This ${subject} has already been paid.`;
|
||||
case "CANCELLED":
|
||||
return `This ${subject} has been cancelled.`;
|
||||
case "REFUNDED":
|
||||
return `This ${subject} has been refunded.`;
|
||||
case "EXPIRED":
|
||||
return `This ${subject} has expired and can no longer be paid.`;
|
||||
// A bill reference we issued whose order has since vanished from the domain app. Same
|
||||
// wording as an unknown Bill_Id — from the teller's side it is the same situation.
|
||||
case "NOT_FOUND":
|
||||
return "Bill not found.";
|
||||
default:
|
||||
return `This ${subject} is no longer payable.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live "still payable?" hop to the owning domain app — routing comes from
|
||||
* `intent.service` (plan D3). This hop is the double-payment guard (§6.3) and the source of
|
||||
* the mandatory Full_Name: NOT optional, and on the /cbe/payment path never served from cache.
|
||||
* Short timeout, no retries — CBE holds its own timeout over ours.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BillResolverService {
|
||||
private readonly logger = new Logger(BillResolverService.name);
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async billQuery(intent: PaymentIntent): Promise<BillQueryResult> {
|
||||
const base =
|
||||
intent.service === PaymentService.PASSENGER
|
||||
? this.config.get<string>("cbeBill.passengerApiBaseUrl")
|
||||
: this.config.get<string>("cbeBill.freightApiBaseUrl");
|
||||
const url = `${base}/internal/payments/bill-query`;
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<BillQueryResult>(
|
||||
url,
|
||||
{
|
||||
referenceType: intent.referenceType,
|
||||
referenceId: intent.referenceId,
|
||||
},
|
||||
{
|
||||
timeout: this.config.get<number>("cbeBill.domainTimeoutMs") ?? 3000,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
},
|
||||
),
|
||||
);
|
||||
// The passenger API wraps every response in a { success, data } envelope
|
||||
// (global transform interceptor); freight returns the body bare. Accept both.
|
||||
const body = response.data as unknown as {
|
||||
success?: boolean;
|
||||
data?: BillQueryResult;
|
||||
};
|
||||
return body && typeof body === "object" && "success" in body && body.data
|
||||
? body.data
|
||||
: (response.data as BillQueryResult);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`bill-query ${intent.service}/${intent.referenceId} unreachable: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
|
||||
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts
Normal file
39
apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Verifies the bearer tokens WE minted for CBE at /cbe/oauth/token (plan D7). Its secret
|
||||
* (CBE_BILL_JWT_SECRET) is disjoint from ServiceAuthGuard's shared token: a CBE token must
|
||||
* never authenticate a call to /payments/*, and vice versa.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CbeAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException("Missing bearer token");
|
||||
}
|
||||
|
||||
try {
|
||||
await this.jwtService.verifyAsync(auth.substring(7), {
|
||||
secret: this.config.get<string>("cbeBill.jwtSecret"),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CbeAuthGuard } from "./cbe-auth.guard";
|
||||
import { CbeExceptionFilter } from "./cbe-exception.filter";
|
||||
import { CbeBillService } from "./cbe-bill.service";
|
||||
import { TokenRequestDto } from "./dto/token-request.dto";
|
||||
import { TokenResponseDto } from "./dto/token-response.dto";
|
||||
import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto";
|
||||
import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto";
|
||||
import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto";
|
||||
import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are
|
||||
* the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then
|
||||
* presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200
|
||||
* with Response_Code "3"; only authentication answers 401 (plan D6/D7).
|
||||
*/
|
||||
@ApiTags("CBE Unified Bill (inbound)")
|
||||
@Controller("cbe")
|
||||
@UseFilters(CbeExceptionFilter)
|
||||
export class CbeBillController {
|
||||
constructor(private readonly cbeBillService: CbeBillService) {}
|
||||
|
||||
@Post("oauth/token")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "OAuth client_credentials token for CBE (we are the auth server)" })
|
||||
async token(@Body() dto: TokenRequestDto): Promise<TokenResponseDto> {
|
||||
return this.cbeBillService.generateToken(dto);
|
||||
}
|
||||
|
||||
@Post("query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(CbeAuthGuard)
|
||||
@ApiOperation({ summary: "Bill lookup — amount due + payer name for a Bill_Id" })
|
||||
async query(@Body() dto: CbeQueryRequestDto): Promise<CbeQueryResponseDto> {
|
||||
return this.cbeBillService.query(dto);
|
||||
}
|
||||
|
||||
@Post("payment")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(CbeAuthGuard)
|
||||
@ApiOperation({ summary: "Settle a bill — customer already debited by CBE" })
|
||||
async payment(
|
||||
@Body() dto: CbePaymentRequestDto,
|
||||
): Promise<CbePaymentResponseDto> {
|
||||
return this.cbeBillService.pay(dto);
|
||||
}
|
||||
}
|
||||
28
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts
Normal file
28
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { IntentsModule } from "../intents/intents.module";
|
||||
import { CbeBillOperation } from "./entities/cbe-bill-operation.entity";
|
||||
import { BillResolverService } from "./bill-resolver.service";
|
||||
import { CbeAuthGuard } from "./cbe-auth.guard";
|
||||
import { CbeBillController } from "./cbe-bill.controller";
|
||||
import { CbeBillRepository } from "./cbe-bill.repository";
|
||||
import { CbeBillService } from "./cbe-bill.service";
|
||||
|
||||
/**
|
||||
* Inbound CBE Unified Bill Payment module (docs/cbe/). Its auth domain is disjoint from the
|
||||
* rest of the app: tokens are minted and verified with CBE_BILL_JWT_SECRET only (plan D7) —
|
||||
* JwtModule is registered bare and the secret passed explicitly at sign/verify time.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([CbeBillOperation]),
|
||||
HttpModule,
|
||||
JwtModule.register({}),
|
||||
IntentsModule,
|
||||
],
|
||||
controllers: [CbeBillController],
|
||||
providers: [CbeBillService, CbeBillRepository, BillResolverService, CbeAuthGuard],
|
||||
})
|
||||
export class CbeBillModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
CbeBillOperation,
|
||||
CbeOperation,
|
||||
} from "./entities/cbe-bill-operation.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CbeBillRepository extends BaseRepository<CbeBillOperation> {
|
||||
constructor(
|
||||
@InjectRepository(CbeBillOperation)
|
||||
repository: Repository<CbeBillOperation>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The prior attempt for CBE's per-attempt id — the §6.5 idempotency lookup. */
|
||||
async findByEndToEndTxnId(
|
||||
endToEndTxnId: string,
|
||||
operation: CbeOperation,
|
||||
): Promise<CbeBillOperation | null> {
|
||||
return this.repository.findOne({ where: { endToEndTxnId, operation } });
|
||||
}
|
||||
|
||||
/** A SUCCESSful settlement already carrying this Cbe_Txn_Ref — blocks replay across bills. */
|
||||
async findSettledByCbeTxnRef(
|
||||
cbeTxnRef: string,
|
||||
): Promise<CbeBillOperation | null> {
|
||||
return this.repository.findOne({
|
||||
where: { cbeTxnRef, operation: "PAYMENT", tradeStatus: "SUCCESS" },
|
||||
});
|
||||
}
|
||||
}
|
||||
387
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
Normal file
387
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { ProviderMethod, ProviderPaymentStatus } from "@edr/types";
|
||||
import { BillReferenceService } from "../intents/bill-reference.service";
|
||||
import { IntentsRepository } from "../intents/intents.repository";
|
||||
import { IntentsService } from "../intents/intents.service";
|
||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||
import {
|
||||
BillNotPayableReason,
|
||||
BillResolverService,
|
||||
defaultPaymentReason,
|
||||
reasonToDescription,
|
||||
} from "./bill-resolver.service";
|
||||
import { CbeBillRepository } from "./cbe-bill.repository";
|
||||
import { CbeBillOperation } from "./entities/cbe-bill-operation.entity";
|
||||
import { TokenRequestDto } from "./dto/token-request.dto";
|
||||
import { TokenResponseDto } from "./dto/token-response.dto";
|
||||
import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto";
|
||||
import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto";
|
||||
import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto";
|
||||
import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
|
||||
import { CbeBillError, toCbeFailure } from "./mappers/cbe-error.mapper";
|
||||
import {
|
||||
mapQueryFailure,
|
||||
mapQuerySuccess,
|
||||
} from "./mappers/cbe-query.mapper";
|
||||
import {
|
||||
mapPaymentFailure,
|
||||
mapPaymentSuccess,
|
||||
} from "./mappers/cbe-payment.mapper";
|
||||
|
||||
/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
|
||||
const AMOUNT_TOLERANCE = 0.01;
|
||||
|
||||
/**
|
||||
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
|
||||
* speak, so both paths flow through one description mapper.
|
||||
*/
|
||||
function localReason(intent: PaymentIntent): BillNotPayableReason {
|
||||
switch (intent.status) {
|
||||
case ProviderPaymentStatus.SUCCEEDED:
|
||||
return "ALREADY_PAID";
|
||||
case ProviderPaymentStatus.CANCELLED:
|
||||
// expireIntent() cancels with failureCode EXPIRED — an abandoned bill, not a cancellation.
|
||||
return intent.failureCode === "EXPIRED" ? "EXPIRED" : "CANCELLED";
|
||||
default:
|
||||
return "NOT_PAYABLE";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
|
||||
* Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
|
||||
* controller); the exception filter only catches auth, validation, and the unexpected.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CbeBillService {
|
||||
private readonly logger = new Logger(CbeBillService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly cbeBillRepository: CbeBillRepository,
|
||||
private readonly intentsRepository: IntentsRepository,
|
||||
private readonly intentsService: IntentsService,
|
||||
private readonly billReferenceService: BillReferenceService,
|
||||
private readonly billResolver: BillResolverService,
|
||||
) {}
|
||||
|
||||
/* ------------------------------------------------------------------ token */
|
||||
|
||||
async generateToken(dto: TokenRequestDto): Promise<TokenResponseDto> {
|
||||
this.assertEnabled();
|
||||
|
||||
const clientId = this.config.get<string>("cbeBill.clientId");
|
||||
const clientSecret = this.config.get<string>("cbeBill.clientSecret");
|
||||
// Unset credentials must fail closed — never let "" === "" mint a token.
|
||||
const valid =
|
||||
!!clientId &&
|
||||
!!clientSecret &&
|
||||
dto.client_id === clientId &&
|
||||
dto.client_secret === clientSecret &&
|
||||
dto.grant_type === "client_credentials" &&
|
||||
dto.scope === this.config.get<string>("cbeBill.scope");
|
||||
if (!valid) throw new UnauthorizedException("Invalid credentials");
|
||||
|
||||
const expiresIn = this.config.get<number>("cbeBill.tokenExpiresIn") ?? 3600;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ iss: "edr-payment-api", clientId: dto.client_id, scope: dto.scope },
|
||||
{ secret: this.config.get<string>("cbeBill.jwtSecret"), expiresIn },
|
||||
);
|
||||
|
||||
return {
|
||||
token_type: "Bearer",
|
||||
access_token: accessToken,
|
||||
expires_in: expiresIn,
|
||||
scope: dto.scope,
|
||||
consented_on: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ query */
|
||||
|
||||
async query(dto: CbeQueryRequestDto): Promise<CbeQueryResponseDto> {
|
||||
this.assertEnabled();
|
||||
|
||||
// Audit first — unlike the reference (which left this commented out), every query attempt
|
||||
// is persisted; counter disputes are exactly what this row is for (plan Phase 3.2).
|
||||
const audit = await this.upsertAudit("QUERY", dto.End_To_End_Txn_Id, {
|
||||
billId: dto.Bill_Id,
|
||||
destinationApiName: dto.Destination_Api_Name,
|
||||
requestPayload: dto as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
try {
|
||||
const intent = await this.resolveIntent(dto.Bill_Id);
|
||||
this.assertIntentPayable(intent);
|
||||
|
||||
// The live domain hop — the double-payment guard (§6.3). Not optional.
|
||||
const billQuery = await this.billResolver.billQuery(intent);
|
||||
if (!billQuery.stillPayable) {
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
|
||||
const response = mapQuerySuccess(dto, {
|
||||
amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor,
|
||||
fullName: billQuery.payerName || intent.payerName || "",
|
||||
paymentReason:
|
||||
billQuery.paymentReason || defaultPaymentReason(intent.referenceType),
|
||||
});
|
||||
await this.finishAudit(audit, {
|
||||
intentId: intent.id,
|
||||
tradeStatus: "SUCCESS",
|
||||
response,
|
||||
});
|
||||
return response;
|
||||
} catch (err) {
|
||||
const failure = toCbeFailure(err);
|
||||
if (!(err instanceof CbeBillError)) {
|
||||
this.logger.error(
|
||||
`/cbe/query ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`,
|
||||
);
|
||||
}
|
||||
const response = mapQueryFailure(dto, failure.description);
|
||||
await this.finishAudit(audit, {
|
||||
tradeStatus: "FAILED",
|
||||
failureClass: failure.failureClass,
|
||||
response,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ payment */
|
||||
|
||||
async pay(dto: CbePaymentRequestDto): Promise<CbePaymentResponseDto> {
|
||||
this.assertEnabled();
|
||||
|
||||
// §6.5 idempotency on CBE's per-attempt id, in order.
|
||||
const prior = await this.cbeBillRepository.findByEndToEndTxnId(
|
||||
dto.End_To_End_Txn_Id,
|
||||
"PAYMENT",
|
||||
);
|
||||
if (prior) {
|
||||
if (prior.tradeStatus === "SUCCESS") {
|
||||
// Replay the stored body verbatim. Never re-settle.
|
||||
return prior.responsePayload as unknown as CbePaymentResponseDto;
|
||||
}
|
||||
if (prior.tradeStatus === "PENDING") {
|
||||
return mapPaymentFailure(dto, "Payment is being processed.");
|
||||
}
|
||||
if (prior.failureClass === "BUSINESS") {
|
||||
// Final — retrying cannot change the answer. Replay what we told CBE last time.
|
||||
return (
|
||||
(prior.responsePayload as unknown as CbePaymentResponseDto) ??
|
||||
mapPaymentFailure(
|
||||
dto,
|
||||
prior.responseDescription ?? "Payment already failed.",
|
||||
)
|
||||
);
|
||||
}
|
||||
// FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement.
|
||||
}
|
||||
|
||||
// Cbe_Txn_Ref replay across different bills/attempts (partial-unique backstop in the DB).
|
||||
const settled = await this.cbeBillRepository.findSettledByCbeTxnRef(
|
||||
dto.Cbe_Txn_Ref,
|
||||
);
|
||||
if (settled) {
|
||||
return mapPaymentFailure(
|
||||
dto,
|
||||
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let audit: CbeBillOperation;
|
||||
if (prior) {
|
||||
// Transient retry reuses the row — UNIQUE (end_to_end_txn_id, operation) forbids a second.
|
||||
audit =
|
||||
(await this.cbeBillRepository.update(prior.id, {
|
||||
tradeStatus: "PENDING",
|
||||
failureClass: null,
|
||||
cbeTxnRef: dto.Cbe_Txn_Ref,
|
||||
requestPayload: dto as unknown as Record<string, unknown>,
|
||||
})) ?? prior;
|
||||
} else {
|
||||
try {
|
||||
audit = await this.cbeBillRepository.create({
|
||||
operation: "PAYMENT",
|
||||
billId: dto.Bill_Id,
|
||||
endToEndTxnId: dto.End_To_End_Txn_Id,
|
||||
cbeTxnRef: dto.Cbe_Txn_Ref,
|
||||
destinationApiName: dto.Destination_Api_Name,
|
||||
tradeStatus: "PENDING",
|
||||
requestPayload: dto as unknown as Record<string, unknown>,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
|
||||
// Concurrent duplicate of the same attempt lost the insert race.
|
||||
return mapPaymentFailure(dto, "Payment is being processed.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let intent: PaymentIntent | undefined;
|
||||
try {
|
||||
intent = await this.resolveIntent(dto.Bill_Id);
|
||||
|
||||
if (dto.Currency && dto.Currency !== intent.currency) {
|
||||
throw new CbeBillError("Payment currency does not match.", "BUSINESS");
|
||||
}
|
||||
|
||||
// Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous
|
||||
// failure (§6.1): after this we settle and reconcile downstream.
|
||||
const billQuery = await this.billResolver.billQuery(intent);
|
||||
if (!billQuery.stillPayable) {
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
|
||||
const amount = Number(dto.Amount);
|
||||
if (
|
||||
!Number.isFinite(amount) ||
|
||||
Math.abs(amount - intent.amountMinor) >
|
||||
intent.amountMinor * AMOUNT_TOLERANCE
|
||||
) {
|
||||
throw new CbeBillError("Payment amount does not match.", "BUSINESS");
|
||||
}
|
||||
|
||||
const paidAt = new Date(dto.Timestamp);
|
||||
// Existing state machine, unmodified — intent + outbox commit in one transaction.
|
||||
await this.intentsService.applyProviderResult(intent.id, {
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: dto.Cbe_Txn_Ref,
|
||||
paidAt: Number.isNaN(paidAt.getTime()) ? new Date() : paidAt,
|
||||
confirmedAmountMinor: amount,
|
||||
});
|
||||
|
||||
const response = mapPaymentSuccess(dto, intent.merchantOrderId);
|
||||
await this.finishAudit(audit, {
|
||||
intentId: intent.id,
|
||||
tradeStatus: "SUCCESS",
|
||||
response,
|
||||
});
|
||||
this.logger.log(
|
||||
`bill ${dto.Bill_Id} settled by CBE txn ${dto.Cbe_Txn_Ref} (intent ${intent.id})`,
|
||||
);
|
||||
return response;
|
||||
} catch (err) {
|
||||
const failure = toCbeFailure(err);
|
||||
if (!(err instanceof CbeBillError)) {
|
||||
this.logger.error(
|
||||
`/cbe/payment ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`,
|
||||
);
|
||||
}
|
||||
const response = mapPaymentFailure(dto, failure.description);
|
||||
await this.finishAudit(audit, {
|
||||
intentId: intent?.id,
|
||||
tradeStatus: "FAILED",
|
||||
failureClass: failure.failureClass,
|
||||
response,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
/** Kill switch (CBE_BILL_ENABLED) — 503 so CBE classes it as transport error and retries. */
|
||||
assertEnabled(): void {
|
||||
if (!this.config.get<boolean>("cbeBill.enabled")) {
|
||||
throw new ServiceUnavailableException("CBE bill payment is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheap local gate before the domain hop: what OUR record of this attempt says. Only
|
||||
* REQUIRES_ACTION is payable; every other status gets a description naming the actual reason,
|
||||
* because CBE reads it back to the payer standing at the counter. All BUSINESS — none of these
|
||||
* states can change back, so a same-End_To_End_Txn_Id retry cannot produce a different answer.
|
||||
*/
|
||||
private assertIntentPayable(intent: PaymentIntent): void {
|
||||
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
|
||||
if (intent.status === ProviderPaymentStatus.PROCESSING) {
|
||||
throw new CbeBillError("Payment is being processed.", "BUSINESS");
|
||||
}
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(localReason(intent), intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
|
||||
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
|
||||
private async resolveIntent(billId: string): Promise<PaymentIntent> {
|
||||
if (!this.billReferenceService.isValid(billId)) {
|
||||
throw new CbeBillError("Bill not found.", "BUSINESS");
|
||||
}
|
||||
const intent = await this.intentsRepository.findByBillReference(billId);
|
||||
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
|
||||
throw new CbeBillError("Bill not found.", "BUSINESS");
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
/** Insert the audit row; a CBE retry of the same QUERY attempt reuses (and refreshes) its row. */
|
||||
private async upsertAudit(
|
||||
operation: "QUERY",
|
||||
endToEndTxnId: string,
|
||||
data: {
|
||||
billId: string;
|
||||
destinationApiName: string;
|
||||
requestPayload: Record<string, unknown>;
|
||||
},
|
||||
): Promise<CbeBillOperation> {
|
||||
try {
|
||||
return await this.cbeBillRepository.create({
|
||||
operation,
|
||||
endToEndTxnId,
|
||||
tradeStatus: "PENDING",
|
||||
...data,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
|
||||
const existing = await this.cbeBillRepository.findByEndToEndTxnId(
|
||||
endToEndTxnId,
|
||||
operation,
|
||||
);
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async finishAudit(
|
||||
audit: CbeBillOperation,
|
||||
outcome: {
|
||||
intentId?: string;
|
||||
tradeStatus: "SUCCESS" | "FAILED";
|
||||
failureClass?: "BUSINESS" | "TRANSIENT";
|
||||
response: { Response_Code: string; Response_Description: string };
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.cbeBillRepository.update(audit.id, {
|
||||
intentId: outcome.intentId ?? audit.intentId,
|
||||
tradeStatus: outcome.tradeStatus,
|
||||
failureClass: outcome.failureClass ?? null,
|
||||
responseCode: outcome.response.Response_Code,
|
||||
responseDescription: outcome.response.Response_Description,
|
||||
responsePayload: outcome.response as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
|
||||
/**
|
||||
* Controller-scoped safety net for anything that escapes CbeBillService's own error handling
|
||||
* (auth failures, DTO validation, unhandled throws). CBE's contract (plan D6): HTTP 200 for
|
||||
* every business outcome, 401 only for authentication.
|
||||
*
|
||||
* Exception: the CBE_BILL_ENABLED kill switch throws ServiceUnavailableException and stays
|
||||
* HTTP 503 with a non-0/1/3 code — the spec classes "any other code" as a transport error
|
||||
* ("retry, contact admin"), which is exactly what a kill switch should signal; a 200/code-3
|
||||
* would tell CBE the failure is final.
|
||||
*/
|
||||
@Catch()
|
||||
export class CbeExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(CbeExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
if (exception instanceof UnauthorizedException) {
|
||||
response.status(HttpStatus.UNAUTHORIZED).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "1",
|
||||
Response_Description: exception.message || "Unauthorized",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof ServiceUnavailableException) {
|
||||
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "9",
|
||||
Response_Description: "Service temporarily unavailable.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
// class-validator errors arrive as BadRequestException with message: string[].
|
||||
const body = exception.getResponse();
|
||||
const message =
|
||||
typeof body === "object" && body !== null && "message" in body
|
||||
? ([] as string[]).concat((body as { message: string }).message).join("; ")
|
||||
: exception.message;
|
||||
response.status(HttpStatus.OK).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: message || "Invalid request",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`unhandled /cbe/* error: ${exception instanceof Error ? exception.stack : String(exception)}`,
|
||||
);
|
||||
response.status(HttpStatus.OK).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: "Internal server error.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { AdditionalFieldDto } from "./cbe-query-request.dto";
|
||||
|
||||
/**
|
||||
* CBE → us, POST /cbe/payment (AAFDA spec §3.5). Mandatory fields per spec; the optional tail
|
||||
* (payer identity, channel) mirrors the reference implementation pending the Q1 sample files.
|
||||
*/
|
||||
export class CbePaymentRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Destination_Api_Name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
End_To_End_Txn_Id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Cbe_Txn_Ref!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Timestamp!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Bill_Id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Amount!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
Currency!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Phone_No?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Credit_Acct_Number?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
First_Name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Last_Name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Full_Name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Tin_Number?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Cheque_No?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Bank_Code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Payment_Method?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [AdditionalFieldDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdditionalFieldDto)
|
||||
Additional_Fields?: AdditionalFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Us → CBE, POST /cbe/payment response (AAFDA spec §3.8). */
|
||||
export class CbePaymentResponseDto {
|
||||
Destination_Api_Name!: string;
|
||||
End_To_End_Txn_Id!: string;
|
||||
Cbe_Txn_Ref!: string;
|
||||
Destination_Txn_Ref!: string;
|
||||
Status!: string;
|
||||
Response_Code!: string;
|
||||
Response_Description!: string;
|
||||
Additional_Fields!: { Key: string; Value: string }[];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class AdditionalFieldDto {
|
||||
@IsString()
|
||||
Key!: string;
|
||||
|
||||
@IsString()
|
||||
Value!: string;
|
||||
}
|
||||
|
||||
/** CBE → us, POST /cbe/query (AAFDA spec §2.5). Field names are CBE's, Pascal_Snake. */
|
||||
export class CbeQueryRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Destination_Api_Name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
End_To_End_Txn_Id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Bill_Id!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [AdditionalFieldDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdditionalFieldDto)
|
||||
Additional_Fields?: AdditionalFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Us → CBE, POST /cbe/query response (AAFDA spec §2.8, shape mirrored from the reference
|
||||
* implementation pending the Q1 sample files). Empty-string fields are deliberate — the
|
||||
* reference sends the full envelope with blanks rather than omitting keys.
|
||||
*/
|
||||
export class CbeQueryResponseDto {
|
||||
Destination_Api_Name!: string;
|
||||
End_To_End_Txn_Id!: string;
|
||||
Bill_Id!: string;
|
||||
Total_Amount!: string;
|
||||
Penalty_Amount!: string;
|
||||
Bill_Amount!: string;
|
||||
First_Name!: string;
|
||||
Last_Name!: string;
|
||||
Full_Name!: string;
|
||||
Payment_Reason!: string;
|
||||
Tin_Number!: string;
|
||||
Credit_Acct_Number!: string;
|
||||
Transaction_Type!: string;
|
||||
Timestamp!: string;
|
||||
Status!: string;
|
||||
Response_Code!: string;
|
||||
Response_Description!: string;
|
||||
Additional_Fields!: { Key: string; Value: string }[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
/** CBE → us, POST /cbe/oauth/token (AAFDA spec §1.5). Field names are CBE's, snake_case. */
|
||||
export class TokenRequestDto {
|
||||
@ApiProperty({ example: "client_credentials" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
grant_type!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
client_id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
client_secret!: string;
|
||||
|
||||
@ApiProperty({ example: "Unified_Outgoing" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scope!: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Us → CBE, POST /cbe/oauth/token response (AAFDA spec §1.7). */
|
||||
export class TokenResponseDto {
|
||||
token_type!: string;
|
||||
access_token!: string;
|
||||
expires_in!: number;
|
||||
scope!: string;
|
||||
/** Unix seconds at issue time. */
|
||||
consented_on!: number;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
|
||||
export type CbeOperation = "QUERY" | "PAYMENT";
|
||||
export type CbeTradeStatus = "PENDING" | "SUCCESS" | "FAILED";
|
||||
/** Drives the same-End_To_End_Txn_Id retry policy (plan §6.5): BUSINESS is final, TRANSIENT retryable. */
|
||||
export type CbeFailureClass = "BUSINESS" | "TRANSIENT";
|
||||
|
||||
/**
|
||||
* CBE-protocol-level audit and idempotency ledger (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2).
|
||||
* Separate from payment_intent because it tracks CBE's transaction identity — not ours — and
|
||||
* must retain the exact response body we returned so a retry replays it byte-for-byte.
|
||||
*
|
||||
* The UNIQUE (end_to_end_txn_id, operation) and partial-unique cbe_txn_ref indexes live in the
|
||||
* CreateCbeBillOperation migration.
|
||||
*/
|
||||
@Entity({ name: "cbe_bill_operation" })
|
||||
@Index("idx_cbe_bill_operation_bill", ["billId", "operation"])
|
||||
export class CbeBillOperation extends BaseEntity {
|
||||
@Column({ name: "operation", type: "varchar", length: 16 })
|
||||
operation!: CbeOperation;
|
||||
|
||||
/** Bill_Id exactly as received from CBE. */
|
||||
@Column({ name: "bill_id", type: "varchar", length: 32 })
|
||||
billId!: string;
|
||||
|
||||
/** CBE's per-attempt id — the idempotency key of the protocol. */
|
||||
@Column({ name: "end_to_end_txn_id", type: "varchar", length: 128 })
|
||||
endToEndTxnId!: string;
|
||||
|
||||
/** CBE core-banking reference; set on PAYMENT. */
|
||||
@Column({ name: "cbe_txn_ref", type: "varchar", length: 128, nullable: true })
|
||||
cbeTxnRef?: string | null;
|
||||
|
||||
/** Echoed back in every response. */
|
||||
@Column({
|
||||
name: "destination_api_name",
|
||||
type: "varchar",
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
destinationApiName?: string | null;
|
||||
|
||||
/** Our payment_intent.id once the bill resolved to an intent. Soft reference, no FK. */
|
||||
@Column({ name: "intent_id", type: "uuid", nullable: true })
|
||||
intentId?: string | null;
|
||||
|
||||
@Column({ name: "trade_status", type: "varchar", length: 16 })
|
||||
tradeStatus!: CbeTradeStatus;
|
||||
|
||||
@Column({
|
||||
name: "failure_class",
|
||||
type: "varchar",
|
||||
length: 16,
|
||||
nullable: true,
|
||||
})
|
||||
failureClass?: CbeFailureClass | null;
|
||||
|
||||
@Column({ name: "response_code", type: "varchar", length: 8, nullable: true })
|
||||
responseCode?: string | null;
|
||||
|
||||
@Column({ name: "response_description", type: "text", nullable: true })
|
||||
responseDescription?: string | null;
|
||||
|
||||
/** Raw inbound body, verbatim. */
|
||||
@Column({ name: "request_payload", type: "jsonb" })
|
||||
requestPayload!: Record<string, unknown>;
|
||||
|
||||
/** EXACT body we returned — replayed verbatim when CBE retries a settled End_To_End_Txn_Id. */
|
||||
@Column({ name: "response_payload", type: "jsonb", nullable: true })
|
||||
responsePayload?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CbeFailureClass } from "../entities/cbe-bill-operation.entity";
|
||||
|
||||
/**
|
||||
* A CBE business/transport outcome we detected ourselves. `failureClass` drives the §6.5
|
||||
* same-End_To_End_Txn_Id retry policy: BUSINESS outcomes are final (retrying cannot change
|
||||
* the answer), TRANSIENT ones (our 5xx, domain app unreachable) may be retried by CBE.
|
||||
*/
|
||||
export class CbeBillError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly failureClass: CbeFailureClass,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CbeBillError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only
|
||||
* 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6).
|
||||
*/
|
||||
export function toCbeFailure(err: unknown): {
|
||||
description: string;
|
||||
failureClass: CbeFailureClass;
|
||||
} {
|
||||
if (err instanceof CbeBillError) {
|
||||
return { description: err.message, failureClass: err.failureClass };
|
||||
}
|
||||
return { description: "Internal server error.", failureClass: "TRANSIENT" };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CbePaymentRequestDto } from "../dto/cbe-payment-request.dto";
|
||||
import { CbePaymentResponseDto } from "../dto/cbe-payment-response.dto";
|
||||
|
||||
export function mapPaymentSuccess(
|
||||
request: CbePaymentRequestDto,
|
||||
destinationTxnRef: string,
|
||||
): CbePaymentResponseDto {
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
|
||||
Destination_Txn_Ref: destinationTxnRef,
|
||||
Status: "SUCCESS",
|
||||
Response_Code: "0",
|
||||
Response_Description: "Success",
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPaymentFailure(
|
||||
request: CbePaymentRequestDto,
|
||||
description: string,
|
||||
): CbePaymentResponseDto {
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
|
||||
Destination_Txn_Ref: "",
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: description,
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CbeQueryRequestDto } from "../dto/cbe-query-request.dto";
|
||||
import { CbeQueryResponseDto } from "../dto/cbe-query-response.dto";
|
||||
|
||||
export function mapQuerySuccess(
|
||||
request: CbeQueryRequestDto,
|
||||
input: { amountMajor: number; fullName: string; paymentReason: string },
|
||||
): CbeQueryResponseDto {
|
||||
const amount = input.amountMajor.toFixed(2);
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Bill_Id: request.Bill_Id,
|
||||
Total_Amount: amount,
|
||||
Penalty_Amount: "0.00",
|
||||
Bill_Amount: amount,
|
||||
First_Name: "",
|
||||
Last_Name: "",
|
||||
Full_Name: input.fullName,
|
||||
Payment_Reason: input.paymentReason,
|
||||
Tin_Number: "",
|
||||
Credit_Acct_Number: "",
|
||||
Transaction_Type: "",
|
||||
Timestamp: new Date().toISOString(),
|
||||
Status: "SUCCESS",
|
||||
Response_Code: "0",
|
||||
Response_Description: "Success",
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapQueryFailure(
|
||||
request: CbeQueryRequestDto,
|
||||
description: string,
|
||||
): CbeQueryResponseDto {
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Bill_Id: request.Bill_Id,
|
||||
Total_Amount: "",
|
||||
Penalty_Amount: "",
|
||||
Bill_Amount: "",
|
||||
First_Name: "",
|
||||
Last_Name: "",
|
||||
Full_Name: "",
|
||||
Payment_Reason: "",
|
||||
Tin_Number: "",
|
||||
Credit_Acct_Number: "",
|
||||
Transaction_Type: "",
|
||||
Timestamp: new Date().toISOString(),
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: description,
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DataSource } from "typeorm";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
|
||||
describe("BillReferenceService", () => {
|
||||
const dataSource = {
|
||||
query: jest.fn().mockResolvedValue([{ nextval: "10000001" }]),
|
||||
} as unknown as DataSource;
|
||||
const service = new BillReferenceService(dataSource);
|
||||
|
||||
it("generates a 12-digit numeric reference that validates", async () => {
|
||||
const ref = await service.generate();
|
||||
expect(ref).toMatch(/^\d{12}$/);
|
||||
expect(ref.startsWith("00010000001")).toBe(true);
|
||||
expect(service.isValid(ref)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a single mistyped digit", async () => {
|
||||
const ref = await service.generate();
|
||||
const flipped =
|
||||
ref.slice(0, 5) + ((Number(ref[5]) + 1) % 10) + ref.slice(6);
|
||||
expect(service.isValid(flipped)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects adjacent transpositions", async () => {
|
||||
const ref = await service.generate();
|
||||
// Transpose the last two differing adjacent body digits.
|
||||
const digits = ref.split("");
|
||||
const i = digits.findIndex((d, idx) => idx < 11 && d !== digits[idx + 1]);
|
||||
[digits[i], digits[i + 1]] = [digits[i + 1], digits[i]];
|
||||
expect(service.isValid(digits.join(""))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects wrong length and non-numeric input", () => {
|
||||
expect(service.isValid("12345")).toBe(false);
|
||||
expect(service.isValid("00045123389A")).toBe(false);
|
||||
expect(service.isValid("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
/**
|
||||
* CBE_BILL bill reference numbers (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5).
|
||||
*
|
||||
* 12 numeric digits: an 11-digit Postgres-sequence value, zero-padded, plus a trailing Luhn
|
||||
* check digit. Numeric-only so it is typeable on any USSD keypad; the check digit rejects
|
||||
* most single-digit typos and adjacent transpositions before any DB lookup; sequence-backed
|
||||
* so uniqueness is guaranteed without a collision-retry loop.
|
||||
*
|
||||
* Subject to Phase 0-Q2 — if CBE imposes their own length/charset constraint, theirs wins.
|
||||
*/
|
||||
const SEQUENCE = "edr_payment.cbe_bill_reference_seq";
|
||||
const TOTAL_LENGTH = 12;
|
||||
|
||||
@Injectable()
|
||||
export class BillReferenceService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async generate(): Promise<string> {
|
||||
const rows: [{ nextval: string }] = await this.dataSource.query(
|
||||
`SELECT nextval('${SEQUENCE}')`,
|
||||
);
|
||||
const body = rows[0].nextval.padStart(TOTAL_LENGTH - 1, "0");
|
||||
return body + luhnCheckDigit(body);
|
||||
}
|
||||
|
||||
/** Format + check-digit validation — the cheap reject before any DB hit. */
|
||||
isValid(billReference: string): boolean {
|
||||
if (!/^\d+$/.test(billReference) || billReference.length !== TOTAL_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
const body = billReference.slice(0, -1);
|
||||
return luhnCheckDigit(body) === billReference.slice(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard Luhn check digit over a numeric string. */
|
||||
function luhnCheckDigit(digits: string): string {
|
||||
let sum = 0;
|
||||
// Rightmost body digit is doubled (it sits next to the check digit position).
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
let d = Number(digits[digits.length - 1 - i]);
|
||||
if (i % 2 === 0) {
|
||||
d *= 2;
|
||||
if (d > 9) d -= 9;
|
||||
}
|
||||
sum += d;
|
||||
}
|
||||
return String((10 - (sum % 10)) % 10);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
@@ -101,6 +102,25 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
idempotencyKey?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Payer full name snapshot (CBE_BILL: fallback Full_Name for /cbe/query when the " +
|
||||
"domain app is unreachable)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
payerName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Intent expiry, ISO-8601 (CBE_BILL: the booking's own payment deadline — never a " +
|
||||
"provider session TTL)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class IntentReferenceQueryDto {
|
||||
|
||||
@@ -109,6 +109,23 @@ export class PaymentIntent extends BaseEntity {
|
||||
})
|
||||
idempotencyKey?: string | null;
|
||||
|
||||
/**
|
||||
* CBE_BILL only: the short numeric Bill_Id the customer types at a CBE channel
|
||||
* (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5). Null for every other provider.
|
||||
*/
|
||||
@Column({
|
||||
name: "bill_reference",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
nullable: true,
|
||||
unique: true,
|
||||
})
|
||||
billReference?: string | null;
|
||||
|
||||
/** Payer full name snapshot — fallback for CBE /cbe/query Full_Name when bill-query is down. */
|
||||
@Column({ name: "payer_name", type: "varchar", length: 128, nullable: true })
|
||||
payerName?: string | null;
|
||||
|
||||
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ProvidersModule } from "../providers/providers.module";
|
||||
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
import { IntentsController } from "./intents.controller";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
import { IntentsService } from "./intents.service";
|
||||
@@ -15,7 +16,7 @@ import { IntentsService } from "./intents.service";
|
||||
ProvidersModule,
|
||||
],
|
||||
controllers: [IntentsController],
|
||||
providers: [IntentsService, IntentsRepository],
|
||||
exports: [IntentsService, IntentsRepository],
|
||||
providers: [IntentsService, IntentsRepository, BillReferenceService],
|
||||
exports: [IntentsService, IntentsRepository, BillReferenceService],
|
||||
})
|
||||
export class IntentsModule {}
|
||||
|
||||
@@ -70,6 +70,13 @@ export class IntentsRepository extends BaseRepository<PaymentIntent> {
|
||||
});
|
||||
}
|
||||
|
||||
/** CBE_BILL: resolve the intent behind a Bill_Id presented by CBE. */
|
||||
async findByBillReference(
|
||||
billReference: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
return this.repository.findOne({ where: { billReference } });
|
||||
}
|
||||
|
||||
async findByMerchantOrderId(
|
||||
merchantOrderId: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
ProviderPaymentStatus,
|
||||
} from "@edr/types";
|
||||
import { CacBankProvider } from "@edr/payment-providers";
|
||||
import { IntentsService } from "./intents.service";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
|
||||
/**
|
||||
* CBE_BILL regression tests for plan D5 (docs/cbe/CBE_IMPLEMENTATION_PLAN.md): the provider is
|
||||
* deliberately absent from PAYMENT_PROVIDER_MAP, so the pull-side refresh must return the
|
||||
* cached intent untouched instead of calling a provider. This is load-bearing — a stub
|
||||
* provider entry would make the reconciliation sweep expire live CBE bills.
|
||||
*/
|
||||
describe("IntentsService CBE_BILL", () => {
|
||||
const providers = new Map();
|
||||
let repository: jest.Mocked<
|
||||
Pick<
|
||||
IntentsRepository,
|
||||
"create" | "findById" | "findByIdempotencyKey" | "update"
|
||||
>
|
||||
>;
|
||||
let billReferenceService: { generate: jest.Mock };
|
||||
let service: IntentsService;
|
||||
|
||||
const request: InitiatePaymentRequest = {
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: "booking-1",
|
||||
amountMinor: 1500,
|
||||
currency: "ETB",
|
||||
provider: ProviderMethod.CBE_BILL,
|
||||
payerName: "Abebe Kebede",
|
||||
expiresAt: "2026-08-01T12:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
create: jest.fn(async (data) => ({ id: "intent-1", ...data })),
|
||||
findById: jest.fn(),
|
||||
findByIdempotencyKey: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
} as never;
|
||||
billReferenceService = {
|
||||
generate: jest.fn().mockResolvedValue("000100000015"),
|
||||
};
|
||||
service = new IntentsService(
|
||||
repository as unknown as IntentsRepository,
|
||||
{} as DataSource,
|
||||
providers as never,
|
||||
{} as CacBankProvider,
|
||||
billReferenceService as unknown as BillReferenceService,
|
||||
);
|
||||
});
|
||||
|
||||
it("initiates without a provider session: REQUIRES_ACTION + SHOW_BILL_REFERENCE", async () => {
|
||||
const snapshot = await service.initiate(request);
|
||||
|
||||
expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
|
||||
expect(snapshot.billReference).toBe("000100000015");
|
||||
expect(snapshot.clientAction).toMatchObject({
|
||||
type: "SHOW_BILL_REFERENCE",
|
||||
billReference: "000100000015",
|
||||
});
|
||||
// The booking's own deadline, not a provider-session TTL (plan §6.4).
|
||||
expect(snapshot.expiresAt).toBe("2026-08-01T12:00:00.000Z");
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
billReference: "000100000015",
|
||||
payerName: "Abebe Kebede",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-ETB currency (plan D8)", async () => {
|
||||
await expect(
|
||||
service.initiate({ ...request, currency: "DJF" }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("getIntent leaves a stale CBE_BILL intent untouched (no provider in map — plan D5)", async () => {
|
||||
const intent = {
|
||||
id: "intent-1",
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: "booking-1",
|
||||
merchantOrderId: "PSG-x",
|
||||
provider: ProviderMethod.CBE_BILL,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
amountMinor: 1500,
|
||||
currency: "ETB",
|
||||
billReference: "000100000015",
|
||||
// Stale enough that a mapped provider WOULD be queried.
|
||||
updatedAt: new Date(Date.now() - 60_000),
|
||||
} as unknown as PaymentIntent;
|
||||
repository.findById.mockResolvedValue(intent);
|
||||
const applySpy = jest.spyOn(service, "applyProviderResult");
|
||||
|
||||
const snapshot = await service.getIntent("intent-1");
|
||||
|
||||
expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
|
||||
expect(applySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TERMINAL_INTENT_STATUSES,
|
||||
} from "./entities/payment-intent.entity";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
|
||||
/** Don't hit the provider again if the intent was refreshed this recently. */
|
||||
const REFRESH_MIN_AGE_MS = 5_000;
|
||||
@@ -69,6 +70,7 @@ export class IntentsService {
|
||||
@Inject(PAYMENT_PROVIDER_MAP)
|
||||
private readonly providers: PaymentProviderMap,
|
||||
private readonly cacBankProvider: CacBankProvider,
|
||||
private readonly billReferenceService: BillReferenceService,
|
||||
) {}
|
||||
|
||||
/* ------------------------------------------------------------------ initiate */
|
||||
@@ -85,6 +87,12 @@ export class IntentsService {
|
||||
if (byKey) return this.toSnapshot(byKey);
|
||||
}
|
||||
|
||||
// CBE_BILL is inbound-only: there is no provider session to open and deliberately no entry
|
||||
// in PAYMENT_PROVIDER_MAP (plan D5 — the sweep and refreshIfStale must no-op on it).
|
||||
if (request.provider === ProviderMethod.CBE_BILL) {
|
||||
return this.initiateCbeBill(request);
|
||||
}
|
||||
|
||||
// Free method changes: no reuse/supersede. Every initiate opens a fresh intent, so a booking
|
||||
// may accumulate many intents (each method attempt is its own row). The `idempotencyKey` check
|
||||
// above still collapses exact duplicate submissions (e.g. a double-click). Confirm-once is
|
||||
@@ -140,6 +148,54 @@ export class IntentsService {
|
||||
return this.toSnapshot(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md). Intent-first: the bill
|
||||
* reference is created here, before CBE ever sees the bill; settlement arrives later through
|
||||
* the inbound /cbe/payment endpoint and the unchanged applyProviderResult() state machine.
|
||||
*/
|
||||
private async initiateCbeBill(
|
||||
request: InitiatePaymentRequest,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
// D8: CBE settles ETB only. The domain app must price/charge the order in ETB.
|
||||
if (request.currency !== "ETB") {
|
||||
throw new BadRequestException(
|
||||
`CBE_BILL supports ETB only (got ${request.currency})`,
|
||||
);
|
||||
}
|
||||
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const billReference = await this.billReferenceService.generate();
|
||||
// expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider
|
||||
// session TTL (plan §6.4: a short TTL would make the sweep cancel the booking within the hour).
|
||||
const expiresAt = request.expiresAt ? new Date(request.expiresAt) : null;
|
||||
|
||||
const intent = await this.intentsRepository.create({
|
||||
service: request.service,
|
||||
referenceType: request.referenceType,
|
||||
referenceId: request.referenceId,
|
||||
merchantOrderId,
|
||||
provider: request.provider,
|
||||
amountMinor: request.amountMinor,
|
||||
currency: request.currency,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
clientAction: {
|
||||
type: "SHOW_BILL_REFERENCE",
|
||||
billReference,
|
||||
instructions:
|
||||
"Pay this bill at any CBE branch, CBE Birr app, mobile banking or USSD.",
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
},
|
||||
idempotencyKey: request.idempotencyKey ?? null,
|
||||
expiresAt,
|
||||
billReference,
|
||||
payerName: request.payerName ?? null,
|
||||
});
|
||||
this.logger.log(
|
||||
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${billReference})`,
|
||||
);
|
||||
return this.toSnapshot(intent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ confirm (OTP providers) */
|
||||
|
||||
async confirm(
|
||||
@@ -626,6 +682,7 @@ export class IntentsService {
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
expiresAt: intent.expiresAt?.toISOString(),
|
||||
billReference: intent.billReference ?? undefined,
|
||||
providerResponse: intent.rawInitiation ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ export enum ProviderMethod {
|
||||
CARD = "CARD",
|
||||
DMONEY = "DMONEY",
|
||||
CAC_BANK = "CAC_BANK",
|
||||
/**
|
||||
* CBE Unified Bill Payment — inbound biller integration. We never call CBE: the customer
|
||||
* takes the bill reference to any CBE channel and CBE calls payment-api's /cbe/* endpoints.
|
||||
* No entry in PAYMENT_PROVIDER_MAP by design (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D5).
|
||||
*/
|
||||
CBE_BILL = "CBE_BILL",
|
||||
}
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
@@ -40,6 +46,13 @@ export type ClientAction =
|
||||
type: "COLLECT_OTP";
|
||||
providerOrderId: string;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
/** CBE_BILL: show the bill reference the customer pays at any CBE channel. */
|
||||
type: "SHOW_BILL_REFERENCE";
|
||||
billReference: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
@@ -130,6 +143,16 @@ export interface InitiatePaymentRequest {
|
||||
failureUrl?: string;
|
||||
/** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */
|
||||
idempotencyKey?: string;
|
||||
/**
|
||||
* Payer full name snapshot (CBE_BILL: fallback for the mandatory Full_Name in /cbe/query
|
||||
* responses when the domain app's bill-query is unreachable).
|
||||
*/
|
||||
payerName?: string;
|
||||
/**
|
||||
* Intent expiry, ISO-8601. CBE_BILL: the booking's own payment deadline — NOT a provider
|
||||
* session TTL (the reconciliation sweep cancels the intent when this passes).
|
||||
*/
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/** Body of `POST /payments/intents/:id/confirm` (OTP-based providers such as CAC Bank). */
|
||||
@@ -154,6 +177,8 @@ export type PaymentIntentSnapshot ={
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
expiresAt?: string;
|
||||
/** CBE_BILL only: the short numeric Bill_Id the customer pays at a CBE channel. */
|
||||
billReference?: string;
|
||||
/**
|
||||
* Raw provider payload for inspection/debugging — the audit copy of the provider
|
||||
* initiation response merged with the latest status-query response (secrets redacted
|
||||
|
||||
93
pnpm-lock.yaml
generated
93
pnpm-lock.yaml
generated
@@ -98,10 +98,10 @@ importers:
|
||||
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@tria-plc/api-common':
|
||||
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(3400edbb67a81ad1009ef960f3b47a6d)
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5)
|
||||
'@tria-plc/iamapi-common':
|
||||
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.15.tgz
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f)
|
||||
amqp-connection-manager:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(amqplib@2.0.1)
|
||||
@@ -816,10 +816,10 @@ importers:
|
||||
version: 8.1.6
|
||||
'@tria-plc/api-common':
|
||||
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65)
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)
|
||||
'@tria-plc/iamapi-common':
|
||||
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991)
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)
|
||||
'@types/bcrypt':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -1135,6 +1135,9 @@ importers:
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt':
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
@@ -2830,10 +2833,10 @@ packages:
|
||||
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
|
||||
'@nestjs/jwt@10.2.0':
|
||||
resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==}
|
||||
'@nestjs/jwt@11.0.2':
|
||||
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
|
||||
'@nestjs/mapped-types@2.0.5':
|
||||
resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==}
|
||||
@@ -4760,8 +4763,8 @@ packages:
|
||||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.5':
|
||||
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/lodash@4.17.24':
|
||||
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
||||
@@ -4775,6 +4778,9 @@ packages:
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/multer@2.1.0':
|
||||
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
|
||||
|
||||
@@ -8565,10 +8571,6 @@ packages:
|
||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||
engines: {'0': node >= 0.2.0}
|
||||
|
||||
jsonwebtoken@9.0.2:
|
||||
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
@@ -8595,15 +8597,9 @@ packages:
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
jwa@1.4.2:
|
||||
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@3.2.3:
|
||||
resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
@@ -13863,11 +13859,11 @@ snapshots:
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
eventemitter2: 6.4.9
|
||||
|
||||
'@nestjs/jwt@10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@types/jsonwebtoken': 9.0.5
|
||||
jsonwebtoken: 9.0.2
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
jsonwebtoken: 9.0.3
|
||||
|
||||
'@nestjs/mapped-types@2.0.5(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
@@ -16291,18 +16287,18 @@ snapshots:
|
||||
|
||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(3400edbb67a81ad1009ef960f3b47a6d)':
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f)
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
change-case: 5.4.4
|
||||
@@ -16335,18 +16331,18 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65)':
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991)
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
change-case: 5.4.4
|
||||
@@ -16379,18 +16375,18 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(578386f46cf99fd4720e3e99f196f69e)':
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(3400edbb67a81ad1009ef960f3b47a6d)
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5)
|
||||
api-common: 1.2.2
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
@@ -16414,18 +16410,18 @@ snapshots:
|
||||
- '@faker-js/faker'
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991)':
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65)
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)
|
||||
api-common: 1.2.2
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
@@ -16889,8 +16885,9 @@ snapshots:
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.5':
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 20.19.42
|
||||
|
||||
'@types/lodash@4.17.24': {}
|
||||
@@ -16901,6 +16898,8 @@ snapshots:
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/multer@2.1.0':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
@@ -21319,19 +21318,6 @@ snapshots:
|
||||
|
||||
jsonparse@1.3.1: {}
|
||||
|
||||
jsonwebtoken@9.0.2:
|
||||
dependencies:
|
||||
jws: 3.2.3
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.8.2
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
@@ -21392,23 +21378,12 @@ snapshots:
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
jwa@1.4.2:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@3.2.3:
|
||||
dependencies:
|
||||
jwa: 1.4.2
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
|
||||
Reference in New Issue
Block a user