mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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,8 +2138,33 @@ 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:
|
||||
// per delivering truck (generated on truck exit), signed one by one.
|
||||
|
||||
@@ -210,6 +210,7 @@ export class CompaniesController {
|
||||
const data = await this.companiesService.fetchETradeData(
|
||||
dto.tin,
|
||||
companyId,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ETradeResponseDto(data);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 || "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -199,7 +199,7 @@ 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(
|
||||
@@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
quantity: 20,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(requestRepo.save).toHaveBeenCalled();
|
||||
expect(stored.quantity).toBe(50);
|
||||
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',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(/only 20 wagon\(s\).*available/i);
|
||||
expect(requestRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
export * from "./BookingSchedulingWindowCard";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || "—"}
|
||||
</Text>
|
||||
),
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
onDataLoaded(result);
|
||||
}
|
||||
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,17 +164,28 @@ 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"
|
||||
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken
|
||||
? "verified"
|
||||
: notFound
|
||||
? "not-found"
|
||||
: errorMessage
|
||||
? "error"
|
||||
: "idle";
|
||||
: needsChoice
|
||||
? "choose-business"
|
||||
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken
|
||||
? "verified"
|
||||
: notFound
|
||||
? "not-found"
|
||||
: errorMessage
|
||||
? "error"
|
||||
: "idle";
|
||||
|
||||
const lastReportedStatus = useRef<ETradeStatus | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,9 +273,11 @@ export function BookingPaymentPanel({
|
||||
{paid ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
|
||||
{paid
|
||||
? "Paid"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
|
||||
: draining
|
||||
? "Payment processing"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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're confirming the payments that
|
||||
came in. If you already paid, it can take a few minutes to appear —
|
||||
please don't pay again. This page updates on its own.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '',
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,17 +211,30 @@ 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)
|
||||
: rawFare;
|
||||
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;
|
||||
@@ -282,8 +320,8 @@ export class PaymentsService {
|
||||
remainingMs <= 0
|
||||
? "The payment window for this booking has expired. Please make a new booking."
|
||||
: `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` +
|
||||
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
|
||||
`Please make a new booking.`,
|
||||
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
|
||||
`Please make a new booking.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,18 +361,24 @@ 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(
|
||||
booking.totalMinor,
|
||||
booking.currency,
|
||||
booking.currency,
|
||||
chargeCurrency,
|
||||
);
|
||||
}
|
||||
@@ -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,17 +968,20 @@ 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;
|
||||
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
|
||||
|
||||
|
||||
return this.prisma.paymentMethod.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
@@ -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,78 +1675,106 @@ 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,
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.destinationStationId,
|
||||
seatIds: booking.seats.map((s: any) => s.seatId),
|
||||
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,
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.destinationStationId,
|
||||
seatIds: seatsForLeg(1),
|
||||
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,
|
||||
scheduleId: b.returnScheduleId,
|
||||
originStationId: b.returnOriginStationId,
|
||||
destinationStationId: b.returnDestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
seatIds: seatsForLeg(2),
|
||||
});
|
||||
}
|
||||
} else if (booking.bookingType === 'TRANSIT') {
|
||||
} else if (booking.bookingType === "TRANSIT") {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.leg2OriginStationId, // transit station
|
||||
seatIds: seatsForLeg(1),
|
||||
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,
|
||||
scheduleId: b.leg2ScheduleId,
|
||||
originStationId: b.leg2OriginStationId,
|
||||
destinationStationId: b.leg2DestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
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,
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.leg2OriginStationId,
|
||||
seatIds: seatsForLeg(1),
|
||||
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,
|
||||
scheduleId: b.leg2ScheduleId,
|
||||
originStationId: b.leg2OriginStationId,
|
||||
destinationStationId: b.leg2DestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
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,
|
||||
seatIds: seatsForLeg(3),
|
||||
scheduleId: b.returnScheduleId,
|
||||
originStationId: b.returnOriginStationId,
|
||||
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,
|
||||
scheduleId: b.returnLeg2ScheduleId,
|
||||
originStationId: b.returnLeg2OriginStationId,
|
||||
destinationStationId: b.returnLeg2DestStationId,
|
||||
seatIds: seatsForLeg(4),
|
||||
seatIds: seatsForLeg(4),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1629,10 +1784,10 @@ export class PaymentsService {
|
||||
const journey = await this.prisma.journey.create({
|
||||
data: {
|
||||
passengerId: booking.passengerId,
|
||||
bookingId: booking.id,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
bookingId: booking.id,
|
||||
status: "CONFIRMED",
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
} as any,
|
||||
});
|
||||
|
||||
@@ -1644,23 +1799,27 @@ 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) {
|
||||
for (let i = originIdx; i < destIdx; i++) {
|
||||
journeySegments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId: leg.scheduleId,
|
||||
segmentOrder: segmentOrder++,
|
||||
journeyId: journey.id,
|
||||
scheduleId: leg.scheduleId,
|
||||
segmentOrder: segmentOrder++,
|
||||
seatId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
// 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 mapped = this.mapStatus(response.data.tradeStatus);
|
||||
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>,
|
||||
};
|
||||
}
|
||||
|
||||
// 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>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
providerTxnId: response.params?.transactionId,
|
||||
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));
|
||||
}
|
||||
|
||||
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(tradeStatus);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
143
packages/payment-providers/src/providers/ebirr/ebirr.types.ts
Normal file
143
packages/payment-providers/src/providers/ebirr/ebirr.types.ts
Normal 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>;
|
||||
53
packages/payment-providers/src/utils/msisdn.ts
Normal file
53
packages/payment-providers/src/utils/msisdn.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 1–N of N"
|
||||
// footer. Having rows to draw is the default case, so default to success.
|
||||
status = "success",
|
||||
onRowClick,
|
||||
rowStyle,
|
||||
rowClassName,
|
||||
|
||||
Reference in New Issue
Block a user