feat: ( payment ) implement cbe payment

This commit is contained in:
Abubeker
2026-07-31 07:50:10 +00:00
parent e4a2c61224
commit 37855b0a83
52 changed files with 2244 additions and 368 deletions

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface {
name = "AddCbeBillPaymentMethod3050000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen
// per the local convention (see 2460000000000-AddCacBankPaymentMethod).
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values directly.
}
}

View File

@@ -995,6 +995,7 @@ export class BillingService {
): Promise<InitiateResponseDto> { ): Promise<InitiateResponseDto> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({ const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) }, where: { id: invoiceId, status: In(OPEN_STATUSES) },
relations: { company: true },
}); });
if (!invoice) { if (!invoice) {
throw new NotFoundException( throw new NotFoundException(
@@ -1023,6 +1024,10 @@ export class BillingService {
method: opts.method ?? "TELEBIRR", method: opts.method ?? "TELEBIRR",
platform: opts.platform, platform: opts.platform,
payerAccount: opts.payerAccount, 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, returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl, failureUrl: opts.failureUrl,
}); });
@@ -1033,8 +1038,9 @@ export class BillingService {
.update({ id: invoice.id }, { paymentId: result.intentId }); .update({ id: invoice.id }, { paymentId: result.intentId });
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only. // billing must not simulate it. Kept for local demos only. NEVER for CBE_BILL —
if (!result.immediateSuccess) { // its bill must stay open until CBE actually settles it via /cbe/payment.
if (!result.immediateSuccess && opts.method !== "CBE_BILL") {
await this.payment.handlePaymentEvent({ await this.payment.handlePaymentEvent({
eventType: "payment.succeeded", eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`, eventId: `demo-${result.intentId}`,
@@ -1079,4 +1085,52 @@ export class BillingService {
paidAt, 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;
}> {
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,
reason: expired ? "EXPIRED" : balance > 0 ? null : "ALREADY_PAID",
};
}
const latest = await repo.findOne({
where: { sourceId: referenceId },
relations: { company: true },
order: { createdAt: "DESC" },
});
return {
stillPayable: false,
payerName: latest?.company?.name ?? null,
currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null,
currency: latest?.currency ?? null,
reason:
latest?.status === Freight.InvoiceStatus.Paid
? "ALREADY_PAID"
: "CANCELLED",
};
}
} }

View File

@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
type PaymentType = string 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" type Currency = "ETB" | "USD"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" 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" }) @Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
referenceType?: string; 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 method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] }) @Column({ type: "enum", enum: ["ETB", "USD"] })

View File

@@ -1,30 +1,42 @@
import { import {
Body, Body,
Controller, Controller,
forwardRef,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Inject,
Logger, Logger,
Post, Post,
UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common"; import {
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; PaymentEventDto,
MarkPaidResponseDto,
BillQueryRequestDto,
BillQueryResponseDto,
} from "./internal-payment.dto";
import { PaymentService } from "./payment.service"; 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. * Consumer side of the payment microservice's outbox relay. Only the payment service may
* WARNING: currently unauthenticated — anyone who can reach the API can mark * call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
* this HTTP endpoint remains as a transport-agnostic fallback. * this HTTP endpoint remains as a transport-agnostic fallback.
*/ */
@ApiTags("Internal Payments") @ApiTags("Internal Payments")
@Public() @UseGuards(ServiceAuthGuard)
@Controller("internal/payments") @Controller("internal/payments")
export class InternalPaymentController { export class InternalPaymentController {
private readonly logger = new Logger(InternalPaymentController.name); 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") @Post("mark-paid")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -36,4 +48,16 @@ export class InternalPaymentController {
this.logger.log(`Marking payment ${event} as PAID`); this.logger.log(`Marking payment ${event} as PAID`);
return this.paymentService.handlePaymentEvent(event); return this.paymentService.handlePaymentEvent(event);
} }
@Post("bill-query")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
})
async billQuery(
@Body() request: BillQueryRequestDto,
): Promise<BillQueryResponseDto> {
return this.billingService.billQuery(request.referenceId);
}
} }

View File

@@ -51,3 +51,24 @@ export class MarkPaidResponseDto {
@ApiPropertyOptional() alreadyFinalized?: boolean; @ApiPropertyOptional() alreadyFinalized?: boolean;
@ApiPropertyOptional() reason?: string; @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 {
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
@ApiProperty() @IsString() referenceId!: string;
}
export class BillQueryResponseDto {
@ApiProperty() stillPayable!: boolean;
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
@ApiPropertyOptional() reason?: string | null;
}

View File

@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
payerAccount?: string; payerAccount?: string;
returnUrl?: string; returnUrl?: string;
failureUrl?: 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 { export interface InitiateIntentResult {
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
CARD: "card", CARD: "card",
DMONEY: "dmoney", DMONEY: "dmoney",
CAC_BANK: "cac-bank", CAC_BANK: "cac-bank",
CBE_BILL: "cbe-bill",
}; };
/** /**
@@ -190,8 +195,13 @@ export class PaymentService {
*/ */
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> { async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try { 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({ const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT, service: PaymentServiceEnum.FREIGHT,
@@ -199,11 +209,15 @@ export class PaymentService {
referenceId: input.referenceId, referenceId: input.referenceId,
orderRef: input.orderRef, orderRef: input.orderRef,
// amountMinor: input.amountMinor, // 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, currency: input.currency,
provider: input.method as ProviderMethod, provider: input.method as ProviderMethod,
platform: input.platform, platform: input.platform,
payerAccount: input.payerAccount, payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
returnUrl: returnUrl:
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl: failureUrl:

View File

@@ -60,8 +60,10 @@ export class RefundDto {
} }
export class ClientActionDto { export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) @ApiProperty({
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; 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)" }) @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string; url?: string;
@@ -80,6 +82,17 @@ export class ClientActionDto {
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
message?: string; 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 { export class InitiateResponseDto {

View File

@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "card", label: "Card" }, { value: "card", label: "Card" },
{ value: "dmoney", label: "D-Money" }, { value: "dmoney", label: "D-Money" },
{ value: "cac-bank", label: "CAC Bank" }, { value: "cac-bank", label: "CAC Bank" },
{ value: "cbe-bill", label: "CBE Bill" },
]; ];
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {

View File

@@ -19,7 +19,8 @@ export type PaymentMethod =
| "waafi" | "waafi"
| "card" | "card"
| "dmoney" | "dmoney"
| "cac-bank"; | "cac-bank"
| "cbe-bill";
export interface PaymentRow { export interface PaymentRow {
id: string; id: string;

View File

@@ -9,6 +9,7 @@ import {
Divider, Divider,
Group, Group,
Loader, Loader,
Modal,
Paper, Paper,
SimpleGrid, SimpleGrid,
Stack, Stack,
@@ -72,6 +73,12 @@ export default function InvoiceDetailPage() {
} = useQuery(api.invoices.get.queryOptions({ input: { id } })); } = useQuery(api.invoices.get.queryOptions({ input: { id } }));
const [payModalOpen, setPayModalOpen] = useState(false); 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 // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// one of the signed-in customer's own invoices (unlike the admin-facing // one of the signed-in customer's own invoices (unlike the admin-facing
@@ -80,6 +87,11 @@ export default function InvoiceDetailPage() {
mutationFn: (method: PaymentMethod) => mutationFn: (method: PaymentMethod) =>
api.invoices.pay.call({ id, payload: { method, platform: "web" } }), api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
onSuccess: (data, method) => { onSuccess: (data, method) => {
if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") {
setPayModalOpen(false);
setBillAction(data.clientAction);
return;
}
const redirectUrl = const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url ? data.clientAction.url
@@ -393,6 +405,62 @@ export default function InvoiceDetailPage() {
} }
onConfirm={(method) => payMutation.mutate(method)} onConfirm={(method) => payMutation.mutate(method)}
/> />
{/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */}
<Modal
opened={!!billAction}
onClose={() => setBillAction(null)}
centered
radius={18}
size={440}
title={<Text fw={800}>Pay at CBE</Text>}
>
<Stack gap="sm">
<Text fz="sm" c={MUTED}>
{billAction?.instructions ??
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
</Text>
<Group
justify="space-between"
px={16}
py={13}
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Text ff="monospace" fz={24} fw={800} c={INK} style={{ letterSpacing: 3 }}>
{billAction?.billReference}
</Text>
<Button
variant="light"
size="xs"
onClick={() => {
if (billAction?.billReference) {
navigator.clipboard?.writeText(billAction.billReference);
toast.success("Bill number copied");
}
}}
>
Copy
</Button>
</Group>
<Text fz="sm" c={MUTED}>
Amount due:{" "}
<Text span fw={700} c={INK}>
{formatCurrency(amountDue, invoice.currency)}
</Text>
</Text>
{billAction?.expiresAt && (
<Text fz="sm" c={MUTED}>
Pay before:{" "}
<Text span fw={700} c={INK}>
{fmtDate(billAction.expiresAt)}
</Text>
</Text>
)}
<Text fz="xs" c={MUTED}>
The invoice updates automatically once CBE confirms your payment.
</Text>
</Stack>
</Modal>
</Stack> </Stack>
</Box> </Box>
); );

View File

@@ -14,7 +14,7 @@ interface ProviderOption {
accent: string; accent: string;
} }
// Only Telebirr and Waafi are enabled for now. // Only Telebirr, Waafi and CBE bill payment are enabled for now.
const PROVIDERS: ProviderOption[] = [ const PROVIDERS: ProviderOption[] = [
{ {
method: "TELEBIRR", method: "TELEBIRR",
@@ -32,6 +32,14 @@ const PROVIDERS: ProviderOption[] = [
currencies: ["USD"], currencies: ["USD"],
accent: "#2E5B96", accent: "#2E5B96",
}, },
{
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",
},
]; ];
/** /**

View File

@@ -12,7 +12,8 @@ export type PaymentMethod =
| "WAAFI" | "WAAFI"
| "CARD" | "CARD"
| "DMONEY" | "DMONEY"
| "CAC_BANK"; | "CAC_BANK"
| "CBE_BILL";
export type PaymentPlatform = "web" | "mobile"; export type PaymentPlatform = "web" | "mobile";
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
} }
export interface ClientAction { export interface ClientAction {
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
url?: string; url?: string;
appId?: string; appId?: string;
receiveCode?: string; receiveCode?: string;
shortCode?: string; shortCode?: string;
providerOrderId?: string; providerOrderId?: string;
message?: string; message?: string;
/** SHOW_BILL_REFERENCE (CBE bill payment) */
billReference?: string;
instructions?: string;
expiresAt?: string;
} }
export interface InitiateResponse { export interface InitiateResponse {

View File

@@ -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';

View File

@@ -147,6 +147,7 @@ enum PaymentMethodType {
WAAFI WAAFI
DMONEY DMONEY
CAC_BANK CAC_BANK
CBE_BILL
@@schema("passenger") @@schema("passenger")
} }

View File

@@ -4,11 +4,17 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Post,
SetMetadata,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; 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"; import { PaymentsService } from "./payments.service";
/** /**
@@ -18,6 +24,9 @@ import { PaymentsService } from "./payments.service";
* consumer when RabbitMQ lands — the handler logic is transport-agnostic. * consumer when RabbitMQ lands — the handler logic is transport-agnostic.
*/ */
@ApiTags("Internal Payments") @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) @UseGuards(ServiceAuthGuard)
@Controller("internal/payments") @Controller("internal/payments")
export class InternalPaymentsController { export class InternalPaymentsController {
@@ -32,4 +41,16 @@ export class InternalPaymentsController {
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> { async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
return this.paymentsService.handlePaymentEvent(event); return this.paymentsService.handlePaymentEvent(event);
} }
@Post("bill-query")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
})
async billQuery(
@Body() request: BillQueryRequestDto,
): Promise<BillQueryResponseDto> {
return this.paymentsService.billQuery(request.referenceId);
}
} }

View File

@@ -55,3 +55,24 @@ export class MarkPaidResponseDto {
@ApiPropertyOptional() alreadyFinalized?: boolean; @ApiPropertyOptional() alreadyFinalized?: boolean;
@ApiPropertyOptional() reason?: string; @ApiPropertyOptional() reason?: string;
} }
/**
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
*/
export class BillQueryRequestDto {
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
@ApiProperty() @IsString() referenceId!: string;
}
export class BillQueryResponseDto {
@ApiProperty() stillPayable!: boolean;
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
@ApiPropertyOptional() reason?: string | null;
}

View File

@@ -25,6 +25,7 @@ export enum PaymentMethodTypeEnum {
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit) CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
CARD = "CARD", // International CARD = "CARD", // International
WALLET = "WALLET", // Internal WALLET = "WALLET", // Internal
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
} }
export type PaymentPlatformDto = "web" | "mobile"; export type PaymentPlatformDto = "web" | "mobile";
@@ -115,8 +116,10 @@ export class SupportedPaymentMethodDto {
} }
export class ClientActionDto { export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) @ApiProperty({
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; 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)" }) @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string; url?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -135,6 +138,14 @@ export class ClientActionDto {
providerOrderId?: string; providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
message?: string; 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 { export class InitiateResponseDto {

View File

@@ -3,6 +3,7 @@ import { PaymentsService } from "./payments.service";
import { PaymentClientService } from "./payment-client.service"; import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service"; import { CurrencyService } from "../currency/currency.service";
import { PrismaService } from "../../common/prisma.service"; import { PrismaService } from "../../common/prisma.service";
import { AuditService } from "../../common/audit.service";
import { SeatsService } from "../seats/seats.service"; import { SeatsService } from "../seats/seats.service";
import { TicketsService } from "../tickets/tickets.service"; import { TicketsService } from "../tickets/tickets.service";
import { EventEmitter2 } from "@nestjs/event-emitter"; import { EventEmitter2 } from "@nestjs/event-emitter";
@@ -27,6 +28,7 @@ describe("PaymentsService", () => {
booking: { booking: {
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
}, },
paymentIntent: { paymentIntent: {
findUnique: jest.fn(), findUnique: jest.fn(),
@@ -81,6 +83,8 @@ describe("PaymentsService", () => {
convertEtbMinorToChargeMajor: jest.fn((minor: number) => convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
Promise.resolve(minor), Promise.resolve(minor),
), ),
displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100),
convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100),
getRateOrThrow: jest.fn(), getRateOrThrow: jest.fn(),
}; };
@@ -109,6 +113,7 @@ describe("PaymentsService", () => {
{ provide: EventEmitter2, useValue: mockEventEmitter }, { provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: PaymentClientService, useValue: mockPaymentClient }, { provide: PaymentClientService, useValue: mockPaymentClient },
{ provide: CurrencyService, useValue: mockCurrencyService }, { provide: CurrencyService, useValue: mockCurrencyService },
{ provide: AuditService, useValue: { log: jest.fn() } },
], ],
}).compile(); }).compile();

View File

@@ -24,7 +24,12 @@ import {
PaymentRegionEnum, PaymentRegionEnum,
ForceConfirmDto, ForceConfirmDto,
} from "./payments.dto"; } 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 { import {
PaymentClientService, PaymentClientService,
PaymentDiagnostic, 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); 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) // 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({ const paymentMethod = await this.prisma.paymentMethod.findUnique({
where: { type: method }, where: { type: method },
}); });
const chargeCurrency = ( const chargeCurrency =
paymentMethod?.currency ?? booking.currency method === PaymentMethodType.CBE_BILL
).toUpperCase(); ? "ETB"
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase(); const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null; const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
let chargeAmount: number; 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 === bookingDisplayCurrency &&
chargeCurrency !== 'ETB' && chargeCurrency !== 'ETB' &&
bookingDisplayTotalMinor != null 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({ const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER, service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING, referenceType: PaymentReferenceType.BOOKING,
@@ -297,6 +334,8 @@ export class PaymentsService {
payerAccount: dto.payerAccount, payerAccount: dto.payerAccount,
returnUrl, returnUrl,
failureUrl, failureUrl,
payerName,
expiresAt,
}); });
let intent = await this.syncIntentProjection(booking.id, snapshot); let intent = await this.syncIntentProjection(booking.id, snapshot);
@@ -350,6 +389,97 @@ export class PaymentsService {
return this.formatIntentStatus(intent); return this.formatIntentStatus(intent);
} }
/**
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check +
* payer identity for a booking. Called by the payment service while a CBE teller/app is
* waiting — read-only and fast. This is the double-payment guard: once the booking is
* confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3).
*/
async billQuery(bookingId: string): Promise<BillQueryResponseDto> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, passenger: { include: { user: true } } },
});
if (!booking) return { stillPayable: false, reason: "CANCELLED" };
const base = {
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
payerName:
booking.seats.find((s) => s.leg === 1)?.passengerName ??
booking.seats[0]?.passengerName ??
booking.passenger?.user?.fullName ??
null,
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
),
currency: "ETB",
};
if (booking.status === "CONFIRMED" || booking.paidAt) {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
if (booking.status !== "PENDING_PAYMENT") {
return { ...base, stillPayable: false, reason: "CANCELLED" };
}
const deadline = await this.computeBookingPaymentDeadline(booking.id);
if (deadline && deadline.getTime() < Date.now()) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
return { ...base, stillPayable: true, reason: null };
}
/**
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
* origin-segment time and that stop's own check-in window, falling back to the route default.
*/
private async computeBookingPaymentDeadline(
bookingId: string,
): Promise<Date | null> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: {
createdAt: true,
originStationId: true,
schedule: {
select: {
departureAt: true,
stopTimes: {
select: {
stationId: true,
plannedArrivalAt: true,
plannedDepartureAt: true,
},
},
route: {
select: {
checkinMinutesBefore: true,
stops: {
select: { stationId: true, checkinMinutesBefore: true },
},
},
},
},
},
},
});
if (!booking?.schedule) return null;
const originStop = booking.schedule.stopTimes?.find(
(s) => s.stationId === booking.originStationId,
);
const dep = (originStop?.plannedArrivalAt ??
originStop?.plannedDepartureAt ??
booking.schedule.departureAt) as Date;
const originRouteStop = booking.schedule.route?.stops?.find(
(s) => s.stationId === booking.originStationId,
);
const checkinMinutes =
originRouteStop?.checkinMinutesBefore ??
booking.schedule.route?.checkinMinutesBefore ??
undefined;
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
}
private resolveReturnUrls( private resolveReturnUrls(
method: PaymentMethodType, method: PaymentMethodType,
requestOrigin?: string | null, requestOrigin?: string | null,
@@ -1117,15 +1247,17 @@ export class PaymentsService {
return { processed: false, reason: "booking-not-found" }; return { processed: false, reason: "booking-not-found" };
} }
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled // C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor`
// amount against the booking's display-currency total (the amount the customer agreed to pay); // carries the charge amount in MAJOR units (the intent's "real/major price" — what
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency // initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding. // normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor; // the display↔charge currency divergence is tracked separately under the USD/DJF
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01)); // findings. The 1% tolerance absorbs rounding.
if (event.amountMinor < expectedMinor - shortPayTolerance) { const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
if (event.amountMinor < expectedMajor - shortPayTolerance) {
this.logger.error( 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" }; return { processed: false, reason: "amount-mismatch" };
} }

View File

@@ -20,6 +20,8 @@ import {
ChevronLeft, ChevronLeft,
KeyRound, KeyRound,
Landmark, Landmark,
Copy,
Check,
} from "lucide-react"; } from "lucide-react";
const getIconForMethod = (methodId: string) => { const getIconForMethod = (methodId: string) => {
@@ -45,6 +47,13 @@ export default function PaymentPage() {
const [otpCode, setOtpCode] = useState(""); const [otpCode, setOtpCode] = useState("");
const [otpMessage, setOtpMessage] = useState<string | null>(null); const [otpMessage, setOtpMessage] = useState<string | null>(null);
const [otpError, setOtpError] = useState<string | null>(null); const [otpError, setOtpError] = useState<string | null>(null);
// CBE bill payment: the bill reference the customer pays at any CBE channel.
const [billAction, setBillAction] = useState<{
billReference: string;
instructions?: string;
expiresAt?: string;
} | null>(null);
const [billCopied, setBillCopied] = useState(false);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -144,6 +153,16 @@ export default function PaymentPage() {
return; 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') { if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId); setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION"); updateStatus("REQUIRES_ACTION");
@@ -190,6 +209,34 @@ export default function PaymentPage() {
// While the CBE bill dialog is open, poll the intent; the booking confirms server-side
// once CBE settles the bill. Never claim success on any client-side signal.
const { data: billIntentStatus } = useQuery<any>({
queryKey: ["cbe-bill-intent-status", bookingId],
queryFn: () => apiClient.get(`/payments/intents/${bookingId}`),
enabled: !!billAction && !!bookingId,
refetchInterval: 5_000,
});
useEffect(() => {
if (billAction && billIntentStatus?.status === "SUCCEEDED") {
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [billAction, billIntentStatus?.status]);
const copyBillReference = async () => {
if (!billAction) return;
try {
await navigator.clipboard.writeText(billAction.billReference);
setBillCopied(true);
setTimeout(() => setBillCopied(false), 2000);
} catch {
/* clipboard unavailable — the number is still shown on screen */
}
};
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit). // Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
const startPayment = (mobile?: string) => { const startPayment = (mobile?: string) => {
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return; if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
@@ -569,6 +616,62 @@ export default function PaymentPage() {
</div> </div>
)} )}
{/* CBE bill payment — show the bill reference; confirmation comes from polling */}
{billAction && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<Landmark className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Pay at CBE</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{billAction.instructions ??
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
</p>
<div className="flex items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3">
<span className="font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100 select-all">
{billAction.billReference}
</span>
<button
onClick={copyBillReference}
className="btn-secondary px-3 py-2 flex items-center gap-1 text-sm"
title="Copy bill number"
>
{billCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
{billCopied ? "Copied" : "Copy"}
</button>
</div>
<div className="text-sm text-gray-600 dark:text-gray-300 mt-3 space-y-1">
<p>
Amount:{" "}
<span className="font-semibold">
ETB {(totalAmountDisplay ?? 0).toFixed(2)}
</span>
</p>
{billAction.expiresAt && (
<p>
Pay before:{" "}
<span className="font-semibold">
{format(new Date(billAction.expiresAt), "MMM d, yyyy HH:mm")}
</span>
</p>
)}
</div>
<div className="flex items-center gap-2 mt-4 text-xs text-gray-500 dark:text-gray-400">
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
Waiting for payment confirmation this page updates automatically once CBE
confirms your payment.
</div>
<button
onClick={() => setBillAction(null)}
className="btn-secondary w-full py-2.5 mt-4"
>
Close (pay later)
</button>
</div>
</div>
)}
{/* Two-column grid */} {/* Two-column grid */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start"> <div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">

View File

@@ -25,6 +25,7 @@
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.0.0", "@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",

View File

@@ -13,6 +13,8 @@ import ebirrConfig from "./config/ebirr.config";
import cardConfig from "./config/card.config"; import cardConfig from "./config/card.config";
import dmoneyConfig from "./config/dmoney.config"; import dmoneyConfig from "./config/dmoney.config";
import cacConfig from "./config/cac.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 { HealthModule } from "./modules/health/health.module";
import { IntentsModule } from "./modules/intents/intents.module"; import { IntentsModule } from "./modules/intents/intents.module";
import { OutboxModule } from "./modules/outbox/outbox.module"; import { OutboxModule } from "./modules/outbox/outbox.module";
@@ -36,6 +38,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
cardConfig, cardConfig,
dmoneyConfig, dmoneyConfig,
cacConfig, cacConfig,
cbeBillConfig,
], ],
}), }),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
@@ -47,6 +50,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
HealthModule, HealthModule,
ProvidersModule, ProvidersModule,
IntentsModule, IntentsModule,
CbeBillModule,
WebhooksModule, WebhooksModule,
OutboxModule, OutboxModule,
ReconciliationModule, ReconciliationModule,

View File

@@ -0,0 +1,26 @@
import { registerAs } from "@nestjs/config";
/**
* CBE Unified Bill Payment — the INBOUND biller integration (docs/cbe/). Deliberately separate
* from cbe.config.ts, which belongs to the outbound CBE_BIRR wallet gateway: two disjoint
* credential/auth domains that rotate independently (plan D7).
*/
export default registerAs("cbeBill", () => ({
/** Kill switch — all /cbe/* endpoints answer 503 while false. */
enabled: process.env.CBE_BILL_ENABLED === "true",
/** Credentials CBE presents to /cbe/oauth/token. */
clientId: process.env.CBE_BILL_CLIENT_ID || "",
clientSecret: process.env.CBE_BILL_CLIENT_SECRET || "",
/** Signs/verifies the bearer tokens WE issue to CBE — never shared with ServiceAuthGuard. */
jwtSecret: process.env.CBE_BILL_JWT_SECRET || "",
tokenExpiresIn: Number(process.env.CBE_BILL_TOKEN_EXPIRES_IN || 3600),
scope: process.env.CBE_BILL_SCOPE || "Unified_Outgoing",
/** bill-query hop to the owning domain app. Short — CBE holds its own timeout over ours. */
domainTimeoutMs: Number(process.env.CBE_BILL_DOMAIN_TIMEOUT_MS || 3000),
passengerApiBaseUrl: (
process.env.PASSENGER_API_BASE_URL || "http://localhost:3002"
).replace(/\/$/, ""),
freightApiBaseUrl: (
process.env.FREIGHT_API_BASE_URL || "http://localhost:3001"
).replace(/\/$/, ""),
}));

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.1, §5):
* - `bill_reference` — the short numeric Bill_Id CBE presents back to us; unique, null for
* every non-CBE intent.
* - `payer_name` — payer snapshot captured at initiate; fallback for /cbe/query Full_Name.
* - `cbe_bill_reference_seq` — backs the 11-digit sequence body of the bill reference.
*
* DATA SAFETY: purely additive — new nullable columns and a new sequence; no existing rows
* or values are touched.
*/
export class AddCbeBillReference1782300000000 implements MigrationInterface {
name = "AddCbeBillReference1782300000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "bill_reference" varchar(32)`,
);
await queryRunner.query(
`ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "payer_name" varchar(128)`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_payment_intent_bill_reference" ON "edr_payment"."payment_intent" ("bill_reference") WHERE "bill_reference" IS NOT NULL`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS "edr_payment"."cbe_bill_reference_seq" START 10000001`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP SEQUENCE IF EXISTS "edr_payment"."cbe_bill_reference_seq"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "edr_payment"."uq_payment_intent_bill_reference"`,
);
await queryRunner.query(
`ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "payer_name"`,
);
await queryRunner.query(
`ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "bill_reference"`,
);
}
}

View File

@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* CBE-protocol audit/idempotency ledger for the Unified Bill Payment integration
* (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2).
*
* - UNIQUE (end_to_end_txn_id, operation): DB-level backstop for the application idempotency
* checks — a concurrent duplicate of the same CBE attempt cannot create two rows.
* - Partial UNIQUE (cbe_txn_ref) on settled PAYMENTs: blocks Cbe_Txn_Ref replay across bills.
*
* DATA SAFETY: new table only; nothing existing is touched.
*/
export class CreateCbeBillOperation1782400000000 implements MigrationInterface {
name = "CreateCbeBillOperation1782400000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "edr_payment"."cbe_bill_operation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"operation" varchar(16) NOT NULL,
"bill_id" varchar(32) NOT NULL,
"end_to_end_txn_id" varchar(128) NOT NULL,
"cbe_txn_ref" varchar(128),
"destination_api_name" varchar(64),
"intent_id" uuid,
"trade_status" varchar(16) NOT NULL,
"failure_class" varchar(16),
"response_code" varchar(8),
"response_description" text,
"request_payload" jsonb NOT NULL,
"response_payload" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_e2e" ON "edr_payment"."cbe_bill_operation" ("end_to_end_txn_id", "operation")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_txn_ref" ON "edr_payment"."cbe_bill_operation" ("cbe_txn_ref") WHERE "operation" = 'PAYMENT' AND "trade_status" = 'SUCCESS'`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "idx_cbe_bill_operation_bill" ON "edr_payment"."cbe_bill_operation" ("bill_id", "operation")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "edr_payment"."cbe_bill_operation"`,
);
}
}

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
import { PaymentService } from "@edr/types";
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
import { CbeBillError } from "./mappers/cbe-error.mapper";
/** 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: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
reason?: string | null;
}
const REASON_DESCRIPTIONS: Record<string, string> = {
CANCELLED: "Bill has been cancelled.",
ALREADY_PAID: "Bill already paid.",
EXPIRED: "Bill has expired.",
};
export function reasonToDescription(reason?: string | null): string {
return (
(reason && REASON_DESCRIPTIONS[reason]) || "Bill is not payable."
);
}
/**
* The live "still payable?" hop to the owning domain app — routing comes from
* `intent.service` (plan D3). This hop is the double-payment guard (§6.3) and the source of
* the mandatory Full_Name: NOT optional, and on the /cbe/payment path never served from cache.
* Short timeout, no retries — CBE holds its own timeout over ours.
*/
@Injectable()
export class BillResolverService {
private readonly logger = new Logger(BillResolverService.name);
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
) {}
async billQuery(intent: PaymentIntent): Promise<BillQueryResult> {
const base =
intent.service === PaymentService.PASSENGER
? this.config.get<string>("cbeBill.passengerApiBaseUrl")
: this.config.get<string>("cbeBill.freightApiBaseUrl");
const url = `${base}/internal/payments/bill-query`;
try {
const response = await firstValueFrom(
this.http.post<BillQueryResult>(
url,
{
referenceType: intent.referenceType,
referenceId: intent.referenceId,
},
{
timeout: this.config.get<number>("cbeBill.domainTimeoutMs") ?? 3000,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
},
),
);
// The passenger API wraps every response in a { success, data } envelope
// (global transform interceptor); freight returns the body bare. Accept both.
const body = response.data as unknown as {
success?: boolean;
data?: BillQueryResult;
};
return body && typeof body === "object" && "success" in body && body.data
? body.data
: (response.data as BillQueryResult);
} catch (err) {
this.logger.warn(
`bill-query ${intent.service}/${intent.referenceId} unreachable: ${
err instanceof Error ? err.message : String(err)
}`,
);
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
}
}
}

View File

@@ -0,0 +1,39 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
/**
* Verifies the bearer tokens WE minted for CBE at /cbe/oauth/token (plan D7). Its secret
* (CBE_BILL_JWT_SECRET) is disjoint from ServiceAuthGuard's shared token: a CBE token must
* never authenticate a call to /payments/*, and vice versa.
*/
@Injectable()
export class CbeAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const auth = request.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing bearer token");
}
try {
await this.jwtService.verifyAsync(auth.substring(7), {
secret: this.config.get<string>("cbeBill.jwtSecret"),
});
return true;
} catch {
throw new UnauthorizedException("Invalid or expired token");
}
}
}

