Merge pull request #1225 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-10 23:59:43 +03:00
committed by GitHub
70 changed files with 2897 additions and 836 deletions

View File

@@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants';
import { BookingContractService } from './booking-contract.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
@@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
/**
* The allocated train as the backoffice booking detail page needs it: which
* train, its window phase, and both the planned and actual clock. Attached by
* `findById` only when the booking is on a schedule.
*/
export interface TrainScheduleSummary {
id: string;
reference: string | null;
trainNumber: string | null;
status: string | null;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
actualDepartureAt: string | null;
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
}
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
items: Booking[];
@@ -1738,6 +1757,20 @@ export class BookingsService {
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
pending.has(b.id);
}
this.attachPaymentDrainEnds(bookings);
}
/**
* Derived, no query: end of the settlement drain tail after `paymentDeadline`.
* The portal hides "Pay now" between the deadline and this instant — a payment
* started just before the buzzer is still settling, so offering to pay again
* would invite a double payment.
*/
private attachPaymentDrainEnds(bookings: Booking[]): void {
for (const b of bookings) {
(b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt =
paymentDrainEndsAtIso(b.paymentDeadline);
}
}
async findAll(
@@ -2105,7 +2138,32 @@ export class BookingsService {
.findOne({ where: { id: booking.trainScheduleId } });
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
// Backoffice staff view: the allocated train's identity and clock, so the
// detail page can state which train the booking rides and when it runs
// without a second round-trip to the schedules API.
(
booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null }
).trainScheduleSummary = schedule
? {
id: schedule.id,
reference: schedule.reference ?? null,
trainNumber: schedule.trainNumber ?? null,
status: schedule.status ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null,
actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null,
actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null,
windowPhase: schedule.windowPhase ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
}
: null;
}
// End of this booking's own pay window including the settlement drain tail —
// the deadline staff should quote, since a payment landing inside the drain
// still counts (see paymentDrainEndsAtIso).
(booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt =
paymentDrainEndsAtIso(booking.paymentDeadline);
// A generated-but-unsigned handover means the customer must approve delivery
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:

View File

@@ -210,6 +210,7 @@ export class CompaniesController {
const data = await this.companiesService.fetchETradeData(
dto.tin,
companyId,
dto.licenceNumber,
);
return new ETradeResponseDto(data);
}

View File

@@ -3541,9 +3541,10 @@ export class CompaniesService {
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration(
tin: string,
licenceNumber?: string,
): Promise<CompanyRegistrationData> {
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
await this.etradeService.resolveCompanyData(tin, licenceNumber);
if (!businessInfo) {
throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
@@ -3555,8 +3556,15 @@ export class CompaniesService {
);
}
async fetchETradeData(tin: string, excludeCompanyId?: string) {
const registrationData = await this.resolveEtradeRegistration(tin);
async fetchETradeData(
tin: string,
excludeCompanyId?: string,
licenceNumber?: string,
) {
const registrationData = await this.resolveEtradeRegistration(
tin,
licenceNumber,
);
const tinTaken = await this.companiesRepo.existsByTin(
tin,
excludeCompanyId,
@@ -3583,7 +3591,13 @@ export class CompaniesService {
if (!touched) return;
const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin);
// Re-verify the licence the customer actually chose. Without it a TIN
// holding several licences would silently snap back to eTrade's first one on
// every save, overwriting the selection with a different business's record.
const registration = await this.resolveEtradeRegistration(
tin,
dto.licenceNumber ?? company.licenceNumber ?? undefined,
);
const fresh: Partial<
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
> = {

View File

@@ -1,4 +1,4 @@
import { CompanyRegistrationData } from "@edr/types";
import { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
export class ETradeResponseDto implements CompanyRegistrationData {
companyName!: string;
@@ -19,6 +19,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
managerEmail?: string;
managerPhone!: string;
tinTaken?: boolean;
businesses?: ETradeBusinessOption[];
constructor(data: CompanyRegistrationData) {
this.companyName = data.companyName;
@@ -39,5 +40,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone;
this.tinTaken = data.tinTaken;
this.businesses = data.businesses;
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty } from "class-validator";
import { IsString, IsNotEmpty, IsOptional, MaxLength } from "class-validator";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class FetchETradeDto {
@@ -6,4 +6,14 @@ export class FetchETradeDto {
@IsNotEmpty()
@IsTin({ message: "TIN must be exactly 10 digits" })
tin!: string;
/**
* Which of the TIN's business licences to resolve. Omitted on the first
* lookup — the response lists them all so the customer can pick, and the pick
* comes back here.
*/
@IsOptional()
@IsString()
@MaxLength(100)
licenceNumber?: string;
}

View File

@@ -36,13 +36,13 @@ export class UpdateProfileDto {
@IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string;
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
// TIN. Both portal forms enforce that; without it here the API happily stored
// whatever a stale client sent, and the two layers disagreed about what the
// column may hold.
// Ethiopian VAT registration numbers are 10 digits (the same shape as the
// TIN), but some are issued with an 11th. Both portal forms enforce the same
// range; without it here the API happily stored whatever a stale client sent,
// and the two layers disagreed about what the column may hold.
@IsOptional()
@IsString()
@Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" })
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" })
vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the

View File

@@ -0,0 +1,87 @@
import { ETradeService } from './etrade.service';
import type { ETradeBusinessInfo, ETradeCompanyInfo } from '@edr/types';
/**
* A TIN routinely holds several business licences (import of vehicles, export of
* coffee, freight forwarding…), all under the same trade name. The customer
* picks one, and every later lookup has to resolve that same licence — snapping
* back to eTrade's first would silently swap their company record.
*/
const companyInfo = (): ETradeCompanyInfo =>
({
Tin: '0045014036',
BusinessName: 'PAVE LOGISTICS AND TRADING P L C',
Businesses: [
{
LicenceNumber: 'MT/AA/14/670/11551235/2017',
TradesName: 'PAVE LOGISTICS AND TRADING P L C',
RenewedTo: '7/7/2026',
SubGroups: [
{ Code: 66331, Description: 'Export trade in minerals' },
],
},
{
LicenceNumber: 'MT/AA/14/670/128936/2007',
TradesName: 'PAVE LOGISTICS AND TRADING P L C',
RenewedTo: '7/7/2026',
SubGroups: [{ Code: 72131, Description: '(72131)Freight Forwarders' }],
},
],
}) as unknown as ETradeCompanyInfo;
describe('ETradeService business selection', () => {
const build = () => {
const service = new ETradeService({} as never);
const fetched: string[] = [];
jest
.spyOn(service, 'getCompanyInfoByTin')
.mockResolvedValue(companyInfo());
jest
.spyOn(service, 'getBusinessByLicenseNo')
.mockImplementation(async (licenceNo: string) => {
fetched.push(licenceNo);
return { LicenceNumber: licenceNo } as ETradeBusinessInfo;
});
return { service, fetched };
};
it('defaults to the first licence when none is chosen', async () => {
const { service, fetched } = build();
await service.resolveCompanyData('0045014036');
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
});
it('resolves the chosen licence', async () => {
const { service, fetched } = build();
await service.resolveCompanyData('0045014036', 'MT/AA/14/670/128936/2007');
expect(fetched).toEqual(['MT/AA/14/670/128936/2007']);
});
it('falls back to the first licence when the chosen one is gone', async () => {
const { service, fetched } = build();
await service.resolveCompanyData('0045014036', 'NO/SUCH/LICENCE');
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
});
it('lists every licence for the picker, code prefixes stripped', () => {
const { service } = build();
const data = service.extractRegistrationData(
{ LicenceNumber: 'x' } as ETradeBusinessInfo,
companyInfo(),
);
expect(data.businesses).toEqual([
{
licenceNumber: 'MT/AA/14/670/11551235/2017',
tradeName: 'PAVE LOGISTICS AND TRADING P L C',
activity: 'Export trade in minerals',
renewedTo: '7/7/2026',
},
{
licenceNumber: 'MT/AA/14/670/128936/2007',
tradeName: 'PAVE LOGISTICS AND TRADING P L C',
activity: 'Freight Forwarders',
renewedTo: '7/7/2026',
},
]);
});
});

View File

@@ -66,7 +66,15 @@ export class ETradeService {
}
}
async resolveCompanyData(tin: string): Promise<{
/**
* @param licenceNumber which of the TIN's licences to resolve. Defaults to the
* first one — a TIN with several licences is only unambiguous once the
* customer has picked one (see {@link ETradeBusinessOption}).
*/
async resolveCompanyData(
tin: string,
licenceNumber?: string,
): Promise<{
companyInfo: ETradeCompanyInfo;
businessInfo: ETradeBusinessInfo | null;
}> {
@@ -76,10 +84,15 @@ export class ETradeService {
return { companyInfo, businessInfo: null };
}
const latestBusiness = companyInfo.Businesses[0];
// An unknown licence falls back to the first rather than 400-ing: eTrade can
// drop or renumber a licence between the customer picking it and the save
// that re-verifies it, and that must not lock them out of their own profile.
const selected =
companyInfo.Businesses.find((b) => b.LicenceNumber === licenceNumber) ??
companyInfo.Businesses[0];
try {
const businessInfo = await this.getBusinessByLicenseNo(
latestBusiness.LicenceNumber,
selected.LicenceNumber,
tin,
);
return { companyInfo, businessInfo };
@@ -124,6 +137,16 @@ export class ETradeService {
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
managerName: primaryManager?.ManagerNameEng || "",
managerPhone: primaryManager?.RegularPhone || "",
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
licenceNumber: b.LicenceNumber,
tradeName: b.TradesName?.trim() || "",
activity: (b.SubGroups ?? [])
// Some descriptions repeat the code inline ("(65611)Import trade …").
.map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim())
.filter(Boolean)
.join(", "),
renewedTo: b.RenewedTo || "",
})),
};
}
}

View File

@@ -91,11 +91,15 @@ export class LastMileRequestsController {
return this.contractService.sign(id, dto, user?.id ?? null);
}
// Customer-facing like :id/contract/view — the portal's confirm page opens
// this straight from the departure notification link before the customer
// has done anything else, so it can't be staff-only. Service ownership-
// checks against the resolved company; staff may also open it.
@Get(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.requestsService.findById(id);
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
return this.requestsService.findById(id, user?.id ?? null);
}
// No @BookingStaff — the customer (portal) fills this, not backoffice staff.

View File

@@ -199,11 +199,25 @@ export class LastMileRequestsService {
};
}
async findById(id: string): Promise<LastMileRequest> {
/**
* `userId` is set only when a portal customer calls this directly (the
* confirm-page deep link from the departure notification, before they've
* submitted or signed anything) — staff and every internal caller pass
* nothing and skip the check, same convention as submit()/sign().
*/
async findById(id: string, userId?: string | null): Promise<LastMileRequest> {
const record = await this.requestsRepository.findById(id, {
relations: { booking: { company: true } },
});
if (!record) throw new NotFoundException(`Last-mile request ${id} not found`);
if (userId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
if (companyId && record.booking?.companyId && companyId !== record.booking.companyId) {
throw new BadRequestException('This request does not belong to your company');
}
}
return record;
}

View File

@@ -69,6 +69,7 @@ export class ClientActionDto {
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"AWAIT_PUSH",
"SHOW_BILL_REFERENCE",
],
})
@@ -77,6 +78,7 @@ export class ClientActionDto {
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "AWAIT_PUSH"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
@@ -104,9 +106,16 @@ export class ClientActionDto {
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
@ApiPropertyOptional({
description: "Set when type=COLLECT_OTP or type=AWAIT_PUSH",
})
message?: string;
@ApiPropertyOptional({
description: "Set when type=AWAIT_PUSH (masked MSISDN the push prompt went to)",
})
payerAccountMasked?: string;
@ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
})

View File

@@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit {
// (provider query errored / payment still in flight) means we could not
// confirm "not paid" — never expire on unknown; the next settle tick
// asks again.
if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
const reconcile = await this.billing.reconcilePayable(booking.id);
if (reconcile.paid) {
this.logger.log(
`[BATCH] expire skipped for ${booking.reference} — gateway ` +
`reconcile found a settled payment; payment.succeeded will allocate it`,
);
return;
}
if (reconcile.unverifiable) {
this.logger.warn(
`[BATCH] expire deferred for ${booking.reference} — settlement ` +
`unverifiable at the gateway; retrying next settle tick`,
);
return;
}
}
// TODO: CBE has no reconcile endpoint yet — re-enable once available.
// if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
// const reconcile = await this.billing.reconcilePayable(booking.id);
// if (reconcile.paid) {
// this.logger.log(
// `[BATCH] expire skipped for ${booking.reference} — gateway ` +
// `reconcile found a settled payment; payment.succeeded will allocate it`,
// );
// return;
// }
// if (reconcile.unverifiable) {
// this.logger.warn(
// `[BATCH] expire deferred for ${booking.reference} — settlement ` +
// `unverifiable at the gateway; retrying next settle tick`,
// );
// return;
// }
// }
}
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {

View File

@@ -510,6 +510,8 @@ export class TrainBuilderService {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
importTrainNumber: null,
exportTrainNumber: null,
});
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
@@ -544,6 +546,8 @@ export class TrainBuilderService {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Maintenance,
importTrainNumber: null,
exportTrainNumber: null,
});
// Audit row: which train it came off and when. The wagon does not change
// yard here, so from/to are the same — the ledger is the wagon's history
@@ -761,7 +765,13 @@ export class TrainBuilderService {
.getRepository(Wagon)
.update(
{ trainId: train.id },
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
{
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
importTrainNumber: null,
exportTrainNumber: null,
},
);
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
await manager.getRepository(Train).remove(train);
@@ -1021,6 +1031,10 @@ export class TrainBuilderService {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
// Wagon inherits the train's run numbers on coupling — no per-wagon
// number entry, they ride whatever numbers the train was built with.
importTrainNumber: train.importTrainNumber,
exportTrainNumber: train.exportTrainNumber,
});
}
return toAttach;

View File

@@ -14,7 +14,8 @@ import {
* A count-only wagon-transfer request. The requester picks source yard, wagon
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
* type currently in the source yard, and a reason is mandatory.
* type currently in the source yard (enforced in the service, which is the only
* layer that can count them), and a reason is mandatory.
*/
export class CreateTransferRequestDto {
@IsUUID()

View File

@@ -199,10 +199,29 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
});
describe('createRequest', () => {
it('accepts a count larger than what the yard holds today', async () => {
it('accepts a count up to what the yard holds today', async () => {
wagonRepo.count.mockResolvedValue(20);
await service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 20,
reason: 'Grain campaign',
},
'user-1',
);
expect(requestRepo.save).toHaveBeenCalled();
expect(stored.quantity).toBe(20);
});
it('refuses a count larger than what the yard holds today', async () => {
wagonRepo.count.mockResolvedValue(20);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
@@ -211,10 +230,27 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
reason: 'Grain campaign',
},
'user-1',
);
),
).rejects.toThrow(/only 20 wagon\(s\).*available/i);
expect(requestRepo.save).not.toHaveBeenCalled();
});
expect(requestRepo.save).toHaveBeenCalled();
expect(stored.quantity).toBe(50);
it('refuses when the yard has nothing of that type available', async () => {
wagonRepo.count.mockResolvedValue(0);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 1,
reason: 'Grain campaign',
},
'user-1',
),
).rejects.toThrow(/no available wagons/i);
expect(requestRepo.save).not.toHaveBeenCalled();
});
it('still refuses a same-yard move', async () => {

View File

@@ -75,10 +75,11 @@ export class WagonTransferRequestsService {
) {}
/**
* Record a PENDING request. Count-only — no wagons are picked here, and the
* count is NOT capped by what the source yard holds today: OCC fulfils in
* instalments, so asking for 50 while only 20 sit there is a normal, useful
* request. A reason is mandatory and is shown on the OCC queue.
* Record a PENDING request. Count-only — no wagons are picked here, but the
* count IS capped by what the source yard can hand over right now: a request
* may not exceed the AVAILABLE, uncoupled wagons of that type in the source
* yard (the same number the yard desk shows). A reason is mandatory and is
* shown on the OCC queue.
*/
async createRequest(
dto: CreateTransferRequestDto,
@@ -89,6 +90,20 @@ export class WagonTransferRequestsService {
'Source and destination yard must be different',
);
}
const available = await this.countAvailable(
dto.fromYardId,
dto.wagonTypeId,
);
if (available === 0) {
throw new BadRequestException(
'No available wagons of this type in the source yard',
);
}
if (dto.quantity > available) {
throw new BadRequestException(
`Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`,
);
}
const request = this.requestRepo.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,

View File

@@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { CalendarClock } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { SectionCard } from "./SectionCard";
export interface BookingSchedulingWindowCardProps {
booking: BookingDetail;
}
/** Full date + time — staff read these against the operating clock, so no time is dropped. */
function formatStamp(iso: string | null | undefined): string | null {
if (!iso) return null;
const ms = new Date(iso).getTime();
if (!Number.isFinite(ms)) return null;
return new Date(ms).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */
function formatRelative(iso: string, nowMs: number): string {
const diff = new Date(iso).getTime() - nowMs;
const past = diff < 0;
const totalMinutes = Math.floor(Math.abs(diff) / 60_000);
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
// Keep minutes when they're the only unit, so sub-hour gaps never read "0".
if (minutes || parts.length === 0) parts.push(`${minutes}m`);
const span = parts.slice(0, 2).join(" ");
return past ? `${span} ago` : `in ${span}`;
}
/**
* Length of a window as "1h 30m" / "45m". Null unless both ends are real and
* ordered — the pay window is configurable per schedule, so this is read off the
* actual stamps rather than assuming any fixed duration.
*/
function formatDuration(
from: string | null | undefined,
to: string | null | undefined,
): string | null {
if (!from || !to) return null;
const fromMs = new Date(from).getTime();
const toMs = new Date(to).getTime();
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null;
const minutes = Math.round((toMs - fromMs) / 60_000);
if (minutes <= 0) return null;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (!hours) return `${rest}m`;
return rest ? `${hours}h ${rest}m` : `${hours}h`;
}
function Row({
label,
value,
hint,
tone,
}: {
label: string;
value: string;
hint?: string | null;
tone?: "muted" | "warning" | "danger";
}) {
const valueColor =
tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark";
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="md">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Box style={{ textAlign: "right", minWidth: 0 }}>
<Text size="sm" fw={600} c={valueColor}>
{value}
</Text>
{hint ? (
<Text size="xs" c="dimmed">
{hint}
</Text>
) : null}
</Box>
</Group>
);
}
/**
* Backoffice-only staff view of the scheduling clock: which batch/train the
* booking is scheduled for, when its pay window closes, and the train's
* planned vs actual departure/arrival (i.e. when the run actually ended).
*/
export function BookingSchedulingWindowCard({
booking,
}: BookingSchedulingWindowCardProps) {
const schedule = booking.trainScheduleSummary ?? null;
// The pay-window end staff should quote is the drain end (a payment landing
// inside the drain still counts); fall back to the raw deadline if the API
// predates that field.
const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null;
// One shared ticking clock so every relative label in the card stays in sync.
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const interval = setInterval(() => setNowMs(Date.now()), 30_000);
return () => clearInterval(interval);
}, []);
// How long the customer actually had to pay: start → the raw deadline, NOT the
// drain end (the drain is settlement grace, not payable time).
const windowDuration = formatDuration(
booking.selectedForBatchAt,
booking.paymentDeadline,
);
const hasAnything =
Boolean(schedule) ||
Boolean(payWindowEndsAt) ||
Boolean(booking.selectedForBatchAt) ||
Boolean(booking.holdExpiresAt);
if (!hasAnything) return null;
const payWindowClosed = payWindowEndsAt
? new Date(payWindowEndsAt).getTime() <= nowMs
: false;
const trainLabel =
schedule?.trainNumber ??
schedule?.reference ??
(schedule ? "Assigned train" : null);
return (
<SectionCard
icon={CalendarClock}
title="Scheduling & payment window"
subtitle="Staff view — batch allocation and the operating clock"
accent="indigo"
extra={<SchedulingStatusBadge status={booking.schedulingStatus} />}
>
<Stack gap="sm">
{trainLabel ? (
<Row
label="Scheduled on train"
value={trainLabel}
hint={
schedule?.reference && schedule.reference !== trainLabel
? schedule.reference
: null
}
/>
) : (
<Row
label="Scheduled on train"
value="Not yet allocated"
tone="muted"
hint="The booking has not been placed on a train schedule"
/>
)}
{schedule?.status ? (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
Train status
</Text>
<Group gap="xs">
{schedule.windowPhase ? (
<Badge variant="light" color="gray" size="sm">
{schedule.windowPhase.replace(/_/g, " ")}
</Badge>
) : null}
<Badge variant="light" color="indigo" size="sm">
{schedule.status}
</Badge>
</Group>
</Group>
) : null}
{booking.selectedForBatchAt ? (
<Row
label="Payment window started"
value={formatStamp(booking.selectedForBatchAt) ?? "—"}
hint={
windowDuration
? `${windowDuration} window`
: formatRelative(booking.selectedForBatchAt, nowMs)
}
/>
) : null}
{payWindowEndsAt ? (
<Row
label="Payment window ends"
value={formatStamp(payWindowEndsAt) ?? "—"}
tone={payWindowClosed ? "danger" : "warning"}
hint={
payWindowClosed
? `Closed ${formatRelative(payWindowEndsAt, nowMs)}`
: `Closes ${formatRelative(payWindowEndsAt, nowMs)}`
}
/>
) : null}
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Row
label="Wagon hold expires"
value={formatStamp(booking.holdExpiresAt) ?? "—"}
tone="warning"
hint={formatRelative(booking.holdExpiresAt, nowMs)}
/>
) : null}
{schedule ? (
<>
<Row
label="Departure"
value={
formatStamp(schedule.actualDepartureAt) ??
formatStamp(schedule.scheduledDepartureDate) ??
"—"
}
hint={
schedule.actualDepartureAt
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
: "Planned"
}
/>
<Row
label={schedule.actualArrivalAt ? "Arrived (trip ended)" : "Arrival"}
value={
formatStamp(schedule.actualArrivalAt) ??
formatStamp(schedule.scheduledArrivalDate) ??
"—"
}
hint={
schedule.actualArrivalAt
? `Actual · planned ${formatStamp(schedule.scheduledArrivalDate) ?? "—"}`
: "Planned — the train has not arrived yet"
}
/>
</>
) : null}
</Stack>
</SectionCard>
);
}

