diff --git a/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts new file mode 100644 index 000000000..449f3cebe --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface { + name = "AddCbeBillPaymentMethod3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // PostgreSQL does not support removing enum values directly. + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 525ccfefc..850791f02 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { 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, }); @@ -1105,7 +1131,8 @@ export class BillingService { // code — so the demo shortcut must never fire for it. 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 +1178,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), + }; + } } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 5c4a4f7f7..ce072beca 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -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"] }) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 0fc5a6ba5..b5ff51c48 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -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 { + return this.billingService.billQuery(request.referenceId); + } } diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index 1bf8c3f82..e69a0e15a 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 0c166d164..0f628e39c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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 = { CARD: "card", DMONEY: "dmoney", CAC_BANK: "cac-bank", + CBE_BILL: "cbe-bill", }; /** @@ -214,8 +219,13 @@ export class PaymentService { async initiate(input: InitiateIntentInput): Promise { 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: diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 3b86a940c..255da0ea2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -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 { diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index d3e4387b0..60044e550 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -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 = { diff --git a/apps/edr-freight-web/backoffice/src/services/payments.service.ts b/apps/edr-freight-web/backoffice/src/services/payments.service.ts index bc4083f97..4615417ca 100644 --- a/apps/edr-freight-web/backoffice/src/services/payments.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/payments.service.ts @@ -19,7 +19,8 @@ export type PaymentMethod = | "waafi" | "card" | "dmoney" - | "cac-bank"; + | "cac-bank" + | "cbe-bill"; export interface PaymentRow { id: string; diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 30cb1597d..1477b7189 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -9,6 +9,7 @@ import { Divider, Group, Loader, + Modal, Paper, SimpleGrid, Stack, @@ -69,6 +70,12 @@ export default function InvoiceDetailPage() { } = useQuery(api.invoices.get.queryOptions({ input: { id } })); const [payModalOpen, setPayModalOpen] = useState(false); + // CBE bill payment: the bill reference to pay at any CBE channel (no redirect). + const [billAction, setBillAction] = useState<{ + billReference?: string; + instructions?: string; + expiresAt?: string; + } | null>(null); // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges // one of the signed-in customer's own invoices (unlike the admin-facing @@ -377,6 +384,62 @@ export default function InvoiceDetailPage() { pay.pay(id, method, payerAccount) } /> + + {/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */} + setBillAction(null)} + centered + radius={18} + size={440} + title={Pay at CBE} + > + + + {billAction?.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} + + + + {billAction?.billReference} + + + + + Amount due:{" "} + + {formatCurrency(amountDue, invoice.currency)} + + + {billAction?.expiresAt && ( + + Pay before:{" "} + + {fmtDate(billAction.expiresAt)} + + + )} + + The invoice updates automatically once CBE confirms your payment. + + + ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 0cc6d7bff..2e9477f80 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -26,7 +26,7 @@ interface ProviderOption { accent: string; } -// Only Telebirr, Waafi and CAC Bank are enabled for now. +// Only Telebirr, Waafi and CBE bill payment are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -51,6 +51,14 @@ 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. */ diff --git a/apps/edr-freight-web/portal/src/services/payments.service.ts b/apps/edr-freight-web/portal/src/services/payments.service.ts index c62e31108..5d592adbb 100644 --- a/apps/edr-freight-web/portal/src/services/payments.service.ts +++ b/apps/edr-freight-web/portal/src/services/payments.service.ts @@ -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 { diff --git a/apps/edr-passenger-api/prisma/migrations/20260731000001_add_cbe_bill_payment_method/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260731000001_add_cbe_bill_payment_method/migration.sql new file mode 100644 index 000000000..5ebaacb79 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260731000001_add_cbe_bill_payment_method/migration.sql @@ -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'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 467bff071..00090ff1a 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -147,6 +147,7 @@ enum PaymentMethodType { WAAFI DMONEY CAC_BANK + CBE_BILL @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index 98262c4c3..df5949bde 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -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 { 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 { + return this.paymentsService.billQuery(request.referenceId); + } } diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts index 7f732ae51..d2b88372e 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index e0b594fd2..dc389e5a1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 5271acef4..8c4edf083 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -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(); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 6fc91556a..14e48dce5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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 { + 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 { + 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" }; } diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts index 4313d5426..2c5b780fc 100644 --- a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -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], + }, ]; diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index 05235e3a8..82cc62ef6 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -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, diff --git a/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts index 9e5ef560e..03b7cf6a4 100644 --- a/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts +++ b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts @@ -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() diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx index 4e6a71950..c2965ea5c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx @@ -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' }, diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index d27a505fa..685ba5ee2 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -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(null); const [otpError, setOtpError] = useState(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({ + 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() { )} + {/* CBE bill payment — show the bill reference; confirmation comes from polling */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount:{" "} + + ETB {(totalAmountDisplay ?? 0).toFixed(2)} + +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {format(new Date(billAction.expiresAt), "MMM d, yyyy HH:mm")} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + {/* Two-column grid */}
diff --git a/apps/edr-payment-api/package.json b/apps/edr-payment-api/package.json index bdcda721b..5b306c839 100644 --- a/apps/edr-payment-api/package.json +++ b/apps/edr-payment-api/package.json @@ -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", diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts index 410a8146b..c3c1f88f7 100644 --- a/apps/edr-payment-api/src/app.module.ts +++ b/apps/edr-payment-api/src/app.module.ts @@ -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, diff --git a/apps/edr-payment-api/src/config/cbe-bill.config.ts b/apps/edr-payment-api/src/config/cbe-bill.config.ts new file mode 100644 index 000000000..7fa528464 --- /dev/null +++ b/apps/edr-payment-api/src/config/cbe-bill.config.ts @@ -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(/\/$/, ""), +})); diff --git a/apps/edr-payment-api/src/migrations/1782300000000-AddCbeBillReference.ts b/apps/edr-payment-api/src/migrations/1782300000000-AddCbeBillReference.ts new file mode 100644 index 000000000..9cef39533 --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1782300000000-AddCbeBillReference.ts @@ -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 { + 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 { + 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"`, + ); + } +} diff --git a/apps/edr-payment-api/src/migrations/1782400000000-CreateCbeBillOperation.ts b/apps/edr-payment-api/src/migrations/1782400000000-CreateCbeBillOperation.ts new file mode 100644 index 000000000..075fbdf5c --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1782400000000-CreateCbeBillOperation.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP TABLE IF EXISTS "edr_payment"."cbe_bill_operation"`, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts new file mode 100644 index 000000000..f46571c6b --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts @@ -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 { + const base = + intent.service === PaymentService.PASSENGER + ? this.config.get("cbeBill.passengerApiBaseUrl") + : this.config.get("cbeBill.freightApiBaseUrl"); + const url = `${base}/internal/payments/bill-query`; + + try { + const response = await firstValueFrom( + this.http.post( + url, + { + referenceType: intent.referenceType, + referenceId: intent.referenceId, + }, + { + timeout: this.config.get("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"); + } + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts new file mode 100644 index 000000000..bb4319cf8 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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("cbeBill.jwtSecret"), + }); + return true; + } catch { + throw new UnauthorizedException("Invalid or expired token"); + } + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts new file mode 100644 index 000000000..46b8f9009 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts @@ -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 { + 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 { + 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 { + return this.cbeBillService.pay(dto); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts new file mode 100644 index 000000000..8efe8399f --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts @@ -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 {} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.repository.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.repository.ts new file mode 100644 index 000000000..d71c69ff5 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.repository.ts @@ -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 { + constructor( + @InjectRepository(CbeBillOperation) + repository: Repository, + ) { + super(repository); + } + + /** The prior attempt for CBE's per-attempt id — the §6.5 idempotency lookup. */ + async findByEndToEndTxnId( + endToEndTxnId: string, + operation: CbeOperation, + ): Promise { + 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 { + return this.repository.findOne({ + where: { cbeTxnRef, operation: "PAYMENT", tradeStatus: "SUCCESS" }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts new file mode 100644 index 000000000..5b1a6cb28 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -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 { + this.assertEnabled(); + + const clientId = this.config.get("cbeBill.clientId"); + const clientSecret = this.config.get("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("cbeBill.scope"); + if (!valid) throw new UnauthorizedException("Invalid credentials"); + + const expiresIn = this.config.get("cbeBill.tokenExpiresIn") ?? 3600; + const accessToken = await this.jwtService.signAsync( + { iss: "edr-payment-api", clientId: dto.client_id, scope: dto.scope }, + { secret: this.config.get("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 { + 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, + }); + + 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 { + 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, + })) ?? 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, + }); + } 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("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 { + 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; + }, + ): Promise { + 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 { + 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, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts new file mode 100644 index 000000000..dcbb5d82a --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts @@ -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(); + + 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.", + }); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-request.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-request.dto.ts new file mode 100644 index 000000000..04b6a054d --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-request.dto.ts @@ -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[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-response.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-response.dto.ts new file mode 100644 index 000000000..3dbffab2c --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-response.dto.ts @@ -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 }[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-request.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-request.dto.ts new file mode 100644 index 000000000..f44f9ea9b --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-request.dto.ts @@ -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[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-response.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-response.dto.ts new file mode 100644 index 000000000..443ccde0a --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-response.dto.ts @@ -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 }[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/token-request.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-request.dto.ts new file mode 100644 index 000000000..15dccb2aa --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-request.dto.ts @@ -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; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/token-response.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-response.dto.ts new file mode 100644 index 000000000..9fc183bb8 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-response.dto.ts @@ -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; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/entities/cbe-bill-operation.entity.ts b/apps/edr-payment-api/src/modules/cbe-bill/entities/cbe-bill-operation.entity.ts new file mode 100644 index 000000000..2182c9cb1 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/entities/cbe-bill-operation.entity.ts @@ -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; + + /** 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 | null; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts new file mode 100644 index 000000000..7cda33117 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts @@ -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" }; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts new file mode 100644 index 000000000..d90d94c91 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts @@ -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: [], + }; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts new file mode 100644 index 000000000..95caadc75 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts @@ -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: [], + }; +} diff --git a/apps/edr-payment-api/src/modules/intents/bill-reference.service.spec.ts b/apps/edr-payment-api/src/modules/intents/bill-reference.service.spec.ts new file mode 100644 index 000000000..2956d5e45 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/bill-reference.service.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-payment-api/src/modules/intents/bill-reference.service.ts b/apps/edr-payment-api/src/modules/intents/bill-reference.service.ts new file mode 100644 index 000000000..e383d6352 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/bill-reference.service.ts @@ -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 { + 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); +} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index a9c8edfea..6d602b28a 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -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 { diff --git a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts index 12fa3c402..4025cfc30 100644 --- a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts +++ b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts @@ -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; diff --git a/apps/edr-payment-api/src/modules/intents/intents.module.ts b/apps/edr-payment-api/src/modules/intents/intents.module.ts index 9e9774ad9..f968726b0 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.module.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.module.ts @@ -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 {} diff --git a/apps/edr-payment-api/src/modules/intents/intents.repository.ts b/apps/edr-payment-api/src/modules/intents/intents.repository.ts index dcda00222..a2ff075dc 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.repository.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.repository.ts @@ -70,6 +70,13 @@ export class IntentsRepository extends BaseRepository { }); } + /** CBE_BILL: resolve the intent behind a Bill_Id presented by CBE. */ + async findByBillReference( + billReference: string, + ): Promise { + return this.repository.findOne({ where: { billReference } }); + } + async findByMerchantOrderId( merchantOrderId: string, ): Promise { diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts new file mode 100644 index 000000000..715184424 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 7b389af78..c0b246b83 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -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 { + // 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, }; } diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index f5d8c7e4c..2731aa794 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2631397e..fb519083d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,7 +589,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -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==} @@ -12250,11 +12246,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12289,7 +12285,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -12298,14 +12294,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12320,9 +12309,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -12337,13 +12326,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12496,18 +12485,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12731,7 +12708,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12897,7 +12874,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -13043,7 +13020,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -13853,7 +13830,7 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@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/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@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 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(@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) @@ -13863,11 +13840,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: @@ -13885,6 +13862,20 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 + '@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@0.10.9))(amqplib@0.10.9)(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) + '@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) + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/websockets': 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) + amqp-connection-manager: 5.0.0(amqplib@0.10.9) + amqplib: 0.10.9 + optional: true + '@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)': 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) @@ -13982,7 +13973,7 @@ 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) tslib: 2.8.1 optionalDependencies: - '@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/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@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 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/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)': @@ -14214,7 +14205,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -16282,7 +16273,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16296,7 +16287,7 @@ snapshots: '@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) @@ -16340,7 +16331,7 @@ snapshots: '@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) @@ -16384,7 +16375,7 @@ snapshots: '@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) @@ -16414,18 +16405,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 @@ -16573,130 +16564,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -16889,8 +16756,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 +16769,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/multer@2.1.0': dependencies: '@types/express': 5.0.6 @@ -17057,7 +16927,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -17067,7 +16937,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17086,7 +16956,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17101,7 +16971,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -17381,7 +17251,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -17434,6 +17304,12 @@ snapshots: amqplib: 0.10.9 promise-breaker: 6.0.0 + amqp-connection-manager@5.0.0(amqplib@0.10.9): + dependencies: + amqplib: 0.10.9 + promise-breaker: 6.0.0 + optional: true + amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 @@ -17905,16 +17781,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -18068,7 +17934,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -19069,7 +18935,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -19089,7 +18955,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -19322,7 +19188,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -19450,7 +19316,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -19680,7 +19546,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19733,7 +19599,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19886,7 +19752,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20132,7 +19998,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20413,7 +20279,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20426,14 +20292,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20858,7 +20724,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -21319,19 +21185,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 +21245,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 @@ -21530,7 +21372,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -22305,7 +22147,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22645,7 +22487,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -22674,7 +22516,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22927,15 +22769,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -23509,7 +23342,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23627,7 +23460,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23845,7 +23678,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23855,7 +23688,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23866,7 +23699,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -23875,7 +23708,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23887,7 +23720,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -24167,24 +24000,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24210,7 +24025,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -24717,7 +24532,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24741,7 +24556,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -25044,7 +24859,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25080,7 +24895,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2