View File

@@ -0,0 +1,57 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseFilters,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { CbeAuthGuard } from "./cbe-auth.guard";
import { CbeExceptionFilter } from "./cbe-exception.filter";
import { CbeBillService } from "./cbe-bill.service";
import { TokenRequestDto } from "./dto/token-request.dto";
import { TokenResponseDto } from "./dto/token-response.dto";
import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto";
import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto";
import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto";
import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
/**
* CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are
* the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then
* presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200
* with Response_Code "3"; only authentication answers 401 (plan D6/D7).
*/
@ApiTags("CBE Unified Bill (inbound)")
@Controller("cbe")
@UseFilters(CbeExceptionFilter)
export class CbeBillController {
constructor(private readonly cbeBillService: CbeBillService) {}
@Post("oauth/token")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "OAuth client_credentials token for CBE (we are the auth server)" })
async token(@Body() dto: TokenRequestDto): Promise<TokenResponseDto> {
return this.cbeBillService.generateToken(dto);
}
@Post("query")
@HttpCode(HttpStatus.OK)
@UseGuards(CbeAuthGuard)
@ApiOperation({ summary: "Bill lookup — amount due + payer name for a Bill_Id" })
async query(@Body() dto: CbeQueryRequestDto): Promise<CbeQueryResponseDto> {
return this.cbeBillService.query(dto);
}
@Post("payment")
@HttpCode(HttpStatus.OK)
@UseGuards(CbeAuthGuard)
@ApiOperation({ summary: "Settle a bill — customer already debited by CBE" })
async payment(
@Body() dto: CbePaymentRequestDto,
): Promise<CbePaymentResponseDto> {
return this.cbeBillService.pay(dto);
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { JwtModule } from "@nestjs/jwt";
import { TypeOrmModule } from "@nestjs/typeorm";
import { IntentsModule } from "../intents/intents.module";
import { CbeBillOperation } from "./entities/cbe-bill-operation.entity";
import { BillResolverService } from "./bill-resolver.service";
import { CbeAuthGuard } from "./cbe-auth.guard";
import { CbeBillController } from "./cbe-bill.controller";
import { CbeBillRepository } from "./cbe-bill.repository";
import { CbeBillService } from "./cbe-bill.service";
/**
* Inbound CBE Unified Bill Payment module (docs/cbe/). Its auth domain is disjoint from the
* rest of the app: tokens are minted and verified with CBE_BILL_JWT_SECRET only (plan D7) —
* JwtModule is registered bare and the secret passed explicitly at sign/verify time.
*/
@Module({
imports: [
TypeOrmModule.forFeature([CbeBillOperation]),
HttpModule,
JwtModule.register({}),
IntentsModule,
],
controllers: [CbeBillController],
providers: [CbeBillService, CbeBillRepository, BillResolverService, CbeAuthGuard],
})
export class CbeBillModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import {
CbeBillOperation,
CbeOperation,
} from "./entities/cbe-bill-operation.entity";
@Injectable()
export class CbeBillRepository extends BaseRepository<CbeBillOperation> {
constructor(
@InjectRepository(CbeBillOperation)
repository: Repository<CbeBillOperation>,
) {
super(repository);
}
/** The prior attempt for CBE's per-attempt id — the §6.5 idempotency lookup. */
async findByEndToEndTxnId(
endToEndTxnId: string,
operation: CbeOperation,
): Promise<CbeBillOperation | null> {
return this.repository.findOne({ where: { endToEndTxnId, operation } });
}
/** A SUCCESSful settlement already carrying this Cbe_Txn_Ref — blocks replay across bills. */
async findSettledByCbeTxnRef(
cbeTxnRef: string,
): Promise<CbeBillOperation | null> {
return this.repository.findOne({
where: { cbeTxnRef, operation: "PAYMENT", tradeStatus: "SUCCESS" },
});
}
}

View File

@@ -0,0 +1,357 @@
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 {
BillResolverService,
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;
/**
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
* Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
* controller); the exception filter only catches auth, validation, and the unexpected.
*/
@Injectable()
export class CbeBillService {
private readonly logger = new Logger(CbeBillService.name);
constructor(
private readonly config: ConfigService,
private readonly jwtService: JwtService,
private readonly cbeBillRepository: CbeBillRepository,
private readonly intentsRepository: IntentsRepository,
private readonly intentsService: IntentsService,
private readonly billReferenceService: BillReferenceService,
private readonly billResolver: BillResolverService,
) {}
/* ------------------------------------------------------------------ token */
async generateToken(dto: TokenRequestDto): Promise<TokenResponseDto> {
this.assertEnabled();
const clientId = this.config.get<string>("cbeBill.clientId");
const clientSecret = this.config.get<string>("cbeBill.clientSecret");
// Unset credentials must fail closed — never let "" === "" mint a token.
const valid =
!!clientId &&
!!clientSecret &&
dto.client_id === clientId &&
dto.client_secret === clientSecret &&
dto.grant_type === "client_credentials" &&
dto.scope === this.config.get<string>("cbeBill.scope");
if (!valid) throw new UnauthorizedException("Invalid credentials");
const expiresIn = this.config.get<number>("cbeBill.tokenExpiresIn") ?? 3600;
const accessToken = await this.jwtService.signAsync(
{ iss: "edr-payment-api", clientId: dto.client_id, scope: dto.scope },
{ secret: this.config.get<string>("cbeBill.jwtSecret"), expiresIn },
);
return {
token_type: "Bearer",
access_token: accessToken,
expires_in: expiresIn,
scope: dto.scope,
consented_on: Math.floor(Date.now() / 1000),
};
}
/* ------------------------------------------------------------------ query */
async query(dto: CbeQueryRequestDto): Promise<CbeQueryResponseDto> {
this.assertEnabled();
// Audit first — unlike the reference (which left this commented out), every query attempt
// is persisted; counter disputes are exactly what this row is for (plan Phase 3.2).
const audit = await this.upsertAudit("QUERY", dto.End_To_End_Txn_Id, {
billId: dto.Bill_Id,
destinationApiName: dto.Destination_Api_Name,
requestPayload: dto as unknown as Record<string, unknown>,
});
try {
const intent = await this.resolveIntent(dto.Bill_Id);
if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) {
throw new CbeBillError(
intent.status === ProviderPaymentStatus.SUCCEEDED
? "Bill already paid."
: "Bill is not payable.",
"BUSINESS",
);
}
// 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),
"BUSINESS",
);
}
const response = mapQuerySuccess(dto, {
amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor,
fullName: billQuery.payerName || intent.payerName || "",
});
await this.finishAudit(audit, {
intentId: intent.id,
tradeStatus: "SUCCESS",
response,
});
return response;
} catch (err) {
const failure = toCbeFailure(err);
if (!(err instanceof CbeBillError)) {
this.logger.error(
`/cbe/query ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`,
);
}
const response = mapQueryFailure(dto, failure.description);
await this.finishAudit(audit, {
tradeStatus: "FAILED",
failureClass: failure.failureClass,
response,
});
return response;
}
}
/* ------------------------------------------------------------------ payment */
async pay(dto: CbePaymentRequestDto): Promise<CbePaymentResponseDto> {
this.assertEnabled();
// §6.5 idempotency on CBE's per-attempt id, in order.
const prior = await this.cbeBillRepository.findByEndToEndTxnId(
dto.End_To_End_Txn_Id,
"PAYMENT",
);
if (prior) {
if (prior.tradeStatus === "SUCCESS") {
// Replay the stored body verbatim. Never re-settle.
return prior.responsePayload as unknown as CbePaymentResponseDto;
}
if (prior.tradeStatus === "PENDING") {
return mapPaymentFailure(dto, "Payment is being processed.");
}
if (prior.failureClass === "BUSINESS") {
// Final — retrying cannot change the answer. Replay what we told CBE last time.
return (
(prior.responsePayload as unknown as CbePaymentResponseDto) ??
mapPaymentFailure(
dto,
prior.responseDescription ?? "Payment already failed.",
)
);
}
// FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement.
}
// Cbe_Txn_Ref replay across different bills/attempts (partial-unique backstop in the DB).
const settled = await this.cbeBillRepository.findSettledByCbeTxnRef(
dto.Cbe_Txn_Ref,
);
if (settled) {
return mapPaymentFailure(
dto,
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
);
}
let audit: CbeBillOperation;
if (prior) {
// Transient retry reuses the row — UNIQUE (end_to_end_txn_id, operation) forbids a second.
audit =
(await this.cbeBillRepository.update(prior.id, {
tradeStatus: "PENDING",
failureClass: null,
cbeTxnRef: dto.Cbe_Txn_Ref,
requestPayload: dto as unknown as Record<string, unknown>,
})) ?? prior;
} else {
try {
audit = await this.cbeBillRepository.create({
operation: "PAYMENT",
billId: dto.Bill_Id,
endToEndTxnId: dto.End_To_End_Txn_Id,
cbeTxnRef: dto.Cbe_Txn_Ref,
destinationApiName: dto.Destination_Api_Name,
tradeStatus: "PENDING",
requestPayload: dto as unknown as Record<string, unknown>,
});
} catch (err) {
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
// Concurrent duplicate of the same attempt lost the insert race.
return mapPaymentFailure(dto, "Payment is being processed.");
}
throw err;
}
}
let intent: PaymentIntent | undefined;
try {
intent = await this.resolveIntent(dto.Bill_Id);
if (dto.Currency && dto.Currency !== intent.currency) {
throw new CbeBillError("Payment currency does not match.", "BUSINESS");
}
// Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous
// failure (§6.1): after this we settle and reconcile downstream.
const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) {
throw new CbeBillError(
reasonToDescription(billQuery.reason),
"BUSINESS",
);
}
const amount = Number(dto.Amount);
if (
!Number.isFinite(amount) ||
Math.abs(amount - intent.amountMinor) >
intent.amountMinor * AMOUNT_TOLERANCE
) {
throw new CbeBillError("Payment amount does not match.", "BUSINESS");
}
const paidAt = new Date(dto.Timestamp);
// Existing state machine, unmodified — intent + outbox commit in one transaction.
await this.intentsService.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: dto.Cbe_Txn_Ref,
paidAt: Number.isNaN(paidAt.getTime()) ? new Date() : paidAt,
confirmedAmountMinor: amount,
});
const response = mapPaymentSuccess(dto, intent.merchantOrderId);
await this.finishAudit(audit, {
intentId: intent.id,
tradeStatus: "SUCCESS",
response,
});
this.logger.log(
`bill ${dto.Bill_Id} settled by CBE txn ${dto.Cbe_Txn_Ref} (intent ${intent.id})`,
);
return response;
} catch (err) {
const failure = toCbeFailure(err);
if (!(err instanceof CbeBillError)) {
this.logger.error(
`/cbe/payment ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`,
);
}
const response = mapPaymentFailure(dto, failure.description);
await this.finishAudit(audit, {
intentId: intent?.id,
tradeStatus: "FAILED",
failureClass: failure.failureClass,
response,
});
return response;
}
}
/* ------------------------------------------------------------------ helpers */
/** Kill switch (CBE_BILL_ENABLED) — 503 so CBE classes it as transport error and retries. */
assertEnabled(): void {
if (!this.config.get<boolean>("cbeBill.enabled")) {
throw new ServiceUnavailableException("CBE bill payment is disabled");
}
}
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
private async resolveIntent(billId: string): Promise<PaymentIntent> {
if (!this.billReferenceService.isValid(billId)) {
throw new CbeBillError("Bill not found.", "BUSINESS");
}
const intent = await this.intentsRepository.findByBillReference(billId);
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
throw new CbeBillError("Bill not found.", "BUSINESS");
}
return intent;
}
/** Insert the audit row; a CBE retry of the same QUERY attempt reuses (and refreshes) its row. */
private async upsertAudit(
operation: "QUERY",
endToEndTxnId: string,
data: {
billId: string;
destinationApiName: string;
requestPayload: Record<string, unknown>;
},
): Promise<CbeBillOperation> {
try {
return await this.cbeBillRepository.create({
operation,
endToEndTxnId,
tradeStatus: "PENDING",
...data,
});
} catch (err) {
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
const existing = await this.cbeBillRepository.findByEndToEndTxnId(
endToEndTxnId,
operation,
);
if (existing) return existing;
}
throw err;
}
}
private async finishAudit(
audit: CbeBillOperation,
outcome: {
intentId?: string;
tradeStatus: "SUCCESS" | "FAILED";
failureClass?: "BUSINESS" | "TRANSIENT";
response: { Response_Code: string; Response_Description: string };
},
): Promise<void> {
await this.cbeBillRepository.update(audit.id, {
intentId: outcome.intentId ?? audit.intentId,
tradeStatus: outcome.tradeStatus,
failureClass: outcome.failureClass ?? null,
responseCode: outcome.response.Response_Code,
responseDescription: outcome.response.Response_Description,
responsePayload: outcome.response as unknown as Record<string, unknown>,
});
}
}

