mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +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();
|
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 { CompaniesService } from "../companies/companies.service";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
import {
|
import {
|
||||||
InvoiceDocumentModel,
|
InvoiceDocumentModel,
|
||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
@@ -352,6 +352,34 @@ export class BillingService {
|
|||||||
return this.payInvoice(id, opts);
|
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). */
|
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||||
async documentForUser(
|
async documentForUser(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -1035,6 +1063,17 @@ export class BillingService {
|
|||||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
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({
|
const result = await this.payment.initiate({
|
||||||
referenceId: invoice.sourceId,
|
referenceId: invoice.sourceId,
|
||||||
source: invoice.source,
|
source: invoice.source,
|
||||||
@@ -1062,7 +1101,12 @@ export class BillingService {
|
|||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept commented for local demos only.
|
// billing must not simulate it. Kept 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({
|
await this.payment.handlePaymentEvent({
|
||||||
eventType: "payment.succeeded",
|
eventType: "payment.succeeded",
|
||||||
eventId: `demo-${result.intentId}`,
|
eventId: `demo-${result.intentId}`,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
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. */
|
/** Gateway options for paying an invoice from the customer portal. */
|
||||||
export class PayInvoiceDto {
|
export class PayInvoiceDto {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
import { sendPdf } from "./billing.controller";
|
import { sendPdf } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
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,
|
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||||
@@ -96,4 +96,20 @@ export class PortalBillingController {
|
|||||||
failureUrl: dto.failureUrl,
|
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
|
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||||
* national ID number, so none is collected or derived here.
|
* national ID number, so none is collected or derived here.
|
||||||
*
|
*
|
||||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
* Only the OWNER's credential varies by nationality:
|
||||||
* Fayda, and their details can't be edited afterwards.
|
* - Ethiopian company: the owner is verified through Fayda.
|
||||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
* - 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
|
* supplies a typed passport number — required on its own, whether or not the
|
||||||
* owner also completes a (purely optional) Fayda verification.
|
* 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 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
|
* the portal offers a "same as owner" copy for, but it is never itself
|
||||||
* Fayda-verified or gated on.
|
* Fayda-verified or gated on.
|
||||||
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
},
|
},
|
||||||
changeRequestRepo: {
|
changeRequestRepo: {
|
||||||
findPendingByCompanyId: jest.fn(async () => null),
|
findPendingByCompanyId: jest.fn(async () => null),
|
||||||
|
findLatestOpenByCompanyId: jest.fn(async () => null),
|
||||||
findByCompanyId: jest.fn(async () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
id: "cr-1",
|
id: "cr-1",
|
||||||
@@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stages the change for review on an approved company", async () => {
|
it("stages an owner re-verification for review on an approved company", async () => {
|
||||||
// Swapping the person who can act for a live company is exactly what the
|
// The owner is the live company's identity proof, so re-verifying one is
|
||||||
// backoffice review exists for, so it must not rewrite the row directly.
|
// 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({
|
const { service, ctx, deps } = makeService({
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Active,
|
||||||
});
|
});
|
||||||
@@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
state: "s",
|
state: "s",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses to rename a verified person by hand", async () => {
|
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);
|
).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({
|
const { service } = makeService({
|
||||||
profileTypes: [ProfileType.importer],
|
profileTypes: [ProfileType.importer],
|
||||||
nationality: CompanyNationality.Foreign,
|
nationality: CompanyNationality.Foreign,
|
||||||
@@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
"user-1",
|
"user-1",
|
||||||
ProfileType.freightForwarder,
|
ProfileType.freightForwarder,
|
||||||
),
|
),
|
||||||
).resolves.toBeDefined();
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
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 () =>
|
findPendingByCompanyId: jest.fn(async () =>
|
||||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
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 () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
id: "cr-1",
|
id: "cr-1",
|
||||||
|
|||||||
@@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [
|
|||||||
"poaLocation",
|
"poaLocation",
|
||||||
"poaAddress",
|
"poaAddress",
|
||||||
] as const;
|
] 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. */
|
/** Mandatory once the company operates as a freight forwarder. */
|
||||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||||
{ key: "poaName", label: "PoA name" },
|
{ key: "poaName", label: "PoA name" },
|
||||||
@@ -844,10 +866,11 @@ export class CompaniesService {
|
|||||||
*
|
*
|
||||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||||
* as before. The company/role pending→approve gate already covers first-run.
|
* 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
|
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
|
||||||
* a pending change request (merging into any open one) so a backoffice
|
* still write straight through; everything else does NOT touch the live
|
||||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
* Company but is staged in a pending change request (merging into any open
|
||||||
* customer until the review resolves.
|
* 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(
|
async updateProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -882,9 +905,37 @@ export class CompaniesService {
|
|||||||
return new ProfileResponseDto(profile, updated);
|
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);
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
const fields = this.pickDefined(dto);
|
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(
|
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||||
company.id,
|
company.id,
|
||||||
@@ -894,7 +945,7 @@ export class CompaniesService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
request =
|
request =
|
||||||
(await this.changeRequestRepo.update(existing.id, {
|
(await this.changeRequestRepo.update(existing.id, {
|
||||||
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
note: null,
|
||||||
@@ -910,7 +961,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
request = await this.changeRequestRepo.create({
|
request = await this.changeRequestRepo.create({
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
snapshot: fields,
|
snapshot: staged,
|
||||||
status: ChangeRequestStatus.Pending,
|
status: ChangeRequestStatus.Pending,
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
@@ -922,8 +973,9 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live company is unchanged; surface the pending state for the settings page.
|
// Only the personnel half (if any) landed; surface the pending state for
|
||||||
return new ProfileResponseDto(profile, company, request);
|
// the settings page.
|
||||||
|
return new ProfileResponseDto(profile, live, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List a company's change requests, newest first (backoffice review). */
|
/** List a company's change requests, newest first (backoffice review). */
|
||||||
@@ -1720,16 +1772,11 @@ export class CompaniesService {
|
|||||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
// No company types its PoA details — they arrive from the Fayda
|
||||||
// from the Fayda verification — so reporting them as missing fields would
|
// verification whatever the nationality — so reporting them as missing
|
||||||
// ask for something the form no longer offers. The identity block below
|
// fields would ask for something no form offers. The identity block below
|
||||||
// reports "verify your PoA" instead.
|
// reports "verify your PoA" instead.
|
||||||
const missingPoaFields =
|
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
||||||
poaRequired && !identity.faydaRequired
|
|
||||||
? REQUIRED_POA_FIELDS.filter(
|
|
||||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const delegation = await this.getPoaDelegationState(company.id);
|
const delegation = await this.getPoaDelegationState(company.id);
|
||||||
const delegationDue = poaRequired || poaProvided;
|
const delegationDue = poaRequired || poaProvided;
|
||||||
const missingDelegation = delegationDue && !delegation.onFile;
|
const missingDelegation = delegationDue && !delegation.onFile;
|
||||||
@@ -1754,9 +1801,7 @@ export class CompaniesService {
|
|||||||
...(identity.faydaRequired && !identity.owner.verified
|
...(identity.faydaRequired && !identity.owner.verified
|
||||||
? ["Verify the company owner's identity with Fayda"]
|
? ["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"]
|
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||||
: []),
|
: []),
|
||||||
...(identity.passportRequired && !identity.owner.passportNumber
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
@@ -1768,26 +1813,20 @@ export class CompaniesService {
|
|||||||
// fields, required documents, one license per operational profile, and the
|
// fields, required documents, one license per operational profile, and the
|
||||||
// PoA details/paper whenever those are mandatory.
|
// PoA details/paper whenever those are mandatory.
|
||||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||||
const poaItemCount =
|
const poaItemCount = delegationDue ? 1 : 0;
|
||||||
(poaRequired && !identity.faydaRequired
|
|
||||||
? REQUIRED_POA_FIELDS.length
|
|
||||||
: 0) + (delegationDue ? 1 : 0);
|
|
||||||
// One item per identity credential the company has to prove: the owner
|
// One item per identity credential the company has to prove: the owner
|
||||||
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||||
// is one and Fayda is what's mandatory here.
|
// there is one — that one is Fayda whatever the nationality.
|
||||||
const identityItemCount = identity.faydaRequired
|
const ownerCredentialDue =
|
||||||
? delegationDue
|
identity.faydaRequired || identity.passportRequired;
|
||||||
? 2
|
const ownerCredentialProven = identity.faydaRequired
|
||||||
: 1
|
? identity.owner.verified
|
||||||
: identity.passportRequired
|
: Boolean(identity.owner.passportNumber);
|
||||||
? 1
|
const identityItemCount =
|
||||||
: 0;
|
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||||
const missingIdentityCount = identity.faydaRequired
|
const missingIdentityCount =
|
||||||
? (identity.owner.verified ? 0 : 1) +
|
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||||
(delegationDue && !identity.poa.verified ? 1 : 0)
|
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||||
: identity.passportRequired && !identity.owner.passportNumber
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
const total =
|
const total =
|
||||||
requiredInfo.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
@@ -2453,11 +2492,13 @@ export class CompaniesService {
|
|||||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// An approved company's profile edits are staged for backoffice review, and
|
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||||
// swapping the person who can act for the company is exactly the kind of
|
// is staged for backoffice review rather than quietly rewriting a live
|
||||||
// edit that review exists for — so a verification lands the same way an
|
// record. The PoA is personnel — the company names its own representative,
|
||||||
// ordinary edit does, rather than quietly rewriting a live record.
|
// and the delegation letter backing them is what the reviewer sees — so a
|
||||||
if (company.status === CompanyStatus.Active) {
|
// 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);
|
await this.stageIdentityChange(company, userId, identity);
|
||||||
return this.getCompanyIdentityState(company);
|
return this.getCompanyIdentityState(company);
|
||||||
}
|
}
|
||||||
@@ -2586,21 +2627,23 @@ export class CompaniesService {
|
|||||||
): void {
|
): void {
|
||||||
const state = buildCompanyIdentityState(company);
|
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.passportRequired) {
|
||||||
if (!state.owner.passportNumber) {
|
if (!state.owner.passportNumber) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Add the company owner's passport number before continuing.",
|
"Add the company owner's passport number before continuing.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
} else if (!state.owner.verified) {
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.owner.verified) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verify the company owner's identity with Fayda before continuing.",
|
"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) =>
|
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
|
|||||||
(p) => p.type === ProfileType.freightForwarder,
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||||
|
|
||||||
const complete = faydaRequired
|
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||||
? owner.verified && (!poaDue || poa.verified)
|
// 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);
|
: !passportRequired || Boolean(owner.passportNumber);
|
||||||
|
const complete = ownerProven && (!poaDue || poa.verified);
|
||||||
|
|
||||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
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 { HttpService } from "@nestjs/axios";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
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> {
|
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||||
const url = `${this.baseUrl}${path}`;
|
const url = `${this.baseUrl}${path}`;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
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,
|
ConfigModule,
|
||||||
forwardRef(() => BillingModule),
|
forwardRef(() => BillingModule),
|
||||||
// forwardRef(() => TrainSchedulingModule),
|
// 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);
|
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
|
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
* 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_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||||
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||||
|
CONFIRM_INVOICE_OTP: (id: string) =>
|
||||||
|
`/api/billing/my-invoices/${id}/confirm`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVOICES: {
|
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 { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
PackagePlus,
|
PackagePlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
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 { invoicesService } from "@/services/invoices.service";
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
isPayable(inv.status),
|
isPayable(inv.status),
|
||||||
)?.id;
|
)?.id;
|
||||||
|
|
||||||
const payMutation = useMutation({
|
const pay = useInvoicePayment();
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payItem !== null}
|
opened={payItem !== null}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayItem(null);
|
setPayItem(null);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
currency={undefined}
|
currency={undefined}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={
|
||||||
payMutation.isError
|
pay.error ??
|
||||||
? payMutation.error instanceof Error
|
(payItemInvoices.length > 0 && !payableInvoiceId
|
||||||
? payMutation.error.message
|
? "No payable invoice found for this booking yet. Please refresh or contact support."
|
||||||
: "Could not start payment. Please try again."
|
: null)
|
||||||
: null
|
}
|
||||||
|
otp={pay.otp}
|
||||||
|
onConfirm={(method, payerAccount) =>
|
||||||
|
payableInvoiceId &&
|
||||||
|
pay.pay(payableInvoiceId, method, payerAccount)
|
||||||
}
|
}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
</ModalSafeWrapper>
|
</ModalSafeWrapper>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -287,9 +287,11 @@ export default function SettingsPage() {
|
|||||||
icon={<Clock size={18} />}
|
icon={<Clock size={18} />}
|
||||||
title="Changes submitted for review"
|
title="Changes submitted for review"
|
||||||
>
|
>
|
||||||
Your recent changes are awaiting administrator approval. Editing is
|
Your recent changes are awaiting administrator approval. Company
|
||||||
disabled until the review is complete — you'll be notified once it's
|
details and documents can't be edited until the review is complete —
|
||||||
approved or if any changes are requested.
|
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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{reviewStatus === "rejected" && (
|
{reviewStatus === "rejected" && (
|
||||||
@@ -358,9 +360,17 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* While a change request is pending, every panel's inputs + submit
|
{/* While a change request is pending, the reviewed panels' inputs +
|
||||||
buttons are disabled via the native fieldset; tab switching stays
|
submit buttons are disabled via the native fieldset; tab switching
|
||||||
enabled so the customer can still review what they submitted. */}
|
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">
|
<Tabs.Panel value="company">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
||||||
@@ -368,19 +378,13 @@ export default function SettingsPage() {
|
|||||||
<OperationalServicesCard profile={profile} />
|
<OperationalServicesCard profile={profile} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="contact">
|
<Tabs.Panel value="contact">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabContactPerson profile={profile} mode="edit" />
|
||||||
<TabContactPerson profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="gm">
|
<Tabs.Panel value="gm">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabGeneralManager profile={profile} mode="edit" />
|
||||||
<TabGeneralManager profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="poa">
|
<Tabs.Panel value="poa">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="documents">
|
<Tabs.Panel value="documents">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
buildOnboardingSchema,
|
buildOnboardingSchema,
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
type FormData,
|
type FormData,
|
||||||
hasPoaDetails,
|
|
||||||
POA_DELEGATION_FILE_KEY,
|
POA_DELEGATION_FILE_KEY,
|
||||||
stepFields,
|
stepFields,
|
||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
buildOnboardingSchema(
|
buildOnboardingSchema(identity?.passportRequired === true),
|
||||||
requirePoa,
|
|
||||||
verifiedIdentity,
|
|
||||||
identity?.passportRequired === true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
|
|||||||
// them and re-enables editing.
|
// them and re-enables editing.
|
||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
|
||||||
|
|
||||||
// General Manager source. The company step's email/phone are seeded from
|
// 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
|
// 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 gmName = watch("generalManagerName");
|
||||||
const gmEmail = watch("generalManagerEmail");
|
const gmEmail = watch("generalManagerEmail");
|
||||||
const gmPhone = watch("generalManagerPhone");
|
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
|
// 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.
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
}, [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) => {
|
const toggleContactSameAsGm = (checked: boolean) => {
|
||||||
setContactSameAsGm(checked);
|
setContactSameAsGm(checked);
|
||||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// the same rule on save, so skipping it here only costs the customer a
|
||||||
// round-trip.
|
// 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 delegationRequired = requirePoa || poaProvided;
|
||||||
const delegationPresent =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(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.");
|
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
||||||
step === "poa" &&
|
|
||||||
verifiedIdentity &&
|
|
||||||
requirePoa &&
|
|
||||||
!identity?.poa.verified
|
|
||||||
) {
|
|
||||||
setSaveError(
|
setSaveError(
|
||||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
"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."
|
? "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."}
|
: "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>
|
</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 && (
|
{identity && (
|
||||||
<FaydaVerifyPanel
|
<FaydaVerifyPanel
|
||||||
subject="poa"
|
subject="poa"
|
||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={identity.faydaRequired}
|
required={requirePoa}
|
||||||
onVerified={() => onIdentityChange?.()}
|
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
|
{/* The city is the one field the Fayda address claim does not
|
||||||
reliably decompose into, so it stays typed either way. */}
|
reliably decompose into, so it stays typed. */}
|
||||||
{verifiedIdentity && (
|
<TextInput
|
||||||
<TextInput
|
label="PoA Location"
|
||||||
label="PoA Location"
|
placeholder="City, Country"
|
||||||
placeholder="City, Country"
|
error={errors.poaLocation?.message}
|
||||||
error={errors.poaLocation?.message}
|
{...register("poaLocation")}
|
||||||
{...register("poaLocation")}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{poaDocumentSetting && (
|
{poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -37,10 +37,8 @@ export function buildPayload(
|
|||||||
generalManagerName: data.generalManagerName,
|
generalManagerName: data.generalManagerName,
|
||||||
generalManagerEmail: data.generalManagerEmail,
|
generalManagerEmail: data.generalManagerEmail,
|
||||||
generalManagerPhone: data.generalManagerPhone,
|
generalManagerPhone: data.generalManagerPhone,
|
||||||
poaName: data.poaName || undefined,
|
// The representative's own details are written by their Fayda
|
||||||
poaPhone: data.poaPhone || undefined,
|
// verification, so the city is all the form has to send.
|
||||||
poaAddress: data.poaAddress || undefined,
|
|
||||||
poaEmail: data.poaEmail || undefined,
|
|
||||||
poaLocation: data.poaLocation || undefined,
|
poaLocation: data.poaLocation || undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -88,13 +86,7 @@ export function stepPayload(
|
|||||||
contactPersonPhone: d.contactPersonPhone,
|
contactPersonPhone: d.contactPersonPhone,
|
||||||
};
|
};
|
||||||
case "poa":
|
case "poa":
|
||||||
return {
|
return { poaLocation: d.poaLocation || undefined };
|
||||||
poaName: d.poaName || undefined,
|
|
||||||
poaPhone: d.poaPhone || undefined,
|
|
||||||
poaEmail: d.poaEmail || undefined,
|
|
||||||
poaLocation: d.poaLocation || undefined,
|
|
||||||
poaAddress: d.poaAddress || undefined,
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
|
|||||||
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
||||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
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
|
* The PoA's identifying fields are never typed — they come from the Fayda
|
||||||
* rather than optional. Everyone else keeps the optional PoA — but once they
|
* verification, whatever the company's nationality — so nothing here requires
|
||||||
* start filling it in, the identifying fields have to be complete (the
|
* them. A freight forwarder's mandatory PoA is gated on the verification
|
||||||
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
* itself, and its delegation letter alongside it, both in CompanyProfileForm
|
||||||
* since files live outside the form state).
|
* (files live outside form state).
|
||||||
|
*
|
||||||
|
* That leaves the owner's passport number as the only conditional field.
|
||||||
*/
|
*/
|
||||||
export function buildOnboardingSchema(
|
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. */
|
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||||
passportRequired = false,
|
passportRequired = false,
|
||||||
) {
|
) {
|
||||||
const poaRequired = requirePoa && !faydaOwnedPoa;
|
if (!passportRequired) return onboardingSchema;
|
||||||
if (!poaRequired && !passportRequired) return onboardingSchema;
|
|
||||||
return onboardingSchema.superRefine((d, ctx) => {
|
return onboardingSchema.superRefine((d, ctx) => {
|
||||||
const required: [keyof FormData, string][] = [];
|
if (!d.ownerPassportNumber?.trim()) {
|
||||||
if (poaRequired) {
|
ctx.addIssue({
|
||||||
required.push(
|
code: z.ZodIssueCode.custom,
|
||||||
["poaName", "PoA name is required for freight forwarders"],
|
path: ["ownerPassportNumber"],
|
||||||
["poaEmail", "PoA email is required for freight forwarders"],
|
message: "The owner's passport number is required",
|
||||||
["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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"contactPersonPhone",
|
||||||
],
|
],
|
||||||
poa: [...POA_FIELDS],
|
poa: ["poaLocation"],
|
||||||
documents: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -27,10 +27,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import {
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
paymentsService,
|
|
||||||
type PaymentMethod,
|
|
||||||
} from "@/services/payments.service";
|
|
||||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
@@ -76,17 +73,7 @@ export default function InvoiceDetailPage() {
|
|||||||
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
||||||
// one of the signed-in customer's own invoices (unlike the admin-facing
|
// one of the signed-in customer's own invoices (unlike the admin-facing
|
||||||
// /payments/initiate, which takes any invoiceId with no ownership check).
|
// /payments/initiate, which takes any invoiceId with no ownership check).
|
||||||
const payMutation = useMutation({
|
const pay = useInvoicePayment();
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -249,7 +236,7 @@ export default function InvoiceDetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<CreditCard size={16} />}
|
leftSection={<CreditCard size={16} />}
|
||||||
loading={payMutation.isPending}
|
loading={pay.processing}
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
styles={{
|
styles={{
|
||||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||||
@@ -376,22 +363,19 @@ export default function InvoiceDetailPage() {
|
|||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payModalOpen}
|
opened={payModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayModalOpen(false);
|
setPayModalOpen(false);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
||||||
currency={invoice.currency}
|
currency={invoice.currency}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={pay.error}
|
||||||
payMutation.isError
|
otp={pay.otp}
|
||||||
? payMutation.error instanceof Error
|
onConfirm={(method, payerAccount) =>
|
||||||
? payMutation.error.message
|
pay.pay(id, method, payerAccount)
|
||||||
: "Could not start payment. Please try again."
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Group, Tabs } from "@mantine/core";
|
import { Group, Tabs } from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
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 type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
@@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero";
|
|||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
|
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||||
|
|
||||||
export function ReadonlyBookingView({
|
export function ReadonlyBookingView({
|
||||||
booking,
|
booking,
|
||||||
@@ -53,7 +48,6 @@ export function ReadonlyBookingView({
|
|||||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||||
useScrollToHash();
|
useScrollToHash();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
|
||||||
const { viewer } = useFileViewer();
|
const { viewer } = useFileViewer();
|
||||||
|
|
||||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||||
@@ -63,44 +57,11 @@ export function ReadonlyBookingView({
|
|||||||
: "/contracts/new";
|
: "/contracts/new";
|
||||||
const onRebook = () => navigate(rebookTo);
|
const onRebook = () => navigate(rebookTo);
|
||||||
|
|
||||||
// Billing is invoice-centric — resolve the booking's currently payable
|
// Billing is invoice-centric — the shared hook resolves the booking's
|
||||||
// invoice (same query/key BookingPaymentPanel uses, so this shares its
|
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
|
||||||
// cache) and pay it through the ownership-checked portal route.
|
// shares that cache), charges it through the ownership-checked portal route,
|
||||||
const { data: bookingInvoices = [] } = useQuery({
|
// and handles redirect vs CAC Bank OTP.
|
||||||
queryKey: ["booking-invoices", booking.id],
|
const pay = useBookingPayment(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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pricing = booking.pricingBreakdown;
|
const pricing = booking.pricingBreakdown;
|
||||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||||
@@ -171,7 +132,7 @@ export function ReadonlyBookingView({
|
|||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
label="Pay now"
|
label="Pay now"
|
||||||
onClick={() => setPayModalOpen(true)}
|
onClick={pay.open}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -278,8 +239,8 @@ export function ReadonlyBookingView({
|
|||||||
<BookingPaymentPanel
|
<BookingPaymentPanel
|
||||||
booking={booking}
|
booking={booking}
|
||||||
pricing={pricing}
|
pricing={pricing}
|
||||||
onPay={() => setPayModalOpen(true)}
|
onPay={pay.open}
|
||||||
paying={payMutation.isPending}
|
paying={pay.processing}
|
||||||
showCountdown={showCountdown}
|
showCountdown={showCountdown}
|
||||||
/>
|
/>
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
@@ -301,24 +262,14 @@ export function ReadonlyBookingView({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payModalOpen}
|
opened={pay.modalOpen}
|
||||||
onClose={() => {
|
onClose={pay.close}
|
||||||
if (!payMutation.isPending) {
|
|
||||||
setPayModalOpen(false);
|
|
||||||
payMutation.reset();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={pay.error}
|
||||||
payMutation.isError
|
otp={pay.otp}
|
||||||
? payMutation.error instanceof Error
|
onConfirm={pay.confirm}
|
||||||
? payMutation.error.message
|
|
||||||
: "Could not start payment. Please try again."
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
{viewer}
|
{viewer}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
import {
|
||||||
import { Check, ShieldCheck } from "lucide-react";
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
||||||
import type { PaymentMethod } from "@/services/payments.service";
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
|
|
||||||
interface ProviderOption {
|
interface ProviderOption {
|
||||||
method: PaymentMethod;
|
method: PaymentMethod;
|
||||||
label: string;
|
label: string;
|
||||||
description: 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 this provider settles in. */
|
||||||
currencies: string[];
|
currencies: string[];
|
||||||
accent: string;
|
accent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only Telebirr and Waafi are enabled for now.
|
// Only Telebirr, Waafi and CAC Bank are enabled for now.
|
||||||
const PROVIDERS: ProviderOption[] = [
|
const PROVIDERS: ProviderOption[] = [
|
||||||
{
|
{
|
||||||
method: "TELEBIRR",
|
method: "TELEBIRR",
|
||||||
@@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [
|
|||||||
currencies: ["USD"],
|
currencies: ["USD"],
|
||||||
accent: "#2E5B96",
|
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,
|
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||||
* ETB → Telebirr. Falls back to the first provider when unknown.
|
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||||
@@ -91,13 +113,27 @@ function ProviderRow({
|
|||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{option.logo ? (
|
||||||
src={option.logo}
|
<Image
|
||||||
alt={`${option.label} logo`}
|
src={option.logo}
|
||||||
w={52}
|
alt={`${option.label} logo`}
|
||||||
h={52}
|
w={52}
|
||||||
fit="cover"
|
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>
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||||
@@ -135,6 +171,7 @@ export function PaymentMethodModal({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
processing,
|
processing,
|
||||||
error,
|
error,
|
||||||
|
otp,
|
||||||
}: {
|
}: {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -142,12 +179,19 @@ export function PaymentMethodModal({
|
|||||||
amountLabel?: string;
|
amountLabel?: string;
|
||||||
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
onConfirm: (method: PaymentMethod) => void;
|
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
|
||||||
processing?: boolean;
|
processing?: boolean;
|
||||||
error?: string | null;
|
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 [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.
|
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,6 +200,89 @@ export function PaymentMethodModal({
|
|||||||
}
|
}
|
||||||
}, [providers, method]);
|
}, [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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
@@ -215,6 +342,22 @@ export function PaymentMethodModal({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -228,7 +371,9 @@ export function PaymentMethodModal({
|
|||||||
<Group gap={6} align="center" justify="center" mb={12}>
|
<Group gap={6} align="center" justify="center" mb={12}>
|
||||||
<ShieldCheck size={14} color="#0A8A5F" />
|
<ShieldCheck size={14} color="#0A8A5F" />
|
||||||
<Text fz="11.5px" c="#7A8794">
|
<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>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -248,15 +393,21 @@ export function PaymentMethodModal({
|
|||||||
<Button
|
<Button
|
||||||
radius={12}
|
radius={12}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
disabled={processing}
|
disabled={processing || !canSubmit}
|
||||||
loading={processing}
|
loading={processing}
|
||||||
onClick={() => onConfirm(method)}
|
onClick={() =>
|
||||||
|
onConfirm(method, needsMobile ? mobile.trim() : undefined)
|
||||||
|
}
|
||||||
styles={{
|
styles={{
|
||||||
root: { height: 48, flex: 1 },
|
root: { height: 48, flex: 1 },
|
||||||
label: { fontSize: 14, fontWeight: 800 },
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{processing ? "Redirecting…" : "Continue to payment"}
|
{processing
|
||||||
|
? needsMobile
|
||||||
|
? "Sending OTP…"
|
||||||
|
: "Redirecting…"
|
||||||
|
: "Continue to payment"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
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 { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import {
|
import {
|
||||||
warehouseInvoicesService,
|
warehouseInvoicesService,
|
||||||
type PortalWarehouseInvoice,
|
type PortalWarehouseInvoice,
|
||||||
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
|
|
||||||
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||||
|
|
||||||
const payMutation = useMutation({
|
// Warehouse fees are charged through the warehouse route, but they are the
|
||||||
mutationFn: (method: PaymentMethod) => {
|
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
|
||||||
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
|
||||||
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
warehouseInvoicesService.payOnline(invoiceId, {
|
||||||
method,
|
method,
|
||||||
platform: "web",
|
platform: "web",
|
||||||
});
|
payerAccount,
|
||||||
},
|
}),
|
||||||
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;
|
|
||||||
|
|
||||||
const closePayModal = () => {
|
const closePayModal = () => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayInvoice(null);
|
setPayInvoice(null);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||||
}
|
}
|
||||||
currency={payInvoice?.currency}
|
currency={payInvoice?.currency}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
onConfirm={(method, payerAccount) =>
|
||||||
processing={payMutation.isPending}
|
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
|
||||||
error={payError}
|
}
|
||||||
|
processing={pay.processing}
|
||||||
|
error={pay.error}
|
||||||
|
otp={pay.otp}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function PayNowButton({
|
|||||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||||
processing={pay.processing}
|
processing={pay.processing}
|
||||||
error={pay.error}
|
error={pay.error}
|
||||||
|
otp={pay.otp}
|
||||||
onConfirm={pay.confirm}
|
onConfirm={pay.confirm}
|
||||||
/>
|
/>
|
||||||
</ModalSafeWrapper>
|
</ModalSafeWrapper>
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import {
|
import { type PaymentMethod } from "@/services/payments.service";
|
||||||
paymentsService,
|
|
||||||
type PaymentMethod,
|
|
||||||
} from "@/services/payments.service";
|
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared payment flow for a single booking: opens the method modal, fires
|
* Shared payment flow for a single booking: opens the method modal, fires
|
||||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||||
* invoice, and redirects the browser to the provider (or the fallback
|
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||||
* checkout page). Reused by the booking detail page, the booking list, and
|
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||||
* the home page so "Pay now" behaves identically everywhere.
|
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||||
|
* behaves identically everywhere.
|
||||||
*/
|
*/
|
||||||
export function useBookingPayment(bookingId: string) {
|
export function useBookingPayment(bookingId: string) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [noInvoice, setNoInvoice] = useState(false);
|
||||||
|
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
queryKey: ["booking-invoices", bookingId],
|
queryKey: ["booking-invoices", bookingId],
|
||||||
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
|
|||||||
});
|
});
|
||||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
||||||
|
|
||||||
const mutation = useMutation({
|
const flow = useInvoicePayment();
|
||||||
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 open = () => setModalOpen(true);
|
const open = () => setModalOpen(true);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (!mutation.isPending) {
|
if (!flow.processing) {
|
||||||
setModalOpen(false);
|
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 {
|
return {
|
||||||
modalOpen,
|
modalOpen,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
processing: mutation.isPending,
|
processing: flow.processing,
|
||||||
error,
|
error: noInvoice
|
||||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
? "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 LicenseFile,
|
||||||
type LicenseFileStatus,
|
type LicenseFileStatus,
|
||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
import { verifaydaService } from "@/services/verifayda.service";
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
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({
|
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(),
|
poaLocation: z.string().optional(),
|
||||||
poaAddress: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
|
|||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo(
|
||||||
return {
|
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||||
poaName: profile.poaName ?? "",
|
[profile],
|
||||||
poaEmail: profile.poaEmail ?? "",
|
);
|
||||||
poaPhone: profile.poaPhone ?? "",
|
|
||||||
poaLocation: profile.poaLocation ?? "",
|
|
||||||
poaAddress: profile.poaAddress ?? "",
|
|
||||||
};
|
|
||||||
}, [profile]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
|
|||||||
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
||||||
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
||||||
|
|
||||||
// The letter is staged locally, not uploaded on pick. Uploading immediately
|
// The letter is staged locally, not uploaded on pick: the paper is the one
|
||||||
// would open a change request, which locks the whole settings page (see
|
// thing here that still goes to a reviewer, so picking it must not open a
|
||||||
// SettingsPage's `locked` fieldset) before the text fields could be saved.
|
// change request before the customer has committed to the save. Save submits
|
||||||
// Save submits the file and the fields together, into one change request.
|
// the file and the fields together.
|
||||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||||
const [saveBlocked, setSaveBlocked] = useState(false);
|
const [saveBlocked, setSaveBlocked] = useState(false);
|
||||||
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
|
|||||||
const requirePoa = profile.companyProfiles.some(
|
const requirePoa = profile.companyProfiles.some(
|
||||||
(p) => p.type === "freight_forwarder",
|
(p) => p.type === "freight_forwarder",
|
||||||
);
|
);
|
||||||
// An Ethiopian company does not type its representative's details — they
|
// No company types its representative's details — they come from the Fayda
|
||||||
// come from the Fayda verification. A foreign company keeps the typed form:
|
// verification whatever the nationality, since a representative acts for the
|
||||||
// its representative may hold no Fayda ID.
|
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||||
|
// has been verified.
|
||||||
const identity = profile.identity;
|
const identity = profile.identity;
|
||||||
const verifiedIdentity = identity?.faydaRequired === true;
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
|
|
||||||
const poaValues = watch([
|
|
||||||
"poaName",
|
|
||||||
"poaEmail",
|
|
||||||
"poaPhone",
|
|
||||||
"poaLocation",
|
|
||||||
"poaAddress",
|
|
||||||
]);
|
|
||||||
const poaProvided = verifiedIdentity
|
|
||||||
? (identity?.poa.verified ?? false)
|
|
||||||
: poaValues.some((v) => v?.trim());
|
|
||||||
const letterRequired = requirePoa || poaProvided;
|
const letterRequired = requirePoa || poaProvided;
|
||||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||||
|
|
||||||
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: FormData) => {
|
mutationFn: async (data: FormData) => {
|
||||||
// Every identity field except the city is written by the verification, so
|
// Every identity field except the city is written by the verification, so
|
||||||
// an Ethiopian company only ever saves the paper and the location here.
|
// only the paper and the location are ever saved here.
|
||||||
const fields = verifiedIdentity
|
const fields = { poaLocation: data.poaLocation || undefined };
|
||||||
? { poaLocation: data.poaLocation || undefined }
|
|
||||||
: {
|
|
||||||
poaName: data.poaName || undefined,
|
|
||||||
poaPhone: data.poaPhone || undefined,
|
|
||||||
poaEmail: data.poaEmail || undefined,
|
|
||||||
poaLocation: data.poaLocation || undefined,
|
|
||||||
poaAddress: data.poaAddress || undefined,
|
|
||||||
};
|
|
||||||
// A fresh upload already stages the removal of every paper on file, so
|
// A fresh upload already stages the removal of every paper on file, so
|
||||||
// the explicit removals only need applying when no replacement was
|
// the explicit removals only need applying when no replacement was
|
||||||
// picked. Saving the details after it means the API sees the new paper.
|
// picked. Saving the details after it means the API sees the new paper.
|
||||||
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
|
|||||||
subject="poa"
|
subject="poa"
|
||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={identity.faydaRequired}
|
required={requirePoa}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
pendingReview={Boolean(
|
|
||||||
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
|
||||||
?.faydaIdentity?.poaFaydaSub,
|
|
||||||
)}
|
|
||||||
onVerified={() => {
|
onVerified={() => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Name, email, phone and address are written by the Fayda
|
{/* Name, email, phone and address are all written by the Fayda
|
||||||
verification for an Ethiopian company, so only the city — which
|
verification, so only the city — which the address claim does
|
||||||
the address claim does not reliably decompose into — is typed. */}
|
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
|
|||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
{!verifiedIdentity && (
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Address"
|
|
||||||
placeholder="Full Address"
|
|
||||||
error={errors.poaAddress?.message}
|
|
||||||
{...register("poaAddress")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
)}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
|
|||||||
</Stack>
|
</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">
|
<Group gap={6} c="edr-amber-text">
|
||||||
<Clock size={13} />
|
<Clock size={13} />
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
{mode === "edit" &&
|
{mode === "edit" &&
|
||||||
verifiedIdentity &&
|
|
||||||
identity?.poa.verified &&
|
identity?.poa.verified &&
|
||||||
!requirePoa && (
|
!requirePoa && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -73,4 +73,10 @@ export const invoicesService = {
|
|||||||
});
|
});
|
||||||
return data.data ?? data;
|
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
|
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
|
||||||
restart: "no"
|
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:
|
freight-api-e2e:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -74,6 +139,10 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio-init-e2e:
|
minio-init-e2e:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
fayda-mock-e2e:
|
||||||
|
condition: service_healthy
|
||||||
|
etrade-mock-e2e:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
DB_HOST: postgres-freight-e2e
|
DB_HOST: postgres-freight-e2e
|
||||||
@@ -104,7 +173,21 @@ services:
|
|||||||
MINIO_REGION: us-east-1
|
MINIO_REGION: us-east-1
|
||||||
# External integrations off
|
# External integrations off
|
||||||
RABBITMQ_ENABLED: "false"
|
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 —
|
# 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).
|
# blackhole it so e2e never sends SMS (failures are logged, non-fatal).
|
||||||
OZIKING_SMS_URL: http://127.0.0.1:9/sms
|
OZIKING_SMS_URL: http://127.0.0.1:9/sms
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
acceptExport,
|
acceptExport,
|
||||||
apiPost,
|
apiPost,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
|
clearToOperationRequestPending,
|
||||||
createImportSchedule,
|
createImportSchedule,
|
||||||
db,
|
db,
|
||||||
departureAt,
|
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", () => {
|
it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => {
|
||||||
bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY });
|
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) => {
|
withBooking("CXA", (b) => {
|
||||||
expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING");
|
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]));
|
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", () => {
|
it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => {
|
||||||
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
|
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
|
||||||
|
clearToOperationRequestPending("CXB", BOOKING_DAY);
|
||||||
acceptExport("CXB");
|
acceptExport("CXB");
|
||||||
markPaid("CXB");
|
markPaid("CXB");
|
||||||
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons
|
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
acceptOperation,
|
acceptOperation,
|
||||||
apiPost,
|
apiPost,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
|
clearIntercityToFullyExecuted,
|
||||||
|
clearToOperationRequestPending,
|
||||||
closeBookingWindow,
|
closeBookingWindow,
|
||||||
completeDocReview,
|
completeDocReview,
|
||||||
createImportSchedule,
|
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", () => {
|
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 });
|
bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY });
|
||||||
|
clearToOperationRequestPending("BM1", BOOKING_DAY);
|
||||||
acceptOperation("BM1");
|
acceptOperation("BM1");
|
||||||
bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
|
bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
|
||||||
|
clearToOperationRequestPending("BMSUB", BOOKING_DAY);
|
||||||
acceptOperation("BMSUB");
|
acceptOperation("BMSUB");
|
||||||
|
|
||||||
withSchedule(DEPARTURE, (s) => {
|
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", () => {
|
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
|
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) => {
|
withSchedule(DEPARTURE, (s) => {
|
||||||
withBooking("BMIC", (b) => {
|
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));
|
withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||||
|
|
||||||
bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
|
bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
|
||||||
|
clearToOperationRequestPending("BMG", GIANT_DAY);
|
||||||
acceptOperation("BMG");
|
acceptOperation("BMG");
|
||||||
withSchedule(GIANT_DEPARTURE, (s) => {
|
withSchedule(GIANT_DEPARTURE, (s) => {
|
||||||
closeBookingWindow(s.id);
|
closeBookingWindow(s.id);
|
||||||
@@ -208,7 +215,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
|
|||||||
expectFailure: "must take the whole",
|
expectFailure: "must take the whole",
|
||||||
});
|
});
|
||||||
bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
|
bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
|
||||||
pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5);
|
clearToOperationRequestPending("BMG", REMAINDER_DAY);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
acceptExport,
|
acceptExport,
|
||||||
apiPost,
|
apiPost,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
|
clearToOperationRequestPending,
|
||||||
completeBookingMilestone,
|
completeBookingMilestone,
|
||||||
createImportSchedule,
|
createImportSchedule,
|
||||||
db,
|
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", () => {
|
it("six exporters book wheat inside the one window", () => {
|
||||||
BOOKINGS.forEach((b) => {
|
BOOKINGS.forEach((b) => {
|
||||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
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,
|
acceptOperation,
|
||||||
apiPost,
|
apiPost,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
|
clearToOperationRequestPending,
|
||||||
closeBookingWindow,
|
closeBookingWindow,
|
||||||
completeBookingMilestone,
|
completeBookingMilestone,
|
||||||
completeDocReview,
|
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", () => {
|
it("customer books all six wheat shipments inside the first window", () => {
|
||||||
BOOKINGS.forEach((b) => {
|
BOOKINGS.forEach((b) => {
|
||||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
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 {
|
import {
|
||||||
acceptOperation,
|
acceptOperation,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
|
clearToOperationRequestPending,
|
||||||
closeBookingWindow,
|
closeBookingWindow,
|
||||||
completeDocReview,
|
completeDocReview,
|
||||||
createImportSchedule,
|
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", () => {
|
it("seven customers book wheat in the first window; operations accepts them in priority order", () => {
|
||||||
ORDER.forEach((suffix) => {
|
ORDER.forEach((suffix) => {
|
||||||
bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
|
bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
|
||||||
|
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||||
acceptOperation(suffix);
|
acceptOperation(suffix);
|
||||||
});
|
});
|
||||||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
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 });
|
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(/^Contract Kind/, "General Contract");
|
||||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||||
cy.contains("Rail Transport Only", { timeout: 15000 }).click();
|
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 });
|
cy.contains("button", "Continue").click({ force: true });
|
||||||
|
|
||||||
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
|
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
acceptExport,
|
acceptExport,
|
||||||
apiPost,
|
apiPost,
|
||||||
bookContainers,
|
bookContainers,
|
||||||
|
clearToOperationRequestPending,
|
||||||
createImportSchedule,
|
createImportSchedule,
|
||||||
db,
|
db,
|
||||||
dbBooking,
|
dbBooking,
|
||||||
@@ -118,7 +119,7 @@ describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retri
|
|||||||
scheduledDate: BOOKING_DAY,
|
scheduledDate: BOOKING_DAY,
|
||||||
});
|
});
|
||||||
isoSeed += b.twenty + b.forty;
|
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).
|
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { clearBookingClearance } from "./import-utils";
|
||||||
|
|
||||||
const customer = "user@gmail.com";
|
const customer = "user@gmail.com";
|
||||||
const companyTin = "0102030405"; // seed-company.sql
|
const companyTin = "0102030405"; // seed-company.sql
|
||||||
const opsStaff = "operation@edr.local";
|
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(/^Contract Kind/, "One-Time Contract");
|
||||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
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.contains("button", "Continue").click({ force: true });
|
||||||
|
|
||||||
// Container contracts auto-cover both 20ft & 40ft (no size picker) and the
|
// 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.contains("button", "Confirm & book").click();
|
||||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
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) => {
|
withBooking("CONTAINER", (b) => {
|
||||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||||
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
|
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(/^Contract Kind/, "One-Time Contract");
|
||||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
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.contains("button", "Continue").click({ force: true });
|
||||||
|
|
||||||
cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
|
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.contains("button", "Confirm & book").click();
|
||||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
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) => {
|
withBooking("BULK", (b) => {
|
||||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
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) {
|
export function apiGet(email: string, path: string, failOnStatusCode = true) {
|
||||||
return tokenFor(email).then((token) =>
|
return tokenFor(email).then((token) =>
|
||||||
cy.request({
|
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):
|
* Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A):
|
||||||
* AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize →
|
* AWAITING_DOCUMENTS → ... → OPERATION_REQUEST_PENDING (clearToOperationRequestPending)
|
||||||
* CLEARANCE_READY → customer proceeds with the shipment day →
|
* → ops accept → FULLY_EXECUTED (pool).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function clearGeneralBooking(
|
export function clearGeneralBooking(
|
||||||
suffix: string,
|
suffix: string,
|
||||||
@@ -471,22 +582,7 @@ export function clearGeneralBooking(
|
|||||||
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
|
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
|
||||||
mode: "import" | "export" = "import",
|
mode: "import" | "export" = "import",
|
||||||
) {
|
) {
|
||||||
withBooking(suffix, (b) => {
|
clearToOperationRequestPending(suffix, scheduledDate);
|
||||||
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]);
|
|
||||||
});
|
|
||||||
if (mode === "export") acceptExport(suffix);
|
if (mode === "export") acceptExport(suffix);
|
||||||
else acceptOperation(suffix);
|
else acceptOperation(suffix);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
acceptOperation,
|
acceptOperation,
|
||||||
apiPost,
|
apiPost,
|
||||||
bookContainers,
|
bookContainers,
|
||||||
|
clearToOperationRequestPending,
|
||||||
closeBookingWindow,
|
closeBookingWindow,
|
||||||
completeBookingMilestone,
|
completeBookingMilestone,
|
||||||
completeDocReview,
|
completeDocReview,
|
||||||
@@ -113,7 +114,7 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
|
|||||||
scheduledDate: BOOKING_DAY,
|
scheduledDate: BOOKING_DAY,
|
||||||
});
|
});
|
||||||
isoSeed += b.twenty + b.forty;
|
isoSeed += b.twenty + b.forty;
|
||||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
resetCorridorDay,
|
resetCorridorDay,
|
||||||
acceptOperation,
|
acceptOperation,
|
||||||
bookContainers,
|
bookContainers,
|
||||||
|
clearToOperationRequestPending,
|
||||||
closeBookingWindow,
|
closeBookingWindow,
|
||||||
completeDocReview,
|
completeDocReview,
|
||||||
createImportSchedule,
|
createImportSchedule,
|
||||||
@@ -91,6 +92,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
|
|||||||
scheduledDate: BOOKING_DAY,
|
scheduledDate: BOOKING_DAY,
|
||||||
});
|
});
|
||||||
isoSeed += s.twenty + s.forty;
|
isoSeed += s.twenty + s.forty;
|
||||||
|
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||||
acceptOperation(suffix);
|
acceptOperation(suffix);
|
||||||
});
|
});
|
||||||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||||||
@@ -217,7 +219,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
|
|||||||
twenty: 32,
|
twenty: 32,
|
||||||
scheduledDate: REMAINDER_DAY,
|
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
|
* 7. backoffice — operations approves the document, then finalizes document
|
||||||
* approval → FULLY_EXECUTED
|
* approval → FULLY_EXECUTED
|
||||||
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
||||||
* no shipment date) → booking OPERATION_REQUEST_PENDING
|
* no shipment date) → booking AWAITING_DOCUMENTS → customer
|
||||||
* 9. backoffice — operations accepts the operation request → booking
|
* uploads a doc, GL approves, finalize → DOMESTIC bookings
|
||||||
* FULLY_EXECUTED (intercity waiting pool)
|
* 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
|
* 10. backoffice — operations creates the EXPORT route
|
||||||
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
|
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
|
||||||
* 11. backoffice — operations schedules the export train (built Train-Builder
|
* 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).
|
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { clearIntercityBookingClearance } from "./import-utils";
|
||||||
|
|
||||||
const customer = "user@gmail.com";
|
const customer = "user@gmail.com";
|
||||||
const companyTin = "0102030405"; // seed-company.sql
|
const companyTin = "0102030405"; // seed-company.sql
|
||||||
const opsStaff = "operation@edr.local";
|
const opsStaff = "operation@edr.local";
|
||||||
@@ -153,12 +157,13 @@ function createIntercityContract() {
|
|||||||
cy.loginPortal(customer);
|
cy.loginPortal(customer);
|
||||||
cy.visitPortal("/contracts/new");
|
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(/^Operation Type/, /^Intercity$/);
|
||||||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
|
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
|
||||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
|
||||||
cy.contains("button", "Continue").click({ force: true });
|
cy.contains("button", "Continue").click({ force: true });
|
||||||
|
|
||||||
// Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container
|
// 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\/.+/);
|
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||||
|
|
||||||
withBooking((b) => {
|
withBooking((b) => {
|
||||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
|
||||||
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
|
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) => {
|
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);
|
expect(b.train_schedule_id).to.eq(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
apiPost,
|
apiPost,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
bookContainers,
|
bookContainers,
|
||||||
|
clearToOperationRequestPending,
|
||||||
completeBookingMilestone,
|
completeBookingMilestone,
|
||||||
createImportSchedule,
|
createImportSchedule,
|
||||||
db,
|
db,
|
||||||
@@ -120,7 +121,7 @@ describe("mixed export: containers and wheat share one 54-wagon FCFS train", { r
|
|||||||
} else {
|
} else {
|
||||||
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
|
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,
|
apiPost,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
bookContainers,
|
bookContainers,
|
||||||
|
clearToOperationRequestPending,
|
||||||
closeBookingWindow,
|
closeBookingWindow,
|
||||||
completeBookingMilestone,
|
completeBookingMilestone,
|
||||||
completeDocReview,
|
completeDocReview,
|
||||||
@@ -113,7 +114,7 @@ describe("mixed import: containers and wheat share one 54-wagon train", { retrie
|
|||||||
} else {
|
} else {
|
||||||
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
|
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,
|
acceptOperation,
|
||||||
bookBulk,
|
bookBulk,
|
||||||
bookContainers,
|
bookContainers,
|
||||||
|
clearToOperationRequestPending,
|
||||||
closeBookingWindow,
|
closeBookingWindow,
|
||||||
completeDocReview,
|
completeDocReview,
|
||||||
createImportSchedule,
|
createImportSchedule,
|
||||||
@@ -96,10 +97,12 @@ describe("mixed import: container split; container-freed wagons promote the bulk
|
|||||||
scheduledDate: BOOKING_DAY,
|
scheduledDate: BOOKING_DAY,
|
||||||
});
|
});
|
||||||
isoSeed += b.twenty;
|
isoSeed += b.twenty;
|
||||||
|
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||||
acceptOperation(b.suffix);
|
acceptOperation(b.suffix);
|
||||||
});
|
});
|
||||||
BULKS.forEach((b) => {
|
BULKS.forEach((b) => {
|
||||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
||||||
|
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||||
acceptOperation(b.suffix);
|
acceptOperation(b.suffix);
|
||||||
});
|
});
|
||||||
CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1));
|
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,
|
twenty: 20,
|
||||||
scheduledDate: REMAINDER_DAY,
|
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.
|
* journey's user/company from the DB instead of module variables.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { completeFaydaVerification } from "./import-utils";
|
||||||
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const email = `e2e.onboard.${stamp}@example.com`;
|
const email = `e2e.onboard.${stamp}@example.com`;
|
||||||
// Ethiopian mobile: 9 + 8 digits, unique per run.
|
// Ethiopian mobile: 9 + 8 digits, unique per run.
|
||||||
const phoneNational = `9${String(stamp).slice(-8)}`;
|
const phoneNational = `9${String(stamp).slice(-8)}`;
|
||||||
const signupPassword = "Password@e2e1";
|
const signupPassword = "Password@e2e1";
|
||||||
const companyName = `E2E Onboard Co ${stamp}`;
|
|
||||||
const tin = String(stamp).slice(-10).padStart(10, "1");
|
const tin = String(stamp).slice(-10).padStart(10, "1");
|
||||||
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
|
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;
|
const portal = () => Cypress.env("portalUrl") as string;
|
||||||
|
|
||||||
@@ -61,12 +61,6 @@ function fillPhone(index: number, national: string) {
|
|||||||
|
|
||||||
describe("customer onboarding journey", { retries: 0 }, () => {
|
describe("customer onboarding journey", { retries: 0 }, () => {
|
||||||
it("signs up with OTP and completes the onboarding wizard", () => {
|
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`);
|
cy.visit(`${portal()}/signup`);
|
||||||
|
|
||||||
fill(/^First name/, "Onboard");
|
fill(/^First name/, "Onboard");
|
||||||
@@ -91,20 +85,39 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
|||||||
cy.contains("button", "Importer").click();
|
cy.contains("button", "Importer").click();
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||||
|
|
||||||
// Company step. TIN first — the eTrade auto-lookup fails in e2e (no
|
// Ethiopian companies gate the "Owner identity" step on Fayda
|
||||||
// external network) and the form allows manual entry.
|
// verification — a real popup + SMS OTP flow that can't run in e2e.
|
||||||
cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin);
|
// Complete it via the API against fayda-mock-e2e (the profile this
|
||||||
fill(/^Company Name/, companyName);
|
// attaches to was just created by the nationality/role step above).
|
||||||
fill(/^Company Email/, `ops.${stamp}@example.com`);
|
// The wizard already fetched `identity` once when this step mounted —
|
||||||
fillPhone(0, "911234567");
|
// completing verification out-of-band (no popup, so no onVerified
|
||||||
fill(/^Location/, "Addis Ababa, Ethiopia");
|
// 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);
|
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();
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||||
|
|
||||||
// Personnel (general manager).
|
// 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
|
* takes explicit Start/End dates and pre-fills neither, so "Accept & start
|
||||||
* approval" stays disabled until both are set.
|
* approval" stays disabled until both are set.
|
||||||
*
|
*
|
||||||
* Mantine's DateInput parses typed text with its valueFormat, which defaults to
|
* The fields are Mantine DateTimePickers — a button that opens a calendar
|
||||||
* "MMMM D, YYYY" — the same shape en-US toLocaleDateString produces.
|
* 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) => {
|
Cypress.Commands.add("acceptValidityWindow", (days = 365) => {
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000);
|
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 [
|
const pickDate = (label: string, date: Date) => {
|
||||||
["Start date", start],
|
|
||||||
["End date", end],
|
|
||||||
] as const) {
|
|
||||||
cy.contains("label", label)
|
cy.contains("label", label)
|
||||||
.invoke("attr", "for")
|
.invoke("attr", "for")
|
||||||
.then((id) => {
|
.then((id) => cy.get(`[id="${id}"]`).click({ force: true }));
|
||||||
cy.get(`[id="${id}"]`)
|
|
||||||
.clear({ force: true })
|
// month view -> year view -> decade view.
|
||||||
.type(asInput(value), { force: true })
|
for (let i = 0; i < 2; i++) {
|
||||||
// DateInput commits on blur; it also closes the calendar popover,
|
cy.get('.mantine-Popover-dropdown [data-direction="previous"]')
|
||||||
// which would otherwise sit over the submit button.
|
.siblings("button")
|
||||||
.blur();
|
.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 { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
import { generateKeyPairSync } from "node:crypto";
|
||||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
@@ -110,6 +111,24 @@ async function resolvePorts() {
|
|||||||
return ports;
|
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) {
|
function envFor(ports) {
|
||||||
return {
|
return {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -120,6 +139,8 @@ function envFor(ports) {
|
|||||||
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`,
|
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`,
|
||||||
CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_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`,
|
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,
|
CacPaymentInitiateResponse,
|
||||||
} from "./cac-bank.types";
|
} 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_MIN_AMOUNT = 10;
|
||||||
const CAC_MAX_AMOUNT = 100_000;
|
const CAC_MAX_AMOUNT = 100_000;
|
||||||
|
|
||||||
@@ -73,10 +75,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerMobile = normalizeCacMobile(input.payerAccount);
|
const customerMobile = normalizeCacMobile(input.payerAccount);
|
||||||
const amount = this.toMajorAmount(input.amountMinor, input.currency);
|
const amount = this.toMajorAmount(input.amountMinor);
|
||||||
if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
|
const currency = (input.currency || this.defaultCurrency).toUpperCase();
|
||||||
|
if (
|
||||||
|
currency === "DJF" &&
|
||||||
|
(amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT)
|
||||||
|
) {
|
||||||
throw new Error(
|
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)`,
|
`(${CAC_MIN_AMOUNT}–${CAC_MAX_AMOUNT} DJF)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,7 +91,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
app_key: this.appKey,
|
app_key: this.appKey,
|
||||||
api_key: this.apiKey,
|
api_key: this.apiKey,
|
||||||
customer_mobile: customerMobile,
|
customer_mobile: customerMobile,
|
||||||
currency: input.currency || this.defaultCurrency,
|
currency,
|
||||||
desc: `${input.orderRef}`.slice(0, 500),
|
desc: `${input.orderRef}`.slice(0, 500),
|
||||||
vender_ref: input.merchantOrderId,
|
vender_ref: input.merchantOrderId,
|
||||||
amount,
|
amount,
|
||||||
@@ -303,12 +309,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
return this.auth;
|
return this.auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DJF has no fractional units — amountMinor is the major amount. */
|
/**
|
||||||
private toMajorAmount(amountMinor: number, currency: string): number {
|
* Both callers (freight's invoice.balanceAmount, passenger's CurrencyService) already
|
||||||
if (currency.toUpperCase() === "DJF") {
|
* hand off a major-currency amount — e.g. 700 for $700, not 70000 cents — with the target
|
||||||
return amountMinor;
|
* 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.
|
||||||
return amountMinor / 100;
|
*/
|
||||||
|
private toMajorAmount(amountMinor: number): number {
|
||||||
|
return amountMinor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeKeys(
|
private sanitizeKeys(
|
||||||
|
|||||||
Reference in New Issue
Block a user