mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #1042 from Tria-plc/freight/chore/payment-test
CAC Integration to the freight api, tests fix
This commit is contained in:
@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
|
||||
expect(manager.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
const openInvoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: "PREPAID",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
currency: "USD",
|
||||
balanceAmount: 500,
|
||||
totalAmount: 500,
|
||||
paymentId: "intent-1",
|
||||
dueAt: null,
|
||||
};
|
||||
|
||||
const build = (payment: Record<string, unknown>) => {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(openInvoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ getRepository: () => repo } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
makeEvents() as never,
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
|
||||
const initiate = jest.fn();
|
||||
const { service } = build({ initiate });
|
||||
|
||||
await expect(
|
||||
service.payInvoice("inv-1", { method: "CAC_BANK" }),
|
||||
).rejects.toThrow(/payerAccount/);
|
||||
expect(initiate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
|
||||
const handlePaymentEvent = jest.fn();
|
||||
const { service } = build({
|
||||
initiate: jest.fn().mockResolvedValue({
|
||||
intentId: "intent-1",
|
||||
immediateSuccess: false,
|
||||
response: {
|
||||
intentId: "intent-1",
|
||||
status: "REQUIRES_ACTION",
|
||||
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
|
||||
},
|
||||
}),
|
||||
handlePaymentEvent,
|
||||
});
|
||||
|
||||
await service.payInvoice("inv-1", {
|
||||
method: "CAC_BANK",
|
||||
payerAccount: "77123456",
|
||||
});
|
||||
|
||||
expect(handlePaymentEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms the OTP against the intent stamped on the invoice", async () => {
|
||||
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
|
||||
const { service } = build({ confirmOtp });
|
||||
|
||||
await service.confirmInvoiceOtp("inv-1", "123456");
|
||||
|
||||
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
@@ -352,6 +352,34 @@ export class BillingService {
|
||||
return this.payInvoice(id, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the CAC Bank OTP for one of the customer's own invoices
|
||||
* (ownership-checked). Settlement of the invoice happens inside the payment
|
||||
* service when the OTP succeeds.
|
||||
*/
|
||||
async confirmInvoiceOtpForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
otp: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.confirmInvoiceOtp(id, otp);
|
||||
}
|
||||
|
||||
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
|
||||
async confirmInvoiceOtp(
|
||||
invoiceId: string,
|
||||
otp: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: invoiceId } });
|
||||
if (!invoice?.paymentId) {
|
||||
throw new NotFoundException("No payment to confirm for this invoice");
|
||||
}
|
||||
return this.payment.confirmOtp(invoice.paymentId, otp);
|
||||
}
|
||||
|
||||
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async documentForUser(
|
||||
id: string,
|
||||
@@ -1035,6 +1063,17 @@ export class BillingService {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
||||
// required up front (the payment service rejects it otherwise, as a 502 here).
|
||||
if (
|
||||
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
|
||||
!opts.payerAccount?.trim()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"payerAccount (mobile number) is required for CAC Bank",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: invoice.sourceId,
|
||||
source: invoice.source,
|
||||
@@ -1062,7 +1101,12 @@ export class BillingService {
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
if (!result.immediateSuccess) {
|
||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||
// code — so the demo shortcut must never fire for it.
|
||||
if (
|
||||
!result.immediateSuccess &&
|
||||
result.response.clientAction?.type !== "COLLECT_OTP"
|
||||
) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */
|
||||
export class ConfirmOtpDto {
|
||||
@ApiProperty({ description: "One-time password SMSed by the bank." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
/** Gateway options for paying an invoice from the customer portal. */
|
||||
export class PayInvoiceDto {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { sendPdf } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
|
||||
/**
|
||||
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||
@@ -96,4 +96,20 @@ export class PortalBillingController {
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("my-invoices/:id/confirm")
|
||||
@ApiOperation({
|
||||
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
|
||||
})
|
||||
confirmOtp(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: ConfirmOtpDto,
|
||||
) {
|
||||
return this.billingService.confirmInvoiceOtpForUser(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.otp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
||||
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||
* national ID number, so none is collected or derived here.
|
||||
*
|
||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
||||
* Fayda, and their details can't be edited afterwards.
|
||||
* Only the OWNER's credential varies by nationality:
|
||||
* - Ethiopian company: the owner is verified through Fayda.
|
||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
||||
* supplies a typed passport number — required on its own, whether or not the
|
||||
* owner also completes a (purely optional) Fayda verification.
|
||||
*
|
||||
* The PoA does not vary. A representative acts for the company inside Ethiopia
|
||||
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
|
||||
* is named, both nationalities must verify them, and their details come from
|
||||
* the verified payload rather than the form.
|
||||
*
|
||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||
* the portal offers a "same as owner" copy for, but it is never itself
|
||||
* Fayda-verified or gated on.
|
||||
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
},
|
||||
changeRequestRepo: {
|
||||
findPendingByCompanyId: jest.fn(async () => null),
|
||||
findLatestOpenByCompanyId: jest.fn(async () => null),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
@@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("stages the change for review on an approved company", async () => {
|
||||
// Swapping the person who can act for a live company is exactly what the
|
||||
// backoffice review exists for, so it must not rewrite the row directly.
|
||||
it("stages an owner re-verification for review on an approved company", async () => {
|
||||
// The owner is the live company's identity proof, so re-verifying one is
|
||||
// exactly what the backoffice review exists for: it must not rewrite the
|
||||
// row directly.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
|
||||
await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies a PoA verification live on an approved company", async () => {
|
||||
// The PoA is personnel the company names for itself — the delegation paper
|
||||
// is what a reviewer actually judges — so it does not go to review.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
@@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
||||
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||
});
|
||||
|
||||
it("refuses to rename a verified person by hand", async () => {
|
||||
@@ -367,7 +391,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
|
||||
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
ownerPassportNumber: "P1234567",
|
||||
...POA_VERIFIED,
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
||||
// The owner's credential is nationality-specific; the representative's is
|
||||
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
||||
// typed foreign name is not a representative the platform can accept.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
@@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||
|
||||
@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
findPendingByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findLatestOpenByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
|
||||
@@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
/**
|
||||
* Personnel an approved company maintains itself: its contact person, its
|
||||
* general manager and its Power of Attorney. These name who to talk to, not
|
||||
* what the company is allowed to do, so freezing the settings page until a
|
||||
* reviewer gets to a new phone number costs more than it protects. They write
|
||||
* straight to the live row even for an active company.
|
||||
*
|
||||
* The PoA's *delegation letter* is deliberately not here — the paper is the
|
||||
* thing that actually evidences the delegation, so it still goes through
|
||||
* review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
|
||||
*/
|
||||
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
|
||||
"contactPersonName",
|
||||
"contactPersonPosition",
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
"contactVerifiedPhone",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
...POA_ATTRIBUTES,
|
||||
];
|
||||
/** Mandatory once the company operates as a freight forwarder. */
|
||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: "poaName", label: "PoA name" },
|
||||
@@ -844,10 +866,11 @@ export class CompaniesService {
|
||||
*
|
||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||
* as before. The company/role pending→approve gate already covers first-run.
|
||||
* - Company already `active` → do NOT touch the live Company. Stage the edit in
|
||||
* a pending change request (merging into any open one) so a backoffice
|
||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
||||
* customer until the review resolves.
|
||||
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
|
||||
* still write straight through; everything else does NOT touch the live
|
||||
* Company but is staged in a pending change request (merging into any open
|
||||
* one) so a backoffice reviewer can approve (apply) or reject (with a
|
||||
* note). Only the staged half locks the customer until the review resolves.
|
||||
*/
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
@@ -882,9 +905,37 @@ export class CompaniesService {
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
// Approved company: stage the change for review, leaving the live row intact.
|
||||
// Approved company: personnel details apply immediately, the rest is staged
|
||||
// for review with the live row left intact.
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const fields = this.pickDefined(dto);
|
||||
const selfService: Record<string, any> = {};
|
||||
const staged: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
|
||||
else staged[key] = value;
|
||||
}
|
||||
|
||||
let live = company;
|
||||
if (Object.keys(selfService).length > 0) {
|
||||
live =
|
||||
(await this.companiesRepo.update(
|
||||
company.id,
|
||||
this.mapProfileDtoToCompanyUpdates(company, selfService),
|
||||
)) ?? company;
|
||||
live.companyProfiles = company.companyProfiles;
|
||||
}
|
||||
|
||||
if (Object.keys(staged).length === 0) {
|
||||
// Nothing a reviewer needs to see. Any request already open (a document
|
||||
// upload, an owner verification) still surfaces so its banner survives —
|
||||
// it just no longer gains fields it was never asked to review.
|
||||
return new ProfileResponseDto(
|
||||
profile,
|
||||
live,
|
||||
await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||
company.id,
|
||||
@@ -894,7 +945,7 @@ export class CompaniesService {
|
||||
if (existing) {
|
||||
request =
|
||||
(await this.changeRequestRepo.update(existing.id, {
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
@@ -910,7 +961,7 @@ export class CompaniesService {
|
||||
);
|
||||
request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot: fields,
|
||||
snapshot: staged,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
@@ -922,8 +973,9 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Live company is unchanged; surface the pending state for the settings page.
|
||||
return new ProfileResponseDto(profile, company, request);
|
||||
// Only the personnel half (if any) landed; surface the pending state for
|
||||
// the settings page.
|
||||
return new ProfileResponseDto(profile, live, request);
|
||||
}
|
||||
|
||||
/** List a company's change requests, newest first (backoffice review). */
|
||||
@@ -1720,16 +1772,11 @@ export class CompaniesService {
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
||||
// from the Fayda verification — so reporting them as missing fields would
|
||||
// ask for something the form no longer offers. The identity block below
|
||||
// No company types its PoA details — they arrive from the Fayda
|
||||
// verification whatever the nationality — so reporting them as missing
|
||||
// fields would ask for something no form offers. The identity block below
|
||||
// reports "verify your PoA" instead.
|
||||
const missingPoaFields =
|
||||
poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
||||
const delegation = await this.getPoaDelegationState(company.id);
|
||||
const delegationDue = poaRequired || poaProvided;
|
||||
const missingDelegation = delegationDue && !delegation.onFile;
|
||||
@@ -1754,9 +1801,7 @@ export class CompaniesService {
|
||||
...(identity.faydaRequired && !identity.owner.verified
|
||||
? ["Verify the company owner's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.faydaRequired &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!identity.poa.verified
|
||||
...((poaRequired || poaProvided) && !identity.poa.verified
|
||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.passportRequired && !identity.owner.passportNumber
|
||||
@@ -1768,26 +1813,20 @@ export class CompaniesService {
|
||||
// fields, required documents, one license per operational profile, and the
|
||||
// PoA details/paper whenever those are mandatory.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const poaItemCount =
|
||||
(poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.length
|
||||
: 0) + (delegationDue ? 1 : 0);
|
||||
const poaItemCount = delegationDue ? 1 : 0;
|
||||
// One item per identity credential the company has to prove: the owner
|
||||
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
||||
// is one and Fayda is what's mandatory here.
|
||||
const identityItemCount = identity.faydaRequired
|
||||
? delegationDue
|
||||
? 2
|
||||
: 1
|
||||
: identity.passportRequired
|
||||
? 1
|
||||
: 0;
|
||||
const missingIdentityCount = identity.faydaRequired
|
||||
? (identity.owner.verified ? 0 : 1) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0)
|
||||
: identity.passportRequired && !identity.owner.passportNumber
|
||||
? 1
|
||||
: 0;
|
||||
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||
// there is one — that one is Fayda whatever the nationality.
|
||||
const ownerCredentialDue =
|
||||
identity.faydaRequired || identity.passportRequired;
|
||||
const ownerCredentialProven = identity.faydaRequired
|
||||
? identity.owner.verified
|
||||
: Boolean(identity.owner.passportNumber);
|
||||
const identityItemCount =
|
||||
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||
const missingIdentityCount =
|
||||
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||
const total =
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
@@ -2453,11 +2492,13 @@ export class CompaniesService {
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
// An approved company's profile edits are staged for backoffice review, and
|
||||
// swapping the person who can act for the company is exactly the kind of
|
||||
// edit that review exists for — so a verification lands the same way an
|
||||
// ordinary edit does, rather than quietly rewriting a live record.
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||
// is staged for backoffice review rather than quietly rewriting a live
|
||||
// record. The PoA is personnel — the company names its own representative,
|
||||
// and the delegation letter backing them is what the reviewer sees — so a
|
||||
// PoA verification lands live, matching the typed PoA fields in
|
||||
// `SELF_SERVICE_ATTRIBUTES`.
|
||||
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
||||
await this.stageIdentityChange(company, userId, identity);
|
||||
return this.getCompanyIdentityState(company);
|
||||
}
|
||||
@@ -2586,21 +2627,23 @@ export class CompaniesService {
|
||||
): void {
|
||||
const state = buildCompanyIdentityState(company);
|
||||
|
||||
// Only the owner's credential is nationality-specific: Fayda for an
|
||||
// Ethiopian company, a typed passport number for a foreign one.
|
||||
if (state.passportRequired) {
|
||||
if (!state.owner.passportNumber) {
|
||||
throw new BadRequestException(
|
||||
"Add the company owner's passport number before continuing.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.owner.verified) {
|
||||
} else if (!state.owner.verified) {
|
||||
throw new BadRequestException(
|
||||
"Verify the company owner's identity with Fayda before continuing.",
|
||||
);
|
||||
}
|
||||
|
||||
// The representative is not. A PoA acts for the company inside Ethiopia
|
||||
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
||||
// a foreign company nominates one rather than typing a name.
|
||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
|
||||
@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
const complete = faydaRequired
|
||||
? owner.verified && (!poaDue || poa.verified)
|
||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
||||
// proven with Fayda — a foreign company nominates a representative who holds
|
||||
// one rather than typing a name nothing backs.
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
const complete = ownerProven && (!poaDue || poa.verified);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
@@ -67,6 +72,33 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
|
||||
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
|
||||
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
|
||||
* re-enter the code.
|
||||
*/
|
||||
async confirmOtp(intentId: string, otp: string): Promise<PaymentIntentSnapshot> {
|
||||
try {
|
||||
return await this.call<PaymentIntentSnapshot>(
|
||||
"POST",
|
||||
`/payments/intents/${intentId}/confirm`,
|
||||
{ otp },
|
||||
);
|
||||
} catch (err) {
|
||||
// `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
|
||||
// unknown intent or a bad OTP is client-fixable, so translate both to 400.
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
throw new BadRequestException("PaymentIntent not found");
|
||||
}
|
||||
if (err instanceof BadGatewayException) {
|
||||
const detail = err.message.replace(/^Payment service error: /, "");
|
||||
throw new BadRequestException(detail);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
|
||||
@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
// CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old
|
||||
// 10s cap 502'd every CAC charge while the bank was still working —
|
||||
// orphaning an intent the payer had already been texted about. Matches
|
||||
// the passenger API's budget.
|
||||
HttpModule.register({
|
||||
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
||||
}),
|
||||
ConfigModule,
|
||||
forwardRef(() => BillingModule),
|
||||
// forwardRef(() => TrainSchedulingModule),
|
||||
|
||||
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { of, throwError } from "rxjs";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types";
|
||||
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
/** Local intent projection row (the invoice's `paymentId` points at this). */
|
||||
function localIntent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "intent-1",
|
||||
refId: "booking-1",
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
status: "action-required",
|
||||
method: "cac-bank",
|
||||
merchantOrderId: "EDR_INV_1",
|
||||
clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRepo(rows: Record<string, unknown>[]) {
|
||||
const store = [...rows];
|
||||
return {
|
||||
findOneBy: jest.fn((where: Record<string, unknown>) =>
|
||||
Promise.resolve(
|
||||
store.find((r) =>
|
||||
Object.entries(where).every(([k, v]) => r[k] === v),
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
|
||||
const row = store.find((r) => r.id === where.id);
|
||||
if (row) Object.assign(row, data);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PaymentService.confirmOtp", () => {
|
||||
const build = (
|
||||
client: Partial<PaymentClientService>,
|
||||
rows = [localIntent()],
|
||||
) => {
|
||||
const repo = makeRepo(rows);
|
||||
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
|
||||
const service = new PaymentService(
|
||||
repo as never,
|
||||
client as never,
|
||||
billing as never,
|
||||
);
|
||||
return { service, repo, billing };
|
||||
};
|
||||
|
||||
it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => {
|
||||
const paidAt = "2026-07-31T10:00:00.000Z";
|
||||
const { service, repo, billing } = build({
|
||||
getIntentByReference: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }),
|
||||
confirmOtp: jest.fn().mockResolvedValue({
|
||||
intentId: "gw-1",
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: "11709363209530624",
|
||||
paidAt,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.confirmOtp("intent-1", "8280");
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
{ id: "intent-1" },
|
||||
expect.objectContaining({
|
||||
status: "success",
|
||||
transactionId: "11709363209530624",
|
||||
}),
|
||||
);
|
||||
// Billing settles the invoice linked by this intent id.
|
||||
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
|
||||
"intent-1",
|
||||
"11709363209530624",
|
||||
new Date(paidAt),
|
||||
);
|
||||
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
|
||||
});
|
||||
|
||||
it("forwards the OTP against the GATEWAY intent id, not the local one", async () => {
|
||||
const confirmOtp = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION });
|
||||
const { service } = build({
|
||||
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
|
||||
confirmOtp,
|
||||
});
|
||||
|
||||
await service.confirmOtp("intent-1", "8280");
|
||||
|
||||
expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280");
|
||||
});
|
||||
|
||||
it("leaves the intent open and does not settle when the OTP is not accepted", async () => {
|
||||
const { service, repo, billing } = build({
|
||||
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
|
||||
confirmOtp: jest.fn().mockResolvedValue({
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
failureMessage: "OTP confirmation failed",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.confirmOtp("intent-1", "0000");
|
||||
|
||||
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
{ id: "intent-1" },
|
||||
expect.objectContaining({ status: "action-required" }),
|
||||
);
|
||||
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
|
||||
});
|
||||
|
||||
it("404s when the gateway has no active intent for the reference", async () => {
|
||||
const { service } = build({
|
||||
getIntentByReference: jest.fn().mockResolvedValue(null),
|
||||
confirmOtp: jest.fn(),
|
||||
});
|
||||
|
||||
await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentClientService.confirmOtp", () => {
|
||||
const axiosErr = (status: number, message: string) =>
|
||||
new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status,
|
||||
statusText: "",
|
||||
data: { message },
|
||||
headers: new AxiosHeaders(),
|
||||
config: { headers: new AxiosHeaders() },
|
||||
},
|
||||
);
|
||||
|
||||
const build = (request: jest.Mock) =>
|
||||
new PaymentClientService({ request } as never);
|
||||
|
||||
it("posts the OTP to the payment service intent-confirm route", async () => {
|
||||
const request = jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } }));
|
||||
|
||||
const result = await build(request).confirmOtp("gw-1", "8280");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: expect.stringContaining("/payments/intents/gw-1/confirm"),
|
||||
data: { otp: "8280" },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("SUCCEEDED");
|
||||
});
|
||||
|
||||
it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => {
|
||||
const request = jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
throwError(() => axiosErr(400, "OTP confirmation failed")),
|
||||
);
|
||||
|
||||
await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => {
|
||||
const request = jest
|
||||
.fn()
|
||||
.mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found")));
|
||||
|
||||
await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -374,6 +374,53 @@ export class PaymentService {
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
|
||||
* id (the invoice's `paymentId`) so the right invoice settles even when several
|
||||
* invoices share a domain reference. The active gateway intent is looked up by
|
||||
* reference, the OTP is forwarded, and the projection is refreshed. On success
|
||||
* billing settles the linked invoice (idempotent — the outbox path converges too).
|
||||
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
|
||||
*/
|
||||
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
(local.referenceType as PaymentReferenceType) ??
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
local.refId,
|
||||
);
|
||||
if (!snapshot) {
|
||||
throw new NotFoundException("No active payment to confirm");
|
||||
}
|
||||
|
||||
const confirmed = await this.paymentClient.confirmOtp(
|
||||
snapshot.intentId,
|
||||
otp,
|
||||
);
|
||||
|
||||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: confirmed.providerTxnId,
|
||||
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
} else {
|
||||
await this.paymentRepo.update(
|
||||
{ id: local.id },
|
||||
{
|
||||
status: this.toLocalStatus(confirmed.status),
|
||||
failerCode: confirmed.failureCode ?? undefined,
|
||||
failureMessage: confirmed.failureMessage ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||
|
||||
@@ -201,6 +201,8 @@ export const URL_CONSTANTS = {
|
||||
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||
CONFIRM_INVOICE_OTP: (id: string) =>
|
||||
`/api/billing/my-invoices/${id}/confirm`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
|
||||
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal file
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { useState } from "react";
|
||||
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import {
|
||||
paymentsService,
|
||||
type InitiateResponse,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
|
||||
/** How the invoice is charged — overridable for warehouse fee invoices. */
|
||||
type InitiateFn = (
|
||||
invoiceId: string,
|
||||
method: PaymentMethod,
|
||||
payerAccount?: string,
|
||||
) => Promise<InitiateResponse>;
|
||||
|
||||
const payViaBilling: InitiateFn = (invoiceId, method, payerAccount) =>
|
||||
invoicesService.pay(invoiceId, { method, platform: "web", payerAccount });
|
||||
|
||||
/** The server's message (`{ message }` / `{ message: [] }`), or a fallback. */
|
||||
function apiMessage(err: unknown, fallback: string): string {
|
||||
const message = (err as AxiosError<{ message?: string | string[] }>)?.response
|
||||
?.data?.message;
|
||||
const first = Array.isArray(message) ? message[0] : message;
|
||||
return first || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* One payment flow for every "pay this invoice" entry point: initiate, then
|
||||
* either redirect to the provider or — for CAC Bank, an OTP debit with no
|
||||
* redirect — collect the SMS'd code and confirm it in-app. Pass `initiate` to
|
||||
* charge through a different endpoint (warehouse fee invoices); OTP
|
||||
* confirmation always goes through billing, which owns the intent either way.
|
||||
*/
|
||||
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
||||
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
|
||||
const [otpMessage, setOtpMessage] = useState<string | undefined>();
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (vars: {
|
||||
invoiceId: string;
|
||||
method: PaymentMethod;
|
||||
payerAccount?: string;
|
||||
}) => initiate(vars.invoiceId, vars.method, vars.payerAccount),
|
||||
onSuccess: (data, vars) => {
|
||||
if (data?.clientAction?.type === "COLLECT_OTP") {
|
||||
setOtpMessage(
|
||||
data.clientAction.message ?? "Enter the OTP sent to your phone",
|
||||
);
|
||||
setOtpInvoiceId(vars.invoiceId);
|
||||
return;
|
||||
}
|
||||
window.location.href =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: vars.invoiceId,
|
||||
method: vars.method,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const otpMutation = useMutation({
|
||||
mutationFn: (otp: string) =>
|
||||
invoicesService.confirmOtp(otpInvoiceId as string, otp),
|
||||
// Settled — reload so the invoice/booking re-reads its now-paid state.
|
||||
onSuccess: () => {
|
||||
setOtpInvoiceId(null);
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
payMutation.reset();
|
||||
otpMutation.reset();
|
||||
setOtpInvoiceId(null);
|
||||
};
|
||||
|
||||
return {
|
||||
processing: payMutation.isPending,
|
||||
error: payMutation.isError
|
||||
? apiMessage(
|
||||
payMutation.error,
|
||||
payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again.",
|
||||
)
|
||||
: null,
|
||||
pay: (invoiceId: string, method: PaymentMethod, payerAccount?: string) =>
|
||||
payMutation.mutate({ invoiceId, method, payerAccount }),
|
||||
reset,
|
||||
/** Drives the modal's OTP step; `open` only for CAC Bank. */
|
||||
otp: {
|
||||
open: otpInvoiceId !== null,
|
||||
message: otpMessage,
|
||||
submitting: otpMutation.isPending,
|
||||
// A wrong/expired OTP is a 400 — keep the step open so the payer retries.
|
||||
error: otpMutation.isError
|
||||
? apiMessage(
|
||||
otpMutation.error,
|
||||
"Invalid or expired OTP. Please try again.",
|
||||
)
|
||||
: null,
|
||||
submit: (otp: string) => otpMutation.mutate(otp),
|
||||
cancel: () => {
|
||||
otpMutation.reset();
|
||||
setOtpInvoiceId(null);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type InvoicePaymentFlow = ReturnType<typeof useInvoicePayment>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
PackagePlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
||||
isPayable(inv.status),
|
||||
)?.id;
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payableInvoiceId) {
|
||||
throw new Error(
|
||||
"No payable invoice found for this booking yet. Please refresh or contact support.",
|
||||
);
|
||||
}
|
||||
return api.invoices.pay.call({
|
||||
id: payableInvoiceId,
|
||||
payload: { method, platform: "web" },
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const url =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: payableInvoiceId!,
|
||||
method,
|
||||
});
|
||||
window.location.href = url;
|
||||
},
|
||||
});
|
||||
const pay = useInvoicePayment();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
||||
<PaymentMethodModal
|
||||
opened={payItem !== null}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
if (!pay.processing) {
|
||||
setPayItem(null);
|
||||
payMutation.reset();
|
||||
pay.reset();
|
||||
}
|
||||
}}
|
||||
currency={undefined}
|
||||
processing={payMutation.isPending}
|
||||
processing={pay.processing}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
pay.error ??
|
||||
(payItemInvoices.length > 0 && !payableInvoiceId
|
||||
? "No payable invoice found for this booking yet. Please refresh or contact support."
|
||||
: null)
|
||||
}
|
||||
otp={pay.otp}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
payableInvoiceId &&
|
||||
pay.pay(payableInvoiceId, method, payerAccount)
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
</Card>
|
||||
|
||||
@@ -287,9 +287,11 @@ export default function SettingsPage() {
|
||||
icon={<Clock size={18} />}
|
||||
title="Changes submitted for review"
|
||||
>
|
||||
Your recent changes are awaiting administrator approval. Editing is
|
||||
disabled until the review is complete — you'll be notified once it's
|
||||
approved or if any changes are requested.
|
||||
Your recent changes are awaiting administrator approval. Company
|
||||
details and documents can't be edited until the review is complete —
|
||||
you'll be notified once it's approved or if any changes are
|
||||
requested. Your contact person, general manager and Power of
|
||||
Attorney stay editable.
|
||||
</Alert>
|
||||
)}
|
||||
{reviewStatus === "rejected" && (
|
||||
@@ -358,9 +360,17 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* While a change request is pending, every panel's inputs + submit
|
||||
buttons are disabled via the native fieldset; tab switching stays
|
||||
enabled so the customer can still review what they submitted. */}
|
||||
{/* While a change request is pending, the reviewed panels' inputs +
|
||||
submit buttons are disabled via the native fieldset; tab switching
|
||||
stays enabled so the customer can still review what they
|
||||
submitted.
|
||||
|
||||
Personnel panels below (contact person, general manager, Power of
|
||||
Attorney) are deliberately outside the lock: the API applies those
|
||||
edits live rather than staging them, so locking them here would
|
||||
re-impose the approval wait the API no longer does. The PoA's
|
||||
delegation letter is still reviewed — that lock lives on the file
|
||||
itself, not the panel. */}
|
||||
<Tabs.Panel value="company">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
||||
@@ -368,19 +378,13 @@ export default function SettingsPage() {
|
||||
<OperationalServicesCard profile={profile} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="contact">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="gm">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="poa">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="documents">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
buildOnboardingSchema,
|
||||
type CompanyStep,
|
||||
type FormData,
|
||||
hasPoaDetails,
|
||||
POA_DELEGATION_FILE_KEY,
|
||||
stepFields,
|
||||
} from "./companyProfileForm/schema";
|
||||
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(
|
||||
buildOnboardingSchema(
|
||||
requirePoa,
|
||||
verifiedIdentity,
|
||||
identity?.passportRequired === true,
|
||||
),
|
||||
buildOnboardingSchema(identity?.passportRequired === true),
|
||||
),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
|
||||
// them and re-enables editing.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
// General Manager source. The company step's email/phone are seeded from
|
||||
// eTrade (and the account email) but stay editable, so the link reads the
|
||||
@@ -367,9 +361,6 @@ export default function CompanyProfileForm({
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
const contactName = watch("contactPersonName");
|
||||
const contactEmail = watch("contactPersonEmail");
|
||||
const contactPhone = watch("contactPersonPhone");
|
||||
|
||||
// While linked, mirror the source values into the (disabled) target fields so
|
||||
// the copy stays current even if the user goes back and edits the source.
|
||||
@@ -381,26 +372,6 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
|
||||
// The contact-person step has no address of its own, so the linked PoA takes
|
||||
// the company's composed address. poaLocation (the city) stays typed on the
|
||||
// PoA step — the company step no longer has a location field to mirror.
|
||||
const companyAddress = watch("companyAddress");
|
||||
|
||||
useEffect(() => {
|
||||
if (!poaSameAsContact) return;
|
||||
setValue("poaName", contactName ?? "");
|
||||
setValue("poaEmail", contactEmail ?? "");
|
||||
setValue("poaPhone", contactPhone ?? "");
|
||||
setValue("poaAddress", companyAddress ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
poaSameAsContact,
|
||||
contactName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
companyAddress,
|
||||
]);
|
||||
|
||||
const toggleContactSameAsGm = (checked: boolean) => {
|
||||
setContactSameAsGm(checked);
|
||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||
@@ -411,17 +382,6 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
const togglePoaSameAsContact = (checked: boolean) => {
|
||||
setPoaSameAsContact(checked);
|
||||
if (!checked) {
|
||||
setValue("poaName", "");
|
||||
setValue("poaEmail", "");
|
||||
setValue("poaPhone", "");
|
||||
setValue("poaLocation", "");
|
||||
setValue("poaAddress", "");
|
||||
}
|
||||
};
|
||||
|
||||
// The DARS delegation paper ships in the same nationality document set as the
|
||||
// rest (the API guarantees it is there), but belongs on the PoA step next to
|
||||
// the details it evidences — so it's split out here and the Documents step
|
||||
@@ -551,7 +511,9 @@ export default function CompanyProfileForm({
|
||||
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
||||
// the same rule on save, so skipping it here only costs the customer a
|
||||
// round-trip.
|
||||
const poaProvided = hasPoaDetails(watch());
|
||||
// A PoA exists exactly when one has been verified — the details are the
|
||||
// verification's output, so there is nothing else that could stand for one.
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const delegationRequired = requirePoa || poaProvided;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
@@ -638,12 +600,7 @@ export default function CompanyProfileForm({
|
||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
step === "poa" &&
|
||||
verifiedIdentity &&
|
||||
requirePoa &&
|
||||
!identity?.poa.verified
|
||||
) {
|
||||
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
||||
setSaveError(
|
||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
||||
);
|
||||
@@ -876,71 +833,27 @@ export default function CompanyProfileForm({
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
|
||||
</Text>
|
||||
{/* A representative acts for the company inside Ethiopia
|
||||
whoever owns it, so the PoA is proven with Fayda regardless of
|
||||
nationality — their name, email, phone and address all come
|
||||
from the verification and are never typed here. */}
|
||||
{identity && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={identity.faydaRequired}
|
||||
required={requirePoa}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
/>
|
||||
)}
|
||||
{!verifiedIdentity && watch("contactPersonName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsContact}
|
||||
onToggle={togglePoaSameAsContact}
|
||||
title="Same as contact person"
|
||||
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
{!verifiedIdentity && (
|
||||
<>
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
{/* The city is the one field the Fayda address claim does not
|
||||
reliably decompose into, so it stays typed either way. */}
|
||||
{verifiedIdentity && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
)}
|
||||
reliably decompose into, so it stays typed. */}
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
|
||||
{poaDocumentSetting && (
|
||||
<>
|
||||
|
||||
@@ -37,10 +37,8 @@ export function buildPayload(
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: data.generalManagerPhone,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
// The representative's own details are written by their Fayda
|
||||
// verification, so the city is all the form has to send.
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
},
|
||||
};
|
||||
@@ -88,13 +86,7 @@ export function stepPayload(
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
};
|
||||
case "poa":
|
||||
return {
|
||||
poaName: d.poaName || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
poaAddress: d.poaAddress || undefined,
|
||||
};
|
||||
return { poaLocation: d.poaLocation || undefined };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
|
||||
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
|
||||
export const POA_FIELDS = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const satisfies readonly (keyof FormData)[];
|
||||
|
||||
/** True once the customer has entered any Power of Attorney detail. */
|
||||
export const hasPoaDetails = (d: Partial<FormData>) =>
|
||||
POA_FIELDS.some((f) => d[f]?.trim());
|
||||
|
||||
/**
|
||||
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
|
||||
* rather than optional. Everyone else keeps the optional PoA — but once they
|
||||
* start filling it in, the identifying fields have to be complete (the
|
||||
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
||||
* since files live outside the form state).
|
||||
* The PoA's identifying fields are never typed — they come from the Fayda
|
||||
* verification, whatever the company's nationality — so nothing here requires
|
||||
* them. A freight forwarder's mandatory PoA is gated on the verification
|
||||
* itself, and its delegation letter alongside it, both in CompanyProfileForm
|
||||
* (files live outside form state).
|
||||
*
|
||||
* That leaves the owner's passport number as the only conditional field.
|
||||
*/
|
||||
export function buildOnboardingSchema(
|
||||
requirePoa: boolean,
|
||||
/**
|
||||
* True when the PoA's identity fields come from a Fayda verification rather
|
||||
* than the form (Ethiopian companies). Requiring them here would fail
|
||||
* validation against inputs the step no longer renders — the verification
|
||||
* itself is what the step gates on instead.
|
||||
*/
|
||||
faydaOwnedPoa = false,
|
||||
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||
passportRequired = false,
|
||||
) {
|
||||
const poaRequired = requirePoa && !faydaOwnedPoa;
|
||||
if (!poaRequired && !passportRequired) return onboardingSchema;
|
||||
if (!passportRequired) return onboardingSchema;
|
||||
return onboardingSchema.superRefine((d, ctx) => {
|
||||
const required: [keyof FormData, string][] = [];
|
||||
if (poaRequired) {
|
||||
required.push(
|
||||
["poaName", "PoA name is required for freight forwarders"],
|
||||
["poaEmail", "PoA email is required for freight forwarders"],
|
||||
["poaPhone", "PoA phone is required for freight forwarders"],
|
||||
);
|
||||
}
|
||||
if (passportRequired) {
|
||||
required.push([
|
||||
"ownerPassportNumber",
|
||||
"The owner's passport number is required",
|
||||
]);
|
||||
}
|
||||
for (const [path, message] of required) {
|
||||
if (!d[path]?.trim()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
||||
}
|
||||
if (!d.ownerPassportNumber?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["ownerPassportNumber"],
|
||||
message: "The owner's passport number is required",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
poa: [...POA_FIELDS],
|
||||
poa: ["poaLocation"],
|
||||
documents: [],
|
||||
additional: [],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -27,10 +27,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
@@ -76,17 +73,7 @@ export default function InvoiceDetailPage() {
|
||||
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
||||
// one of the signed-in customer's own invoices (unlike the admin-facing
|
||||
// /payments/initiate, which takes any invoiceId with no ownership check).
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({ invoiceId: id, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
const pay = useInvoicePayment();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -249,7 +236,7 @@ export default function InvoiceDetailPage() {
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
loading={pay.processing}
|
||||
onClick={handlePay}
|
||||
styles={{
|
||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||
@@ -376,22 +363,19 @@ export default function InvoiceDetailPage() {
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
if (!pay.processing) {
|
||||
setPayModalOpen(false);
|
||||
payMutation.reset();
|
||||
pay.reset();
|
||||
}
|
||||
}}
|
||||
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
||||
currency={invoice.currency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
pay.pay(id, method, payerAccount)
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
@@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
export function ReadonlyBookingView({
|
||||
booking,
|
||||
@@ -53,7 +48,6 @@ export function ReadonlyBookingView({
|
||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||
useScrollToHash();
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
const { viewer } = useFileViewer();
|
||||
|
||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||
@@ -63,44 +57,11 @@ export function ReadonlyBookingView({
|
||||
: "/contracts/new";
|
||||
const onRebook = () => navigate(rebookTo);
|
||||
|
||||
// Billing is invoice-centric — resolve the booking's currently payable
|
||||
// invoice (same query/key BookingPaymentPanel uses, so this shares its
|
||||
// cache) and pay it through the ownership-checked portal route.
|
||||
const { data: bookingInvoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
});
|
||||
const payableInvoiceId = bookingInvoices.find((inv) =>
|
||||
isPayable(inv.status),
|
||||
)?.id;
|
||||
|
||||
// POST /billing/my-invoices/:id/pay creates the intent and returns the
|
||||
// provider's redirect URL (clientAction.url). Send the browser straight
|
||||
// there; fall back to the public /payments/checkout page if no redirect
|
||||
// URL came back.
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payableInvoiceId) {
|
||||
throw new Error(
|
||||
"No payable invoice found for this booking yet. Please refresh or contact support.",
|
||||
);
|
||||
}
|
||||
return api.invoices.pay.call({
|
||||
id: payableInvoiceId,
|
||||
payload: { method, platform: "web" },
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: payableInvoiceId!,
|
||||
method,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
// Billing is invoice-centric — the shared hook resolves the booking's
|
||||
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
|
||||
// shares that cache), charges it through the ownership-checked portal route,
|
||||
// and handles redirect vs CAC Bank OTP.
|
||||
const pay = useBookingPayment(booking.id);
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||
@@ -171,7 +132,7 @@ export function ReadonlyBookingView({
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
label="Pay now"
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
onClick={pay.open}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -278,8 +239,8 @@ export function ReadonlyBookingView({
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={pricing}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
onPay={pay.open}
|
||||
paying={pay.processing}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<ScheduleCard
|
||||
@@ -301,24 +262,14 @@ export function ReadonlyBookingView({
|
||||
</Tabs>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
setPayModalOpen(false);
|
||||
payMutation.reset();
|
||||
}
|
||||
}}
|
||||
opened={pay.modalOpen}
|
||||
onClose={pay.close}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Check, Landmark, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
||||
import type { PaymentMethod } from "@/services/payments.service";
|
||||
|
||||
interface ProviderOption {
|
||||
method: PaymentMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
logo: string;
|
||||
/** Logo asset; falls back to a bank glyph when the provider has none. */
|
||||
logo?: string;
|
||||
/** Currencies this provider settles in. */
|
||||
currencies: string[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// Only Telebirr and Waafi are enabled for now.
|
||||
// Only Telebirr, Waafi and CAC Bank are enabled for now.
|
||||
const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
@@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [
|
||||
currencies: ["USD"],
|
||||
accent: "#2E5B96",
|
||||
},
|
||||
{
|
||||
method: "CAC_BANK",
|
||||
label: "CAC Bank",
|
||||
description: "Djibouti bank debit · confirmed by SMS OTP",
|
||||
currencies: ["USD"],
|
||||
accent: "#8A5A17",
|
||||
},
|
||||
];
|
||||
|
||||
/** Providers that debit against an SMS OTP instead of redirecting to a page. */
|
||||
const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK";
|
||||
|
||||
/**
|
||||
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||
@@ -91,13 +113,27 @@ function ProviderRow({
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={option.logo}
|
||||
alt={`${option.label} logo`}
|
||||
w={52}
|
||||
h={52}
|
||||
fit="cover"
|
||||
/>
|
||||
{option.logo ? (
|
||||
<Image
|
||||
src={option.logo}
|
||||
alt={`${option.label} logo`}
|
||||
w={52}
|
||||
h={52}
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Landmark size={24} color={option.accent} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||
@@ -135,6 +171,7 @@ export function PaymentMethodModal({
|
||||
onConfirm,
|
||||
processing,
|
||||
error,
|
||||
otp,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -142,12 +179,19 @@ export function PaymentMethodModal({
|
||||
amountLabel?: string;
|
||||
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||
currency?: string | null;
|
||||
onConfirm: (method: PaymentMethod) => void;
|
||||
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
|
||||
processing?: boolean;
|
||||
error?: string | null;
|
||||
/** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */
|
||||
otp?: InvoicePaymentFlow["otp"];
|
||||
}) {
|
||||
const providers = useMemo(() => providersForCurrency(currency), [currency]);
|
||||
const providers = useMemo(
|
||||
() => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)),
|
||||
[currency, otp],
|
||||
);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||
useEffect(() => {
|
||||
@@ -156,6 +200,89 @@ export function PaymentMethodModal({
|
||||
}
|
||||
}, [providers, method]);
|
||||
|
||||
// A fresh OTP round always starts empty.
|
||||
useEffect(() => {
|
||||
if (otp?.open) setCode("");
|
||||
}, [otp?.open]);
|
||||
|
||||
// CAC Bank debits the account behind this number and SMSes the OTP to it.
|
||||
const needsMobile = isOtpMethod(method);
|
||||
const canSubmit = !needsMobile || mobile.trim().length > 0;
|
||||
|
||||
if (otp?.open) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={otp.cancel}
|
||||
centered
|
||||
radius={18}
|
||||
size={420}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
// A stray click must not drop the payer out of a live OTP window —
|
||||
// Cancel is the only way back.
|
||||
closeOnClickOutside={false}
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||
>
|
||||
<Box px={24} py={24}>
|
||||
<Text fw={800} fz="18px" c="#10202F">
|
||||
Enter OTP
|
||||
</Text>
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
{otp.message}
|
||||
</Text>
|
||||
|
||||
<Box mt={18}>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
oneTimeCode
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={(value) => otp.submit(value)}
|
||||
aria-label="One-time password"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{otp.error && (
|
||||
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
|
||||
{otp.error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap={10} wrap="nowrap" mt={20}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={12}
|
||||
onClick={otp.cancel}
|
||||
disabled={otp.submitting}
|
||||
styles={{
|
||||
root: { height: 46, flex: "0 0 38%" },
|
||||
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
loading={otp.submitting}
|
||||
disabled={otp.submitting || code.trim().length === 0}
|
||||
onClick={() => otp.submit(code.trim())}
|
||||
styles={{
|
||||
root: { height: 46, flex: 1 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
Confirm payment
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -215,6 +342,22 @@ export function PaymentMethodModal({
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{needsMobile && (
|
||||
<TextInput
|
||||
mt={12}
|
||||
label="Mobile number"
|
||||
description="CAC Bank sends a one-time password to this number to authorise the debit."
|
||||
placeholder="77xxxxxx"
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(e.currentTarget.value)}
|
||||
disabled={processing}
|
||||
styles={{
|
||||
label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" },
|
||||
description: { fontSize: 11.5 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -228,7 +371,9 @@ export function PaymentMethodModal({
|
||||
<Group gap={6} align="center" justify="center" mb={12}>
|
||||
<ShieldCheck size={14} color="#0A8A5F" />
|
||||
<Text fz="11.5px" c="#7A8794">
|
||||
Secured · you'll be redirected to your provider to pay
|
||||
{needsMobile
|
||||
? "Secured · you'll confirm with the OTP sent to your phone"
|
||||
: "Secured · you'll be redirected to your provider to pay"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -248,15 +393,21 @@ export function PaymentMethodModal({
|
||||
<Button
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
disabled={processing}
|
||||
disabled={processing || !canSubmit}
|
||||
loading={processing}
|
||||
onClick={() => onConfirm(method)}
|
||||
onClick={() =>
|
||||
onConfirm(method, needsMobile ? mobile.trim() : undefined)
|
||||
}
|
||||
styles={{
|
||||
root: { height: 48, flex: 1 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
{processing ? "Redirecting…" : "Continue to payment"}
|
||||
{processing
|
||||
? needsMobile
|
||||
? "Sending OTP…"
|
||||
: "Redirecting…"
|
||||
: "Continue to payment"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import {
|
||||
warehouseInvoicesService,
|
||||
type PortalWarehouseInvoice,
|
||||
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
|
||||
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
||||
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
||||
method,
|
||||
platform: "web",
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
if (!payInvoice) return;
|
||||
// Redirect to the provider (or the fallback checkout page) — same as the
|
||||
// booking "Pay now" flow, so behaviour is identical everywhere.
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
const payError = payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null;
|
||||
// Warehouse fees are charged through the warehouse route, but they are the
|
||||
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
|
||||
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
|
||||
warehouseInvoicesService.payOnline(invoiceId, {
|
||||
method,
|
||||
platform: "web",
|
||||
payerAccount,
|
||||
}),
|
||||
);
|
||||
|
||||
const closePayModal = () => {
|
||||
if (!payMutation.isPending) {
|
||||
if (!pay.processing) {
|
||||
setPayInvoice(null);
|
||||
payMutation.reset();
|
||||
pay.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||
}
|
||||
currency={payInvoice?.currency}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
processing={payMutation.isPending}
|
||||
error={payError}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
|
||||
}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@ export function PayNowButton({
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { type PaymentMethod } from "@/services/payments.service";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||
* invoice, and redirects the browser to the provider (or the fallback
|
||||
* checkout page). Reused by the booking detail page, the booking list, and
|
||||
* the home page so "Pay now" behaves identically everywhere.
|
||||
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||
* behaves identically everywhere.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [noInvoice, setNoInvoice] = useState(false);
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", bookingId],
|
||||
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
|
||||
});
|
||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payableInvoiceId) {
|
||||
throw new Error(
|
||||
"No payable invoice found for this booking yet. Please refresh or contact support.",
|
||||
);
|
||||
}
|
||||
return api.invoices.pay.call({
|
||||
id: payableInvoiceId,
|
||||
payload: { method, platform: "web" },
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: payableInvoiceId!,
|
||||
method,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
const flow = useInvoicePayment();
|
||||
|
||||
const open = () => setModalOpen(true);
|
||||
|
||||
const close = () => {
|
||||
if (!mutation.isPending) {
|
||||
if (!flow.processing) {
|
||||
setModalOpen(false);
|
||||
mutation.reset();
|
||||
setNoInvoice(false);
|
||||
flow.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const error = mutation.isError
|
||||
? mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null;
|
||||
|
||||
return {
|
||||
modalOpen,
|
||||
open,
|
||||
close,
|
||||
processing: mutation.isPending,
|
||||
error,
|
||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
||||
processing: flow.processing,
|
||||
error: noInvoice
|
||||
? "No payable invoice found for this booking yet. Please refresh or contact support."
|
||||
: flow.error,
|
||||
otp: flow.otp,
|
||||
confirm: (method: PaymentMethod, payerAccount?: string) => {
|
||||
if (!payableInvoiceId) {
|
||||
setNoInvoice(true);
|
||||
return;
|
||||
}
|
||||
setNoInvoice(false);
|
||||
flow.pay(payableInvoiceId, method, payerAccount);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,20 +39,15 @@ import {
|
||||
type LicenseFile,
|
||||
type LicenseFileStatus,
|
||||
} from "@/services/companies.service";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The representative's name, email, phone and address all come from their
|
||||
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
|
||||
// is the only detail this form owns.
|
||||
const schema = z.object({
|
||||
poaName: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaLocation: z.string().optional(),
|
||||
poaAddress: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
|
||||
const { view, viewer } = useFileViewer();
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
poaName: profile.poaName ?? "",
|
||||
poaEmail: profile.poaEmail ?? "",
|
||||
poaPhone: profile.poaPhone ?? "",
|
||||
poaLocation: profile.poaLocation ?? "",
|
||||
poaAddress: profile.poaAddress ?? "",
|
||||
};
|
||||
}, [profile]);
|
||||
const defaultValues = useMemo(
|
||||
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
|
||||
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
||||
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
||||
|
||||
// The letter is staged locally, not uploaded on pick. Uploading immediately
|
||||
// would open a change request, which locks the whole settings page (see
|
||||
// SettingsPage's `locked` fieldset) before the text fields could be saved.
|
||||
// Save submits the file and the fields together, into one change request.
|
||||
// The letter is staged locally, not uploaded on pick: the paper is the one
|
||||
// thing here that still goes to a reviewer, so picking it must not open a
|
||||
// change request before the customer has committed to the save. Save submits
|
||||
// the file and the fields together.
|
||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||
const [saveBlocked, setSaveBlocked] = useState(false);
|
||||
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
|
||||
const requirePoa = profile.companyProfiles.some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
// An Ethiopian company does not type its representative's details — they
|
||||
// come from the Fayda verification. A foreign company keeps the typed form:
|
||||
// its representative may hold no Fayda ID.
|
||||
// No company types its representative's details — they come from the Fayda
|
||||
// verification whatever the nationality, since a representative acts for the
|
||||
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
const poaValues = watch([
|
||||
"poaName",
|
||||
"poaEmail",
|
||||
"poaPhone",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
]);
|
||||
const poaProvided = verifiedIdentity
|
||||
? (identity?.poa.verified ?? false)
|
||||
: poaValues.some((v) => v?.trim());
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const letterRequired = requirePoa || poaProvided;
|
||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||
|
||||
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: FormData) => {
|
||||
// Every identity field except the city is written by the verification, so
|
||||
// an Ethiopian company only ever saves the paper and the location here.
|
||||
const fields = verifiedIdentity
|
||||
? { poaLocation: data.poaLocation || undefined }
|
||||
: {
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
};
|
||||
// only the paper and the location are ever saved here.
|
||||
const fields = { poaLocation: data.poaLocation || undefined };
|
||||
// A fresh upload already stages the removal of every paper on file, so
|
||||
// the explicit removals only need applying when no replacement was
|
||||
// picked. Saving the details after it means the API sees the new paper.
|
||||
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={identity.faydaRequired}
|
||||
required={requirePoa}
|
||||
disabled={mutation.isPending}
|
||||
pendingReview={Boolean(
|
||||
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
||||
?.faydaIdentity?.poaFaydaSub,
|
||||
)}
|
||||
onVerified={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{/* Name, email, phone and address are written by the Fayda
|
||||
verification for an Ethiopian company, so only the city — which
|
||||
the address claim does not reliably decompose into — is typed. */}
|
||||
{!verifiedIdentity && (
|
||||
<>
|
||||
<TextInput
|
||||
label="PoA Full Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Name, email, phone and address are all written by the Fayda
|
||||
verification, so only the city — which the address claim does
|
||||
not reliably decompose into — is typed. */}
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
{!verifiedIdentity && (
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{profile.reviewStatus === "pending" && (
|
||||
{/* Keyed on the paper's own staged status, not the company's
|
||||
review state: the details on this tab now apply live, so a
|
||||
pending review is just as likely to be about something else. */}
|
||||
{letters.some((f) => f.status !== "live") && (
|
||||
<Group gap={6} c="edr-amber-text">
|
||||
<Clock size={13} />
|
||||
<Text size="xs" fw={500}>
|
||||
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{mode === "edit" &&
|
||||
verifiedIdentity &&
|
||||
identity?.poa.verified &&
|
||||
!requirePoa && (
|
||||
<Button
|
||||
|
||||
@@ -73,4 +73,10 @@ export const invoicesService = {
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */
|
||||
confirmOtp: async (id: string, otp: string): Promise<InitiateResponse> => {
|
||||
const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp });
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { of } from "rxjs";
|
||||
import { CacBankProvider } from "@edr/payment-providers";
|
||||
|
||||
/**
|
||||
* Both freight (invoice.balanceAmount) and passenger (CurrencyService) hand the
|
||||
* provider a MAJOR-currency amount — e.g. 700 for $700, not 70000 cents — with the
|
||||
* target currency's own decimal precision already applied. CAC previously divided
|
||||
* anything that wasn't DJF by 100, undercharging every non-DJF payment 100x (a USD
|
||||
* 700 invoice would have been billed as $7). These pin the fix: the amount reaches
|
||||
* the bank unscaled, and the bank's own DJF bounds don't leak onto other currencies.
|
||||
*/
|
||||
describe("CacBankProvider — amount handling", () => {
|
||||
const config = {
|
||||
get: (key: string) =>
|
||||
({
|
||||
"cac.baseUrl": "https://cac.example",
|
||||
"cac.username": "u",
|
||||
"cac.password": "p",
|
||||
"cac.appKey": "app",
|
||||
"cac.apiKey": "api",
|
||||
"cac.companyServicesId": 21,
|
||||
"cac.currency": "DJF",
|
||||
"cac.tokenTtlMs": 3_600_000,
|
||||
"cac.otpExpiryMs": 600_000,
|
||||
"cac.httpTimeoutMs": 60_000,
|
||||
})[key],
|
||||
};
|
||||
|
||||
function build() {
|
||||
const post = jest.fn();
|
||||
// 1st call: CacBankAuth signin (axios parses this one normally — no raw-text
|
||||
// override). 2nd call: PaymentInitiateRequest (raw text; CAC provider parses it
|
||||
// itself via parseCacResponse to preserve oversized ids).
|
||||
post
|
||||
.mockReturnValueOnce(of({ data: { accessToken: "tok" } }))
|
||||
.mockReturnValueOnce(
|
||||
of({ data: JSON.stringify({ description: "ok", paymentRequestId: 1 }) }),
|
||||
);
|
||||
const provider = new CacBankProvider(config as never, { post } as never);
|
||||
return { provider, post };
|
||||
}
|
||||
|
||||
it("sends a USD amount unscaled — no more /100 division", async () => {
|
||||
const { provider, post } = build();
|
||||
|
||||
await provider.initiate({
|
||||
merchantOrderId: "m-1",
|
||||
orderRef: "INV-1",
|
||||
amountMinor: 700,
|
||||
currency: "USD",
|
||||
payerAccount: "77092076",
|
||||
});
|
||||
|
||||
const body = JSON.parse(post.mock.calls[1][1]);
|
||||
expect(body.amount).toBe(700);
|
||||
expect(body.currency).toBe("USD");
|
||||
});
|
||||
|
||||
it("still sends a DJF amount unscaled, matching pre-fix behaviour exactly", async () => {
|
||||
const { provider, post } = build();
|
||||
|
||||
await provider.initiate({
|
||||
merchantOrderId: "m-2",
|
||||
orderRef: "INV-2",
|
||||
amountMinor: 222,
|
||||
currency: "DJF",
|
||||
payerAccount: "77092076",
|
||||
});
|
||||
|
||||
const body = JSON.parse(post.mock.calls[1][1]);
|
||||
expect(body.amount).toBe(222);
|
||||
});
|
||||
|
||||
it("rejects a DJF amount outside the bank's documented 10–100,000 bounds", async () => {
|
||||
const { provider } = build();
|
||||
|
||||
await expect(
|
||||
provider.initiate({
|
||||
merchantOrderId: "m-3",
|
||||
orderRef: "INV-3",
|
||||
amountMinor: 5,
|
||||
currency: "DJF",
|
||||
payerAccount: "77092076",
|
||||
}),
|
||||
).rejects.toThrow(/outside the accepted range/);
|
||||
});
|
||||
|
||||
it("does not apply the DJF bounds to a USD amount outside that DJF range", async () => {
|
||||
const { provider, post } = build();
|
||||
|
||||
// 500,000 is well past the DJF ceiling (100,000) but is a perfectly normal USD
|
||||
// amount — the DJF-only bound must not reject it locally.
|
||||
await provider.initiate({
|
||||
merchantOrderId: "m-4",
|
||||
orderRef: "INV-4",
|
||||
amountMinor: 500_000,
|
||||
currency: "USD",
|
||||
payerAccount: "77092076",
|
||||
});
|
||||
|
||||
const body = JSON.parse(post.mock.calls[1][1]);
|
||||
expect(body.amount).toBe(500_000);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,71 @@ services:
|
||||
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
|
||||
restart: "no"
|
||||
|
||||
# Stand-in for eSignet's token/userinfo endpoints (see fayda-mock/server.js).
|
||||
# The real Fayda authorization step (phone + SMS OTP) can't run in e2e —
|
||||
# Cypress bypasses the popup and drives POST start / POST complete directly,
|
||||
# so this only needs to answer the token exchange + userinfo calls that
|
||||
# `completeVerification` makes server-side.
|
||||
fayda-mock-e2e:
|
||||
image: node:20-alpine
|
||||
volumes:
|
||||
- ./e2e/freight/fayda-mock:/app:ro
|
||||
working_dir: /app
|
||||
command: ["node", "server.js"]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:4400/userinfo').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Stand-in for https://etrade.gov.et — ETradeService's base URL is
|
||||
# hardcoded (not env-configurable like Fayda's endpoints), so this is
|
||||
# reached by DNS alias instead: the "etrade.gov.et" network alias below
|
||||
# makes freight-api-e2e's real hardcoded requests land here. See
|
||||
# etrade-mock/server.js.
|
||||
etrade-mock-e2e:
|
||||
# Debian-based (not -alpine): ships openssl in the base image, so the
|
||||
# self-signed cert below needs no package install at container start
|
||||
# (alpine's apk would have to fetch it from the network every boot).
|
||||
image: node:20
|
||||
volumes:
|
||||
- ./e2e/freight/etrade-mock:/app:ro
|
||||
working_dir: /app
|
||||
environment:
|
||||
TLS_CERT_PATH: /tmp/etrade-mock/cert.pem
|
||||
TLS_KEY_PATH: /tmp/etrade-mock/key.pem
|
||||
# Throwaway self-signed cert generated fresh into /tmp on every
|
||||
# container start — nothing shaped like a key/cert is committed (the
|
||||
# code mount above is :ro, so this couldn't write there anyway).
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >
|
||||
mkdir -p /tmp/etrade-mock &&
|
||||
openssl req -x509 -newkey rsa:2048 -keyout "$$TLS_KEY_PATH" -out "$$TLS_CERT_PATH" -days 1 -nodes -subj "/CN=etrade.gov.et" 2>/dev/null &&
|
||||
node server.js
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- etrade.gov.et
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('https').get({hostname:'localhost',port:443,path:'/api/BusinessMain/GetBusinessByLicenseNo?LicenseNo=x&Tin=x&Lang=en',rejectUnauthorized:false},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
freight-api-e2e:
|
||||
build:
|
||||
context: .
|
||||
@@ -74,6 +139,10 @@ services:
|
||||
condition: service_healthy
|
||||
minio-init-e2e:
|
||||
condition: service_completed_successfully
|
||||
fayda-mock-e2e:
|
||||
condition: service_healthy
|
||||
etrade-mock-e2e:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: "3001"
|
||||
DB_HOST: postgres-freight-e2e
|
||||
@@ -104,7 +173,21 @@ services:
|
||||
MINIO_REGION: us-east-1
|
||||
# External integrations off
|
||||
RABBITMQ_ENABLED: "false"
|
||||
FAYDA_ENABLED: "false"
|
||||
# Fayda ON, pointed at the local mock (fayda-mock-e2e) instead of the
|
||||
# real eSignet infra — see that service's comment above. Cypress drives
|
||||
# verification via the API (start + complete), never the real popup.
|
||||
FAYDA_ENABLED: "true"
|
||||
FAYDA_CLIENT_ID: e2e-fayda-client
|
||||
FAYDA_AUTHORIZATION_ENDPOINT: http://fayda-mock-e2e:4400/authorize
|
||||
FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token
|
||||
FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo
|
||||
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
|
||||
FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
|
||||
# Throwaway e2e-only RSA JWK (client_assertion signing) — the mock
|
||||
# never verifies the signature, this just has to be well-formed.
|
||||
# Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64),
|
||||
# not committed here, so nothing shaped like a private key sits in git.
|
||||
FAYDA_PRIVATE_KEY_BASE64: ${FAYDA_PRIVATE_KEY_BASE64}
|
||||
# SMS strategy has no kill switch and defaults to a real dev endpoint —
|
||||
# blackhole it so e2e never sends SMS (failures are logged, non-fatal).
|
||||
OZIKING_SMS_URL: http://127.0.0.1:9/sms
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
acceptExport,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
createImportSchedule,
|
||||
db,
|
||||
departureAt,
|
||||
@@ -83,8 +84,9 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0
|
||||
|
||||
it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => {
|
||||
bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending("CXA", BOOKING_DAY);
|
||||
|
||||
// Fresh non-customs export booking sits at OPERATION_REQUEST_PENDING.
|
||||
// Cleared non-customs export booking sits at OPERATION_REQUEST_PENDING.
|
||||
withBooking("CXA", (b) => {
|
||||
expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING");
|
||||
cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201]));
|
||||
@@ -112,6 +114,7 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0
|
||||
|
||||
it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => {
|
||||
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending("CXB", BOOKING_DAY);
|
||||
acceptExport("CXB");
|
||||
markPaid("CXB");
|
||||
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearIntercityToFullyExecuted,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
@@ -98,8 +100,10 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
|
||||
|
||||
it("a through-corridor 1 400 T booking and a NAGAD→MOJO 700 T booking share the train", () => {
|
||||
bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending("BM1", BOOKING_DAY);
|
||||
acceptOperation("BM1");
|
||||
bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending("BMSUB", BOOKING_DAY);
|
||||
acceptOperation("BMSUB");
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
@@ -122,7 +126,9 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
|
||||
|
||||
it("bulk intercity ride-along: dateless DOMESTIC wheat accepted onto the import train's free leg", () => {
|
||||
bookBulk({ suffix: "BMIC", tons: 140 }); // 2 wagons MOJO → KALITY, dateless
|
||||
acceptOperation("BMIC");
|
||||
// DOMESTIC bookings skip the shipment-day step entirely — finalize alone
|
||||
// lands FULLY_EXECUTED; staff assign it to a passing train below.
|
||||
clearIntercityToFullyExecuted("BMIC");
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
withBooking("BMIC", (b) => {
|
||||
@@ -159,6 +165,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
|
||||
withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
|
||||
bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
|
||||
clearToOperationRequestPending("BMG", GIANT_DAY);
|
||||
acceptOperation("BMG");
|
||||
withSchedule(GIANT_DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
@@ -208,7 +215,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
|
||||
expectFailure: "must take the whole",
|
||||
});
|
||||
bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
|
||||
pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("BMG", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
acceptExport,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
completeBookingMilestone,
|
||||
createImportSchedule,
|
||||
db,
|
||||
@@ -103,7 +104,7 @@ describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", {
|
||||
it("six exporters book wheat inside the one window", () => {
|
||||
BOOKINGS.forEach((b) => {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeBookingMilestone,
|
||||
completeDocReview,
|
||||
@@ -103,7 +104,7 @@ describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", { retrie
|
||||
it("customer books all six wheat shipments inside the first window", () => {
|
||||
BOOKINGS.forEach((b) => {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import {
|
||||
acceptOperation,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
@@ -84,6 +85,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", {
|
||||
it("seven customers book wheat in the first window; operations accepts them in priority order", () => {
|
||||
ORDER.forEach((suffix) => {
|
||||
bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||
acceptOperation(suffix);
|
||||
});
|
||||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||||
@@ -192,7 +194,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", {
|
||||
});
|
||||
|
||||
bookBulk({ suffix: "BSC", tons: 1120, scheduledDate: REMAINDER_DAY });
|
||||
pollBookingStatus("BSC", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("BSC", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
cy.mantineSelect(/^Contract Kind/, "General Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("Rail Transport Only", { timeout: 15000 }).click();
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
// Contracts are always quoted in USD now — the billing currency is picked
|
||||
// per shipment at booking time, not here.
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
acceptExport,
|
||||
apiPost,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
createImportSchedule,
|
||||
db,
|
||||
dbBooking,
|
||||
@@ -118,7 +119,7 @@ describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retri
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty + b.forty;
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
import { clearBookingClearance } from "./import-utils";
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
const opsStaff = "operation@edr.local";
|
||||
@@ -477,7 +479,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
// Contracts are always quoted in USD now — the billing currency is picked
|
||||
// per shipment at booking time, not here.
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
// Container contracts auto-cover both 20ft & 40ft (no size picker) and the
|
||||
@@ -537,6 +540,12 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
|
||||
withBooking("CONTAINER", (b) => {
|
||||
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
|
||||
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
|
||||
clearBookingClearance(b.id, b.scheduled_date!);
|
||||
});
|
||||
|
||||
withBooking("CONTAINER", (b) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
|
||||
@@ -561,7 +570,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
// Contracts are always quoted in USD now — the billing currency is picked
|
||||
// per shipment at booking time, not here.
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
|
||||
@@ -605,6 +615,11 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
|
||||
withBooking("BULK", (b) => {
|
||||
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
|
||||
clearBookingClearance(b.id, b.scheduled_date!);
|
||||
});
|
||||
|
||||
withBooking("BULK", (b) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
});
|
||||
|
||||
@@ -69,6 +69,44 @@ export function apiPost(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive Fayda identity verification via the API, bypassing the real
|
||||
* popup+SMS flow entirely — `fayda-mock-e2e` stands in for eSignet's
|
||||
* token/userinfo endpoints (see docker-compose.e2e.yaml), so a throwaway
|
||||
* code is enough: start a session, then complete it — mirrors what
|
||||
* FaydaVerifyPanel's popup + postMessage dance does server-side, minus the
|
||||
* UI. Uses the browser's OWN auth-token cookie rather than `tokenFor` —
|
||||
* this only ever runs for a user already logged in in the running test
|
||||
* (the real flow requires being on an authenticated page to see the
|
||||
* "Verify with Fayda" button at all), and `tokenFor` only knows how to log
|
||||
* in fixed seeded accounts, not a signup created earlier in the same test
|
||||
* with its own freshly-chosen password.
|
||||
*/
|
||||
export function completeFaydaVerification(subject: "owner" | "poa") {
|
||||
return cy.getCookie("auth-token").then((cookie) => {
|
||||
expect(cookie, "authenticated session (auth-token cookie)").to.not.be.null;
|
||||
const headers = { Authorization: `Bearer ${cookie!.value}` };
|
||||
return cy
|
||||
.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/fayda/verification/start`,
|
||||
headers,
|
||||
body: { purpose: "VERIFY", platform: "PORTAL" },
|
||||
})
|
||||
.then((res) => {
|
||||
const authorizationUrl: string = res.body.data.authorizationUrl;
|
||||
const state = new URL(authorizationUrl).searchParams.get("state");
|
||||
expect(state, "fayda session state").to.be.a("string");
|
||||
return cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/companies/identity/fayda/complete`,
|
||||
headers,
|
||||
body: { subject, code: "e2e-mock-code", state },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function apiGet(email: string, path: string, failOnStatusCode = true) {
|
||||
return tokenFor(email).then((token) =>
|
||||
cy.request({
|
||||
@@ -457,13 +495,86 @@ export function bookBulk(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one ad-hoc doc → GL approves it → finalize → customer proceeds with
|
||||
* the shipment day. The e2e seed configures no required documents, so one
|
||||
* doc satisfies the 100%-approved gate. Takes a bookingId directly so specs
|
||||
* with their own booking lookup (not the *-suffix convention) can reuse it.
|
||||
*
|
||||
* KNOWN GAP — customs (`customsClearingEnabled`) bookings do NOT use this
|
||||
* path: `finalizeClearance` rejects them outright ("General customs bookings
|
||||
* use phased clearance — complete milestones via the phased actions instead
|
||||
* of finalize"). Their real flow is a separate multi-step phased chain
|
||||
* (transit permit upload → finalizePreClearance → delivery order upload for
|
||||
* IMPORT; a shorter EXPORT_RELEASED milestone for EXPORT) that no spec here
|
||||
* drives yet — see booking-clearance.service.ts / clearance-workflow.service.ts.
|
||||
* Calling this on a customs booking 400s at the finalize step; callers with
|
||||
* customs suffixes in the mix (the full_train specs' BF-series customs:
|
||||
* true bookings) currently accept that those specific bookings — and only
|
||||
* those — fail here until the phased-clearance walk is written.
|
||||
*/
|
||||
export function clearBookingClearance(bookingId: string, scheduledDate: string) {
|
||||
glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e");
|
||||
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, {
|
||||
fileKey: "custom_e2e",
|
||||
status: "APPROVED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(customer, `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate })
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every contract booking is now born in the clearance gate — walk a *-suffix
|
||||
* booking from AWAITING_DOCUMENTS to OPERATION_REQUEST_PENDING via
|
||||
* {@link clearBookingClearance}.
|
||||
*/
|
||||
export function clearToOperationRequestPending(suffix: string, scheduledDate: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
|
||||
clearBookingClearance(b.id, scheduledDate);
|
||||
});
|
||||
pollBookingStatus(suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* DOMESTIC (intercity) bookings never pick a shipment day — there is no
|
||||
* `clearance/proceed` step for them. `finalizeClearance` sends an approved
|
||||
* intercity booking straight to FULLY_EXECUTED (the ride-along pool); staff
|
||||
* assign it to a passing train separately. Takes a bookingId directly, like
|
||||
* {@link clearBookingClearance}.
|
||||
*/
|
||||
export function clearIntercityBookingClearance(bookingId: string) {
|
||||
glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e");
|
||||
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, {
|
||||
fileKey: "custom_e2e",
|
||||
status: "APPROVED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
}
|
||||
|
||||
/** Walk a *-suffix DOMESTIC booking from AWAITING_DOCUMENTS to FULLY_EXECUTED. */
|
||||
export function clearIntercityToFullyExecuted(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
|
||||
clearIntercityBookingClearance(b.id);
|
||||
});
|
||||
pollBookingStatus(suffix, "FULLY_EXECUTED", 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A):
|
||||
* AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize →
|
||||
* CLEARANCE_READY → customer proceeds with the shipment day →
|
||||
* OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e
|
||||
* seed configures no required documents, so one ad-hoc doc satisfies the
|
||||
* 100%-approved gate.
|
||||
* AWAITING_DOCUMENTS → ... → OPERATION_REQUEST_PENDING (clearToOperationRequestPending)
|
||||
* → ops accept → FULLY_EXECUTED (pool).
|
||||
*/
|
||||
export function clearGeneralBooking(
|
||||
suffix: string,
|
||||
@@ -471,22 +582,7 @@ export function clearGeneralBooking(
|
||||
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
|
||||
mode: "import" | "export" = "import",
|
||||
) {
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
|
||||
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
|
||||
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
|
||||
fileKey: "custom_e2e",
|
||||
status: "APPROVED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate })
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
clearToOperationRequestPending(suffix, scheduledDate);
|
||||
if (mode === "export") acceptExport(suffix);
|
||||
else acceptOperation(suffix);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeBookingMilestone,
|
||||
completeDocReview,
|
||||
@@ -113,7 +114,7 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty + b.forty;
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resetCorridorDay,
|
||||
acceptOperation,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
@@ -91,6 +92,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += s.twenty + s.forty;
|
||||
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||
acceptOperation(suffix);
|
||||
});
|
||||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||||
@@ -217,7 +219,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
|
||||
twenty: 32,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("SC", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
* 7. backoffice — operations approves the document, then finalizes document
|
||||
* approval → FULLY_EXECUTED
|
||||
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
||||
* no shipment date) → booking OPERATION_REQUEST_PENDING
|
||||
* 9. backoffice — operations accepts the operation request → booking
|
||||
* FULLY_EXECUTED (intercity waiting pool)
|
||||
* no shipment date) → booking AWAITING_DOCUMENTS → customer
|
||||
* uploads a doc, GL approves, finalize → DOMESTIC bookings
|
||||
* skip the shipment-day step entirely, so finalize alone
|
||||
* lands FULLY_EXECUTED (the intercity waiting pool) — there
|
||||
* is no separate operations "accept the request" step.
|
||||
* 10. backoffice — operations creates the EXPORT route
|
||||
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
|
||||
* 11. backoffice — operations schedules the export train (built Train-Builder
|
||||
@@ -34,6 +36,8 @@
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
import { clearIntercityBookingClearance } from "./import-utils";
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
const opsStaff = "operation@edr.local";
|
||||
@@ -153,12 +157,13 @@ function createIntercityContract() {
|
||||
cy.loginPortal(customer);
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
// Step 0 — Setup. Intercity forces ETB and hides the customs section.
|
||||
// Step 0 — Setup. Intercity hides the customs section; the billing currency
|
||||
// step moved to booking time (intercity always bills ETB there, per app
|
||||
// comment in step2-service-type.tsx), so it is not selected here.
|
||||
cy.mantineSelect(/^Operation Type/, /^Intercity$/);
|
||||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
// Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container
|
||||
@@ -422,21 +427,15 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
|
||||
withBooking((b) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
|
||||
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
|
||||
clearIntercityBookingClearance(b.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("operations accepts the operation request — booking joins the intercity pool", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
|
||||
|
||||
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
|
||||
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
|
||||
|
||||
withBooking((b) => {
|
||||
expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED");
|
||||
expect(b.status, "DOMESTIC finalize skips straight to the pool").to.eq(
|
||||
"FULLY_EXECUTED",
|
||||
);
|
||||
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
|
||||
expect(b.train_schedule_id).to.eq(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
apiPost,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
completeBookingMilestone,
|
||||
createImportSchedule,
|
||||
db,
|
||||
@@ -120,7 +121,7 @@ describe("mixed export: containers and wheat share one 54-wagon FCFS train", { r
|
||||
} else {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
|
||||
}
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
apiPost,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeBookingMilestone,
|
||||
completeDocReview,
|
||||
@@ -113,7 +114,7 @@ describe("mixed import: containers and wheat share one 54-wagon train", { retrie
|
||||
} else {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
|
||||
}
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
acceptOperation,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
@@ -96,10 +97,12 @@ describe("mixed import: container split; container-freed wagons promote the bulk
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty;
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
acceptOperation(b.suffix);
|
||||
});
|
||||
BULKS.forEach((b) => {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
acceptOperation(b.suffix);
|
||||
});
|
||||
CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1));
|
||||
@@ -209,7 +212,7 @@ describe("mixed import: container split; container-freed wagons promote the bulk
|
||||
twenty: 20,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
pollBookingStatus("MSX", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("MSX", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
* journey's user/company from the DB instead of module variables.
|
||||
*/
|
||||
|
||||
import { completeFaydaVerification } from "./import-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const email = `e2e.onboard.${stamp}@example.com`;
|
||||
// Ethiopian mobile: 9 + 8 digits, unique per run.
|
||||
const phoneNational = `9${String(stamp).slice(-8)}`;
|
||||
const signupPassword = "Password@e2e1";
|
||||
const companyName = `E2E Onboard Co ${stamp}`;
|
||||
const tin = String(stamp).slice(-10).padStart(10, "1");
|
||||
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
|
||||
const fan = String(stamp).slice(-13).padStart(16, "3");
|
||||
|
||||
const portal = () => Cypress.env("portalUrl") as string;
|
||||
|
||||
@@ -61,12 +61,6 @@ function fillPhone(index: number, national: string) {
|
||||
|
||||
describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
it("signs up with OTP and completes the onboarding wizard", () => {
|
||||
// The eTrade TIN lookup 400s in e2e (external service unreachable). The
|
||||
// form handles it ("fill in the details manually") but axios also throws
|
||||
// an uncaught rejection — ignore just that one.
|
||||
cy.on("uncaught:exception", (err) =>
|
||||
err.message.includes("Request failed with status code 400") ? false : true,
|
||||
);
|
||||
cy.visit(`${portal()}/signup`);
|
||||
|
||||
fill(/^First name/, "Onboard");
|
||||
@@ -91,20 +85,39 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
cy.contains("button", "Importer").click();
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Company step. TIN first — the eTrade auto-lookup fails in e2e (no
|
||||
// external network) and the form allows manual entry.
|
||||
cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin);
|
||||
fill(/^Company Name/, companyName);
|
||||
fill(/^Company Email/, `ops.${stamp}@example.com`);
|
||||
fillPhone(0, "911234567");
|
||||
fill(/^Location/, "Addis Ababa, Ethiopia");
|
||||
// Ethiopian companies gate the "Owner identity" step on Fayda
|
||||
// verification — a real popup + SMS OTP flow that can't run in e2e.
|
||||
// Complete it via the API against fayda-mock-e2e (the profile this
|
||||
// attaches to was just created by the nationality/role step above).
|
||||
// The wizard already fetched `identity` once when this step mounted —
|
||||
// completing verification out-of-band (no popup, so no onVerified
|
||||
// callback fires) leaves that fetch stale, so reload to force a fresh
|
||||
// one. Wizard progress resumes server-side, so this doesn't lose the
|
||||
// nationality/role step just completed.
|
||||
completeFaydaVerification("owner");
|
||||
cy.reload();
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
|
||||
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
|
||||
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
|
||||
// No as read-only (ETradeCompanyCard) — nothing left to type there, and
|
||||
// Company Email/Phone/Location were dropped from this step entirely (the
|
||||
// Fayda-verified owner supplies contact details now). By label, not
|
||||
// placeholder: the VAT Number field on this same step shares the TIN
|
||||
// field's "0012345678" placeholder, so a placeholder selector matches 2.
|
||||
fill(/^TIN Number/, tin);
|
||||
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
// handleETradeDataLoaded sets several fields in sequence (name, region,
|
||||
// zone, woreda, kebele, houseNo) — each a render, still settling right
|
||||
// after the badge appears. Typing into VAT immediately raced one of
|
||||
// those and detached mid-type; let it finish before touching the form.
|
||||
cy.wait(500);
|
||||
fill(/^VAT Number/, vat);
|
||||
cy.get('input[placeholder="1234567890123456"]').type(fan);
|
||||
cy.mantineSelect(/^Region/, "Addis Ababa");
|
||||
fill(/^Zone/, "Zone 1");
|
||||
fill(/^Woreda/, "Woreda 1");
|
||||
fill(/^Kebele/, "Kebele 1");
|
||||
fill(/^House No/, "123");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Personnel (general manager).
|
||||
|
||||
@@ -141,34 +141,55 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string |
|
||||
* takes explicit Start/End dates and pre-fills neither, so "Accept & start
|
||||
* approval" stays disabled until both are set.
|
||||
*
|
||||
* Mantine's DateInput parses typed text with its valueFormat, which defaults to
|
||||
* "MMMM D, YYYY" — the same shape en-US toLocaleDateString produces.
|
||||
* The fields are Mantine DateTimePickers — a button that opens a calendar
|
||||
* popover, not a typeable input. Zoom out to the decade view (2 clicks on the
|
||||
* header's middle control, which has no [data-direction]) then drill back
|
||||
* down year → month → day, and confirm with the popover's submit (check)
|
||||
* button — clicking a day alone only stages the value, it does not close
|
||||
* the popover or commit the pick.
|
||||
*/
|
||||
const MONTH_ABBR = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
] as const;
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
] as const;
|
||||
|
||||
Cypress.Commands.add("acceptValidityWindow", (days = 365) => {
|
||||
const start = new Date();
|
||||
const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
const asInput = (d: Date) =>
|
||||
d.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
for (const [label, value] of [
|
||||
["Start date", start],
|
||||
["End date", end],
|
||||
] as const) {
|
||||
const pickDate = (label: string, date: Date) => {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`)
|
||||
.clear({ force: true })
|
||||
.type(asInput(value), { force: true })
|
||||
// DateInput commits on blur; it also closes the calendar popover,
|
||||
// which would otherwise sit over the submit button.
|
||||
.blur();
|
||||
});
|
||||
}
|
||||
.then((id) => cy.get(`[id="${id}"]`).click({ force: true }));
|
||||
|
||||
// month view -> year view -> decade view.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
cy.get('.mantine-Popover-dropdown [data-direction="previous"]')
|
||||
.siblings("button")
|
||||
.first()
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
cy.get(".mantine-Popover-dropdown")
|
||||
.contains("button", new RegExp(`^${date.getFullYear()}$`))
|
||||
.click({ force: true });
|
||||
cy.get(".mantine-Popover-dropdown")
|
||||
.contains("button", new RegExp(`^${MONTH_ABBR[date.getMonth()]}$`))
|
||||
.click({ force: true });
|
||||
|
||||
const dayAriaLabel = `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`;
|
||||
cy.get(`.mantine-Popover-dropdown [aria-label="${dayAriaLabel}"]`).click({
|
||||
force: true,
|
||||
});
|
||||
cy.get(".mantine-DateTimePicker-submitButton").click({ force: true });
|
||||
};
|
||||
|
||||
pickDate("Start date", start);
|
||||
pickDate("End date", end);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
124
e2e/freight/etrade-mock/server.js
Normal file
124
e2e/freight/etrade-mock/server.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// Stand-in for https://etrade.gov.et in the e2e stack. ETradeService's base
|
||||
// URL is hardcoded (apps/edr-freight-api/src/modules/companies/services/etrade.service.ts),
|
||||
// not env-configurable, so this is reached by aliasing this container AS
|
||||
// "etrade.gov.et" on the compose network (see docker-compose.e2e.yaml) rather
|
||||
// than by pointing an env var here. HTTPS because the real service is HTTPS
|
||||
// — the cert itself only needs to exist, never be trusted, since
|
||||
// ETradeService's httpsAgent sets rejectUnauthorized:false. The compose
|
||||
// service's command generates a throwaway self-signed cert into
|
||||
// TLS_CERT_PATH/TLS_KEY_PATH before starting this — nothing shaped like a
|
||||
// key/cert is committed here.
|
||||
//
|
||||
// Every TIN resolves to the same canned company — these specs don't care
|
||||
// about per-TIN business logic, only that the lookup succeeds so the rest
|
||||
// of the company-info form (name, address, manager) auto-fills instead of
|
||||
// staying gated behind a "No matching business record" alert.
|
||||
const https = require("node:https");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const PORT = process.env.PORT || 443;
|
||||
|
||||
const options = {
|
||||
key: fs.readFileSync(process.env.TLS_KEY_PATH || "/tmp/etrade-mock/key.pem"),
|
||||
cert: fs.readFileSync(
|
||||
process.env.TLS_CERT_PATH || "/tmp/etrade-mock/cert.pem",
|
||||
),
|
||||
};
|
||||
|
||||
function companyInfo(tin) {
|
||||
return {
|
||||
Tin: tin,
|
||||
LegalCondtion: "Private Limited Company",
|
||||
RegNo: "REG-E2E-0001",
|
||||
RegDate: "2020-01-01",
|
||||
// Embeds the (per-run-unique) TIN rather than a static name — specs
|
||||
// that look a company up by name (e.g. onboarding.cy.ts) need this to
|
||||
// stay unique across repeated e2e runs against the same warm DB, same
|
||||
// as it would be if the customer had typed a real company name.
|
||||
BusinessName: `E2E Mock Trading PLC ${tin}`,
|
||||
BusinessNameAmh: "ኢቱኢ ሞክ ትሬዲንግ",
|
||||
PaidUpCapital: 100000,
|
||||
AssociateShortInfos: [],
|
||||
Businesses: [
|
||||
{
|
||||
MainGuid: "e2e-guid-0001",
|
||||
OwnerTIN: tin,
|
||||
DateRegistered: "2020-01-01",
|
||||
TradeNameAmh: "ኢቱኢ",
|
||||
TradesName: "E2E Mock Trading",
|
||||
LicenceNumber: "LIC-E2E-0001",
|
||||
RenewalDate: "2026-01-01",
|
||||
RenewedFrom: "2025-01-01",
|
||||
RenewedTo: "2027-01-01",
|
||||
BusinessLicensingGroupMain: null,
|
||||
SubGroups: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function businessInfo(tin) {
|
||||
return {
|
||||
MainGuid: "e2e-guid-0001",
|
||||
OwnerTIN: tin,
|
||||
DateRegistered: "2020-01-01",
|
||||
TradeName: "E2E Mock Trading",
|
||||
LicenceNumber: "LIC-E2E-0001",
|
||||
Status: 1,
|
||||
StatusDescription: "Active",
|
||||
Capital: 100000,
|
||||
AssociateShortInfos: [
|
||||
{
|
||||
Position: "Manager",
|
||||
ManagerName: "አበበ በቀለ",
|
||||
ManagerNameEng: "Abebe Bekele",
|
||||
Photo: null,
|
||||
MobilePhone: "+251911223344",
|
||||
RegularPhone: null,
|
||||
},
|
||||
],
|
||||
AddressInfo: {
|
||||
Region: "ADDIS ABABA",
|
||||
Zone: "Zone 1",
|
||||
Woreda: "Woreda 1",
|
||||
Kebele: "Kebele 1",
|
||||
HouseNo: "123",
|
||||
MobilePhone: "+251911223344",
|
||||
RegularPhone: "",
|
||||
},
|
||||
RenewedTo: "2027-01-01",
|
||||
RenewedToDateString: "2027-01-01",
|
||||
RenewalDate: "2026-01-01",
|
||||
RenewedFrom: "2025-01-01",
|
||||
CancellationDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
const server = https.createServer(options, (req, res) => {
|
||||
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||
|
||||
const regMatch = url.pathname.match(
|
||||
/^\/api\/Registration\/GetRegistrationInfoByTin\/([^/]+)\/en$/,
|
||||
);
|
||||
if (req.method === "GET" && regMatch) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(companyInfo(regMatch[1])));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "GET" &&
|
||||
url.pathname === "/api/BusinessMain/GetBusinessByLicenseNo"
|
||||
) {
|
||||
const tin = url.searchParams.get("Tin") || "";
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(businessInfo(tin)));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`etrade-mock listening on ${PORT}`);
|
||||
});
|
||||
52
e2e/freight/fayda-mock/server.js
Normal file
52
e2e/freight/fayda-mock/server.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Minimal eSignet stand-in for the e2e stack. freight-api-e2e's
|
||||
// FAYDA_TOKEN_ENDPOINT / FAYDA_USERINFO_ENDPOINT point here instead of the
|
||||
// real (unreachable) Fayda infrastructure. It does not validate the
|
||||
// client_assertion, code, or code_verifier it's sent — Cypress drives the
|
||||
// verification via the API directly (POST start, then POST complete with a
|
||||
// throwaway code), never through the real popup+SMS flow, so nothing here
|
||||
// needs to be cryptographically real, only shaped like a working OIDC token
|
||||
// + userinfo response.
|
||||
const http = require("node:http");
|
||||
|
||||
const PORT = process.env.PORT || 4400;
|
||||
|
||||
const USERINFO = {
|
||||
sub: "e2e-fayda-sub-0001",
|
||||
name: "Abebe Bekele",
|
||||
"name#en": "Abebe Bekele",
|
||||
email: "abebe.bekele@example.com",
|
||||
phone_number: "+251911223344",
|
||||
gender: "Male",
|
||||
"gender#en": "Male",
|
||||
birthdate: "1990-01-01",
|
||||
"address#en": "Bole Sub City, Addis Ababa, Ethiopia",
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Drain the body so the client's request completes cleanly even though we
|
||||
// never read it (POST /token sends a form-urlencoded body we don't parse).
|
||||
req.on("data", () => {});
|
||||
req.on("end", () => {
|
||||
if (req.method === "POST" && req.url.startsWith("/token")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "e2e-mock-access-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url.startsWith("/userinfo")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(USERINFO));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`fayda-mock listening on ${PORT}`);
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -110,6 +111,24 @@ async function resolvePorts() {
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 — signs a client_assertion
|
||||
* JWT that fayda-mock-e2e never verifies (see docker-compose.e2e.yaml), so
|
||||
* a fresh one every launch is fine; nothing needs it to stay stable across
|
||||
* runs the way ports do. Generated here instead of committed as a literal
|
||||
* blob in the compose file so nothing that looks like a private key sits in
|
||||
* git history, even though this one only unlocks a local mock.
|
||||
*/
|
||||
function fakeFaydaPrivateKeyBase64() {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = privateKey.export({ format: "jwk" });
|
||||
jwk.kty = "RSA";
|
||||
jwk.use = "sig";
|
||||
jwk.alg = "RS256";
|
||||
jwk.kid = "e2e-fayda-mock";
|
||||
return Buffer.from(JSON.stringify(jwk)).toString("base64");
|
||||
}
|
||||
|
||||
function envFor(ports) {
|
||||
return {
|
||||
...process.env,
|
||||
@@ -120,6 +139,8 @@ function envFor(ports) {
|
||||
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`,
|
||||
CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`,
|
||||
E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`,
|
||||
FAYDA_PRIVATE_KEY_BASE64:
|
||||
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ import type {
|
||||
CacPaymentInitiateResponse,
|
||||
} from "./cac-bank.types";
|
||||
|
||||
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */
|
||||
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). Not
|
||||
* documented for other settlement currencies (e.g. USD) — skip the local pre-check there and
|
||||
* let the bank's own validation reject an out-of-range amount. */
|
||||
const CAC_MIN_AMOUNT = 10;
|
||||
const CAC_MAX_AMOUNT = 100_000;
|
||||
|
||||
@@ -73,10 +75,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
}
|
||||
|
||||
const customerMobile = normalizeCacMobile(input.payerAccount);
|
||||
const amount = this.toMajorAmount(input.amountMinor, input.currency);
|
||||
if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
|
||||
const amount = this.toMajorAmount(input.amountMinor);
|
||||
const currency = (input.currency || this.defaultCurrency).toUpperCase();
|
||||
if (
|
||||
currency === "DJF" &&
|
||||
(amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT)
|
||||
) {
|
||||
throw new Error(
|
||||
`CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` +
|
||||
`CAC Bank amount ${amount} DJF is outside the accepted range ` +
|
||||
`(${CAC_MIN_AMOUNT}–${CAC_MAX_AMOUNT} DJF)`,
|
||||
);
|
||||
}
|
||||
@@ -85,7 +91,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
customer_mobile: customerMobile,
|
||||
currency: input.currency || this.defaultCurrency,
|
||||
currency,
|
||||
desc: `${input.orderRef}`.slice(0, 500),
|
||||
vender_ref: input.merchantOrderId,
|
||||
amount,
|
||||
@@ -303,12 +309,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
return this.auth;
|
||||
}
|
||||
|
||||
/** DJF has no fractional units — amountMinor is the major amount. */
|
||||
private toMajorAmount(amountMinor: number, currency: string): number {
|
||||
if (currency.toUpperCase() === "DJF") {
|
||||
return amountMinor;
|
||||
}
|
||||
return amountMinor / 100;
|
||||
/**
|
||||
* Both callers (freight's invoice.balanceAmount, passenger's CurrencyService) already
|
||||
* hand off a major-currency amount — e.g. 700 for $700, not 70000 cents — with the target
|
||||
* currency's own decimal precision already applied (0dp for DJF, 2dp for USD/ETB). CAC Bank
|
||||
* bills in that same major unit, so it is forwarded unchanged.
|
||||
*/
|
||||
private toMajorAmount(amountMinor: number): number {
|
||||
return amountMinor;
|
||||
}
|
||||
|
||||
private sanitizeKeys(
|
||||
|
||||
Reference in New Issue
Block a user