View File

@@ -0,0 +1,72 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from "@nestjs/common";
import { Response } from "express";
/**
* Controller-scoped safety net for anything that escapes CbeBillService's own error handling
* (auth failures, DTO validation, unhandled throws). CBE's contract (plan D6): HTTP 200 for
* every business outcome, 401 only for authentication.
*
* Exception: the CBE_BILL_ENABLED kill switch throws ServiceUnavailableException and stays
* HTTP 503 with a non-0/1/3 code — the spec classes "any other code" as a transport error
* ("retry, contact admin"), which is exactly what a kill switch should signal; a 200/code-3
* would tell CBE the failure is final.
*/
@Catch()
export class CbeExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(CbeExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<Response>();
if (exception instanceof UnauthorizedException) {
response.status(HttpStatus.UNAUTHORIZED).json({
Status: "FAILED",
Response_Code: "1",
Response_Description: exception.message || "Unauthorized",
});
return;
}
if (exception instanceof ServiceUnavailableException) {
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
Status: "FAILED",
Response_Code: "9",
Response_Description: "Service temporarily unavailable.",
});
return;
}
if (exception instanceof HttpException) {
// class-validator errors arrive as BadRequestException with message: string[].
const body = exception.getResponse();
const message =
typeof body === "object" && body !== null && "message" in body
? ([] as string[]).concat((body as { message: string }).message).join("; ")
: exception.message;
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Description: message || "Invalid request",
});
return;
}
this.logger.error(
`unhandled /cbe/* error: ${exception instanceof Error ? exception.stack : String(exception)}`,
);
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Description: "Internal server error.",
});
}
}