View File

@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";
export * from "./BookingCompanyCard";
export * from "./BookingSchedulingWindowCard";

View File

@@ -40,8 +40,6 @@ import type { SidebarItem, SidebarSection } from "./types";
import {
FREIGHT_PERMS,
hasPermission as hasFreightPermission,
isDjiboutiGl,
isEthiopianGl,
isSuperAdmin,
} from "@/lib/permissions";
import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources";
@@ -554,38 +552,10 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
},
];
/** Hrefs of the two document-clearance menu items (stable identifiers). */
export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
// part of their job (create/rebook under a cleared contract, then view that
// booking's clearance), but those routes live outside the clearance prefix —
// without this allowlist the single-prefix lock bounces them out of their own
// workflow. Matched against location.pathname (no query string).
export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
// The ET hub's rows open the shipment clearance detail at this URL.
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
];
const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean =>
item.href === DJ_CLEARANCE_HREF;
const isClearanceItem = (item: SidebarItem): boolean =>
isEtClearanceItem(item) || isDjClearanceItem(item);
/**
* Keep only items the user is permitted to see; drop now-empty sections.
*
* Position-scoped visibility (super_admin sees everything):
* - Super Admin → sees all items (all permissions pass, all tabs visible)
* - Ethiopian GL → sees ONLY the ET document-clearance page.
* - Djibouti GL → sees ONLY the DJ clearance page.
* - Everyone else → sees everything they have permission for, EXCEPT the two
* clearance pages (those are GL-only).
* Super Admin sees everything; everyone else is filtered purely by each
* item's `permission` field (OR across the array when one is given).
*/
export const filterSidebarByPermission = (
sections: SidebarSection[],
@@ -594,9 +564,6 @@ export const filterSidebarByPermission = (
// Superadmin sees every section and item — no permission filtering.
if (isSuperAdmin(user)) return sections;
const etGl = isEthiopianGl(user);
const djGl = isDjiboutiGl(user);
const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
@@ -616,14 +583,6 @@ export const filterSidebarByPermission = (
: item,
)
.filter((item) => {
if (etGl || djGl) {
// GL positions are locked to their single clearance page (parents
// survive only as the path to that page).
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
return isTarget(item) || (item.children?.length ?? 0) > 0;
}
// Everyone else: hide the GL-only clearance pages entirely.
if (isClearanceItem(item)) return false;
if (!permissionAllowed(item)) return false;
if (item.children) return item.children.length > 0;
return true;

View File

@@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => {
/**
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
* for actions that move real wagons; omit it for a transfer REQUEST, which may
* legitimately ask for more than the yard holds today (OCC fulfils it in
* instalments) — the slider then just tracks the current value.
* to the wagons on hand; omitting it leaves the field unbounded and the slider
* simply tracks the current value.
*/
const QuantityField = ({
value,
@@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
{availableCount} available
</Badge>
</Group>
{/* No max: the request may exceed what the yard holds
today — OCC fulfils it in instalments. */}
<QuantityField value={transferQty} onChange={setTransferQty} />
{/* Capped at the wagons actually available in this yard
right now (uncoupled + Available) — a request may not
ask for more than the yard can hand over. */}
<QuantityField
value={transferQty}
onChange={setTransferQty}
max={availableCount}
/>
</div>
<Select
label="Destination yard"

View File

@@ -40,6 +40,7 @@ import {
BookingCompanyCard,
BookingContractSummaryCard,
BookingContainerUnitsCard,
BookingSchedulingWindowCard,
BookingDocumentsPanel,
BookingTrucksPanel,
ContractOrdersPanel,
@@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingSchedulingWindowCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard

View File

@@ -102,6 +102,7 @@ const FleetResourcePage = () => {
const currentYardId = listFilterValues.currentYardId;
const availability = listFilterValues.availability;
const trainNumber = listFilterValues.trainNumber;
const trainId = listFilterValues.trainId;
if (status && status !== "ALL") {
(filters as { status?: string }).status = status;
}
@@ -114,6 +115,9 @@ const FleetResourcePage = () => {
if (trainNumber && trainNumber !== "ALL") {
(filters as { trainNumber?: string }).trainNumber = trainNumber;
}
if (trainId && trainId !== "ALL") {
filters.trainId = trainId;
}
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
const wagonTypeId = listFilterValues.wagonTypeId;
if (wagonTypeId && wagonTypeId !== "ALL") {
@@ -191,6 +195,11 @@ const FleetResourcePage = () => {
const { data: drivers = [] } = useQuery(
api.fleet.list.queryOptions({ input: { slug: "drivers" } }),
);
// Wagons-only: "Train" list filter needs every train's code to pick from.
const { data: trains = [], isLoading: trainsLoading } = useQuery({
...api.trains.list.queryOptions(),
enabled: slug === "wagons",
});
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -247,6 +256,9 @@ const FleetResourcePage = () => {
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map(
(t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }),
);
// Carries capacity + trailer configuration so picking a truck type can
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
@@ -274,8 +286,9 @@ const FleetResourcePage = () => {
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
trains: trainOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
@@ -327,7 +340,8 @@ const FleetResourcePage = () => {
truckTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
yardsLoading ||
trainsLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;

View File

@@ -33,7 +33,8 @@ export type FleetDynamicOptions =
| "truckTypes"
| "wagons"
| "containers"
| "yards";
| "yards"
| "trains";
/**
* A dynamic select option that can carry the record it came from. Picking a
@@ -324,6 +325,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
allLabel: "All trains",
options: TRAIN_RUN_FILTER_OPTIONS,
},
{
key: "trainId",
label: "Train",
allLabel: "All trains",
dynamicOptions: "trains",
},
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "currentYard",

View File

@@ -35,6 +35,7 @@ import {
STATUS_META,
TransferProgress,
TransferStatusBadge,
stripHtmlToText,
wagonTypeLabel,
yardLabel,
} from "./wagon-transfer-ui";
@@ -119,9 +120,9 @@ function RequestItem({ request }: { request: WagonTransferRequest }) {
{wagonTypeLabel(request.wagonType)}
</Badge>
</Group>
{request.reason ? (
{stripHtmlToText(request.reason) ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{request.reason}
{stripHtmlToText(request.reason)}
</Text>
) : null}
</Stack>

View File

@@ -1,3 +1,4 @@
import { Freight } from "@edr/types";
import {
Alert,
Button,
@@ -41,6 +42,30 @@ function useTransferOptions(enabled: boolean) {
};
}
/**
* Wagons the source yard can hand over right now — AVAILABLE and not coupled to
* a built train. Mirrors `countAvailable` on the API, which rejects any request
* asking for more than this, so the field must not let one be filed.
*/
function useAvailableCount(
enabled: boolean,
fromYardId: string | null,
wagonTypeId: string | null,
) {
const { data: wagons = [] } = useQuery({
...api.wagons.list.queryOptions({ input: {} }),
enabled: enabled && Boolean(fromYardId && wagonTypeId),
});
if (!fromYardId || !wagonTypeId) return null;
return wagons.filter(
(w) =>
w.currentYardId === fromYardId &&
w.wagonTypeId === wagonTypeId &&
w.status === Freight.WagonStatus.Available &&
!w.trainId,
).length;
}
export interface TransferRequestFormModalProps {
opened: boolean;
onClose: () => void;
@@ -54,9 +79,9 @@ export interface TransferRequestFormModalProps {
}
/**
* File a wagon-transfer request. The count is deliberately NOT capped by what
* the source yard holds today — OCC fulfils in instalments, so asking for 50
* where 20 sit is a normal request.
* File a wagon-transfer request. The count is capped by what the source yard
* has available right now; the API enforces the same ceiling, so a larger ask
* is rejected rather than queued.
*/
export function TransferRequestFormModal({
opened,
@@ -83,10 +108,23 @@ export function TransferRequestFormModal({
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
const available = useAvailableCount(opened, fromYardId, wagonTypeId);
// A prefilled outstanding count (or a count typed before the yard was picked)
// can exceed what the chosen source yard actually has — pull it back down so
// the field never holds a value the API would reject.
useEffect(() => {
if (available == null) return;
setQuantity((q) => (Number(q) > available ? available : q));
}, [available]);
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
const overAvailable = available != null && Number(quantity) > available;
const valid =
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
!sameYard &&
!overAvailable &&
available !== 0 &&
Number(quantity) >= 1;
const submit = async () => {
@@ -155,10 +193,25 @@ export function TransferRequestFormModal({
/>
<NumberInput
label="How many"
description="Can exceed what the yard holds today — OCC delivers in instalments"
description={
available == null
? "Pick a source yard and wagon type to see what is available"
: `${available} wagon(s) available in the source yard`
}
min={1}
max={available ?? undefined}
clampBehavior={available == null ? "none" : "strict"}
allowNegative={false}
value={quantity}
onChange={setQuantity}
disabled={available === 0}
error={
available === 0
? "This yard has no wagons of that type available"
: overAvailable
? `Only ${available} available`
: undefined
}
required
/>
<Textarea

View File

@@ -10,6 +10,7 @@ import {
Tabs,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
@@ -31,6 +32,7 @@ import { useMutation } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { sanitizeHtml } from "@/shared/lib/sanitize";
import { api } from "@/services/api";
import type {
TransferRequestListFilter,
@@ -55,6 +57,7 @@ import {
fmtDateTime,
isOpenRequest,
outstandingOn,
stripHtmlToText,
wagonTypeLabel,
yardLabel,
} from "./wagon-transfer-ui";
@@ -108,6 +111,9 @@ export default function WagonTransfersPage() {
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
null,
);
const [viewingReason, setViewingReason] = useState<WagonTransferRequest | null>(
null,
);
const filter: TransferRequestListFilter = useMemo(
() => ({
@@ -197,11 +203,29 @@ export default function WagonTransfersPage() {
{
id: "reason",
header: () => <span>Reason</span>,
cell: ({ row }) => (
<Text size="sm" c="dimmed" lineClamp={2} maw={260}>
{row.original.reason || "—"}
cell: ({ row }) => {
const text = stripHtmlToText(row.original.reason);
return text ? (
<UnstyledButton
onClick={() => setViewingReason(row.original)}
data-stop-row-click
>
<Text
size="sm"
c="dimmed"
lineClamp={2}
maw={260}
style={{ textAlign: "left", textDecoration: "underline dotted" }}
>
{text}
</Text>
),
</UnstyledButton>
) : (
<Text size="sm" c="dimmed">
</Text>
);
},
},
{
id: "filed",
@@ -522,6 +546,33 @@ export default function WagonTransfersPage() {
</Stack>
)}
</Modal>
<Modal
opened={Boolean(viewingReason)}
onClose={() => setViewingReason(null)}
radius="md"
title="Reason"
>
{!viewingReason ? null : (
<Stack gap="sm">
<Text size="sm" fw={600}>
{yardLabel(viewingReason.fromYard)}{" "}
<ArrowRight
size={13}
className="inline-block opacity-60"
/>{" "}
{yardLabel(viewingReason.toYard)} ·{" "}
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
{viewingReason.quantity} wagon(s)
</Text>
<Box
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(viewingReason.reason ?? ""),
}}
/>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -3,6 +3,16 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { WagonTransferRequest } from "@/services/wagon.service";
/** Reason/note fields come from a rich-text editor and store HTML — this
* gives a plain-text preview for list/table contexts (full formatting is
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */
export const stripHtmlToText = (html?: string | null): string =>
(html ?? "")
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
y?.label || y?.code || "—";

View File

@@ -131,6 +131,20 @@ export interface BookingFile {
size?: number;
}
/** The allocated train's identity, window phase, and planned/actual clock. */
export interface BookingTrainScheduleSummary {
id: string;
reference: string | null;
trainNumber: string | null;
status: string | null;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
actualDepartureAt: string | null;
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
}
export interface BookingDetail {
id: string;
reference: string;
@@ -188,6 +202,22 @@ export interface BookingDetail {
wagonsRequired?: number | null;
scheduledAt?: string | null;
trainScheduleId?: string | null;
/** Operational status of the allocated train (null until scheduled). */
trainScheduleStatus?: string | null;
/** The allocated train's identity + clock, attached by the detail endpoint. */
trainScheduleSummary?: BookingTrainScheduleSummary | null;
/**
* When the batch engine picked this booking and opened its pay window — the
* start paired with `paymentDeadline` (both are set and cleared together).
*/
selectedForBatchAt?: string | null;
/** End of this booking's pay window (batch/offer deadline). */
paymentDeadline?: string | null;
/**
* End of the pay window including the settlement drain tail — the deadline
* staff should quote, since a payment landing inside the drain still counts.
*/
paymentDrainEndsAt?: string | null;
pnrCode?: string | null;
firstMilePickupAddress?: string | null;
lastMileDeliveryAddress?: string | null;

View File

@@ -1,7 +1,17 @@
import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core";
import { useEffect, useRef } from "react";
import {
Alert,
Button,
Card,
Group,
Loader,
Radio,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { AlertCircle, Download } from "lucide-react";
import { AlertCircle, Building2, Download } from "lucide-react";
import { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types";
@@ -12,6 +22,8 @@ export type ETradeStatus =
| "verified"
| "not-found"
| "taken"
/** eTrade returned several business licences; the customer must pick one. */
| "choose-business"
| "error";
interface ETradeInfoProps {
@@ -36,6 +48,12 @@ interface ETradeInfoProps {
* stays available for a deliberate re-verify.
*/
alreadyVerified?: boolean;
/**
* The licence this company already operates under, if any. Pre-selects it in
* the picker so a deliberate re-verify refreshes that same business rather
* than silently snapping to eTrade's first one.
*/
selectedLicenceNumber?: string;
}
// Digits, not just length: a 10-character non-numeric TIN used to fire a lookup
@@ -51,6 +69,7 @@ export default function ETradeInfo({
onStatusChange,
onReset,
alreadyVerified,
selectedLicenceNumber,
}: ETradeInfoProps) {
const mutation = useETradeData();
const isLoading = mutation.isPending;
@@ -61,14 +80,39 @@ export default function ETradeInfo({
// user has typed a different TIN and overwrite its fields with stale data.
const requestIdRef = useRef(0);
const handleFetch = async () => {
// Which of the TIN's licences the customer operates as. A ref alongside the
// state because handleFetch is called from an effect that doesn't re-run on
// this value.
const [licence, setLicence] = useState<string | null>(
selectedLicenceNumber || null,
);
const licenceRef = useRef(licence);
licenceRef.current = licence;
const handleFetch = async (chosen = licenceRef.current) => {
if (!isValidTin(tin)) return;
const requestId = ++requestIdRef.current;
const result = await mutation.mutateAsync(tin);
const result = await mutation.mutateAsync({
tin,
licenceNumber: chosen ?? undefined,
});
if (requestIdRef.current !== requestId) return;
if (result && !result.tinTaken) {
if (!result || result.tinTaken) return;
// Several licences and no pick yet: the registration data describes only
// eTrade's first one, so it must not be adopted as this company's record
// until the customer says which business they're acting as.
if (!chosen && (result.businesses?.length ?? 0) > 1) return;
onDataLoaded(result);
}
};
// The picker collapses to a one-line summary + "Change" once a business is
// settled on; it only stays open while the choice is still outstanding.
const [pickerOpen, setPickerOpen] = useState(false);
const handleChooseBusiness = (value: string) => {
setLicence(value);
setPickerOpen(false);
handleFetch(value);
};
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
@@ -87,8 +131,12 @@ export default function ETradeInfo({
if (tin !== lastFetchedTin.current) {
// TIN moved away from whatever we last fetched — that result (verified
// data, "taken", or an error) no longer describes this TIN. Drop it so
// the UI doesn't keep showing the previous TIN's outcome.
// the UI doesn't keep showing the previous TIN's outcome. The licence pick
// belongs to the old TIN too, so it goes with it (via the ref as well, so
// the fetch below doesn't reuse it before the state lands).
requestIdRef.current++;
licenceRef.current = null;
setLicence(null);
if (mutation.data || mutation.error) {
mutation.reset();
onReset?.();
@@ -116,10 +164,21 @@ export default function ETradeInfo({
: apiError.message
: null;
const businesses = mutation.data?.businesses ?? [];
const chosenBusiness = businesses.find((b) => b.licenceNumber === licence);
// More than one licence and none of them picked: the lookup succeeded but
// this company's record is still undecided, so it must not read as verified.
// Matched against the list rather than `licence` alone — a saved licence
// eTrade no longer lists is not a choice among what it offers today.
const needsChoice = businesses.length > 1 && !chosenBusiness;
const showPicker = needsChoice || pickerOpen;
const status: ETradeStatus = isLoading
? "loading"
: tinTaken
? "taken"
: needsChoice
? "choose-business"
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken
? "verified"
: notFound
@@ -142,7 +201,11 @@ export default function ETradeInfo({
const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin;
const showLoading = isLoading || willAutoFetch;
const showRetry = isValidTin(tin) && status !== "verified" && !showLoading;
const showRetry =
isValidTin(tin) &&
status !== "verified" &&
status !== "choose-business" &&
!showLoading;
return (
<Stack gap="md">
@@ -170,7 +233,7 @@ export default function ETradeInfo({
className="max-w-none"
variant="filled"
color="edr-green"
onClick={handleFetch}
onClick={() => handleFetch()}
disabled={!isValidTin(tin)}
leftSection={<Download size={16} />}
>
@@ -179,6 +242,91 @@ export default function ETradeInfo({
)}
</div>
{businesses.length > 1 && !showPicker && chosenBusiness && (
<Card padding="sm" radius="md" withBorder>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<Building2 size={18} className="shrink-0 text-edr-muted" />
<Stack gap={2}>
<Text fw={600} size="sm">
{chosenBusiness.activity ||
chosenBusiness.tradeName ||
chosenBusiness.licenceNumber}
</Text>
<Group gap="xs">
<Text size="xs" c="edr-muted" ff="monospace">
{chosenBusiness.licenceNumber}
</Text>
{chosenBusiness.renewedTo && (
<Text size="xs" c="edr-muted">
· valid to {chosenBusiness.renewedTo}
</Text>
)}
</Group>
</Stack>
</Group>
<Button
size="xs"
variant="subtle"
onClick={() => setPickerOpen(true)}
disabled={isLoading}
>
Change
</Button>
</Group>
</Card>
)}
{businesses.length > 1 && showPicker && (
<Stack gap="sm">
<Alert
icon={<Building2 size={16} />}
color={needsChoice ? "yellow" : "blue"}
title={
needsChoice
? `This TIN holds ${businesses.length} business licences`
: "Change business"
}
>
Pick the business you're registering as — its licence and registered
address become this account's record.
</Alert>
<Radio.Group
value={licence}
onChange={handleChooseBusiness}
aria-label="Business licence"
>
<Stack gap="xs">
{businesses.map((b) => (
<Card key={b.licenceNumber} padding="sm" radius="md" withBorder>
<Radio
value={b.licenceNumber}
disabled={isLoading}
label={
<Stack gap={2}>
<Text fw={600} size="sm">
{b.activity || b.tradeName || b.licenceNumber}
</Text>
<Group gap="xs">
<Text size="xs" c="edr-muted" ff="monospace">
{b.licenceNumber}
</Text>
{b.renewedTo && (
<Text size="xs" c="edr-muted">
· valid to {b.renewedTo}
</Text>
)}
</Group>
</Stack>
}
/>
</Card>
))}
</Stack>
</Radio.Group>
</Stack>
)}
{notFound && (
<Alert
icon={<AlertCircle size={16} />}

View File

@@ -224,10 +224,10 @@ export const URL_CONSTANTS = {
},
LAST_MILE_REQUESTS: {
BY_ID: (id: string) => `/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`,
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/api/last-mile-requests/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/api/last-mile-requests/${id}/contract/sign`,
},
};

View File

@@ -3,10 +3,17 @@ import { companiesService } from "@/services/companies.service";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types";
/**
* `licenceNumber` picks which of the TIN's business licences to resolve — a TIN
* routinely holds several, and the customer says which one they operate as.
*/
export function useETradeData() {
return useMutation({
mutationFn: async (tin: string): Promise<CompanyRegistrationData> => {
return companiesService.fetchETradeInfo({ tin });
mutationFn: async (vars: {
tin: string;
licenceNumber?: string;
}): Promise<CompanyRegistrationData> => {
return companiesService.fetchETradeInfo(vars);
},
onError: (error) => {
const { message } = extractApiError(error);

View File

@@ -3,6 +3,7 @@ import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
@@ -35,8 +36,13 @@ export const BookingRow = memo(function BookingRow({
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
// against, so the row falls back to its normal action instead of an empty slot.
// The drain itself still routes here — PayNowButton renders the wait notice.
const canPay =
booking.status === payableStatus && booking.paymentStatus !== "PAID";
booking.status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed";
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);

View File

@@ -302,7 +302,7 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Selected for batch · payment due within 1 hour",
hint: "Selected for batch · payment due before the deadline",
step: "edr-accent",
badgeLabel: "Pay Now",
badgeBg: "edr-amber-soft",

View File

@@ -989,7 +989,9 @@ export default function CompanyProfileForm({
}
if (step === "company" && !tinVerified) {
setSaveError(
"We need to confirm your TIN with eTrade before continuing.",
tinStatus === "choose-business"
? "This TIN holds more than one business licence — pick the one you're registering as."
: "We need to confirm your TIN with eTrade before continuing.",
);
return;
}

View File

@@ -1,70 +1,27 @@
import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core";
import { Badge, Card, Group, SimpleGrid, Text } from "@mantine/core";
import { CheckCircle2 } from "lucide-react";
import { Controller } from "react-hook-form";
import type {
Control,
FieldErrors,
UseFormRegister,
UseFormWatch,
} from "react-hook-form";
import { ETHIOPIAN_REGIONS } from "@edr/types";
import type { UseFormWatch } from "react-hook-form";
import type { FormData } from "./schema";
import { ReadOnlyField } from "./ReadOnlyField";
/**
* One field of the verified-registration card: locked read-only once eTrade
* supplied a value, but falls back to an editable input when eTrade left it
* blank — otherwise a gap in eTrade's own data would leave the field
* permanently empty and the user stuck (zod requires all of these).
* The verified eTrade record, rendered strictly read-only.
*
* A value that fails validation unlocks the same way. eTrade (or a row saved
* before the current rules) can supply something the schema rejects, and a
* rejected value rendered read-only is a step that can never be completed and
* never says why.
* Nothing here is typeable — not even a field eTrade left blank. These values
* are the government's record of the company, so a customer-typed substitute
* would be an unverified claim wearing the badge of a verified one. A gap stays
* a visible gap ("—"), and the schema no longer requires these fields, so it
* cannot block the step either.
*/
function LockedField({
label,
name,
register,
watch,
errors,
}: {
label: string;
name: keyof FormData;
register: UseFormRegister<FormData>;
watch: UseFormWatch<FormData>;
errors: FieldErrors<FormData>;
}) {
const value = watch(name) as string | undefined;
if (value && value.trim() && !errors[name]) {
return <ReadOnlyField label={label} value={value} />;
}
return (
<TextInput
label={label}
description="eTrade didn't provide this — please confirm"
error={errors[name]?.message as string | undefined}
{...register(name)}
/>
);
}
export default function ETradeCompanyCard({
tin,
register,
watch,
errors,
control,
}: {
tin: string;
register: UseFormRegister<FormData>;
watch: UseFormWatch<FormData>;
errors: FieldErrors<FormData>;
control: Control<FormData>;
}) {
const companyName = watch("companyName");
const region = watch("region");
return (
<Card padding="md" radius="md" withBorder>
@@ -88,73 +45,18 @@ export default function ETradeCompanyCard({
</Group>
<SimpleGrid cols={2} spacing="sm">
<LockedField
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="Company Name" value={watch("companyName")} />
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
{/* Membership of the catalog, not mere presence: eTrade's normalizer
returns null for a region it doesn't recognise, and older rows can
hold a spelling that isn't in the list. Showing such a value
read-only left the customer with a required field they could not
correct. */}
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
<ReadOnlyField label="Region" value={region} />
) : (
<Controller
name="region"
control={control}
render={({ field }) => (
<Select
label="Region"
description="eTrade didn't provide this — please confirm"
placeholder="Select region"
searchable
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
error={errors.region?.message}
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
)}
/>
)}
<LockedField
label="Zone"
name="zone"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Woreda"
name="woreda"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Kebele"
name="kebele"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="House No"
name="houseNo"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="Region" value={watch("region")} />
<ReadOnlyField label="Zone" value={watch("zone")} />
<ReadOnlyField label="Woreda" value={watch("woreda")} />
<ReadOnlyField label="Kebele" value={watch("kebele")} />
<ReadOnlyField label="House No" value={watch("houseNo")} />
</SimpleGrid>
</Card>
);

View File

@@ -91,7 +91,7 @@ export function buildPayload(
_user: AuthUser,
): CreateCompanyPayload {
return {
companyName: data.companyName,
companyName: data.companyName ?? "",
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
@@ -128,7 +128,10 @@ export function stepPayload(
case "company": {
const etrade: Partial<UpdateProfilePayload> = {};
for (const key of ETRADE_BUNDLE_FIELDS) {
if (dirty[key]) (etrade as Record<string, unknown>)[key] = d[key];
// Empty is never sent: a field eTrade left blank has no input to fill
// it, and "" fails the API's own validation (region's @IsIn, say),
// which would 400 a save over data the customer cannot supply.
if (dirty[key] && d[key]) (etrade as Record<string, unknown>)[key] = d[key];
}
if (dirty.tinNumber) etrade.tin = d.tinNumber;
return {

View File

@@ -59,10 +59,22 @@ describe("VAT number", () => {
).toBeUndefined();
});
it("accepts eleven digits", () => {
expect(
errorFor(values({ vatNumber: "00123456789" }), "vatNumber"),
).toBeUndefined();
});
it("rejects twelve digits", () => {
expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe(
"VAT number must be 10 or 11 digits",
);
});
// `.length(10)` used to pass this, so a ten-letter string reached the API.
it("rejects ten non-digits", () => {
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe(
"VAT number must be exactly 10 digits",
"VAT number must be 10 or 11 digits",
);
});
@@ -73,11 +85,18 @@ describe("VAT number", () => {
});
});
describe("region", () => {
it("rejects a spelling outside the catalog", () => {
expect(errorFor(values({ region: "Addis Abeba City" }), "region")).toBe(
"Region is required",
);
describe("eTrade-sourced fields", () => {
// They are rendered read-only — there is no input to correct one in — so the
// schema must never reject what eTrade supplied (or failed to supply).
it.each([
"companyName",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
] as const)("accepts a blank %s", (field) => {
expect(errorFor(values({ [field]: "" }), field)).toBeUndefined();
});
});
@@ -88,12 +107,18 @@ describe("stepFields", () => {
it("never gates the company step on a derived or read-only field", () => {
const unreachable = [
"etradePhone",
"companyName",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
];
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
[],

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ETHIOPIAN_REGIONS } from "@edr/types";
import { isValidPhone } from "@/components/PhoneField";
@@ -12,7 +12,8 @@ export type CompanyStep =
| "additional";
export const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
// eTrade-sourced, and never typeable — see the address block below.
companyName: z.string().optional(),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
@@ -25,7 +26,7 @@ export const onboardingSchema = z.object({
vatNumber: z
.string()
.min(1, "VAT number is required")
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"),
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
// The owner's passport number — the foreign-company identity credential
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
// enforced in buildOnboardingSchema since that depends on `nationality`.
@@ -36,24 +37,15 @@ export const onboardingSchema = z.object({
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: z.string().optional(),
// Address fields are user-entered and required (the registration/license
// fields above are read-only confirmations pulled from eTrade).
// Region is a closed set; zone/woreda/kebele stay free text until the platform
// has an authoritative dataset of Ethiopian zones/woredas/kebeles.
//
// Deliberately `.refine` over `z.enum`: the form needs "" as its unselected
// sentinel (default value, and legacy rows whose region isn't in the list). A
// literal union makes "" untypable, which also splits the resolver's
// input/output types and breaks useForm's inference for every other field.
region: z
.string()
.refine((v) => (ETHIOPIAN_REGIONS as readonly string[]).includes(v), {
message: "Region is required",
}),
zone: z.string().min(1, "Zone is required"),
woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number is required"),
// The registered address comes from eTrade and nowhere else — the form
// renders these read-only, so requiring them would be a Continue button that
// fails on a field with no input to fix it. A gap in eTrade's own data stays
// a gap rather than becoming a customer-typed claim wearing eTrade's badge.
region: z.string().optional(),
zone: z.string().optional(),
woreda: z.string().optional(),
kebele: z.string().optional(),
houseNo: z.string().optional(),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPosition: z.string().optional(),
contactPersonEmail: z
@@ -192,17 +184,9 @@ export const ETRADE_BUNDLE_FIELDS = [
* (`REQUIRED_COMPANY_INFO`), and reports it with a message.
*/
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [
"companyName",
"tinNumber",
"vatNumber",
"ownerPassportNumber",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
],
// Only the three fields this step actually renders an input for. The company
// name and the registered address are eTrade's, shown read-only.
company: ["tinNumber", "vatNumber", "ownerPassportNumber"],
personnel: [
"generalManagerName",
"generalManagerEmail",

View File

@@ -40,7 +40,6 @@ export default function CompanyInfoStep({
}: CompanyInfoStepProps) {
const {
register,
control,
watch,
formState: { errors },
} = form;
@@ -51,7 +50,7 @@ export default function CompanyInfoStep({
index={1}
title="VAT number"
status={
watch("vatNumber")?.length === 10 && !errors.vatNumber
(watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber
? "done"
: "todo"
}
@@ -59,7 +58,7 @@ export default function CompanyInfoStep({
<TextInput
aria-label="VAT Number"
placeholder="0012345678"
maxLength={10}
maxLength={11}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
@@ -123,15 +122,10 @@ export default function CompanyInfoStep({
onStatusChange={onETradeStatusChange}
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/>
{tinVerified && (
<ETradeCompanyCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
</Stack>

View File

@@ -18,6 +18,8 @@ import { invoicesService, type PortalInvoice } from "@/services/invoices.service
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice";
import { saveBlob } from "@/utils/download";
import {
@@ -216,6 +218,11 @@ export function BookingPaymentPanel({
(booking.status === "SELECTED_FOR_BATCH" ||
Boolean(booking.paymentDeadline));
// Pay deadline passed but the settlement drain tail hasn't: in-flight payments
// are still landing, so the pay action gives way to a processing countdown.
const payWindow = payWindowState(booking);
const draining = payWindow.phase === "draining" && Boolean(payWindow.drainEndsAt);
const { data: invoices = [] } = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
@@ -266,6 +273,8 @@ export function BookingPaymentPanel({
{paid ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
{paid
? "Paid"
: draining
? "Payment processing"
: showCountdown
? "Pay window open"
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
@@ -279,7 +288,11 @@ export function BookingPaymentPanel({
{/* USD: no online payment — bank transfer + slip to Finance, who confirm
the payment (backoffice flow lands in a later phase). Shown for any
unpaid USD booking, with or without an open pay window. */}
{!paid && offlineUsd && (
{!paid && draining && payWindow.drainEndsAt && (
<PaymentProcessingNotice drainEndsAt={payWindow.drainEndsAt} />
)}
{!paid && !draining && offlineUsd && (
<Box
mt={14}
p={14}
@@ -301,7 +314,7 @@ export function BookingPaymentPanel({
</Box>
)}
{showCountdown && booking.paymentDeadline && (
{showCountdown && !draining && booking.paymentDeadline && (
<Box mt={16}>
<Countdown
deadline={booking.paymentDeadline}

View File

@@ -35,6 +35,7 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { payWindowState } from "./payments/payment-drain";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
@@ -211,7 +212,14 @@ function PrimaryAction({
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
if (status === payableStatus && booking.paymentStatus !== "PAID") {
// A fully-closed pay window (deadline + drain both elapsed) falls through to
// the default action. The drain itself still routes here — PayNowButton
// renders the "payment processing" wait notice instead of a pay action.
if (
status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed"
) {
return <PayNowButton booking={booking} />;
}
// Contract ready for the customer's signature → full-page contract viewer.

View File

@@ -7,6 +7,8 @@ import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { isUsdOfflineBooking } from "./offline-payment";
import { payWindowState } from "./payment-drain";
import { PaymentProcessingNotice } from "./PaymentProcessingNotice";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
@@ -29,6 +31,24 @@ export function PayNowButton({
}: PayNowButtonProps) {
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
const payWindow = payWindowState(booking);
// Pay deadline passed but in-flight payments are still settling: show the
// drain countdown instead of any pay action, so nobody pays a second time.
// Checked before the USD branch — a bank transfer is just as double-payable.
if (payWindow.phase === "draining" && payWindow.drainEndsAt) {
return (
<PaymentProcessingNotice
drainEndsAt={payWindow.drainEndsAt}
variant="inline"
/>
);
}
// Window fully over (drain included) — nothing to pay against anymore.
if (payWindow.phase === "closed") {
return null;
}
// USD is paid by bank transfer and confirmed by Finance — no online payment.
if (isUsdOfflineBooking(booking)) {

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { Box, Group, Text } from "@mantine/core";
import { Loader2 } from "lucide-react";
/** mm:ss left until `target`; clamped at zero so it never shows a negative. */
function secondsLeft(target: number, now: number): string {
const total = Math.max(0, Math.ceil((target - now) / 1000));
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export interface PaymentProcessingNoticeProps {
/** ISO end of the drain tail — the countdown target. */
drainEndsAt: string;
/** Compact single-line form for list rows; full block for the detail page. */
variant?: "inline" | "block";
/** Called once the drain elapses, so the parent can refetch the new state. */
onElapsed?: () => void;
}
/**
* Shown in place of "Pay now" during the settlement drain tail: the pay deadline
* has passed but in-flight payments are still landing, so the customer waits
* rather than paying again.
*/
export function PaymentProcessingNotice({
drainEndsAt,
variant = "block",
onElapsed,
}: PaymentProcessingNoticeProps) {
const targetMs = new Date(drainEndsAt).getTime();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setNow(Date.now());
const interval = setInterval(() => {
const next = Date.now();
setNow(next);
if (next >= targetMs) {
clearInterval(interval);
onElapsed?.();
}
}, 1000);
return () => clearInterval(interval);
}, [targetMs, onElapsed]);
const remaining = secondsLeft(targetMs, now);
if (variant === "inline") {
return (
<Group gap={6} align="center" wrap="nowrap">
<Loader2 size={13} color="#B07D14" className="animate-spin" />
<Text fz={12} fw={700} c="#B07D14" style={{ whiteSpace: "nowrap" }}>
Processing · {remaining}
</Text>
</Group>
);
}
return (
<Box
mt={14}
p={14}
style={{
borderRadius: 10,
backgroundColor: "#FEF6E6",
border: "1px solid #F3E2B8",
}}
>
<Group gap={8} align="center" wrap="nowrap">
<Loader2 size={15} color="#9A5B00" className="animate-spin" />
<Text fz="13px" fw={800} c="#9A5B00">
Payment processing {remaining} left
</Text>
</Group>
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
The payment window has closed and we&apos;re confirming the payments that
came in. If you already paid, it can take a few minutes to appear
please don&apos;t pay again. This page updates on its own.
</Text>
</Box>
);
}

View File

@@ -0,0 +1,60 @@
import { Freight } from "@edr/types";
/**
* Where a booking sits relative to its pay window.
*
* - `open` — the window is live; the customer can pay.
* - `draining` — the deadline passed but the settlement drain tail has not. A
* payment started just before the buzzer may still be settling,
* so we show "processing" and hide every pay action rather than
* invite a second payment for the same booking.
* - `closed` — the drain tail elapsed too; the window is over.
* - `none` — no deadline on the booking (nothing to gate).
*/
export type PayWindowPhase = "open" | "draining" | "closed" | "none";
export interface PayWindowState {
phase: PayWindowPhase;
/** True only while the customer may actually start a payment. */
canPay: boolean;
/** End of the drain tail — the countdown target while `draining`. */
drainEndsAt: string | null;
}
type PayableBooking = Pick<
Freight.IBooking,
"paymentDeadline" | "paymentDrainEndsAt"
>;
/**
* Classify a booking's pay window against `now`.
*
* Falls back to the raw deadline when the server sent no `paymentDrainEndsAt`
* (older payload): with no known tail there is no drain to wait out, so the
* window goes straight from open to closed.
*/
export function payWindowState(
booking: PayableBooking | null | undefined,
now: number = Date.now(),
): PayWindowState {
const deadline = booking?.paymentDeadline ?? null;
if (!deadline) {
return { phase: "none", canPay: true, drainEndsAt: null };
}
const deadlineMs = new Date(deadline).getTime();
if (!Number.isFinite(deadlineMs)) {
return { phase: "none", canPay: true, drainEndsAt: null };
}
if (now < deadlineMs) {
return { phase: "open", canPay: true, drainEndsAt: null };
}
const drainRaw = booking?.paymentDrainEndsAt ?? null;
const drainMs = drainRaw ? new Date(drainRaw).getTime() : NaN;
if (Number.isFinite(drainMs) && now < drainMs) {
return { phase: "draining", canPay: false, drainEndsAt: drainRaw };
}
return { phase: "closed", canPay: false, drainEndsAt: null };
}

View File

@@ -11,7 +11,6 @@ import {
Button,
Card,
Group,
Select,
SimpleGrid,
Stack,
Text,
@@ -21,9 +20,9 @@ import {
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import type { CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, {
@@ -35,7 +34,8 @@ import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/c
import { normalizeIdentityPhones } from "@/pages/accounts/companyProfileForm/helpers";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
// eTrade-sourced and read-only, like the registration block below.
companyName: z.string().optional(),
companyLocation: z.string().min(1, "Location is required"),
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
// no standalone input.
@@ -46,26 +46,22 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
vatNumber: z
.string()
.min(1, "VAT number is required")
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"),
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
ownerPassportNumber: z.string().optional(),
// Registration/address fields are eTrade-sourced — locked once eTrade
// supplies a value, editable only as an escape hatch when it doesn't
// (see LockedField below). Not typed by hand in the normal case.
// Registration/address fields are eTrade-sourced and never typed by hand —
// not even when eTrade leaves one blank, so none of them may be required
// here (there is no input on screen to fix one with).
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: z.string().optional(),
region: z
.string()
.refine((v) => (ETHIOPIAN_REGIONS as readonly string[]).includes(v), {
message: "Region is required",
}),
zone: z.string().min(1, "Zone is required"),
woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number is required"),
region: z.string().optional(),
zone: z.string().optional(),
woreda: z.string().optional(),
kebele: z.string().optional(),
houseNo: z.string().optional(),
});
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
@@ -140,7 +136,6 @@ export default function TabCompanyProfile({
const {
register,
control,
handleSubmit,
reset,
watch,
@@ -219,7 +214,9 @@ export default function TabCompanyProfile({
// authenticity re-check for no reason.
const etradeBundle: Record<string, string | undefined> = {};
for (const key of ETRADE_FIELDS) {
if (dirtyFields[key]) etradeBundle[key] = data[key];
// Never "": eTrade left it blank, no input exists to fill it, and the
// API rejects the empty value (region's @IsIn) — see stepPayload.
if (dirtyFields[key] && data[key]) etradeBundle[key] = data[key];
}
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
@@ -241,7 +238,7 @@ export default function TabCompanyProfile({
: "customer";
const payload: CreateCompanyPayload = {
...base,
companyName: data.companyName,
companyName: data.companyName ?? "",
tin: data.tinNumber,
companyType,
companyProfiles: selectedRoles.map((type) => ({
@@ -332,7 +329,7 @@ export default function TabCompanyProfile({
<TextInput
label="VAT Number"
placeholder="e.g. 0012345678"
maxLength={10}
maxLength={11}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
@@ -400,15 +397,10 @@ export default function TabCompanyProfile({
onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/>
{tinVerified && (
<EtradeLockedCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
<EtradeLockedCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
@@ -474,27 +466,20 @@ export default function TabCompanyProfile({
}
/**
* The verified eTrade record, locked read-only — same escape hatch as
* onboarding's ETradeCompanyCard: a field eTrade left blank falls back to an
* editable input rather than trapping the customer.
* The verified eTrade record, rendered strictly read-only — same rule as
* onboarding's ETradeCompanyCard: nothing here is typeable, not even a field
* eTrade left blank. These are the government's record of the company, so a
* customer-typed substitute would be an unverified claim wearing a verified
* badge. A gap stays a visible gap ("—").
*/
function EtradeLockedCard({
tin,
register,
watch,
errors,
control,
}: {
tin: string;
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
errors: ReturnType<
typeof useForm<CompanyProfileFormData>
>["formState"]["errors"];
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
}) {
const companyName = watch("companyName");
const region = watch("region");
return (
<Card padding="md" radius="md" withBorder>
@@ -507,13 +492,7 @@ function EtradeLockedCard({
</Text>
</Group>
<SimpleGrid cols={2} spacing="sm">
<LockedField
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="Company Name" value={watch("companyName")} />
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField
@@ -523,101 +502,12 @@ function EtradeLockedCard({
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
{/* Membership of the catalog, not mere presence — a stored spelling
outside the list is otherwise uncorrectable. */}
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
<ReadOnlyField label="Region" value={region} />
) : (
<RegionSelect control={control} error={errors.region?.message} />
)}
<LockedField
label="Zone"
name="zone"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Woreda"
name="woreda"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Kebele"
name="kebele"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="House No"
name="houseNo"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="Region" value={watch("region")} />
<ReadOnlyField label="Zone" value={watch("zone")} />
<ReadOnlyField label="Woreda" value={watch("woreda")} />
<ReadOnlyField label="Kebele" value={watch("kebele")} />
<ReadOnlyField label="House No" value={watch("houseNo")} />
</SimpleGrid>
</Card>
);
}
function LockedField({
label,
name,
register,
watch,
errors,
}: {
label: string;
name: keyof CompanyProfileFormData;
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
errors: ReturnType<
typeof useForm<CompanyProfileFormData>
>["formState"]["errors"];
}) {
const value = watch(name) as string | undefined;
// A value that fails validation unlocks too — rendering a rejected value
// read-only is a save that can never succeed and never says why.
if (value?.trim() && !errors[name]) {
return <ReadOnlyField label={label} value={value} />;
}
return (
<TextInput
label={label}
description="eTrade didn't provide this — please confirm"
error={errors[name]?.message as string | undefined}
{...register(name)}
/>
);
}
function RegionSelect({
control,
error,
}: {
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
error?: string;
}) {
return (
<Controller
name="region"
control={control}
render={({ field }) => (
<Select
label="Region"
description="eTrade didn't provide this — please confirm"
placeholder="Select region"
searchable
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
error={error}
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
)}
/>
);
}

View File

@@ -480,7 +480,10 @@ export const companiesService = {
},
/** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => {
fetchETradeInfo: async (payload: {
tin: string;
licenceNumber?: string;
}): Promise<any> => {
const response = await client.post<ApiResponse<any>>(
URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO,
payload,

View File

@@ -87,12 +87,8 @@ CBE_SECRET_KEY=
CBE_NOTIFY_URL=
CBE_RETURN_URL=
# eBirr
EBIRR_BASE_URL=
EBIRR_MERCHANT_CODE=
EBIRR_SECRET_KEY=
EBIRR_NOTIFY_URL=
EBIRR_RETURN_URL=
# eBirr — credentials live in edr-payment-api only; the passenger API never calls providers
# directly. eBirr has no redirect, so there is no EBIRR_RETURN_URL. See docs/ebirr/INTEGRATION.md.
# Card Gateway (Stripe-like)
CARD_BASE_URL=
@@ -140,7 +136,6 @@ WAAFI_SUCCESS_REDIRECT=
WAAFI_FAIL_REDIRECT=
DMONEY_RETURN_URL=
CBE_RETURN_URL=
EBIRR_RETURN_URL=
CARD_RETURN_URL=
# Session Configuration

View File

@@ -23,7 +23,6 @@ import dbConfig from "./config/database.config";
import iamDatabaseConfig from "./config/iam-database.config";
import telebirrConfig from "./config/telebirr.config";
import cbeConfig from "./config/cbe.config";
import ebirrConfig from "./config/ebirr.config";
import cardConfig from "./config/card.config";
import waafiConfig from "./config/waafi.config";
import faydaConfig from "./config/fayda.config";
@@ -75,7 +74,6 @@ import { EOtpType } from "@tria-plc/iamapi-common";
iamDatabaseConfig,
telebirrConfig,
cbeConfig,
ebirrConfig,
cardConfig,
waafiConfig,
faydaConfig,

View File

@@ -1,9 +0,0 @@
import { registerAs } from '@nestjs/config';
export default registerAs('ebirr', () => ({
baseUrl: process.env.EBIRR_BASE_URL || '',
merchantCode: process.env.EBIRR_MERCHANT_CODE || '',
secretKey: process.env.EBIRR_SECRET_KEY || '',
notifyUrl: process.env.EBIRR_NOTIFY_URL || '',
returnUrl: process.env.EBIRR_RETURN_URL || '',
}));

View File

@@ -21,7 +21,7 @@ export enum PaymentMethodTypeEnum {
CBE_BIRR = "CBE_BIRR", // Ethiopia
EBIRR = "EBIRR", // Ethiopia
WAAFI = "WAAFI",
DMONEY= "DMONEY",// Djibouti
DMONEY = "DMONEY", // Djibouti
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
CARD = "CARD", // International
WALLET = "WALLET", // Internal
@@ -57,9 +57,10 @@ export class InitiatePaymentDto {
platform?: PaymentPlatformDto;
@ApiPropertyOptional({
description:
"Payer account / mobile number. Required for OTP-debit methods (CAC_BANK) — " +
"the bank sends the OTP to this number.",
example: "77112233",
"Payer account / mobile number. Required for push-debit methods: CAC_BANK (the bank " +
"sends an OTP to this number) and EBIRR (the wallet pushes a USSD PIN prompt to it). " +
"Ethiopian numbers are accepted as +251…, 251…, 09… or 9… and normalised server-side.",
example: "+251923582676",
})
@IsOptional()
@IsString()
@@ -125,6 +126,7 @@ export class ClientActionDto {
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"AWAIT_PUSH",
"SHOW_BILL_REFERENCE",
],
})
@@ -133,6 +135,7 @@ export class ClientActionDto {
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "AWAIT_PUSH"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@@ -160,10 +163,22 @@ export class ClientActionDto {
"to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.",
})
rawRequest?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
@ApiPropertyOptional({
description: "Set when type=COLLECT_OTP (e.g. CAC Bank)",
})
providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
@ApiPropertyOptional({
description:
"Set when type=COLLECT_OTP or type=AWAIT_PUSH — text to show the payer",
})
message?: string;
@ApiPropertyOptional({
description:
"Set when type=AWAIT_PUSH (eBirr). Masked wallet number the PIN prompt was pushed to, " +
"so the payer can confirm it is their handset. Nothing to navigate to — poll the intent.",
example: "2519****2676",
})
payerAccountMasked?: string;
@ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
})
@@ -180,6 +195,9 @@ export class InitiateResponseDto {
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional() merchantOrderId?: string;
/** Set when initiate already settled terminally (eBirr debits synchronously — no webhook). */
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
/** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */
@ApiPropertyOptional() sessionExpiresAt?: string;
/** The booking's payment deadline: after it, the booking is auto-cancelled. */
@@ -205,18 +223,43 @@ export class IntentStatusDto {
}
export class BookingAmountResponseDto {
@ApiProperty({ example: 'booking-uuid' }) booking_id: string;
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
@ApiProperty({ example: "booking-uuid" }) booking_id: string;
@ApiProperty({
example: "DJF",
description: "Currency of the returned amount",
})
currency: string;
@ApiProperty({
example: 162.5,
description:
"Booking total converted to the requested currency (major units)",
})
amount: number;
}
export class ForceConfirmDto {
@ApiPropertyOptional({ description: 'External payment reference / transaction ID from the vendor', example: 'TXN-123456' })
@IsOptional() @IsString() paymentReference?: string;
@ApiPropertyOptional({
description: "External payment reference / transaction ID from the vendor",
example: "TXN-123456",
})
@IsOptional()
@IsString()
paymentReference?: string;
@ApiPropertyOptional({ enum: PaymentMethodTypeEnum, description: 'Payment method used externally', example: 'TELEBIRR' })
@IsOptional() @IsEnum(PaymentMethodTypeEnum) paymentMethod?: PaymentMethodTypeEnum;
@ApiPropertyOptional({
enum: PaymentMethodTypeEnum,
description: "Payment method used externally",
example: "TELEBIRR",
})
@IsOptional()
@IsEnum(PaymentMethodTypeEnum)
paymentMethod?: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: 'Internal notes about why this was force-confirmed', example: 'Vendor confirmed via phone' })
@IsOptional() @IsString() notes?: string;
@ApiPropertyOptional({
description: "Internal notes about why this was force-confirmed",
example: "Vendor confirmed via phone",
})
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -86,8 +86,10 @@ export class PaymentsService {
) {}
async deletePayment(id: string) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
if (!intent) throw new NotFoundException('Payment intent not found');
const intent = await this.prisma.paymentIntent.findUnique({
where: { id },
});
if (!intent) throw new NotFoundException("Payment intent not found");
await this.prisma.paymentIntent.delete({ where: { id } });
return { deleted: true, id };
}
@@ -147,20 +149,29 @@ export class PaymentsService {
// For package round-trip bookings the stored amountMinor may be the single-leg
// amount. Recompute from the tier price when applicable.
let amountMinor = item.amountMinor;
if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
if (
b?.packageId &&
b?.bookingType === "ROUND_TRIP" &&
b?.priceTier?.priceMinor
) {
const adultFare = b.priceTier.priceMinor * 2;
const childFare = Math.round(adultFare * 0.1);
const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
const correctMinor =
(b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
// Convert to the charge currency ratio: stored amountMinor is in charge currency
// (may be DJF/USD), but correctMinor is in ETB minor. Only override when the
// currency is ETB (most common case); for foreign currencies keep stored value.
if (item.currency === 'ETB') amountMinor = correctMinor;
if (item.currency === "ETB") amountMinor = correctMinor;
}
return {
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
booking: {
bookingRef: b?.bookingRef,
totalMinor: b?.totalMinor,
currency: b?.currency,
},
amountMinor,
currency: item.currency,
method: item.method,
@@ -187,7 +198,11 @@ export class PaymentsService {
priceTierId?: string | null;
displayTotalMinor?: number | null;
}): Promise<number> {
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
if (
!booking.packageId ||
!booking.priceTierId ||
booking.bookingType !== "ROUND_TRIP"
) {
return booking.totalMinor;
}
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
@@ -196,16 +211,29 @@ export class PaymentsService {
return booking.totalMinor;
}
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
const tier = await this.prisma.packagePriceTier.findUnique({
where: { id: booking.priceTierId },
});
if (!tier) return booking.totalMinor;
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
const seats = await this.prisma.bookingSeat.findMany({
where: { bookingId: booking.id, leg: 1 },
select: { passengerCategory: true },
});
const adultCount =
seats.filter((s) => s.passengerCategory === "ADULT").length || 1;
const childCount = seats.filter(
(s) => s.passengerCategory === "CHILD",
).length;
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
// always in the same units as totalMinor (which is always the ETB canonical).
const rawFare = tier.priceMinor * 2;
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
const adultFareMinor =
tier.currency && (tier.currency as string) !== "ETB"
? await this.currencyService.convertAmount(
rawFare,
tier.currency as any,
"ETB" as any,
)
: rawFare;
const childFareMinor = Math.round(adultFareMinor * 0.1);
return adultCount * adultFareMinor + childCount * childFareMinor;
@@ -233,6 +261,14 @@ export class PaymentsService {
);
}
// eBirr is a direct wallet debit — the PIN prompt is pushed to this number over USSD. There
// is no hosted page that could collect it later, so it must be supplied up front.
if (method === PaymentMethodType.EBIRR && !dto.payerAccount?.trim()) {
throw new BadRequestException(
"payerAccount (mobile wallet number) is required for eBirr",
);
}
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
// required — CBE identifies the payer at its own channel.
if (
@@ -272,7 +308,9 @@ export class PaymentsService {
// Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible
// outcome — the provider captures the money and the booking is already CANCELLED when the
// capture lands. WALLET is exempt (returned above): it is an instant internal balance debit.
const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id);
const paymentDeadline = await this.computeBookingPaymentDeadline(
booking.id,
);
const sessionExpiresAt = paymentDeadline
? computePaymentSessionExpiry(paymentDeadline)
: undefined;
@@ -307,8 +345,12 @@ export class PaymentsService {
? "ETB"
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
const bookingDisplayCurrency = (
(booking as any).displayCurrency ?? "ETB"
).toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as
| number
| null;
let chargeAmount: number;
if (method === PaymentMethodType.CBE_BILL) {
@@ -319,13 +361,19 @@ export class PaymentsService {
);
} else if (
chargeCurrency === bookingDisplayCurrency &&
chargeCurrency !== 'ETB' &&
chargeCurrency !== "ETB" &&
bookingDisplayTotalMinor != null
) {
// Display currency matches charge currency — use the pre-converted amount directly.
chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
} else if (chargeCurrency === 'ETB') {
chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
chargeAmount = this.currencyService.displayMinorToChargeMajor(
bookingDisplayTotalMinor,
chargeCurrency,
);
} else if (chargeCurrency === "ETB") {
chargeAmount = this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
);
} else {
// Booking is in ETB — convert to the provider's settlement currency.
chargeAmount = await this.currencyService.convertMinorToChargeMajor(
@@ -398,10 +446,15 @@ export class PaymentsService {
bookingId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm for this booking");
throw new NotFoundException(
"No active payment to confirm for this booking",
);
}
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
let intent = await this.syncIntentProjection(bookingId, confirmed);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
@@ -553,9 +606,8 @@ export class PaymentsService {
[PaymentMethodType.CBE_BIRR]: {
returnUrl: process.env.CBE_RETURN_URL,
},
[PaymentMethodType.EBIRR]: {
returnUrl: process.env.EBIRR_RETURN_URL,
},
// No EBIRR entry: the payer never leaves the page — eBirr pushes a PIN prompt to their
// handset — so there is no browser bounce-back to configure.
[PaymentMethodType.CARD]: {
returnUrl: process.env.CARD_RETURN_URL,
},
@@ -633,7 +685,8 @@ export class PaymentsService {
failureCode: snapshot.failureCode ?? null,
failureMessage: snapshot.failureMessage ?? null,
rawInitiation: (snapshot as any).providerResponse
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
? ((snapshot as any)
.providerResponse as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
};
await this.prisma.paymentIntent.upsert({
@@ -739,6 +792,11 @@ export class PaymentsService {
status: intent.status,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
// eBirr settles inside initiate (its purchase response is the settlement), so a FAILED
// verdict arrives here rather than through a later status poll. Without these the portal
// can only show a generic "please try again" instead of the actual cause.
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
};
}
@@ -792,7 +850,6 @@ export class PaymentsService {
where: { bookingId },
});
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
@@ -884,7 +941,12 @@ export class PaymentsService {
data: { status: "CANCELLED" },
});
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } });
await this.auditService.log({
action: "UPDATE",
entityType: "Payment",
entityId: intent.id,
newData: { status: "REFUNDED", bookingId: dto.bookingId },
});
return { refunded: true, bookingRef: booking?.bookingRef };
}
@@ -906,12 +968,15 @@ export class PaymentsService {
}
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Payment method not found');
const existing = await this.prisma.paymentMethod.findUnique({
where: { id },
});
if (!existing) throw new NotFoundException("Payment method not found");
const updateData: any = {};
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
if (dto.region !== undefined)
updateData.region = dto.region as unknown as PaymentRegion;
if (dto.currency !== undefined) updateData.currency = dto.currency;
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
@@ -958,24 +1023,31 @@ export class PaymentsService {
displayTotalMinor: true,
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) throw new NotFoundException("Booking not found");
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
const requestedCurrency = currency.toUpperCase();
// Source of truth: displayTotalMinor in displayCurrency when available,
// otherwise totalMinor in ETB (bookings with no display currency override).
const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
const sourceCurrency = (booking.displayCurrency ?? "ETB").toUpperCase();
const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;
// Same currency — return directly, no conversion needed.
if (requestedCurrency === sourceCurrency) {
return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
return {
booking_id: bookingId,
currency: requestedCurrency,
amount: sourceMinor / 100,
};
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
where: {
fromCurrency: sourceCurrency as any,
toCurrency: requestedCurrency as any,
},
orderBy: { effectiveDate: "desc" },
});
let rate: number;
@@ -984,18 +1056,28 @@ export class PaymentsService {
} else {
// Try inverse rate
const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
orderBy: { effectiveDate: 'desc' },
where: {
fromCurrency: requestedCurrency as any,
toCurrency: sourceCurrency as any,
},
orderBy: { effectiveDate: "desc" },
});
if (inverseRate) {
rate = 1 / Number(inverseRate.rate);
} else {
// Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
rate = await this.currencyService.getRateOrThrow(
sourceCurrency as any,
requestedCurrency as any,
);
}
}
const converted = (sourceMinor / 100) * rate;
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
return {
booking_id: bookingId,
currency: requestedCurrency,
amount: converted,
};
}
/**
@@ -1117,7 +1199,9 @@ export class PaymentsService {
select: { status: true },
});
if (idempotencyBooking?.status === "CONFIRMED") {
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
const ticketCount = await this.prisma.ticket.count({
where: { bookingId: intent.bookingId },
});
if (ticketCount === 0) {
try {
await this.ticketsService.generate(intent.bookingId);
@@ -1127,7 +1211,9 @@ export class PaymentsService {
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
);
try {
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
await this.ticketsService.smartAssignAndGenerate(
intent.bookingId,
);
} catch (retryErr) {
this.logger.error(
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
@@ -1242,7 +1328,9 @@ export class PaymentsService {
);
}
} else {
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
this.logger.error(
`Error generating ticket for booking ${booking.id}: ${msg}`,
);
}
}
@@ -1262,22 +1350,40 @@ export class PaymentsService {
return { alreadyFinalized: false };
}
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
if (event.eventType === 'payment.failed') {
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
private async handleSupplementaryChargeEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (event.eventType === "payment.failed") {
this.logger.warn(
`supplementary charge ${event.referenceId} payment failed`,
);
return { processed: true };
}
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { id: event.referenceId },
});
if (!charge) {
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
return { processed: false, reason: 'charge-not-found' };
this.logger.error(
`mark-paid: no supplementary charge for reference ${event.referenceId}`,
);
return { processed: false, reason: "charge-not-found" };
}
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
if (charge.status === "PAID")
return { processed: true, alreadyFinalized: true };
await this.prisma.supplementaryCharge.update({
where: { id: charge.id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
data: {
status: "PAID",
paidAt: new Date(),
providerTxnId: event.providerTxnId ?? null,
},
});
await this.auditService.log({
action: "UPDATE",
entityType: "SupplementaryCharge",
entityId: charge.id,
newData: { status: "PAID", providerTxnId: event.providerTxnId },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
return { processed: true };
}
@@ -1339,7 +1445,8 @@ export class PaymentsService {
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
// the display↔charge currency divergence is tracked separately under the USD/DJF
// findings. The 1% tolerance absorbs rounding.
const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
const expectedMajor =
(booking.displayTotalMinor ?? booking.totalMinor) / 100;
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
if (event.amountMinor < expectedMajor - shortPayTolerance) {
this.logger.error(
@@ -1433,15 +1540,22 @@ export class PaymentsService {
return { processed: true, alreadyFinalized };
}
async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new NotFoundException('Booking not found');
async forceConfirmPayment(
bookingId: string,
dto: ForceConfirmDto = {},
): Promise<{ alreadyFinalized: boolean }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
});
if (!booking) throw new NotFoundException("Booking not found");
const resolvedMethod = dto.paymentMethod
? (dto.paymentMethod as unknown as PaymentMethodType)
: PaymentMethodType.TELEBIRR;
let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
let intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId },
});
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
@@ -1461,7 +1575,10 @@ export class PaymentsService {
if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
if (dto.paymentMethod) updateData.method = resolvedMethod;
if (dto.notes) updateData.failureMessage = dto.notes;
if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
if (
intent.status === PaymentIntentStatus.CANCELLED ||
intent.status === PaymentIntentStatus.FAILED
) {
updateData.status = PaymentIntentStatus.PROCESSING;
}
if (Object.keys(updateData).length) {
@@ -1477,7 +1594,17 @@ export class PaymentsService {
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
force: true,
}).then(async (result) => {
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
await this.auditService.log({
action: "UPDATE",
entityType: "Payment",
entityId: intent.id,
newData: {
status: "FORCE_CONFIRMED",
bookingId,
paymentMethod: dto.paymentMethod,
paymentReference: dto.paymentReference,
},
});
return result;
});
}
@@ -1548,27 +1675,38 @@ export class PaymentsService {
// Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
// BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
type LegDef = {
scheduleId: string;
originStationId: string;
destinationStationId: string;
seatIds: string[];
};
const legDefs: LegDef[] = [];
const seatsForLeg = (legNum: number) =>
booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
booking.seats
.filter((s: any) => s.leg === legNum)
.map((s: any) => s.seatId);
if (booking.bookingType === 'ONE_WAY') {
if (booking.bookingType === "ONE_WAY") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: booking.seats.map((s: any) => s.seatId),
});
} else if (booking.bookingType === 'ROUND_TRIP') {
} else if (booking.bookingType === "ROUND_TRIP") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: seatsForLeg(1),
});
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
if (
b.returnScheduleId &&
b.returnOriginStationId &&
b.returnDestinationStationId
) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
@@ -1576,14 +1714,18 @@ export class PaymentsService {
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'TRANSIT') {
} else if (booking.bookingType === "TRANSIT") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId, // transit station
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
if (
b.leg2ScheduleId &&
b.leg2OriginStationId &&
b.leg2DestinationStationId
) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
@@ -1591,14 +1733,18 @@ export class PaymentsService {
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
} else if (booking.bookingType === "ROUND_TRIP_TRANSIT") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId,
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
if (
b.leg2ScheduleId &&
b.leg2OriginStationId &&
b.leg2DestinationStationId
) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
@@ -1606,15 +1752,24 @@ export class PaymentsService {
seatIds: seatsForLeg(2),
});
}
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
if (
b.returnScheduleId &&
b.returnOriginStationId &&
b.returnDestinationStationId
) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
destinationStationId:
b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
seatIds: seatsForLeg(3),
});
}
if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
if (
b.returnLeg2ScheduleId &&
b.returnLeg2OriginStationId &&
b.returnLeg2DestStationId
) {
legDefs.push({
scheduleId: b.returnLeg2ScheduleId,
originStationId: b.returnLeg2OriginStationId,
@@ -1630,7 +1785,7 @@ export class PaymentsService {
data: {
passengerId: booking.passengerId,
bookingId: booking.id,
status: 'CONFIRMED',
status: "CONFIRMED",
totalMinor: booking.totalMinor,
currency: booking.currency,
} as any,
@@ -1644,12 +1799,16 @@ export class PaymentsService {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: leg.scheduleId },
orderBy: { sequence: 'asc' },
orderBy: { sequence: "asc" },
select: { stationId: true, sequence: true },
});
const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
const originIdx = stopTimes.findIndex(
(st) => st.stationId === leg.originStationId,
);
const destIdx = stopTimes.findIndex(
(st) => st.stationId === leg.destinationStationId,
);
if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;
for (const seatId of leg.seatIds) {

View File

@@ -1,7 +1,3 @@
import { createRequire } from "module";
const require = createRequire(import.meta.url);
export default {
plugins: {
tailwindcss: {},

View File

@@ -29,6 +29,13 @@ import {
Check,
} from "lucide-react";
/**
* Poll budget for the eBirr push flow. The payer has to notice a USSD prompt and type a PIN, so
* this is far longer than the redirect flows' 15 attempts: 80 * 1.5s ≈ 2 min, matching the
* server's EBIRR_PUSH_TTL_MS.
*/
const PUSH_POLL_ATTEMPTS = 80;
const getIconForMethod = (methodId: string) => {
if (methodId.includes('CARD')) return CreditCard;
if (methodId.includes('WALLET')) return Wallet;
@@ -60,6 +67,13 @@ export default function PaymentPage() {
} | null>(null);
const [billCopied, setBillCopied] = useState(false);
// eBirr push debit: the wallet has prompted the payer on their own handset for a PIN. There is
// nothing to navigate to — we show this and poll until the intent settles.
const [pushAction, setPushAction] = useState<{
message: string;
payerAccountMasked?: string;
} | null>(null);
// Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're
// polling our own status endpoint for the webhook-backed outcome.
const [verifyingPayment, setVerifyingPayment] = useState(false);
@@ -176,12 +190,14 @@ export default function PaymentPage() {
const res: any = await apiClient.get(`/payments/status/${bookingId}`);
if (res?.status === 'SUCCEEDED') {
setVerifyingPayment(false);
setPushAction(null);
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
return;
}
if (res?.status === 'FAILED' || res?.status === 'CANCELLED') {
setVerifyingPayment(false);
setPushAction(null);
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(res?.failureMessage || "Payment was not completed. Please try again.");
@@ -192,9 +208,10 @@ export default function PaymentPage() {
}
if (attemptsLeft <= 0) {
// Don't call it failed: telebirr may have taken the money and the webhook is simply
// still in flight. Stop spinning, tell the truth, and let the payer re-check.
// Don't call it failed: the gateway may have taken the money and the confirmation is
// simply still in flight. Stop spinning, tell the truth, and let the payer re-check.
setVerifyingPayment(false);
setPushAction(null);
setIsProcessing(false);
setPaymentError(
"We haven't received confirmation yet. If you completed the payment, your booking " +
@@ -236,8 +253,27 @@ export default function PaymentPage() {
platform: isTelebirrMiniApp() ? 'inapp' : 'web',
});
},
onSuccess: async (data: any) => {
onSuccess: (data: any) => {
setPaymentError(null);
setPaymentIntent(data.paymentIntentId || data.intentId);
// A terminal verdict always wins over any clientAction, so this is checked FIRST.
// eBirr settles inside initiate — its debit response is the settlement, there is no
// webhook — so it can come back SUCCEEDED/FAILED while the intent still carries the
// AWAIT_PUSH action it was created with. Reading clientAction first would show "check
// your phone" for a payment that is already decided, and poll until it timed out.
if (data?.status === 'SUCCEEDED') {
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
return;
}
if (data?.status === 'FAILED' || data?.status === 'CANCELLED') {
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(data?.failureMessage || "Payment was not completed. Please try again.");
return;
}
// CAC Bank: no redirect — the bank SMS'd an OTP. Collect it in-app and confirm.
if (data?.clientAction?.type === 'COLLECT_OTP') {
@@ -278,6 +314,21 @@ export default function PaymentPage() {
return;
}
// eBirr fallback only. The debit is normally settled inside initiate and caught by the
// terminal check above; reaching here means the payer outlasted EBIRR_PURCHASE_TIMEOUT_MS
// while the PIN prompt was still on their handset. The money may since have moved, so poll
// rather than guess.
if (data?.clientAction?.type === 'AWAIT_PUSH') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
setPushAction(data.clientAction);
setVerifyingPayment(true);
// Much longer budget than the redirect flows: the payer has to read a USSD prompt and
// type a PIN. PUSH_POLL_ATTEMPTS * 1.5s ≈ 2 min, matching EBIRR_PUSH_TTL_MS.
void pollPaymentStatus(PUSH_POLL_ATTEMPTS);
return;
}
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
@@ -285,11 +336,13 @@ export default function PaymentPage() {
return;
}
setPaymentIntent(data.paymentIntentId || data.intentId);
// Not terminal, and no clientAction we know how to drive. Never assume success: this
// fallthrough used to sleep 2s and route to /booking/confirmation, which showed the payer
// a confirmed booking for a payment that had not happened. Poll for the truth, and if it
// never settles say so rather than inventing an outcome.
updateStatus("PROCESSING");
await new Promise((resolve) => setTimeout(resolve, 2000));
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
setVerifyingPayment(true);
void pollPaymentStatus(15);
},
onError: (error: any) => {
updateStatus("FAILED");
@@ -352,7 +405,12 @@ export default function PaymentPage() {
}
};
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
// Methods that debit an account we must know up front: CAC Bank SMSes an OTP to it, eBirr
// pushes a USSD PIN prompt to it. Neither has a hosted page that could collect it later.
const requiresPayerMobile = (method: string | null): boolean =>
method === 'CAC_BANK' || method === 'EBIRR';
// Fire the actual initiate. `mobile` is only used by the push-debit methods above.
const startPayment = (mobile?: string) => {
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
setIsProcessing(true);
@@ -363,7 +421,7 @@ export default function PaymentPage() {
paymentMethodId: selectedPaymentMethod.id,
currency: displayCurrency,
amountMinor: totalAmount,
payerAccount: selectedMethod === 'CAC_BANK' ? mobile?.trim() : undefined,
payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined,
});
};
@@ -378,9 +436,14 @@ export default function PaymentPage() {
}
setPaymentError(null);
// CAC Bank needs the payer's mobile for the OTP — collect it in a modal before initiating.
if (selectedMethod === 'CAC_BANK') {
if (requiresPayerMobile(selectedMethod)) {
setPhoneError(null);
// Prefill with the contact phone we already hold, but leave it editable — the wallet
// paying is often not the number the booking was made under.
if (!payerMobile.trim()) {
const contactPhone = passengers?.find((p) => p.phone)?.phone;
if (contactPhone) setPayerMobile(contactPhone);
}
setPhoneModalOpen(true);
return;
}
@@ -620,12 +683,26 @@ export default function PaymentPage() {
{isProcessing && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
{verifyingPayment ? (
{pushAction ? (
<>
<Smartphone className="w-14 h-14 text-primary mx-auto mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Check your phone</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{pushAction.message}
</p>
{pushAction.payerAccountMasked && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Sent to {pushAction.payerAccountMasked}
</p>
)}
<Loader2 className="w-6 h-6 text-primary animate-spin mx-auto mt-4" />
</>
) : verifyingPayment ? (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Confirming payment</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Checking with telebirr this only takes a moment.
Checking with your payment provider this only takes a moment.
</p>
</>
) : paymentMutation.isSuccess ? (
@@ -645,7 +722,7 @@ export default function PaymentPage() {
</div>
)}
{/* CAC Bank — collect payer mobile before initiating */}
{/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
{phoneModalOpen && (
<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-sm w-full shadow-2xl">
@@ -654,7 +731,9 @@ export default function PaymentPage() {
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
CAC Bank will send a one-time password to this number to authorize the payment.
{selectedMethod === 'EBIRR'
? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you."
: "CAC Bank will send a one-time password to this number to authorize the payment."}
</p>
<input
type="tel"
@@ -663,7 +742,7 @@ export default function PaymentPage() {
value={payerMobile}
onChange={(e) => { setPayerMobile(e.target.value); setPhoneError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitPhone(); }}
placeholder="77 XX XX XX"
placeholder={selectedMethod === 'EBIRR' ? "09XX XXX XXX" : "77 XX XX XX"}
className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{phoneError && (

View File

@@ -1,9 +1,26 @@
import { registerAs } from "@nestjs/config";
/**
* EbirrPay — direct mobile-wallet debit (docs/ebirr/INTEGRATION.md).
*
* Auth is plain credentials in the request body (§4): there is no signing key, and — because the
* flow has no hosted page and no callback — no notify URL and no return URL either.
*/
export default registerAs("ebirr", () => ({
baseUrl: process.env.EBIRR_BASE_URL || "",
merchantCode: process.env.EBIRR_MERCHANT_CODE || "",
secretKey: process.env.EBIRR_SECRET_KEY || "",
notifyUrl: process.env.EBIRR_NOTIFY_URL || "",
returnUrl: process.env.EBIRR_RETURN_URL || "",
baseUrl: process.env.EBIRR_BASE_URL ?? "",
merchantUid: process.env.EBIRR_MERCHANT_UID ?? "",
apiKey: process.env.EBIRR_API_KEY ?? "",
apiUserId: process.env.EBIRR_API_USER_ID ?? "",
paymentMethod: process.env.EBIRR_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
channelName: process.env.EBIRR_CHANNEL_NAME ?? "WEB",
/**
* How long API_PURCHASE waits for the payer to read the USSD prompt and type their PIN. It is
* awaited on the request path, so it must stay under every timeout in front of it — the
* passenger API's PAYMENT_API_HTTP_TIMEOUT_MS (60s) and nginx's 60s default proxy_read_timeout.
* Raising it past those turns a slow payer into a dropped connection instead of a fallback.
*/
purchaseTimeoutMs: Number(process.env.EBIRR_PURCHASE_TIMEOUT_MS ?? 45_000),
/** How long the intent stays payable before the reconciliation sweep expires it. */
pushTtlMs: Number(process.env.EBIRR_PUSH_TTL_MS ?? 180_000),
insecureTls: process.env.EBIRR_INSECURE_TLS === "true",
}));

View File

@@ -7,7 +7,7 @@ import {
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
import { CacBankProvider } from "@edr/payment-providers";
import { CacBankProvider, EBirrProvider } from "@edr/payment-providers";
import { IntentsService } from "./intents.service";
import { IntentsRepository } from "./intents.repository";
import { BillReferenceService } from "./bill-reference.service";
@@ -61,6 +61,7 @@ describe("IntentsService CBE_BILL", () => {
{} as DataSource,
providers as never,
{} as CacBankProvider,
{} as EBirrProvider,
billReferenceService as unknown as BillReferenceService,
);
});

View File

@@ -6,7 +6,11 @@ import {
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers";
import {
createMerchantOrderId,
CacBankProvider,
EBirrProvider,
} from "@edr/payment-providers";
import {
ConfirmPaymentRequest,
InitiatePaymentRequest,
@@ -70,6 +74,9 @@ export class IntentsService {
@Inject(PAYMENT_PROVIDER_MAP)
private readonly providers: PaymentProviderMap,
private readonly cacBankProvider: CacBankProvider,
// eBirr's debit is awaited on the request path (see settleEBirrPurchase), so we need the
// concrete class for its non-interface `purchase()` — same pattern as CacBankProvider.
private readonly eBirrProvider: EBirrProvider,
private readonly billReferenceService: BillReferenceService,
) {}
@@ -78,7 +85,6 @@ export class IntentsService {
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
if (request.idempotencyKey) {
const byKey = await this.intentsRepository.findByIdempotencyKey(
request.service,
@@ -105,17 +111,20 @@ export class IntentsService {
);
}
// Push-debit providers charge an account we must be told up front — there is no hosted page
// that could collect it later.
if (
request.provider === ProviderMethod.CAC_BANK &&
(request.provider === ProviderMethod.CAC_BANK ||
request.provider === ProviderMethod.EBIRR) &&
!request.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (customer mobile number) is required for CAC_BANK",
`payerAccount (customer mobile number) is required for ${request.provider}`,
);
}
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
const providerInput = {
merchantOrderId,
orderRef: request.orderRef ?? request.referenceId,
amountMinor: request.amountMinor,
@@ -125,7 +134,8 @@ export class IntentsService {
returnUrl: request.returnUrl,
redirectUrl: request.returnUrl,
failureUrl: request.failureUrl,
});
};
const result = await provider.initiate(providerInput);
const intent = await this.intentsRepository.create({
service: request.service,
@@ -145,9 +155,47 @@ export class IntentsService {
this.logger.log(
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
);
if (request.provider === ProviderMethod.EBIRR) {
return this.settleEBirrPurchase(intent.id, providerInput);
}
return this.toSnapshot(intent);
}
/**
* Issue the eBirr debit and hand back the settled intent.
*
* eBirr has no webhook: API_PURCHASE's response IS the settlement notification. So it is awaited
* here, on the request path, and the caller gets a terminal snapshot — the portal shows success
* or the real failure straight from the initiate response, with nothing to poll.
*
* The wait is bounded by EBIRR_PURCHASE_TIMEOUT_MS (45s), which must stay under the passenger
* API's 60s PAYMENT_API_HTTP_TIMEOUT_MS. A payer slower than that comes back PROCESSING and the
* intent keeps its AWAIT_PUSH client action, so the existing poll and the reconciliation sweep
* settle it as before. That fallback is rare but must not be removed: an unanswered purchase may
* still have moved money (vendor doc §10).
*
* purchase() does not throw — transport failures are already mapped to FAILED (never dispatched)
* or PROCESSING (sent, unanswered). The catch is for anything unforeseen: leaving the intent
* REQUIRES_ACTION also lands on the poll/sweep fallback, which is the safe direction.
*/
private async settleEBirrPurchase(
intentId: string,
providerInput: Parameters<EBirrProvider["purchase"]>[0],
): Promise<PaymentIntentSnapshot> {
try {
const status = await this.eBirrProvider.purchase(providerInput);
await this.applyProviderResult(intentId, status);
} catch (err) {
this.logger.error(
`eBirr purchase for intent ${intentId} (${providerInput.merchantOrderId}) could not be ` +
`settled: ${err instanceof Error ? err.message : err} — leaving it to the sweep`,
);
}
return this.snapshotOf(intentId);
}
/**
* 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

View File

@@ -0,0 +1,374 @@
import { of, throwError } from "rxjs";
import { AxiosError } from "axios";
import {
EBirrProvider,
normalizeEthiopianMsisdn,
ProviderPaymentStatus,
} from "@edr/payment-providers";
/**
* eBirr is a direct wallet debit over the ASM envelope (docs/ebirr/INTEGRATION.md), not the
* Alipay-style redirect gateway the pre-rewrite provider was written against. These pin the
* things that were wrong before and the things that are easy to "fix" back by mistake:
*
* - the wire shape must match the request verified by hand against testpayments.ebirr.com,
* including `payerInfo.accountNo` (NOT the doc's `subscriptionId`) and no signature field;
* - the amount reaches eBirr unscaled — the old code divided by 100 and would have charged
* 1/100th of every booking (same class of bug as cac-bank-amount.spec.ts);
* - `initiate()` must not touch the network: the blocking debit is `purchase()`;
* - a timeout must NOT be reported as FAILED — the money may have moved (vendor doc §10).
*/
describe("EBirrProvider", () => {
const config = {
get: (key: string) =>
({
"ebirr.baseUrl": "https://testpayments.ebirr.com",
"ebirr.merchantUid": "M1000003",
"ebirr.apiKey": "API-1234560",
"ebirr.apiUserId": "10000008",
"ebirr.paymentMethod": "MWALLET_ACCOUNT",
"ebirr.channelName": "WEB",
"ebirr.purchaseTimeoutMs": 45_000,
"ebirr.pushTtlMs": 180_000,
})[key],
};
const input = {
merchantOrderId: "EDR-ORDER-1",
orderRef: "EDR-20240001",
amountMinor: 1500.5,
currency: "ETB",
payerAccount: "+251923582676",
};
function build(response?: unknown) {
const post = jest.fn().mockReturnValue(of({ data: response, status: 200 }));
const provider = new EBirrProvider(config as never, { post } as never);
return { provider, post };
}
/**
* Captured verbatim from the live sandbox (08/08/2026) across four scenarios. Note that the
* three failure envelopes are NOT 2001 yet still carry the authoritative `params.state` — the
* reason the provider reads `state` regardless of `responseCode`.
*/
const approved = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:40:42Z",
responseId: "REQ-001-20260506114500",
responseCode: "2001",
errorCode: "0",
responseMsg: "RCS_SUCCESS",
params: {
referenceId: "holyffuot",
transactionId: "619",
orderId: "521",
issuerTransactionId: "10000991513",
txAmount: "1.00",
state: "APPROVED",
},
};
const declined = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:41:58Z",
responseId: "REQ-001-20260506114500",
responseCode: "5206",
errorCode: "E10205",
responseMsg: "Payment Failed (Invalid Credentials)",
params: {
referenceId: "hoflyffuot",
transactionId: "621",
orderId: "522",
txAmount: "1.00",
state: "DECLINED",
description: "Invalid Credentials",
},
};
/** The payer aborted the USSD prompt, or let it lapse — eBirr reports both identically. */
const userAborted = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:42:51Z",
responseId: "REQ-001-20260506114500",
responseCode: "5001",
errorCode: "4004",
responseMsg: "User Aborted",
params: {
referenceId: "hoflyffuofft",
transactionId: "622",
orderId: "523",
txAmount: "1.00",
state: "TIMEOUT",
description: "User Aborted",
},
};
describe("initiate", () => {
it("issues no HTTP call and returns AWAIT_PUSH", async () => {
const { provider, post } = build();
const result = await provider.initiate(input);
expect(post).not.toHaveBeenCalled();
expect(result.clientAction).toEqual({
type: "AWAIT_PUSH",
message: expect.stringContaining("PIN"),
payerAccountMasked: "2519****2676",
});
expect(result.providerOrderId).toBe("EDR-ORDER-1");
});
it("rejects a missing payer account rather than charging nobody", async () => {
const { provider } = build();
await expect(
provider.initiate({ ...input, payerAccount: undefined }),
).rejects.toThrow(/payerAccount/);
});
it("never leaks the api key or the full MSISDN into the audit payload", async () => {
const { provider } = build();
const result = await provider.initiate(input);
const serialized = JSON.stringify(result.rawInitiation);
expect(serialized).not.toContain("API-1234560");
expect(serialized).not.toContain("251923582676");
});
});
describe("purchase", () => {
it("sends the ASM envelope verified against the live sandbox", async () => {
const { provider, post } = build(approved);
await provider.purchase(input);
const [url, body] = post.mock.calls[0];
expect(url).toBe("https://testpayments.ebirr.com/asm");
expect(body).toMatchObject({
schemaVersion: "1.0",
channelName: "WEB",
serviceName: "API_PURCHASE",
serviceParams: {
merchantUid: "M1000003",
apiKey: "API-1234560",
apiUserId: "10000008",
paymentMethod: "MWALLET_ACCOUNT",
// `accountNo`, not the vendor doc's `subscriptionId`
payerInfo: { accountNo: "251923582676" },
transactionInfo: {
referenceId: "EDR-ORDER-1",
invoiceId: "EDR-20240001",
currency: "ETB",
},
},
});
// eBirr wants `YYYY-MM-DD HH:mm:ss`, not the epoch seconds Waafi's /asm takes.
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(body.requestId).toHaveLength(36);
// Nothing is signed — there is no shared secret in this integration.
expect(JSON.stringify(body)).not.toContain("sign");
});
it("sends the amount unscaled — 1500.50 ETB, not 15.005", async () => {
const { provider, post } = build(approved);
await provider.purchase(input);
expect(post.mock.calls[0][1].serviceParams.transactionInfo.amount).toBe(
1500.5,
);
});
it("maps an APPROVED verdict to SUCCEEDED with the provider txn id", async () => {
const { provider } = build(approved);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
expect(result.providerTxnId).toBe("619");
expect(result.failureCode).toBeUndefined();
});
it("maps the live DECLINED response to FAILED with the specific cause", async () => {
const { provider } = build(declined);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("E10205");
// The specific cause, not the generic "Payment Failed (…)" wrapper.
expect(result.failureMessage).toBe("Invalid Credentials");
// eBirr issues a transaction id for failed attempts too — keep it for reconciliation.
expect(result.providerTxnId).toBe("621");
});
it("maps the live User-Aborted/TIMEOUT response to FAILED", async () => {
const { provider } = build(userAborted);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("4004");
expect(result.failureMessage).toBe("User Aborted");
expect(result.providerTxnId).toBe("622");
});
it("never promotes a rejected envelope to SUCCEEDED, even if state says APPROVED", async () => {
const { provider } = build({
...declined,
params: { state: "APPROVED" },
});
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
});
it("falls back to the envelope when a rejection carries no params at all", async () => {
const { provider } = build({
schemaVersion: "1.0",
responseCode: "5001",
errorCode: "E10206",
responseMsg: "Failed to process request",
});
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("E10206");
});
it("maps a timeout to PROCESSING, never FAILED — the money may have moved", async () => {
const post = jest
.fn()
.mockReturnValue(
throwError(() => new AxiosError("timeout of 45000ms exceeded")),
);
const provider = new EBirrProvider(config as never, { post } as never);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.PROCESSING);
});
/**
* The dispatch/no-dispatch split. A request that never left the process cannot have moved
* money and leaves NO transaction for API_GETTRANINFO to find — reporting it as PROCESSING
* stranded the payer on "check your phone" for a push that was never sent, until expiry.
* A request that did go out stays PROCESSING no matter how it broke (vendor doc §10).
*/
function purchaseWithTransportError(
message: string,
code?: string,
): Promise<{ status: ProviderPaymentStatus; failureMessage?: string }> {
const err = new AxiosError(message);
if (code) err.code = code;
const post = jest.fn().mockReturnValue(throwError(() => err));
return new EBirrProvider(
config as never,
{
post,
} as never,
).purchase(input);
}
it.each([
// Node's TCP connect timeout — the SYN was never answered (blocked port / no whitelist).
["connect ETIMEDOUT 197.156.83.125:443", "ETIMEDOUT"],
["connect ECONNREFUSED 10.0.0.1:443", "ECONNREFUSED"],
["getaddrinfo ENOTFOUND testpayments.ebirr.com", "ENOTFOUND"],
["Invalid URL", "ERR_INVALID_URL"],
["certificate has expired", "CERT_HAS_EXPIRED"],
])("fails fast on %s — it never reached eBirr", async (message, code) => {
const result = await purchaseWithTransportError(message, code);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
// A cause the payer can act on, not the generic "please try again".
expect(result.failureMessage).toMatch(/could not reach ebirr/i);
});
it.each([
// Axios's own read timeout reported with the ETIMEDOUT code (clarifyTimeoutError) — the
// request WAS sent, so this must not be confused with a connect timeout.
["timeout of 45000ms exceeded", "ETIMEDOUT"],
// Fired after the body went out; the debit may well have been processed.
["socket hang up", "ECONNRESET"],
["aborted", "ECONNABORTED"],
["something nobody anticipated", undefined],
])(
"keeps %s as PROCESSING — it may have been dispatched",
async (message, code) => {
const result = await purchaseWithTransportError(message, code);
expect(result.status).toBe(ProviderPaymentStatus.PROCESSING);
},
);
});
describe("queryStatus", () => {
it("looks the transaction up by referenceId via API_GETTRANINFO", async () => {
const { provider, post } = build({
schemaVersion: "1.0",
responseCode: "2001",
errorCode: "0",
responseMsg: "RCS_SUCCESS",
params: { status: "Approved", transactionId: "126895" },
});
const result = await provider.queryStatus("EDR-ORDER-1");
expect(post.mock.calls[0][1]).toMatchObject({
serviceName: "API_GETTRANINFO",
serviceParams: { referenceId: "EDR-ORDER-1" },
});
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
expect(result.providerTxnId).toBe("126895");
});
it("treats an unknown transaction as REQUIRES_ACTION, not PROCESSING", async () => {
const { provider } = build({
schemaVersion: "1.0",
responseCode: "5001",
errorCode: "E10206",
responseMsg: "Failed to get transaction info",
});
const result = await provider.queryStatus("EDR-ORDER-1");
// The payer simply hasn't answered the prompt yet. Persisting a PROCESSING guess would
// let the sweep strand them on a push they never touched.
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
});
it("honours a terminal state on a rejected envelope instead of hanging the payer", async () => {
// The purchase endpoint returns rejected envelopes that still carry a verdict
// (5206/DECLINED, 5001/TIMEOUT). If the query endpoint does the same, reading only the
// envelope would report REQUIRES_ACTION and leave the payer waiting until expiry.
const { provider } = build(userAborted);
const result = await provider.queryStatus("EDR-ORDER-1");
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureMessage).toBe("User Aborted");
});
});
describe("normalizeEthiopianMsisdn", () => {
it.each([
["+251923582676", "251923582676"],
["251923582676", "251923582676"],
["0923582676", "251923582676"],
["923582676", "251923582676"],
["+251 92 358 2676", "251923582676"],
["0712345678", "251712345678"],
])("normalises %s to %s", (raw, expected) => {
expect(normalizeEthiopianMsisdn(raw)).toBe(expected);
});
it.each(["", "not-a-number", "0812345678", "09123", "0912345678901"])(
"rejects %s rather than prompting a stranger's handset",
(raw) => {
expect(() => normalizeEthiopianMsisdn(raw)).toThrow();
},
);
});
});

View File

@@ -1,33 +0,0 @@
import { Injectable } from "@nestjs/common";
import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class EBirrWebhookService {
constructor(
private readonly provider: EBirrProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: EBirrWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`,
merchantOrderId: payload.orderNo,
providerTxnId: payload.tradeNo,
signatureValid,
rawStatus: payload.tradeStatus,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.tradeNo,
failureCode: payload.tradeStatus,
},
});
}
}

View File

@@ -14,14 +14,12 @@ import {
CardWebhookPayload,
CbeBirrWebhookPayload,
DMoneyWebhookPayload,
EBirrWebhookPayload,
TelebirrWebhookPayload,
WaafiWebhookHeaders,
WaafiWebhookPayload,
} from "@edr/payment-providers";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
@@ -40,7 +38,6 @@ export class WebhooksController {
constructor(
private readonly telebirr: TelebirrWebhookService,
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
private readonly waafi: WaafiWebhookService,
private readonly dMoney: DMoneyWebhookService,
@@ -89,17 +86,9 @@ export class WebhooksController {
return { success: true };
}
@Post("ebirr")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" })
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
} catch (err) {
this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`);
}
return { code: "0000", message: "success" };
}
// No eBirr route by design: EbirrPay's API-payment flow has no callback. The API_PURCHASE
// response is the settlement notification, and API_GETTRANINFO is the authority for a missing
// or ambiguous one — see docs/ebirr/INTEGRATION.md.
@Post("card")
@HttpCode(HttpStatus.OK)

View File

@@ -8,7 +8,6 @@ import { WebhookProcessorService } from "./webhook-processor.service";
import { WebhooksController } from "./webhooks.controller";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
@@ -25,7 +24,6 @@ import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
WebhookProcessorService,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
WaafiWebhookService,
DMoneyWebhookService,

View File

@@ -60,6 +60,15 @@ export type {
WaafiGetTranInfoResponse,
} from './providers/waafi/waafi.types';
// EbirrPay API-payment request/response types (same ASM envelope as Waafi — see ebirr.types.ts)
export type {
EbirrState,
EbirrPurchaseRequest,
EbirrPurchaseResponse,
EbirrGetTranInfoRequest,
EbirrGetTranInfoResponse,
} from './providers/ebirr/ebirr.types';
// CAC Bank request/response types
export type {
CacSigninRequest,
@@ -76,7 +85,7 @@ export type {
// Webhook payload types
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
// (no eBirr webhook type: EbirrPay has no callback — the API_PURCHASE response is the result)
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
export type {
WaafiWebhookPayload,
@@ -88,5 +97,8 @@ export type {
} from './webhooks/waafi-webhook.types';
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
// Phone-number helpers
export { normalizeEthiopianMsisdn, maskMsisdn } from './utils/msisdn';
// DI token for injecting all providers as an array (future multi-provider wiring)
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import {
@@ -12,225 +12,568 @@ import {
import { AxiosError, AxiosRequestConfig } from "axios";
import { firstValueFrom } from "rxjs";
import * as crypto from "node:crypto";
import * as https from "node:https";
import { maskMsisdn, normalizeEthiopianMsisdn } from "../../utils/msisdn";
import {
EbirrGetTranInfoRequest,
EbirrGetTranInfoResponse,
EbirrPurchaseRequest,
EbirrPurchaseResponse,
} from "./ebirr.types";
interface EBirrInitiateRequest {
merchantCode: string;
orderNo: string;
amount: number;
currency: string;
subject: string;
body: string;
notifyUrl: string;
returnUrl: string;
timestamp: number;
sign: string;
}
/** EbirrPay's "request processed" envelope code. Says nothing about the payment outcome. */
const EBIRR_SUCCESS_CODE = "2001";
/** Status queries are quick lookups — they must not inherit the long purchase timeout. */
const EBIRR_QUERY_TIMEOUT_MS = 10_000;
interface EBirrInitiateResponse {
code: string;
message: string;
data?: {
orderNo: string;
payUrl: string;
expireTime: number;
};
}
interface EBirrQueryResponse {
code: string;
message: string;
data?: {
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
payTime?: number;
};
}
/**
* Transport errors that prove the request **never reached eBirr**: a bad URL, DNS, the TCP
* connect and the TLS handshake all fail before a single byte of the body is written.
*
* This distinction is the whole reason the set exists. No debit can exist for a request that was
* never sent, so there is nothing for `API_GETTRANINFO` to find — reporting these as PROCESSING
* leaves the payer staring at "check your phone" for a prompt that was never pushed, until the
* intent expires. They are a payment failure, and the payer should be told immediately.
*
* Deliberately an allowlist. Anything not named here — notably `ECONNRESET` and `ECONNABORTED`,
* which can both fire *after* the body went out — falls through to PROCESSING, because vendor doc
* §10 is explicit that an ambiguous send must be resolved by querying, never by assuming failure.
*/
const EBIRR_UNDISPATCHED_CODES = new Set([
"ERR_INVALID_URL", // empty/misconfigured EBIRR_BASE_URL — no socket is ever opened
"ENOTFOUND", // DNS: host does not resolve
"EAI_AGAIN", // DNS: resolver timed out
"ECONNREFUSED", // TCP: port closed
"EHOSTUNREACH",
"ENETUNREACH",
"CERT_HAS_EXPIRED", // TLS: handshake fails before the request is written
"DEPTH_ZERO_SELF_SIGNED_CERT",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"ERR_TLS_CERT_ALTNAME_INVALID",
]);
/**
* EbirrPay — direct mobile-wallet debit (docs/ebirr/INTEGRATION.md).
*
* Unlike every other gateway in this package, eBirr has **no hosted page, no redirect and no
* webhook**. `API_PURCHASE` pushes a PIN prompt to the payer's handset over USSD and holds the
* HTTP connection open until they approve or decline it — the synchronous response *is* the
* settlement notification.
*
* The charge is therefore split in two:
*
* - `initiate()` performs **no I/O**. It validates the payer account and builds the
* `AWAIT_PUSH` client action used as the fallback when the debit outlives its timeout.
* - `purchase()` issues the actual blocking debit. payment-api's IntentsService awaits it on
* the request path (`settleEBirrPurchase`) so the payer gets the verdict in the initiate
* response — there is no webhook, so this response *is* the settlement notification.
*
* The wait is bounded by `EBIRR_PURCHASE_TIMEOUT_MS` (45s), which has to stay under the chain's
* own limits — the passenger API's 60s `PAYMENT_API_HTTP_TIMEOUT_MS` and nginx's 60s default
* `proxy_read_timeout`. A payer slower than that yields PROCESSING and the intent falls back to
* `AWAIT_PUSH` for the client poll; the reconciliation sweep settles it via `queryStatus`
* (`API_GETTRANINFO`), which is the authority vendor doc §10 points at for missing or ambiguous
* responses. That path is rare but must not be removed: the money may already have moved.
*/
@Injectable()
export class EBirrProvider implements PaymentProvider {
export class EBirrProvider implements PaymentProvider, OnModuleInit {
readonly method = ProviderMethod.EBIRR;
private readonly logger = new Logger(EBirrProvider.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
) {
const insecure = this.config.get<boolean>("ebirr.insecureTls");
if (insecure) {
this.logger.warn(
"EBIRR_INSECURE_TLS=true — TLS verification disabled for eBirr calls. DEV ONLY.",
);
}
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
}
/** Log the effective eBirr config once at startup (secrets masked) so misconfig is visible. */
onModuleInit(): void {
this.logger.log(
`eBirr config resolved: ${JSON.stringify(this.effectiveConfig())}`,
);
}
/** Snapshot of every resolved eBirr env value; secret fields are masked, not printed raw. */
private effectiveConfig(): Record<string, unknown> {
return {
EBIRR_BASE_URL: this.baseUrl || "(empty)",
EBIRR_MERCHANT_UID: this.merchantUid || "(empty)",
EBIRR_API_USER_ID: this.apiUserId || "(empty)",
EBIRR_API_KEY: this.mask(this.apiKey),
EBIRR_PAYMENT_METHOD: this.paymentMethod,
EBIRR_CHANNEL_NAME: this.channelName,
EBIRR_PURCHASE_TIMEOUT_MS: this.purchaseTimeoutMs,
EBIRR_PUSH_TTL_MS: this.pushTtlMs,
EBIRR_INSECURE_TLS:
this.config.get<boolean>("ebirr.insecureTls") ?? false,
};
}
/** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */
private mask(value: string): string {
if (!value) return "(empty)";
const tail = value.length > 4 ? value.slice(-4) : "";
return `set(len=${value.length},…${tail})`;
}
/**
* Prepare the push debit. Deliberately does no network I/O — the charge itself is `purchase()`.
*
* `providerOrderId` is our own `merchantOrderId`: this flow mints no separate order id (the
* `orderId` the vendor doc §5.4 mentions belongs to the HPP family we don't use), and
* `queryStatus` looks the transaction up by `referenceId` anyway.
*/
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const timestamp = Date.now();
if (!input.payerAccount?.trim()) {
throw new Error(
"eBirr requires payerAccount (the customer's mobile-wallet number)",
);
}
// Throws on a malformed number rather than pushing a PIN prompt to the wrong handset.
const accountNo = normalizeEthiopianMsisdn(input.payerAccount);
const requestBody: EBirrInitiateRequest = {
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
currency: input.currency,
subject: `EDR Ticket`,
body: `Order ${input.orderRef}`,
notifyUrl: this.notifyUrl,
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
returnUrl: input.returnUrl ?? this.returnUrl,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<EBirrInitiateResponse>(
`${this.baseUrl}/gateway/api/pay/create`,
requestBody,
this.logger.log(
`eBirr initiate ref=${input.merchantOrderId} account=${maskMsisdn(accountNo)} ` +
`currency=${input.currency} amount=${this.toAmount(input.amountMinor)} ` +
`(amountMinorIn=${input.amountMinor})`,
);
if (response.code !== "0000" || !response.data?.orderNo) {
throw new Error(`eBirr initiate failed: ${response.message}`);
}
const expiresAt = new Date(response.data.expireTime);
return {
providerOrderId: response.data.orderNo,
clientAction: { type: "REDIRECT", url: response.data.payUrl },
expiresAt,
providerOrderId: input.merchantOrderId,
clientAction: {
type: "AWAIT_PUSH",
message:
"Check your phone and enter your eBirr PIN to approve the payment.",
payerAccountMasked: maskMsisdn(accountNo),
},
expiresAt: new Date(Date.now() + this.pushTtlMs),
rawInitiation: {
request: this.sanitize(requestBody),
response,
request: this.sanitizeRequest(
this.buildPurchaseRequest(input, accountNo),
),
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const timestamp = Date.now();
const requestBody = {
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
}),
};
/**
* Issue the debit. Blocks for as long as the payer takes to enter their PIN, so it must be
* called off the request path.
*
* Never throws: a transport failure or timeout is reported as PROCESSING, not FAILED. The money
* may well have moved, and the vendor doc §10 is explicit that a timeout must be resolved by
* querying, not by assuming failure and re-charging.
*/
async purchase(input: ProviderInitiationInput): Promise<ProviderStatus> {
const accountNo = normalizeEthiopianMsisdn(input.payerAccount ?? "");
const requestBody = this.buildPurchaseRequest(input, accountNo);
const url = `${this.baseUrl}/asm`;
const response = await this.postJson<EBirrQueryResponse>(
`${this.baseUrl}/gateway/api/pay/query`,
requestBody,
this.logger.log(
`eBirr API_PURCHASE → ${url} ref=${input.merchantOrderId} ` +
`account=${maskMsisdn(accountNo)} amount=${requestBody.serviceParams.transactionInfo.amount} ` +
`${input.currency} (awaiting payer PIN, up to ${this.purchaseTimeoutMs}ms)`,
);
if (response.code !== "0000" || !response.data) {
throw new Error(`eBirr query failed: ${response.message}`);
let response: EbirrPurchaseResponse;
try {
response = await this.postJson<EbirrPurchaseResponse>(
url,
requestBody,
this.purchaseTimeoutMs,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const code = (err as { code?: string })?.code;
// Never sent: no debit exists, so there is nothing to reconcile and no reason to make the
// payer wait. Fail it now with a cause they can act on.
if (this.isUndispatched(err)) {
this.logger.error(
`eBirr API_PURCHASE ${input.merchantOrderId} was never dispatched (${code ?? "n/a"}: ` +
`${message}) — no debit exists, failing the intent instead of awaiting reconciliation`,
);
return {
status: ProviderPaymentStatus.FAILED,
failureCode: code ?? "EBIRR_UNREACHABLE",
failureMessage:
"Could not reach eBirr — no payment was taken. Please try again.",
rawResponse: { error: message, code, dispatched: false },
};
}
const mapped = this.mapStatus(response.data.tradeStatus);
// Sent but unanswered: unresolved, NOT failed. The sweep settles it via API_GETTRANINFO.
this.logger.warn(
`eBirr API_PURCHASE ${input.merchantOrderId} did not return a verdict ` +
`(${message}) — leaving PROCESSING for reconciliation`,
);
return {
status: ProviderPaymentStatus.PROCESSING,
rawResponse: { error: message, code, dispatched: true },
};
}
const rawResponse = response as unknown as Record<string, unknown>;
const state = response.params?.state;
const mapped = this.resolveVerdict(response.responseCode, state);
if (mapped === ProviderPaymentStatus.FAILED) {
this.logger.error(
`eBirr API_PURCHASE ${input.merchantOrderId} rejected: ` +
`${response.responseCode}/${response.errorCode} ${response.responseMsg} ` +
`state=${state ?? "n/a"} (txn=${response.params?.transactionId ?? "n/a"})`,
);
} else {
this.logger.log(
`eBirr API_PURCHASE ${input.merchantOrderId} state=${state ?? "n/a"}${mapped} ` +
`(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`,
);
}
return {
status: mapped,
providerTxnId: response.data.tradeNo,
// Present on rejected responses too (eBirr records the failed attempt), so this is
// captured regardless of verdict — it is what reconciliation quotes back to eBirr.
providerTxnId: response.params?.transactionId,
failureCode:
mapped === ProviderPaymentStatus.FAILED
? response.data.tradeStatus
? response.errorCode || response.responseCode
: undefined,
failureMessage:
mapped === ProviderPaymentStatus.FAILED
? // `params.description` is the specific cause ("Invalid Credentials"); responseMsg is
// the generic wrapper ("Payment Failed (Invalid Credentials)").
(response.params?.description ?? response.responseMsg)
: undefined,
rawResponse,
};
}
/**
* Did this error happen *before* the request was written to the wire?
*
* Only errors we can prove were never dispatched may be reported as FAILED — everything else
* has to stay PROCESSING, because a request eBirr received may have moved money.
*/
private isUndispatched(err: unknown): boolean {
// An HTTP response came back — whatever its status, eBirr received the request.
if (err instanceof AxiosError && err.response) return false;
const code = (err as { code?: string })?.code;
const message = err instanceof Error ? err.message : String(err);
// ETIMEDOUT is ambiguous and has to be read from the message. Node reports a TCP connect
// timeout as `connect ETIMEDOUT <ip>:<port>` — nothing was sent. Axios reports its own *read*
// timeout with the same code when `clarifyTimeoutError` is set, but phrases it
// "timeout of Nms exceeded" — that one was sent and is still in flight.
if (code === "ETIMEDOUT") return message.startsWith("connect ");
return !!code && EBIRR_UNDISPATCHED_CODES.has(code);
}
/**
* Decide the outcome from an eBirr response.
*
* Observed against the live sandbox: a *rejected* envelope still carries the authoritative
* verdict in `params.state` — e.g. `5206/E10205` with `state: DECLINED`, and `5001/4004`
* ("User Aborted") with `state: TIMEOUT`. So `state` wins whenever it is present; the envelope
* code is only the fallback for responses that carry no `params` at all.
*
* The one thing `state` may never do is promote a rejected envelope to SUCCEEDED — success
* requires both a 2001 envelope and an approving state.
*/
private resolveVerdict(
responseCode: string,
state: string | undefined,
): ProviderPaymentStatus {
const envelopeOk = responseCode === EBIRR_SUCCESS_CODE;
if (!state) {
return envelopeOk
? ProviderPaymentStatus.PROCESSING
: ProviderPaymentStatus.FAILED;
}
const mapped = this.mapStatus(state);
if (mapped === ProviderPaymentStatus.SUCCEEDED && !envelopeOk) {
return ProviderPaymentStatus.FAILED;
}
return mapped;
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const response = await this.postJson<EbirrGetTranInfoResponse>(
`${this.baseUrl}/asm`,
this.buildGetTranInfoRequest(merchantOrderId),
EBIRR_QUERY_TIMEOUT_MS,
);
const rawState =
response.params?.status ??
response.params?.state ??
response.params?.tranStatusDesc;
// A rejected envelope that still carries a state is a real verdict — the purchase endpoint
// demonstrably does this (5206/DECLINED, 5001/TIMEOUT), so don't assume the query endpoint
// won't. Trust the state; only fall back to the envelope when there is none.
if (rawState) {
const mapped = this.resolveVerdict(response.responseCode, rawState);
this.logger.log(
`eBirr API_GETTRANINFO ${merchantOrderId} state=${rawState}${mapped} ` +
`(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`,
);
return {
status: mapped,
providerTxnId: response.params?.transactionId,
failureCode:
mapped === ProviderPaymentStatus.FAILED
? response.errorCode || rawState
: undefined,
failureMessage:
mapped === ProviderPaymentStatus.FAILED
? (response.params?.description ?? response.responseMsg)
: undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { sign, ...data } = payload;
if (!sign || typeof sign !== "string") return false;
const expectedSign = this.signRequest(data);
return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign));
// No state at all. Same convention as waafi/cac-bank: eBirr returns a bare error envelope
// for a transaction it has no record of, which means the payer hasn't answered the prompt
// yet — that is REQUIRES_ACTION (still waiting on the handset), NOT PROCESSING. Persisting a
// PROCESSING guess would let the sweep strand a payer on a push they never touched. The
// intent still resolves: via purchase()'s verdict, or via expiresAt.
if (response.responseCode !== EBIRR_SUCCESS_CODE) {
this.logger.warn(
`eBirr API_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ` +
`${response.responseMsg} — no state returned, treating as still awaiting the payer`,
);
return {
status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: response as unknown as Record<string, unknown>,
};
}
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
return this.mapStatus(tradeStatus);
return {
status: ProviderPaymentStatus.PROCESSING,
providerTxnId: response.params?.transactionId,
rawResponse: response as unknown as Record<string, unknown>,
};
}
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
switch (tradeStatus?.toUpperCase()) {
case "TRADE_SUCCESS":
private mapStatus(raw: string | undefined): ProviderPaymentStatus {
switch (raw?.toUpperCase()) {
case "APPROVED":
case "SUCCESS":
return ProviderPaymentStatus.SUCCEEDED;
case "TRADE_CLOSED":
case "TRADE_FAILED":
case "CANCELED":
case "CANCELLED":
return ProviderPaymentStatus.CANCELLED;
case "DECLINED":
case "REJECTED":
case "FAILED":
case "EXPIRED":
case "TIMEOUT":
return ProviderPaymentStatus.FAILED;
case "WAIT_BUYER_PAY":
case "PENDING":
case "INITIATED":
return ProviderPaymentStatus.REQUIRES_ACTION;
case "PROCESSING":
return ProviderPaymentStatus.PROCESSING;
default:
return ProviderPaymentStatus.PROCESSING;
}
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString =
sortedKeys.map((key) => `${key}=${data[key]}`).join("&") +
`&key=${this.secretKey}`;
return crypto
.createHash("md5")
.update(signString)
.digest("hex")
.toUpperCase();
private buildPurchaseRequest(
input: ProviderInitiationInput,
accountNo: string,
): EbirrPurchaseRequest {
return {
schemaVersion: "1.0",
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: this.channelName,
serviceName: "API_PURCHASE",
serviceParams: {
merchantUid: this.merchantUid,
apiKey: this.apiKey,
apiUserId: this.apiUserId,
paymentMethod: this.paymentMethod,
payerInfo: { accountNo },
transactionInfo: {
referenceId: input.merchantOrderId,
invoiceId: input.orderRef,
amount: this.toAmount(input.amountMinor),
// Charge exactly the currency the caller already converted to. The provider never
// relabels the currency.
currency: input.currency,
description: `${input.orderRef}`,
},
},
};
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
"Content-Type": "application/json",
private buildGetTranInfoRequest(
merchantOrderId: string,
): EbirrGetTranInfoRequest {
return {
schemaVersion: "1.0",
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: this.channelName,
serviceName: "API_GETTRANINFO",
serviceParams: {
merchantUid: this.merchantUid,
apiKey: this.apiKey,
apiUserId: this.apiUserId,
referenceId: merchantOrderId,
},
timeout: 10_000,
};
}
/**
* `amountMinor` is a misnomer inherited from the shared contract — it carries the *major*
* amount (see PaymentIntent.amountMinor: "real/major price; may be fractional"). Pass it
* through at 2dp. Never divide by 100 (the pre-rewrite code did, charging 1/100th), and don't
* borrow Waafi's `Math.trunc` — that is only correct for 0-decimal DJF and would drop ETB cents.
*/
private toAmount(amountMinor: number): number {
return Number(amountMinor.toFixed(2));
}
/** eBirr expects `YYYY-MM-DD HH:mm:ss` — not the epoch seconds Waafi's `/asm` accepts. */
private timestamp(): string {
const d = new Date();
const pad = (n: number): string => String(n).padStart(2, "0");
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
);
}
/**
* Single funnel for every `/asm` call, so the full wire request and the full wire response are
* both logged for `API_PURCHASE` and `API_GETTRANINFO` alike — at `log`, not `debug`, because
* eBirr has no webhook and no dashboard we can see: when a payer disputes a debit these lines
* are the only record of what we sent and what came back. Bodies pass through `sanitize*` so the
* api key and the payer's full MSISDN never reach the log sink.
*/
private async postJson<T>(
url: string,
body: unknown,
timeout: number,
): Promise<T> {
const config: AxiosRequestConfig = {
headers: { "Content-Type": "application/json" },
timeout,
httpsAgent: this.httpsAgent,
};
const envelope = body as { serviceName?: string; requestId?: string };
const tag = `${envelope.serviceName ?? "UNKNOWN"} requestId=${envelope.requestId ?? "n/a"}`;
this.logger.log(
`eBirr → POST ${url} ${tag} request=${JSON.stringify(this.sanitizeRequest(body))}`,
);
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(
`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
this.logger.log(
`eBirr ${tag} status=${res.status} latency=${Date.now() - started}ms ` +
`response=${JSON.stringify(this.sanitizeResponse(res.data))}`,
);
return res.data;
} catch (err) {
const latency = Date.now() - started;
if (err instanceof AxiosError) {
this.logger.error(
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
`eBirr ${tag} failed after ${latency}ms: status=${err.response?.status} ` +
`response=${JSON.stringify(this.sanitizeResponse(err.response?.data))} ` +
`code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(
`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
`eBirr ${tag} threw after ${latency}ms: ${err instanceof Error ? err.message : err}`,
);
}
throw err;
}
}
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
/**
* Redact the api key and mask the payer MSISDN in an outbound envelope. Covers both service
* shapes: `API_PURCHASE` carries `payerInfo.accountNo`, `API_GETTRANINFO` carries neither.
*/
private sanitizeRequest(body: unknown): Record<string, unknown> {
const envelope = body as EbirrPurchaseRequest;
const params = envelope?.serviceParams as
| (EbirrPurchaseRequest["serviceParams"] & Record<string, unknown>)
| undefined;
if (!params) return body as Record<string, unknown>;
return {
...envelope,
serviceParams: {
...params,
apiKey: "***REDACTED***",
...(params.payerInfo
? { payerInfo: { accountNo: maskMsisdn(params.payerInfo.accountNo) } }
: {}),
},
};
}
/**
* Responses carry no secret, but `params.accountNo` / `params.payerId` echo the payer's wallet
* number back — mask those the same way the request side does.
*/
private sanitizeResponse(data: unknown): unknown {
if (!data || typeof data !== "object") return data;
const response = data as { params?: Record<string, unknown> };
if (!response.params) return data;
const params = { ...response.params };
for (const key of ["accountNo", "payerId"]) {
const value = params[key];
if (typeof value === "string" && value) params[key] = maskMsisdn(value);
}
return { ...response, params };
}
private get baseUrl(): string {
return this.config.get<string>("ebirr.baseUrl") ?? "";
}
private get merchantCode(): string {
return this.config.get<string>("ebirr.merchantCode") ?? "";
private get merchantUid(): string {
return this.config.get<string>("ebirr.merchantUid") ?? "";
}
private get secretKey(): string {
return this.config.get<string>("ebirr.secretKey") ?? "";
private get apiKey(): string {
return this.config.get<string>("ebirr.apiKey") ?? "";
}
private get notifyUrl(): string {
return this.config.get<string>("ebirr.notifyUrl") ?? "";
private get apiUserId(): string {
return this.config.get<string>("ebirr.apiUserId") ?? "";
}
private get returnUrl(): string {
return this.config.get<string>("ebirr.returnUrl") ?? "";
private get paymentMethod(): string {
return this.config.get<string>("ebirr.paymentMethod") ?? "MWALLET_ACCOUNT";
}
private get channelName(): string {
return this.config.get<string>("ebirr.channelName") ?? "WEB";
}
/**
* How long `purchase()` waits for the payer's PIN. This is awaited on the request path (see
* IntentsService.settleEBirrPurchase), so it MUST stay below the passenger API's
* PAYMENT_API_HTTP_TIMEOUT_MS (60s) and any proxy read timeout in front of it.
*/
private get purchaseTimeoutMs(): number {
return this.config.get<number>("ebirr.purchaseTimeoutMs") ?? 45_000;
}
private get pushTtlMs(): number {
return this.config.get<number>("ebirr.pushTtlMs") ?? 180_000;
}
}

View File

@@ -0,0 +1,143 @@
/**
* EbirrPay (API Payment) request/response types — docs/ebirr/EbirrPay for API PAYMENT.md.
*
* EbirrPay is the same ASM platform as WaafiPay: every operation is multiplexed through a single
* `POST /asm`, discriminated by `serviceName`, with the identical envelope and the identical
* `responseCode === '2001'` convention. See `../waafi/waafi.types.ts`.
*
* We use the API family (`API_PURCHASE`, `API_GETTRANINFO`), which is a *direct wallet debit*:
* the purchase call pushes a PIN prompt to the payer's handset over USSD and blocks until they
* approve it. There is no hosted page, no redirect and no webhook — the vendor doc's §5 "Redirects
* the customer to a secure Hosted Payment Page" and §8.3 "EbirrPay returns APIUrl and orderId" are
* stale copy-paste from the HPP family, contradicted by §5.3's own response body. See
* docs/ebirr/INTEGRATION.md.
*/
/** Terminal/intermediate states reported by EbirrPay (`params.state` / `params.status`). */
export type EbirrState =
| 'APPROVED'
| 'DECLINED'
| 'FAILED'
| 'CANCELLED'
| 'EXPIRED'
| 'TIMEOUT'
| string;
/** Common request envelope shared by every `/asm` call (§3). */
export interface EbirrRequestEnvelope<TServiceParams> {
schemaVersion: '1.0';
requestId: string;
timestamp: string;
channelName: string;
serviceName: string;
serviceParams: TServiceParams;
}
/** Common response envelope. `responseCode === '2001'` means the request was processed. */
export interface EbirrResponseEnvelope<TParams> {
schemaVersion: string;
timestamp: string;
responseId: string;
responseCode: string;
errorCode: string;
responseMsg: string;
params?: TParams;
}
// --- API_PURCHASE -----------------------------------------------------------------------------
export interface EbirrPurchaseServiceParams {
merchantUid: string;
/** Merchant API key (§4). The vendor doc also calls this `APIKey` in §6.2/§7.2 — same field. */
apiKey: string;
/** Doc §5.2 types this as a Number, but the live API accepts the string form. */
apiUserId: string;
paymentMethod: string;
/**
* The payer's wallet. Doc §5.2 names this `subscriptionId`, but the field the live sandbox
* actually accepts is `accountNo` — verified by hand against testpayments.ebirr.com.
*/
payerInfo: {
accountNo: string;
};
transactionInfo: {
referenceId: string;
/** Not listed in §5.2 but accepted by the live API and echoed back by API_GETTRANINFO (§7.3). */
invoiceId?: string;
amount: number;
currency: string;
description?: string;
};
}
export type EbirrPurchaseRequest = EbirrRequestEnvelope<EbirrPurchaseServiceParams>;
/**
* §5.3 plus the fields the live sandbox actually returns.
*
* `state` is the authoritative outcome. Critically, it is present on **rejected** envelopes too,
* so it must be read regardless of `responseCode` — observed live:
*
* 2001 / errorCode 0 → state APPROVED
* 5206 / errorCode E10205 → state DECLINED, description "Invalid Credentials"
* 5001 / errorCode 4004 → state TIMEOUT, description "User Aborted"
*
* `transactionId` and `orderId` are issued even for failed attempts.
*/
export interface EbirrPurchaseParams {
accountNo?: string;
accountType?: string;
state?: EbirrState;
merchantCharges?: string;
referenceId?: string;
transactionId?: string;
/** EbirrPay's own order id (doc §11 "EbirrPay order tracking"). Not in §5.3's example. */
orderId?: string;
issuerTransactionId?: string;
txAmount?: string;
/** Specific failure cause, e.g. "Invalid Credentials" / "User Aborted". Not in §5.3. */
description?: string;
}
export type EbirrPurchaseResponse = EbirrResponseEnvelope<EbirrPurchaseParams>;
// --- API_GETTRANINFO --------------------------------------------------------------------------
export interface EbirrGetTranInfoServiceParams {
merchantUid: string;
apiKey: string;
apiUserId: string;
/** Either the merchant referenceId or the EbirrPay transactionId may be supplied. */
referenceId?: string;
transactionId?: string;
}
export type EbirrGetTranInfoRequest = EbirrRequestEnvelope<EbirrGetTranInfoServiceParams>;
/**
* §7.3 — field-for-field the same set as `WaafiGetTranInfoParams`.
*
* The doc renders the first key as `tranStatETBesc`, which is `tranStat` + `usD` + `esc`: the
* vendor ran a global USD→ETB replace over the Waafi doc and corrupted `tranStatusDesc`. The real
* field is `tranStatusDesc`.
*/
export interface EbirrGetTranInfoParams {
tranStatusDesc?: string;
amount?: string;
payerId?: string;
paymentMethod?: string;
description?: string;
tranDate?: string;
currency?: string;
invoiceId?: string;
referenceId?: string;
tranAmount?: string;
transactionId?: string;
orderId?: string;
tranStatusId?: string;
status?: EbirrState;
/** Purchase responses use `state`; assume the query endpoint may too rather than betting on it. */
state?: EbirrState;
}
export type EbirrGetTranInfoResponse = EbirrResponseEnvelope<EbirrGetTranInfoParams>;

View File

@@ -0,0 +1,53 @@
/**
* Ethiopian MSISDN normalisation.
*
* Passenger phone numbers are stored inconsistently — `+2519…`, `2519…` and local `09…` all
* appear (see the variant-building comment in the passenger API's bookings.service). Ethiopian
* mobile wallets want the bare international form with no `+` and no leading zero, e.g.
* `251923582676`.
*
* This is deliberately separate from `normalizeCacMobile` (cac-bank.json.ts), which does the
* opposite for Djibouti — it *strips* the 253 country code to a national number.
*/
/** Ethiopian mobile subscriber numbers are 9 digits and always start with 9 (or 7 for Safaricom). */
const ET_NATIONAL_LENGTH = 9;
const ET_COUNTRY_CODE = '251';
/**
* Convert any accepted Ethiopian phone format to the bare `251XXXXXXXXX` form.
*
* Accepts `+251923582676`, `251923582676`, `0923582676` and `923582676`, plus spaces, dashes and
* parentheses anywhere. Throws on anything that isn't a plausible Ethiopian mobile number rather
* than silently sending a wrong account — a mistyped number would push a PIN prompt to a
* stranger's handset.
*/
export function normalizeEthiopianMsisdn(input: string): string {
const digits = (input ?? '').replace(/[\s()+-]/g, '');
if (!/^\d+$/.test(digits)) {
throw new Error(`Invalid Ethiopian mobile number: ${input}`);
}
let national: string;
if (digits.startsWith('00' + ET_COUNTRY_CODE)) {
national = digits.slice(2 + ET_COUNTRY_CODE.length);
} else if (digits.startsWith(ET_COUNTRY_CODE)) {
national = digits.slice(ET_COUNTRY_CODE.length);
} else if (digits.startsWith('0')) {
national = digits.slice(1);
} else {
national = digits;
}
if (national.length !== ET_NATIONAL_LENGTH || !/^[79]/.test(national)) {
throw new Error(`Invalid Ethiopian mobile number: ${input}`);
}
return `${ET_COUNTRY_CODE}${national}`;
}
/** Mask an MSISDN for display/logging: `251923582676` → `2519****2676`. */
export function maskMsisdn(msisdn: string): string {
if (msisdn.length <= 8) return '****';
return `${msisdn.slice(0, 4)}****${msisdn.slice(-4)}`;
}

View File

@@ -1,12 +0,0 @@
export interface EBirrWebhookPayload {
merchantCode: string;
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
currency?: string;
payTime?: number;
timestamp: number;
sign: string;
[key: string]: unknown;
}

View File

@@ -58,6 +58,17 @@ export type ClientAction =
providerOrderId: string;
message?: string;
}
| {
/**
* Wallet push-debit (eBirr): the provider has already prompted the payer on their own
* handset (USSD/app PIN). There is nothing to navigate to and nothing to collect —
* the client shows `message` and polls the intent until it turns terminal.
*/
type: "AWAIT_PUSH";
message: string;
/** Masked MSISDN the prompt was pushed to, so the payer can confirm it's their phone. */
payerAccountMasked?: string;
}
| {
/** CBE_BILL: show the bill reference the customer pays at any CBE channel. */
type: "SHOW_BILL_REFERENCE";
@@ -77,6 +88,8 @@ export interface ProviderInitiationInput {
* Payer account identifier (e.g. mobile-wallet MSISDN in full international format).
* Optional and provider-specific: some wallet providers (e.g. Waafi HPP with
* MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page.
* For push-debit providers (eBirr, CAC Bank) it is mandatory — it is the account the PIN
* prompt is pushed to, so there is no hosted page that could collect it later.
*/
payerAccount?: string;
/** Optional caller-supplied redirect targets for redirect/HPP-style providers. */

View File

@@ -55,10 +55,24 @@ export interface ETradeCompanyInfo {
RenewedFrom: string;
RenewedTo: string;
BusinessLicensingGroupMain: string | null;
SubGroups: string | null;
SubGroups: Array<{ Code: number; Description: string }> | null;
}>;
}
/**
* One business licence held under a TIN. A single owner routinely holds many
* (import of vehicles, export of coffee, freight forwarding…), all sharing the
* same trade name — the licensed activity is what tells them apart, so that is
* what the customer picks by.
*/
export interface ETradeBusinessOption {
licenceNumber: string;
tradeName: string;
/** The licensed activities ("Import trade in …"), joined. May be empty. */
activity: string;
renewedTo: string;
}
export interface CompanyRegistrationData {
/**
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
@@ -84,4 +98,11 @@ export interface CompanyRegistrationData {
managerPhone: string;
/** True when this TIN is already registered to an existing company. */
tinTaken?: boolean;
/**
* Every licence this TIN holds. More than one means the customer has to say
* which business they are acting as before the registration data above can be
* trusted — it describes whichever licence was selected (the first, by
* default).
*/
businesses?: ETradeBusinessOption[];
}

View File

@@ -717,6 +717,12 @@ export interface IBooking extends BaseEntity {
selectedForBatchAt?: string | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
paymentDeadline?: string | null;
/**
* End of the settlement drain tail that follows `paymentDeadline`. Between the
* two, an in-flight payment can still land, so the customer is shown a
* "payment processing" state instead of a pay action.
*/
paymentDrainEndsAt?: string | null;
containers?: Array<{ type: string; qty: number; vgm: number }> | null;

View File

@@ -1,7 +1,3 @@
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
// Optional PostCSS configuration for applications that need it
export const postcssConfig = {
plugins: {

View File

@@ -13,7 +13,10 @@ import { DataTableFooter } from "./footer";
export function DataTable<TData, TValue>({
columns,
data,
status,
// The body only renders under "success", but the footer renders regardless —
// omitting status gave a blank table under a populated "Showing 1N of N"
// footer. Having rows to draw is the default case, so default to success.
status = "success",
onRowClick,
rowStyle,
rowClassName,