View File

@@ -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[];
}

View File

@@ -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 }[];
}

View File

@@ -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[];
}

View File

@@ -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 }[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,72 @@
import { Column, Entity, Index } from "typeorm";
import { BaseEntity } from "@edr/api-common";
export type CbeOperation = "QUERY" | "PAYMENT";
export type CbeTradeStatus = "PENDING" | "SUCCESS" | "FAILED";
/** Drives the same-End_To_End_Txn_Id retry policy (plan §6.5): BUSINESS is final, TRANSIENT retryable. */
export type CbeFailureClass = "BUSINESS" | "TRANSIENT";
/**
* CBE-protocol-level audit and idempotency ledger (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2).
* Separate from payment_intent because it tracks CBE's transaction identity — not ours — and
* must retain the exact response body we returned so a retry replays it byte-for-byte.
*
* The UNIQUE (end_to_end_txn_id, operation) and partial-unique cbe_txn_ref indexes live in the
* CreateCbeBillOperation migration.
*/
@Entity({ name: "cbe_bill_operation" })
@Index("idx_cbe_bill_operation_bill", ["billId", "operation"])
export class CbeBillOperation extends BaseEntity {
@Column({ name: "operation", type: "varchar", length: 16 })
operation!: CbeOperation;
/** Bill_Id exactly as received from CBE. */
@Column({ name: "bill_id", type: "varchar", length: 32 })
billId!: string;
/** CBE's per-attempt id — the idempotency key of the protocol. */
@Column({ name: "end_to_end_txn_id", type: "varchar", length: 128 })
endToEndTxnId!: string;
/** CBE core-banking reference; set on PAYMENT. */
@Column({ name: "cbe_txn_ref", type: "varchar", length: 128, nullable: true })
cbeTxnRef?: string | null;
/** Echoed back in every response. */
@Column({
name: "destination_api_name",
type: "varchar",
length: 64,
nullable: true,
})
destinationApiName?: string | null;
/** Our payment_intent.id once the bill resolved to an intent. Soft reference, no FK. */
@Column({ name: "intent_id", type: "uuid", nullable: true })
intentId?: string | null;
@Column({ name: "trade_status", type: "varchar", length: 16 })
tradeStatus!: CbeTradeStatus;
@Column({
name: "failure_class",
type: "varchar",
length: 16,
nullable: true,
})
failureClass?: CbeFailureClass | null;
@Column({ name: "response_code", type: "varchar", length: 8, nullable: true })
responseCode?: string | null;
@Column({ name: "response_description", type: "text", nullable: true })
responseDescription?: string | null;
/** Raw inbound body, verbatim. */
@Column({ name: "request_payload", type: "jsonb" })
requestPayload!: Record<string, unknown>;
/** EXACT body we returned — replayed verbatim when CBE retries a settled End_To_End_Txn_Id. */
@Column({ name: "response_payload", type: "jsonb", nullable: true })
responsePayload?: Record<string, unknown> | null;
}

View File

@@ -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" };
}

View File

@@ -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: [],
};
}

View File

@@ -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 },
): 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: "",
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: [],
};
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,52 @@
import { Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";
/**
* CBE_BILL bill reference numbers (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5).
*
* 12 numeric digits: an 11-digit Postgres-sequence value, zero-padded, plus a trailing Luhn
* check digit. Numeric-only so it is typeable on any USSD keypad; the check digit rejects
* most single-digit typos and adjacent transpositions before any DB lookup; sequence-backed
* so uniqueness is guaranteed without a collision-retry loop.
*
* Subject to Phase 0-Q2 — if CBE imposes their own length/charset constraint, theirs wins.
*/
const SEQUENCE = "edr_payment.cbe_bill_reference_seq";
const TOTAL_LENGTH = 12;
@Injectable()
export class BillReferenceService {
constructor(private readonly dataSource: DataSource) {}
async generate(): Promise<string> {
const rows: [{ nextval: string }] = await this.dataSource.query(
`SELECT nextval('${SEQUENCE}')`,
);
const body = rows[0].nextval.padStart(TOTAL_LENGTH - 1, "0");
return body + luhnCheckDigit(body);
}
/** Format + check-digit validation — the cheap reject before any DB hit. */
isValid(billReference: string): boolean {
if (!/^\d+$/.test(billReference) || billReference.length !== TOTAL_LENGTH) {
return false;
}
const body = billReference.slice(0, -1);
return luhnCheckDigit(body) === billReference.slice(-1);
}
}
/** Standard Luhn check digit over a numeric string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Rightmost body digit is doubled (it sits next to the check digit position).
for (let i = 0; i < digits.length; i++) {
let d = Number(digits[digits.length - 1 - i]);
if (i % 2 === 0) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return String((10 - (sum % 10)) % 10);
}

View File

@@ -1,6 +1,7 @@
import { import {
IsEnum, IsEnum,
IsIn, IsIn,
IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsPositive, IsPositive,
@@ -101,6 +102,25 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
@IsString() @IsString()
@MaxLength(128) @MaxLength(128)
idempotencyKey?: string; 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 { export class IntentReferenceQueryDto {

View File

@@ -109,6 +109,23 @@ export class PaymentIntent extends BaseEntity {
}) })
idempotencyKey?: string | null; 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 }) @Column({ name: "expires_at", type: "timestamptz", nullable: true })
expiresAt?: Date | null; expiresAt?: Date | null;

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { ProvidersModule } from "../providers/providers.module"; import { ProvidersModule } from "../providers/providers.module";
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity"; import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
import { PaymentIntent } from "./entities/payment-intent.entity"; import { PaymentIntent } from "./entities/payment-intent.entity";
import { BillReferenceService } from "./bill-reference.service";
import { IntentsController } from "./intents.controller"; import { IntentsController } from "./intents.controller";
import { IntentsRepository } from "./intents.repository"; import { IntentsRepository } from "./intents.repository";
import { IntentsService } from "./intents.service"; import { IntentsService } from "./intents.service";
@@ -15,7 +16,7 @@ import { IntentsService } from "./intents.service";
ProvidersModule, ProvidersModule,
], ],
controllers: [IntentsController], controllers: [IntentsController],
providers: [IntentsService, IntentsRepository], providers: [IntentsService, IntentsRepository, BillReferenceService],
exports: [IntentsService, IntentsRepository], exports: [IntentsService, IntentsRepository, BillReferenceService],
}) })
export class IntentsModule {} export class IntentsModule {}

View File

@@ -70,6 +70,13 @@ export class IntentsRepository extends BaseRepository<PaymentIntent> {
}); });
} }
/** CBE_BILL: resolve the intent behind a Bill_Id presented by CBE. */
async findByBillReference(
billReference: string,
): Promise<PaymentIntent | null> {
return this.repository.findOne({ where: { billReference } });
}
async findByMerchantOrderId( async findByMerchantOrderId(
merchantOrderId: string, merchantOrderId: string,
): Promise<PaymentIntent | null> { ): Promise<PaymentIntent | null> {

View File

@@ -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();
});
});

View File

@@ -28,6 +28,7 @@ import {
TERMINAL_INTENT_STATUSES, TERMINAL_INTENT_STATUSES,
} from "./entities/payment-intent.entity"; } from "./entities/payment-intent.entity";
import { IntentsRepository } from "./intents.repository"; import { IntentsRepository } from "./intents.repository";
import { BillReferenceService } from "./bill-reference.service";
/** Don't hit the provider again if the intent was refreshed this recently. */ /** Don't hit the provider again if the intent was refreshed this recently. */
const REFRESH_MIN_AGE_MS = 5_000; const REFRESH_MIN_AGE_MS = 5_000;
@@ -69,6 +70,7 @@ export class IntentsService {
@Inject(PAYMENT_PROVIDER_MAP) @Inject(PAYMENT_PROVIDER_MAP)
private readonly providers: PaymentProviderMap, private readonly providers: PaymentProviderMap,
private readonly cacBankProvider: CacBankProvider, private readonly cacBankProvider: CacBankProvider,
private readonly billReferenceService: BillReferenceService,
) {} ) {}
/* ------------------------------------------------------------------ initiate */ /* ------------------------------------------------------------------ initiate */
@@ -85,6 +87,12 @@ export class IntentsService {
if (byKey) return this.toSnapshot(byKey); 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 // 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 // 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 // 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); return this.toSnapshot(intent);
} }
/**
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md). Intent-first: the bill
* reference is created here, before CBE ever sees the bill; settlement arrives later through
* the inbound /cbe/payment endpoint and the unchanged applyProviderResult() state machine.
*/
private async initiateCbeBill(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
// D8: CBE settles ETB only. The domain app must price/charge the order in ETB.
if (request.currency !== "ETB") {
throw new BadRequestException(
`CBE_BILL supports ETB only (got ${request.currency})`,
);
}
const merchantOrderId = createMerchantOrderId();
const billReference = await this.billReferenceService.generate();
// expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider
// session TTL (plan §6.4: a short TTL would make the sweep cancel the booking within the hour).
const expiresAt = request.expiresAt ? new Date(request.expiresAt) : null;
const intent = await this.intentsRepository.create({
service: request.service,
referenceType: request.referenceType,
referenceId: request.referenceId,
merchantOrderId,
provider: request.provider,
amountMinor: request.amountMinor,
currency: request.currency,
status: ProviderPaymentStatus.REQUIRES_ACTION,
clientAction: {
type: "SHOW_BILL_REFERENCE",
billReference,
instructions:
"Pay this bill at any CBE branch, CBE Birr app, mobile banking or USSD.",
expiresAt: expiresAt?.toISOString(),
},
idempotencyKey: request.idempotencyKey ?? null,
expiresAt,
billReference,
payerName: request.payerName ?? null,
});
this.logger.log(
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${billReference})`,
);
return this.toSnapshot(intent);
}
/* ------------------------------------------------------------------ confirm (OTP providers) */ /* ------------------------------------------------------------------ confirm (OTP providers) */
async confirm( async confirm(
@@ -626,6 +682,7 @@ export class IntentsService {
failureCode: intent.failureCode ?? undefined, failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined, failureMessage: intent.failureMessage ?? undefined,
expiresAt: intent.expiresAt?.toISOString(), expiresAt: intent.expiresAt?.toISOString(),
billReference: intent.billReference ?? undefined,
providerResponse: intent.rawInitiation ?? undefined, providerResponse: intent.rawInitiation ?? undefined,
}; };
} }

View File

@@ -24,6 +24,12 @@ export enum ProviderMethod {
CARD = "CARD", CARD = "CARD",
DMONEY = "DMONEY", DMONEY = "DMONEY",
CAC_BANK = "CAC_BANK", 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"; export type PaymentPlatform = "web" | "mobile";
@@ -40,6 +46,13 @@ export type ClientAction =
type: "COLLECT_OTP"; type: "COLLECT_OTP";
providerOrderId: string; providerOrderId: string;
message?: 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 { export interface ProviderInitiationInput {
@@ -130,6 +143,16 @@ export interface InitiatePaymentRequest {
failureUrl?: string; failureUrl?: string;
/** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */ /** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */
idempotencyKey?: string; 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). */ /** Body of `POST /payments/intents/:id/confirm` (OTP-based providers such as CAC Bank). */
@@ -154,6 +177,8 @@ export type PaymentIntentSnapshot ={
failureCode?: string; failureCode?: string;
failureMessage?: string; failureMessage?: string;
expiresAt?: 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 * Raw provider payload for inspection/debugging — the audit copy of the provider
* initiation response merged with the latest status-query response (secrets redacted * initiation response merged with the latest status-query response (secrets redacted

475
pnpm-lock.yaml generated
View File

@@ -98,10 +98,10 @@ importers:
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@tria-plc/api-common': '@tria-plc/api-common':
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(5f5e627b5382f261b5d3edb76e50f0e2) version: file:local-packages/tria-plc-api-common-1.4.3.tgz(03a6e716343b866cc0161cef30e92881)
'@tria-plc/iamapi-common': '@tria-plc/iamapi-common':
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz
version: file:local-packages/tria-plc-iamapi-common-0.7.12.tgz(578386f46cf99fd4720e3e99f196f69e) version: file:local-packages/tria-plc-iamapi-common-0.7.12.tgz(cb6b1db7b4758cd12009f4c537b4221f)
amqp-connection-manager: amqp-connection-manager:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(amqplib@2.0.1) version: 5.0.0(amqplib@2.0.1)
@@ -586,7 +586,7 @@ importers:
version: 5.101.0(react@19.2.6) version: 5.101.0(react@19.2.6)
'@tria-plc/iamui': '@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz 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': '@vis.gl/react-google-maps':
specifier: ^1.8.3 specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -813,10 +813,10 @@ importers:
version: 8.1.6 version: 8.1.6
'@tria-plc/api-common': '@tria-plc/api-common':
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz 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': '@tria-plc/iamapi-common':
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz 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': '@types/bcrypt':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
@@ -1132,6 +1132,9 @@ importers:
'@nestjs/core': '@nestjs/core':
specifier: ^11.0.0 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) 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': '@nestjs/platform-express':
specifier: ^11.0.0 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) 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)
@@ -2827,10 +2830,10 @@ packages:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/core': ^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': '@nestjs/jwt@11.0.2':
resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
peerDependencies: 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': '@nestjs/mapped-types@2.0.5':
resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==} resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==}
@@ -4757,8 +4760,8 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/jsonwebtoken@9.0.5': '@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/lodash@4.17.24': '@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
@@ -4772,6 +4775,9 @@ packages:
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.1.0': '@types/multer@2.1.0':
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
@@ -8559,10 +8565,6 @@ packages:
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
engines: {'0': node >= 0.2.0} 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: jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'} engines: {node: '>=12', npm: '>=6'}
@@ -8589,15 +8591,9 @@ packages:
jszip@3.10.1: jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jwa@2.0.1: jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@3.2.3:
resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==}
jws@4.0.1: jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
@@ -12220,11 +12216,11 @@ snapshots:
'@babel/helpers': 7.29.7 '@babel/helpers': 7.29.7
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/template': 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 '@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0 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 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -12259,7 +12255,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@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/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 semver: 6.3.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12268,14 +12264,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7': '@babel/helper-member-expression-to-functions@7.29.7':
dependencies: dependencies:
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12290,9 +12279,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@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/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12307,13 +12296,13 @@ snapshots:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 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: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7': '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies: dependencies:
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7 '@babel/types': 7.29.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12466,18 +12455,6 @@ snapshots:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/types': 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)': '@babel/traverse@7.29.7(supports-color@5.5.0)':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
@@ -12701,7 +12678,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5': '@emotion/babel-plugin@11.13.5':
dependencies: 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 '@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2 '@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0 '@emotion/memoize': 0.9.0
@@ -12867,7 +12844,7 @@ snapshots:
'@eslint/eslintrc@2.1.4': '@eslint/eslintrc@2.1.4':
dependencies: dependencies:
ajv: 6.15.0 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 espree: 9.6.1
globals: 13.24.0 globals: 13.24.0
ignore: 5.3.2 ignore: 5.3.2
@@ -13013,7 +12990,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0': '@humanwhocodes/config-array@0.13.0':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@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 minimatch: 3.1.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13823,7 +13800,7 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
uid: 2.0.2 uid: 2.0.2
optionalDependencies: 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/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) '@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)
@@ -13833,11 +13810,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) '@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 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: 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/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 '@types/jsonwebtoken': 9.0.10
jsonwebtoken: 9.0.2 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)': '@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: dependencies:
@@ -13855,6 +13832,20 @@ snapshots:
class-transformer: 0.5.1 class-transformer: 0.5.1
class-validator: 0.14.4 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)': '@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: 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/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -13952,7 +13943,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) '@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 tslib: 2.8.1
optionalDependencies: 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/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)': '@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)':
@@ -14184,7 +14175,7 @@ snapshots:
'@puppeteer/browsers@2.13.2': '@puppeteer/browsers@2.13.2':
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
extract-zip: 2.0.1 extract-zip: 2.0.1
progress: 2.0.3 progress: 2.0.3
proxy-agent: 6.5.0 proxy-agent: 6.5.0
@@ -16252,7 +16243,7 @@ snapshots:
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
token-types: 6.1.2 token-types: 6.1.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -16261,18 +16252,62 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {}
'@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(03a6e716343b866cc0161cef30e92881)':
dependencies: 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/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/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/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.12.tgz(cb6b1db7b4758cd12009f4c537b4221f)
argon2: 0.43.1
axios: 1.17.0
change-case: 5.4.4
class-transformer: 0.5.1
class-validator: 0.14.4
dotenv: 16.6.1
ethiopian-calendar-date-converter: 2.1.6
ethiopian-date: 0.0.6
exceljs: 4.4.0
file-type: 21.3.4
handlebars: 4.7.9
handlebars-helpers: 0.10.0
jmespath: 0.16.0
jose: 5.10.0
jsonwebtoken: 9.0.3
libphonenumber-js: 1.13.6
libreoffice-convert: 1.8.1
nestjs-minio-client: 2.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/core@11.1.24)
passport-jwt: 4.0.1
qrcode: 1.5.4
reflect-metadata: 0.2.2
rxjs: 7.8.2
style-object-to-css-string: 1.1.3
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))
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(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)))
uuid: 11.1.1
xlsx: 0.18.5
transitivePeerDependencies:
- '@faker-js/faker'
- debug
- supports-color
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt': 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/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/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/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/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))) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991) '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)
argon2: 0.43.1 argon2: 0.43.1
axios: 1.17.0 axios: 1.17.0
change-case: 5.4.4 change-case: 5.4.4
@@ -16305,62 +16340,18 @@ snapshots:
- debug - debug
- supports-color - supports-color
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(5f5e627b5382f261b5d3edb76e50f0e2)': '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.12.tgz(cb6b1db7b4758cd12009f4c537b4221f)':
dependencies: 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/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/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/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/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/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/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))) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.12.tgz(578386f46cf99fd4720e3e99f196f69e) '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(03a6e716343b866cc0161cef30e92881)
argon2: 0.43.1
axios: 1.17.0
change-case: 5.4.4
class-transformer: 0.5.1
class-validator: 0.14.4
dotenv: 16.6.1
ethiopian-calendar-date-converter: 2.1.6
ethiopian-date: 0.0.6
exceljs: 4.4.0
file-type: 21.3.4
handlebars: 4.7.9
handlebars-helpers: 0.10.0
jmespath: 0.16.0
jose: 5.10.0
jsonwebtoken: 9.0.3
libphonenumber-js: 1.13.6
libreoffice-convert: 1.8.1
nestjs-minio-client: 2.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/core@11.1.24)
passport-jwt: 4.0.1
qrcode: 1.5.4
reflect-metadata: 0.2.2
rxjs: 7.8.2
style-object-to-css-string: 1.1.3
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))
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(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)))
uuid: 11.1.1
xlsx: 0.18.5
transitivePeerDependencies:
- '@faker-js/faker'
- debug
- supports-color
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.12.tgz(578386f46cf99fd4720e3e99f196f69e)':
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/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(5f5e627b5382f261b5d3edb76e50f0e2)
api-common: 1.2.2 api-common: 1.2.2
argon2: 0.43.1 argon2: 0.43.1
axios: 1.17.0 axios: 1.17.0
@@ -16384,18 +16375,18 @@ snapshots:
- '@faker-js/faker' - '@faker-js/faker'
- supports-color - 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: 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/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/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/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/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/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/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/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))) '@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 api-common: 1.2.2
argon2: 0.43.1 argon2: 0.43.1
axios: 1.17.0 axios: 1.17.0
@@ -16543,130 +16534,6 @@ snapshots:
- utf-8-validate - utf-8-validate
- vite - 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': '@ts-morph/common@0.27.0':
dependencies: dependencies:
fast-glob: 3.3.3 fast-glob: 3.3.3
@@ -16859,8 +16726,9 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/jsonwebtoken@9.0.5': '@types/jsonwebtoken@9.0.10':
dependencies: dependencies:
'@types/ms': 2.1.0
'@types/node': 20.19.42 '@types/node': 20.19.42
'@types/lodash@4.17.24': {} '@types/lodash@4.17.24': {}
@@ -16871,6 +16739,8 @@ snapshots:
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/ms@2.1.0': {}
'@types/multer@2.1.0': '@types/multer@2.1.0':
dependencies: dependencies:
'@types/express': 5.0.6 '@types/express': 5.0.6
@@ -17027,7 +16897,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1 '@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 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)
eslint: 8.57.1 eslint: 8.57.1
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -17037,7 +16907,7 @@ snapshots:
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1 '@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 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -17056,7 +16926,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1 '@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@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) '@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 eslint: 8.57.1
ts-api-utils: 2.5.0(typescript@5.9.3) ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
@@ -17071,7 +16941,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1 '@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 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 minimatch: 10.2.5
semver: 7.8.2 semver: 7.8.2
tinyglobby: 0.2.17 tinyglobby: 0.2.17
@@ -17351,7 +17221,7 @@ snapshots:
agent-base@6.0.2: agent-base@6.0.2:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -17404,6 +17274,12 @@ snapshots:
amqplib: 0.10.9 amqplib: 0.10.9
promise-breaker: 6.0.0 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): amqp-connection-manager@5.0.0(amqplib@2.0.1):
dependencies: dependencies:
amqplib: 2.0.1 amqplib: 2.0.1
@@ -17875,16 +17751,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: babel-polyfill@6.26.0:
dependencies: dependencies:
babel-runtime: 6.26.0 babel-runtime: 6.26.0
@@ -18038,7 +17904,7 @@ snapshots:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
content-type: 1.0.5 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 http-errors: 2.0.1
iconv-lite: 0.7.2 iconv-lite: 0.7.2
on-finished: 2.4.1 on-finished: 2.4.1
@@ -19039,7 +18905,7 @@ snapshots:
engine.io-client@6.6.5: engine.io-client@6.6.5:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@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 engine.io-parser: 5.2.3
ws: 8.20.1 ws: 8.20.1
xmlhttprequest-ssl: 2.1.2 xmlhttprequest-ssl: 2.1.2
@@ -19059,7 +18925,7 @@ snapshots:
base64id: 2.0.0 base64id: 2.0.0
cookie: 0.7.2 cookie: 0.7.2
cors: 2.8.6 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 engine.io-parser: 5.2.3
ws: 8.21.0 ws: 8.21.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -19292,7 +19158,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): 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: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@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 eslint: 8.57.1
get-tsconfig: 4.14.0 get-tsconfig: 4.14.0
is-bun-module: 2.0.0 is-bun-module: 2.0.0
@@ -19420,7 +19286,7 @@ snapshots:
ajv: 6.15.0 ajv: 6.15.0
chalk: 4.1.2 chalk: 4.1.2
cross-spawn: 7.0.6 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 doctrine: 3.0.0
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint-scope: 7.2.2 eslint-scope: 7.2.2
@@ -19650,7 +19516,7 @@ snapshots:
content-type: 1.0.5 content-type: 1.0.5
cookie: 0.7.2 cookie: 0.7.2
cookie-signature: 1.2.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 depd: 2.0.0
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
@@ -19703,7 +19569,7 @@ snapshots:
extract-zip@2.0.1: extract-zip@2.0.1:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
get-stream: 5.2.0 get-stream: 5.2.0
yauzl: 2.10.0 yauzl: 2.10.0
optionalDependencies: optionalDependencies:
@@ -19854,7 +19720,7 @@ snapshots:
finalhandler@2.1.1: finalhandler@2.1.1:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
on-finished: 2.4.1 on-finished: 2.4.1
@@ -20100,7 +19966,7 @@ snapshots:
dependencies: dependencies:
basic-ftp: 5.3.1 basic-ftp: 5.3.1
data-uri-to-buffer: 6.0.2 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: transitivePeerDependencies:
- supports-color - supports-color
@@ -20381,7 +20247,7 @@ snapshots:
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -20394,14 +20260,14 @@ snapshots:
https-proxy-agent@5.0.1: https-proxy-agent@5.0.1:
dependencies: dependencies:
agent-base: 6.0.2 agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
https-proxy-agent@7.0.6: https-proxy-agent@7.0.6:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -20826,7 +20692,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1: istanbul-lib-source-maps@4.0.1:
dependencies: 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 istanbul-lib-coverage: 3.2.2
source-map: 0.6.1 source-map: 0.6.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -21287,19 +21153,6 @@ snapshots:
jsonparse@1.3.1: {} 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: jsonwebtoken@9.0.3:
dependencies: dependencies:
jws: 4.0.1 jws: 4.0.1
@@ -21360,23 +21213,12 @@ snapshots:
readable-stream: 2.3.8 readable-stream: 2.3.8
setimmediate: 1.0.5 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: jwa@2.0.1:
dependencies: dependencies:
buffer-equal-constant-time: 1.0.1 buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11 ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1 safe-buffer: 5.2.1
jws@3.2.3:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
jws@4.0.1: jws@4.0.1:
dependencies: dependencies:
jwa: 2.0.1 jwa: 2.0.1
@@ -21498,7 +21340,7 @@ snapshots:
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
commander: 13.1.0 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 execa: 8.0.1
lilconfig: 3.1.3 lilconfig: 3.1.3
listr2: 8.3.3 listr2: 8.3.3
@@ -22269,7 +22111,7 @@ snapshots:
dependencies: dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0 '@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4 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 get-uri: 6.0.5
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
@@ -22607,7 +22449,7 @@ snapshots:
proxy-agent@6.5.0: proxy-agent@6.5.0:
dependencies: dependencies:
agent-base: 7.1.4 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 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
lru-cache: 7.18.3 lru-cache: 7.18.3
@@ -22636,7 +22478,7 @@ snapshots:
dependencies: dependencies:
'@puppeteer/browsers': 2.13.2 '@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) 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 devtools-protocol: 0.0.1608973
typed-query-selector: 2.12.2 typed-query-selector: 2.12.2
webdriver-bidi-protocol: 0.4.1 webdriver-bidi-protocol: 0.4.1
@@ -22876,15 +22718,6 @@ snapshots:
- '@babel/core' - '@babel/core'
- react-is - 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): react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies: dependencies:
date-fns: 3.6.0 date-fns: 3.6.0
@@ -23450,7 +23283,7 @@ snapshots:
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0 depd: 2.0.0
is-promise: 4.0.0 is-promise: 4.0.0
parseurl: 1.3.3 parseurl: 1.3.3
@@ -23568,7 +23401,7 @@ snapshots:
send@1.2.1: send@1.2.1:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
@@ -23786,7 +23619,7 @@ snapshots:
socket.io-adapter@2.5.8: socket.io-adapter@2.5.8:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
ws: 8.21.0 ws: 8.21.0
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
@@ -23796,7 +23629,7 @@ snapshots:
socket.io-client@4.8.3: socket.io-client@4.8.3:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@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 engine.io-client: 6.6.5
socket.io-parser: 4.2.6 socket.io-parser: 4.2.6
transitivePeerDependencies: transitivePeerDependencies:
@@ -23807,7 +23640,7 @@ snapshots:
socket.io-parser@4.2.6: socket.io-parser@4.2.6:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@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: transitivePeerDependencies:
- supports-color - supports-color
@@ -23816,7 +23649,7 @@ snapshots:
accepts: 1.3.8 accepts: 1.3.8
base64id: 2.0.0 base64id: 2.0.0
cors: 2.8.6 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 engine.io: 6.6.9
socket.io-adapter: 2.5.8 socket.io-adapter: 2.5.8
socket.io-parser: 4.2.6 socket.io-parser: 4.2.6
@@ -23828,7 +23661,7 @@ snapshots:
socks-proxy-agent@8.0.5: socks-proxy-agent@8.0.5:
dependencies: dependencies:
agent-base: 7.1.4 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 socks: 2.8.9
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -24108,24 +23941,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@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): styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1
@@ -24151,7 +23966,7 @@ snapshots:
dependencies: dependencies:
component-emitter: 1.3.1 component-emitter: 1.3.1
cookiejar: 2.1.4 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 fast-safe-stringify: 2.1.1
form-data: 4.0.5 form-data: 4.0.5
formidable: 3.5.4 formidable: 3.5.4
@@ -24658,7 +24473,7 @@ snapshots:
app-root-path: 3.1.0 app-root-path: 3.1.0
buffer: 6.0.3 buffer: 6.0.3
dayjs: 1.11.21 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) dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1 dotenv: 16.6.1
glob: 10.5.0 glob: 10.5.0
@@ -24682,7 +24497,7 @@ snapshots:
app-root-path: 3.1.0 app-root-path: 3.1.0
buffer: 6.0.3 buffer: 6.0.3
dayjs: 1.11.21 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) dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1 dotenv: 16.6.1
glob: 10.5.0 glob: 10.5.0
@@ -24985,7 +24800,7 @@ snapshots:
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies: dependencies:
cac: 6.7.14 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 es-module-lexer: 1.7.0
pathe: 1.1.2 pathe: 1.1.2
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -25021,7 +24836,7 @@ snapshots:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9 '@vitest/utils': 2.1.9
chai: 5.3.3 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 expect-type: 1.3.0
magic-string: 0.30.21 magic-string: 0.30.21
pathe: 1.1.2 pathe: 1.1.2