diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b3ed1c802..d10f19f10 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -36,8 +36,7 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", - "migration:run": "node dist/scripts/migrate.js", + "migration:run": "nest build && node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts index 4f8dac4c7..4f060ca11 100644 --- a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -43,11 +43,17 @@ export function IsValidPhone(validationOptions?: ValidationOptions) { * Normalize a phone string to canonical E.164. Returns the canonical form when * parseable, otherwise the trimmed original (tolerant — never throws), or the * value unchanged when empty/nullish. + * + * Defaults the country to Ethiopia so bare local numbers (no "+", e.g. eTrade's + * "0355235416") resolve the same way the frontend's own toEthiopianE164 already + * assumes — without this hint, libphonenumber can't infer a country for a + * number with no "+" prefix and silently falls through to the untouched local + * string, which then never matches the "+251…" form submitted by the client. */ export function normalizeE164( value: string | null | undefined, ): string | null | undefined { if (value === undefined || value === null || value === '') return value; - const parsed = parsePhoneNumberFromString(value); + const parsed = parsePhoneNumberFromString(value, 'ET'); return parsed?.isValid() ? parsed.number : value.trim(); } diff --git a/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts new file mode 100644 index 000000000..7a8923f3c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A normalizeUserInfo bug in VerifaydaService read the raw `address#en` / + * `address#am` claim objects (e.g. `{ "zone#en": "...", "region#en": "...", + * "woreda#en": "..." }`) straight through as if they were strings, so any + * company verified before the fix has `attributes.ownerAddress` / + * `poaAddress` stored as that raw object instead of a formatted string — + * which crashes the portal when it tries to render it as text. + * + * Reformats every affected row's ownerAddress/poaAddress into + * "woreda, zone, region" (falling back to whatever #en fields are present, + * in that preferred order, then any leftover fields), mirroring + * VerifaydaService.formatFaydaAddress. Only touches rows where the field is + * still a jsonb object, so it's idempotent and a no-op once repaired. + */ +export class FixFaydaAddressShape3150000000000 implements MigrationInterface { + name = "FixFaydaAddressShape3150000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION pg_temp.format_fayda_address(addr jsonb) + RETURNS text AS $$ + DECLARE + field_order text[] := ARRAY['houseNumber','kebele','woreda','city','subCity','zone','region','postalCode','country']; + f text; + v text; + parts text[] := '{}'; + used_keys text[] := '{}'; + kv record; + BEGIN + IF addr IS NULL OR jsonb_typeof(addr) != 'object' THEN + RETURN NULL; + END IF; + + FOREACH f IN ARRAY field_order LOOP + v := addr ->> (f || '#en'); + IF v IS NOT NULL AND trim(v) != '' THEN + parts := array_append(parts, trim(v)); + used_keys := array_append(used_keys, f || '#en'); + END IF; + END LOOP; + + FOR kv IN SELECT * FROM jsonb_each_text(addr) LOOP + IF kv.key LIKE '%#en' AND NOT (kv.key = ANY(used_keys)) + AND kv.value IS NOT NULL AND trim(kv.value) != '' THEN + parts := array_append(parts, trim(kv.value)); + END IF; + END LOOP; + + IF array_length(parts, 1) IS NULL THEN + RETURN NULL; + END IF; + RETURN array_to_string(parts, ', '); + END; + $$ LANGUAGE plpgsql; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{ownerAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'ownerAddress')) + ) + WHERE jsonb_typeof(attributes -> 'ownerAddress') = 'object'; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{poaAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'poaAddress')) + ) + WHERE jsonb_typeof(attributes -> 'poaAddress') = 'object'; + + DROP FUNCTION pg_temp.format_fayda_address(jsonb); + `); + } + + public async down(): Promise { + // Data repair — not reversible (the original malformed shape isn't worth restoring). + } +} diff --git a/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts b/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts new file mode 100644 index 000000000..35ccdc658 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `freight.payments.paid_at` was created as `date` (CreatePaymentTable) and never + * migrated to `timestamp` alongside its siblings `refunded_at`/`expires_at` + * (UpdatePaymentTimestamp). TypeORM's postgres driver hydrates `date` columns as a + * plain "YYYY-MM-DD" string, not a `Date` — so `PaymentEntity.paidAt` (typed `Date`) + * was actually a string once read back from the DB, and + * `intent.paidAt?.toISOString()` in PaymentService.formatIntentStatus threw + * `TypeError: intent.paidAt.toISOString is not a function`. This hit every + * OTP-confirm response (CAC Bank) because confirmOtp always re-reads the intent + * before formatting the response. + */ +export class FixPaymentPaidAtType3160000000000 implements MigrationInterface { + name = "FixPaymentPaidAtType3160000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN paid_at TYPE timestamp + USING paid_at::timestamp; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN paid_at TYPE date + USING paid_at::date; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 850791f02..88ef92b3d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1126,23 +1126,24 @@ export class BillingService { .update({ id: invoice.id }, { paymentId: result.intentId }); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); - // billing must not simulate it. Kept commented for local demos only. + // billing must not simulate it. Kept for local demos only. // 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" && - opts.method !== "CBE_BILL" - ) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // code — so the demo shortcut must never fire for it. Same for CBE_BILL: its + // bill must stay open until CBE actually settles it via /cbe/payment. + // if ( + // !result.immediateSuccess && + // result.response.clientAction?.type !== "COLLECT_OTP" && + // opts.method !== "CBE_BILL" + // ) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index d9798af5d..e0af07e0b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -928,7 +928,7 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); - await this.assertEtradeFieldsAuthentic(company, dto); + await this.applyEtradeSourcedFields(company, dto); // Naming (or renaming) a Power of Attorney is one of the writes that can // leave the company with a representative and nothing evidencing them, so @@ -3216,22 +3216,24 @@ export class CompaniesService { /** * An eTrade-sourced field can only ever hold what a fresh eTrade lookup for * this TIN actually returns — the portal never lets the customer type these - * once eTrade has supplied them, so a mismatch here means either stale - * client state or a hand-crafted request, and either way the write is - * refused rather than silently trusting it. + * once eTrade has supplied them. Rather than trust the client's copy (stale + * cache, hand-crafted request, or just a formatting mismatch) and reject it, + * refetch eTrade ourselves and overwrite the touched fields with whatever it + * says now — the client's submitted values for these keys only matter as a + * "this field is part of the save" flag, never as data we persist. */ - private async assertEtradeFieldsAuthentic( + private async applyEtradeSourcedFields( company: Company, dto: UpdateProfileDto, ): Promise { const touched = ETRADE_SOURCED_FIELDS.some( - (key) => dto[key] !== undefined, + (key) => key !== "tin" && dto[key] !== undefined, ); if (!touched) return; const tin = dto.tin ?? company.tin; const registration = await this.resolveEtradeRegistration(tin); - const expected: Partial> = { + const fresh: Partial> = { companyName: registration.companyName, licenceNumber: registration.licenceNumber, statusDescription: registration.statusDescription, @@ -3251,21 +3253,11 @@ export class CompaniesService { }; for (const key of ETRADE_SOURCED_FIELDS) { - const submitted = dto[key]; - if (submitted === undefined) continue; - const source = expected[key]; - // eTrade left this field blank — the onboarding/settings card falls back - // to letting the customer type it directly, so nothing to check against. - if (!source) continue; - const same = - key === "etradePhone" - ? normalizeE164(String(submitted)) === normalizeE164(source) - : submitted === source; - if (!same) { - throw new BadRequestException( - `${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`, - ); - } + if (key === "tin" || dto[key] === undefined) continue; + const value = fresh[key]; + // eTrade left this field blank — fall back to whatever the client sent + // (the onboarding/settings card lets the customer type it directly then). + if (value) (dto as Record)[key] = value; } } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9f7d1ed39..596644fab 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -179,9 +179,12 @@ export class UpdateProfileDto { @MaxLength(100) houseNo?: string; + // Not validated as a phone number: eTrade-sourced, so a fresh eTrade lookup + // overwrites whatever the client sends here — see + // CompaniesService.applyEtradeSourcedFields. Presence just flags "this save + // touches an eTrade-owned field." @IsOptional() @IsString() @MaxLength(20) - @IsValidPhone() etradePhone?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 51bdb2df6..3292ee2d3 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -108,7 +108,9 @@ export class ETradeService { dateRegistered: businessInfo.DateRegistered, renewedFrom: businessInfo.RenewedFrom, renewalDate: businessInfo.RenewalDate, - renewedTo: businessInfo.RenewedTo, + // RenewedTo is ISO ("2018-07-07T00:00:00"); RenewedToDateString matches + // RenewedFrom/RenewalDate's "M/D/YYYY" format — use that for consistency. + renewedTo: businessInfo.RenewedToDateString, // eTrade returns uncoded uppercase text and sometimes a zone name in the // Region slot. Map it onto the canonical list; an unresolved value yields // "" so the form asks the user to pick rather than failing validation on diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 557548b00..d5688e1c2 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -5,7 +5,7 @@ import { randomInt } from "node:crypto"; import { OtpRepository } from "./otp.repository"; -import { SmsClientService } from "../notifications/sms-client.service"; +import { NotificationsService } from "../notifications/notifications.service"; import { EmailClientService } from "../notifications/email-client.service"; /** @@ -95,7 +95,7 @@ export class OtpService { logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService, + private readonly notifications: NotificationsService, private readonly emailClient: EmailClientService, ) { } @@ -263,17 +263,24 @@ export class OtpService { } } - /** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */ + /** + * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent + * via NotificationsService's direct-HTTP Ozeking strategy — the same + * transport the notification system uses — rather than the RabbitMQ + * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the + * request, not just that a broker took ownership of the message. + */ private async dispatchSms( phone: string, otp: string, ): Promise { try { - const { queued } = await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); - return { channel: "sms", queued }; + await this.notifications.directSend( + "sms", + phone, + `Your verification code is ${otp}`, + ); + return { channel: "sms", queued: true }; } catch (error) { return { channel: "sms", diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index ce072beca..0cf3b886c 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -49,7 +49,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" }) status!: PaymentStatus - @Column({ type: "date", nullable: true, name: "paid_at" }) + @Column({ type: "timestamp", nullable: true, name: "paid_at" }) paidAt?: Date @Column({ type: "timestamp", nullable: true, name: "refunded_at" }) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index cd2816fab..de3432f51 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -20,6 +20,7 @@ import { Public } from "@edr/api-common"; import { PaymentService } from "./payment.service"; import { BillingService } from "../billing/billing.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { Public } from "@edr/api-common"; /** * Consumer side of the payment microservice's outbox relay. Only the payment service may @@ -29,9 +30,8 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") -// Service-to-service, not user-to-service: exempt from the global JwtGuard -// (there is no end-user JWT on a relay call) and authenticated instead by the -// shared service token that ServiceAuthGuard checks. +// Skips the global JwtGuard (no end-user JWT on a service-to-service call); +// ServiceAuthGuard below still enforces the shared SERVICE_AUTH_TOKEN. @Public() @UseGuards(ServiceAuthGuard) @Controller("internal/payments") diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts index ed0f8da58..aca224c07 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => { ); }); }); + +describe("PaymentService.markIntentSucceeded", () => { + const build = (rows: Record[]) => { + const repo = makeRepo(rows); + const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; + const service = new PaymentService( + repo as never, + {} as never, + billing as never, + ); + return { service, repo, billing }; + }; + + it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => { + const paidAt = new Date("2026-08-01T09:00:00.000Z"); + const { service, repo, billing } = build([ + localIntent({ status: "success", transactionId: "txn-1", paidAt }), + ]); + + const result = await service.markIntentSucceeded("intent-1", { + notify: true, + }); + + expect(result.alreadyFinalized).toBe(true); + // No re-write of the intent row… + expect(repo.update).not.toHaveBeenCalled(); + // …but billing still gets the (idempotent) settle call. + expect(billing.settleByPaymentId).toHaveBeenCalledWith( + "intent-1", + "txn-1", + paidAt, + ); + }); + + it("does not notify billing when notify is false, even when already success", async () => { + const { service, billing } = build([localIntent({ status: "success" })]); + + await service.markIntentSucceeded("intent-1", { notify: false }); + + expect(billing.settleByPaymentId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 0f628e39c..669c7c27b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -232,10 +232,15 @@ export class PaymentService { referenceType: PaymentReferenceType.SHIPMENT, referenceId: input.referenceId, orderRef: input.orderRef, - // amountMinor: input.amountMinor, // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was - // debited against the intent amount, so the 1-birr dev shortcut would break it. - amountMinor: isCbeBill ? input.amountMinor : 1, + // debited against the intent amount, so the dev shortcut would break it. + // CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev + // shortcut floor is 10, not 1. + amountMinor: isCbeBill + ? input.amountMinor + : input.method === ProviderMethod.CAC_BANK + ? 10 + : 1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, @@ -446,7 +451,19 @@ export class PaymentService { ): Promise<{ alreadyFinalized: boolean }> { const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; + if (intent.status === "success") { + // Still notify billing: a prior delivery may have flipped the intent to + // success and then died before the invoice settled (the two steps are not + // atomic). settleByPaymentId is idempotent — no open invoice, no-op. + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId ?? intent.transactionId ?? undefined, + opts.paidAt ?? intent.paidAt ?? undefined, + ); + } + return { alreadyFinalized: true }; + } const paidAt = opts.paidAt ?? new Date(); await this.paymentRepo.update( diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts index dc3558ccc..d4f50eb62 100644 --- a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -7,12 +7,15 @@ export interface GenerateClientAssertionInput { expiresIn?: string; } +// Mirrors the National ID Program's own reference implementation +// (fayda-auth-python): plain {alg: RS256} header, no kid, no jti — eSignet +// resolves the verification key from client_id alone. export async function generateClientAssertion( input: GenerateClientAssertionInput, ): Promise { const privateKey = await importJWK(input.privateJwk, 'RS256'); return new SignJWT({}) - .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setProtectedHeader({ alg: 'RS256' }) .setIssuer(input.clientId) .setSubject(input.clientId) .setAudience(input.audience) diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 16441bd0d..af627f25a 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -396,13 +396,63 @@ export class VerifaydaService { ); } + // Like name/gender, address is flattened by eSignet into top-level + // `address#en` / `address#am` keys — but since it's a structured claim, + // each of those is itself an object whose *leaf* fields carry the same + // locale suffix again, e.g. + // `address#en: { "zone#en": "...", "region#en": "...", "woreda#en": "..." }`. + private static readonly ADDRESS_FIELD_ORDER = [ + 'houseNumber', + 'kebele', + 'woreda', + 'city', + 'subCity', + 'zone', + 'region', + 'postalCode', + 'country', + ]; + + private formatFaydaAddress( + address: Record | undefined, + locale: 'en' | 'am', + ): string | undefined { + if (!address) return undefined; + + const formatted = address[`formatted#${locale}`]; + if (typeof formatted === 'string' && formatted.trim()) return formatted; + + const suffix = `#${locale}`; + const byField = new Map(); + for (const [key, value] of Object.entries(address)) { + if (!key.endsWith(suffix) || typeof value !== 'string' || !value.trim()) continue; + byField.set(key.slice(0, -suffix.length), value); + } + + const ordered = VerifaydaService.ADDRESS_FIELD_ORDER.filter((f) => + byField.has(f), + ).map((f) => byField.get(f)!); + const rest = [...byField.entries()] + .filter(([f]) => !VerifaydaService.ADDRESS_FIELD_ORDER.includes(f)) + .map(([, v]) => v); + + const parts = [...ordered, ...rest]; + return parts.length > 0 ? parts.join(', ') : undefined; + } + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { const nameEn = raw['name#en'] as string | undefined; const nameAm = raw['name#am'] as string | undefined; const genderEn = raw['gender#en'] as string | undefined; const genderAm = raw['gender#am'] as string | undefined; - const addressEn = raw['address#en'] as string | undefined; - const addressAm = raw['address#am'] as string | undefined; + const addressEn = this.formatFaydaAddress( + raw['address#en'] as Record | undefined, + 'en', + ); + const addressAm = this.formatFaydaAddress( + raw['address#am'] as Record | undefined, + 'am', + ); const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; return { diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts index 442a22d2f..03e52d5b0 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -21,7 +21,8 @@ export interface FaydaUserInfo { gender?: string; birthdate?: string; picture?: string; - address?: Record; + 'address#en'?: Record; + 'address#am'?: Record; [key: string]: unknown; } diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index fc00cfabe..b07d7352c 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -1,15 +1,23 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Alert, + Avatar, Badge, Button, Card, Group, - SimpleGrid, Stack, Text, } from "@mantine/core"; -import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react"; +import { + BadgeCheck, + Clock, + Mail, + MapPin, + Phone, + ShieldCheck, + XCircle, +} from "lucide-react"; import { verifaydaService, @@ -42,10 +50,12 @@ interface FaydaVerifyPanelProps { pendingReview?: boolean; } -function formatDate(iso: string | null): string { - if (!iso) return ""; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(); +function getInitials(name: string | null): string { + if (!name) return "?"; + const parts = name.trim().split(/\s+/); + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); } /** @@ -72,6 +82,20 @@ export default function FaydaVerifyPanel({ // panel between steps can't complete a verification against the wrong person. const subjectRef = useRef(subject); subjectRef.current = subject; + // FaydaCallbackPage posts its message from a StrictMode-double-invoked + // effect in dev, so the same one-time-use code+state can arrive twice. + // Track the last state we've started completing so the resend is a no-op. + const handledStateRef = useRef(null); + // Polls the popup so a manually-closed window (no postMessage ever sent) + // still clears `loading` instead of leaving the button spinning forever. + const pollRef = useRef(null); + + const stopPolling = () => { + if (pollRef.current !== null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }; useEffect(() => { const onMessage = async (event: MessageEvent) => { @@ -79,11 +103,15 @@ export default function FaydaVerifyPanel({ if (event.data?.type !== "fayda-callback") return; if (event.data.error) { + stopPolling(); setLoading(false); setError(event.data.errorDescription ?? event.data.error); return; } if (!event.data.code || !event.data.state) return; + if (handledStateRef.current === event.data.state) return; + handledStateRef.current = event.data.state; + stopPolling(); try { const next = await verifaydaService.completeIdentity( @@ -104,13 +132,17 @@ export default function FaydaVerifyPanel({ } }; window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); + return () => { + window.removeEventListener("message", onMessage); + stopPolling(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const startVerification = async () => { setError(null); setLoading(true); + handledStateRef.current = null; try { const authorizationUrl = await verifaydaService.start(); const popup = window.open( @@ -121,8 +153,17 @@ export default function FaydaVerifyPanel({ if (!popup) { setLoading(false); setError("Pop-up blocked — allow pop-ups for this site and try again."); + return; } - // Loading stays on until the popup posts back. + // Loading stays on until the popup posts back — unless the user closes + // it by hand, which never sends a message; poll for that and clear + // loading ourselves so the button doesn't spin forever. + stopPolling(); + pollRef.current = window.setInterval(() => { + if (!popup.closed) return; + stopPolling(); + if (handledStateRef.current === null) setLoading(false); + }, 500); } catch (err) { setLoading(false); setError( @@ -141,7 +182,7 @@ export default function FaydaVerifyPanel({ - {title} identity + {title} {verified ? ( - - - - - - + + + {getInitials(state.name)} + + + + {state.name} + + + } value={state.phone} /> + } value={state.email} /> + + } value={state.address} /> + + )} {error && ( @@ -209,22 +258,16 @@ export default function FaydaVerifyPanel({ ); } -function VerifiedField({ - label, - value, -}: { - label: string; - value: string | null; -}) { +function DataRow({ icon, value }: { icon: ReactNode; value: string | null }) { if (!value) return null; return ( - - - {label} - - + + + {icon} + + {value} - + ); } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 082331c7b..e92b907e2 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core"; +import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core"; import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; import { AlertCircle, Download } from "lucide-react"; @@ -24,49 +24,34 @@ interface ETradeInfoProps { onDataLoaded: (data: CompanyRegistrationData) => void; /** Reports the live lookup status so the parent step can gate on it. */ onStatusChange?: (status: ETradeStatus) => void; + /** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */ + onReset?: () => void; } const isValidTin = (tin: string) => tin.length === 10; -/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */ -function downloadTinRecord(tin: string, data: CompanyRegistrationData) { - const lines = [ - `TIN: ${tin}`, - `Company name: ${data.companyName}`, - `Licence number: ${data.licenceNumber}`, - `Status: ${data.statusDescription}`, - `Date registered: ${data.dateRegistered}`, - `Renewed from: ${data.renewedFrom}`, - `Renewal date: ${data.renewalDate}`, - `Renewed to: ${data.renewedTo}`, - `Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`, - `Manager: ${data.managerName}`, - ]; - const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `tin-${tin}.txt`; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 60_000); -} - export default function ETradeInfo({ tin, register, error, onDataLoaded, onStatusChange, + onReset, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; const tinTaken = mutation.data?.tinTaken; + // Bumped on every TIN change so a fetch already in flight for an older TIN + // is ignored when it lands — otherwise a slow lookup can resolve after the + // user has typed a different TIN and overwrite its fields with stale data. + const requestIdRef = useRef(0); + const handleFetch = async () => { if (!isValidTin(tin)) return; + const requestId = ++requestIdRef.current; const result = await mutation.mutateAsync(tin); + if (requestIdRef.current !== requestId) return; if (result && !result.tinTaken) { onDataLoaded(result); } @@ -78,6 +63,16 @@ export default function ETradeInfo({ // doesn't refire the lookup the moment this mounts. const lastFetchedTin = useRef(tin || null); useEffect(() => { + if (tin !== lastFetchedTin.current) { + // TIN moved away from whatever we last fetched — that result (verified + // data, "taken", or an error) no longer describes this TIN. Drop it so + // the UI doesn't keep showing the previous TIN's outcome. + requestIdRef.current++; + if (mutation.data || mutation.error) { + mutation.reset(); + onReset?.(); + } + } if (isValidTin(tin) && lastFetchedTin.current !== tin) { lastFetchedTin.current = tin; handleFetch(); @@ -86,15 +81,13 @@ export default function ETradeInfo({ }, [tin]); const apiError = - mutation.isError && mutation.error - ? extractApiError(mutation.error) - : null; + mutation.isError && mutation.error ? extractApiError(mutation.error) : null; // A 400 here means eTrade simply has no record for this TIN. const notFound = apiError?.statusCode === 400; const errorMessage = apiError && !notFound ? apiError.message || - "We couldn't reach eTrade to fetch your company information. Please try again." + "We couldn't reach eTrade to fetch your company information. Please try again." : null; const status: ETradeStatus = isLoading @@ -117,49 +110,48 @@ export default function ETradeInfo({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); - const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading"; + // True for the one render between the TIN reaching 10 digits and the + // effect above actually starting the fetch — without this, "Get Data" + // flashes on screen for that frame before `isLoading` ever turns true. + const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin; + const showLoading = isLoading || willAutoFetch; + + const showRetry = isValidTin(tin) && status !== "verified" && !showLoading; return ( - +
- TIN Number (10 digits){" "} - * - - } + aria-label="TIN Number (10 digits), required" placeholder="0012345678" maxLength={10} error={error} {...register} /> + {showLoading && ( + + )} {showRetry && ( - )} - {status === "verified" && mutation.data && !mutation.data.tinTaken && ( - )} - +
{notFound && ( -
- - - - - Your account is approved - -
- - ); - } + // if (companyStatus === "active") { + // return ( + //
+ //
+ // + // + // + // + // Your account is approved + // + //
+ //
+ // ); + // } return null; } - const pendingLabel = pending - .map((p) => p.type.replace(/_/g, " ")) - .join(", "); + const pendingLabel = pending.map((p) => p.type.replace(/_/g, " ")).join(", "); return (
diff --git a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts index 04c91a683..85e951dac 100644 --- a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts +++ b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts @@ -1,5 +1,6 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; +import { Freight } from "@edr/types"; import { useState } from "react"; import { invoicesService } from "@/services/invoices.service"; @@ -34,9 +35,18 @@ function apiMessage(err: unknown, fallback: string): string { * charge through a different endpoint (warehouse fee invoices); OTP * confirmation always goes through billing, which owns the intent either way. */ +/** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */ +interface BillAction { + invoiceId: string; + billReference: string; + instructions?: string; + expiresAt?: string; +} + export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { const [otpInvoiceId, setOtpInvoiceId] = useState(null); const [otpMessage, setOtpMessage] = useState(); + const [billAction, setBillAction] = useState(null); const payMutation = useMutation({ mutationFn: (vars: { @@ -52,6 +62,17 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { setOtpInvoiceId(vars.invoiceId); return; } + // CBE_BILL settles asynchronously via CBE, not the browser — show the + // bill reference instead of redirecting to a (nonexistent) checkout page. + if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + invoiceId: vars.invoiceId, + billReference: data.clientAction.billReference ?? "", + instructions: data.clientAction.instructions, + expiresAt: data.clientAction.expiresAt, + }); + return; + } window.location.href = data?.clientAction?.type === "REDIRECT" && data.clientAction.url ? data.clientAction.url @@ -72,10 +93,27 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { }, }); + // Poll the invoice while the CBE bill dialog is open — CBE settles out of + // band (branch/app/USSD), so this is the only way the browser learns it paid. + useQuery({ + queryKey: ["invoice-bill-poll", billAction?.invoiceId], + queryFn: async () => { + const invoice = await invoicesService.get(billAction!.invoiceId); + if (invoice.status === Freight.InvoiceStatus.Paid) { + setBillAction(null); + window.location.reload(); + } + return invoice; + }, + enabled: billAction !== null, + refetchInterval: 5000, + }); + const reset = () => { payMutation.reset(); otpMutation.reset(); setOtpInvoiceId(null); + setBillAction(null); }; return { @@ -109,6 +147,14 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { setOtpInvoiceId(null); }, }, + /** Drives the modal's "pay at CBE" step; `open` only for CBE_BILL. */ + bill: { + open: billAction !== null, + billReference: billAction?.billReference, + instructions: billAction?.instructions, + expiresAt: billAction?.expiresAt, + close: () => setBillAction(null), + }, }; } diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx index 9581a4f3d..4b16e132d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx @@ -55,9 +55,8 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { invoicesService.listForSource("booking", payItem!.targetId), enabled: payItem !== null, }); - const payableInvoiceId = payItemInvoices.find((inv) => - isPayable(inv.status), - )?.id; + const payableInvoice = payItemInvoices.find((inv) => isPayable(inv.status)); + const payableInvoiceId = payableInvoice?.id; const pay = useInvoicePayment(); @@ -171,7 +170,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { pay.reset(); } }} - currency={undefined} + currency={payableInvoice?.currency} processing={pay.processing} error={ pay.error ?? @@ -180,6 +179,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { : null) } otp={pay.otp} + bill={pay.bill} onConfirm={(method, payerAccount) => payableInvoiceId && pay.pay(payableInvoiceId, method, payerAccount) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 9c62b494d..0dc02eaa4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -197,6 +197,7 @@ export default function CompanyProfileForm({ companyEmail: "", companyPhone: "", companyAddress: "", + etradePhone: "", tinNumber: "", vatNumber: "", ownerPassportNumber: "", @@ -279,6 +280,14 @@ export default function CompanyProfileForm({ // setting region/zone/woreda/kebele/houseNo above is enough — no need to // compose it here. companyPhone is derived below (identity → eTrade → // account), not set directly here. + // etradePhone is the raw number eTrade returned for this TIN — kept as its + // own field (distinct from companyPhone, which prefers the Fayda-verified + // owner's phone) so the backend's "matches eTrade's current record" check + // always compares against what eTrade actually said, not the owner's phone. + setValue( + "etradePhone", + data.managerPhone || data.regularPhone || data.mobilePhone, + ); setEtradeOwner({ name: data.managerName, @@ -288,6 +297,25 @@ export default function CompanyProfileForm({ }); }; + // TIN changed since the last successful lookup — the registration/address + // fields it filled in describe the OLD TIN, not this one, so clear them + // rather than leaving them stale on screen. + const handleETradeReset = () => { + setValue("licenceNumber", ""); + setValue("statusDescription", ""); + setValue("dateRegistered", ""); + setValue("renewedFrom", ""); + setValue("renewalDate", ""); + setValue("renewedTo", ""); + setValue("region", ""); + setValue("zone", ""); + setValue("woreda", ""); + setValue("kebele", ""); + setValue("houseNo", ""); + setValue("etradePhone", ""); + setEtradeOwner(null); + }; + // companyEmail/companyPhone are no longer typed — the Fayda-verified owner // is the highest-trust source (that's the whole point of verifying), eTrade's // registered number and the account email/phone are the fallbacks used @@ -297,7 +325,7 @@ export default function CompanyProfileForm({ shouldValidate: true, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.email, user.email]); + }, [identity?.owner.email, user.email, rehydrate]); useEffect(() => { setValue( @@ -309,7 +337,7 @@ export default function CompanyProfileForm({ { shouldValidate: true }, ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]); + }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]); // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears @@ -649,7 +677,7 @@ export default function CompanyProfileForm({ } > onIdentityChange?.()} @@ -718,6 +746,7 @@ export default function CompanyProfileForm({ error={errors.tinNumber?.message} onDataLoaded={handleETradeDataLoaded} onStatusChange={setTinStatus} + onReset={handleETradeReset} /> {tinVerified && ( {label} - + {value && value.trim() ? value : "—"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx index be65e9c73..0b0878c99 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx @@ -1,4 +1,5 @@ import { Badge, Group, Stack, Text } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { Check, X } from "lucide-react"; import type { ReactNode } from "react"; @@ -32,6 +33,7 @@ export default function StepSection({ children: ReactNode; }) { const badge = STATUS_BADGE[status]; + const isMobile = useMediaQuery("(max-width: 48em)"); return ( @@ -75,7 +77,7 @@ export default function StepSection({ )} -
+
{children}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index f1a9f4301..a3aad0ee2 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -70,7 +70,7 @@ export function stepPayload( woreda: d.woreda, kebele: d.kebele, houseNo: d.houseNo, - etradePhone: d.companyPhone, + etradePhone: d.etradePhone, }; case "personnel": return { @@ -101,6 +101,7 @@ export function toFormValues(p: ProfileResponse): FormData { companyEmail: p.companyEmail ?? "", companyPhone: p.companyPhone ?? "", companyAddress: p.companyAddress ?? "", + etradePhone: p.etradePhone ?? "", tinNumber: tin, vatNumber: p.vatNumber ?? "", ownerPassportNumber: p.identity?.owner.passportNumber ?? "", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 2cd06ef8f..e2842dbe6 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -21,6 +21,10 @@ export const onboardingSchema = z.object({ // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. companyAddress: z.string().optional(), + // Raw phone from the eTrade lookup itself — kept separate from companyPhone + // (which shows the Fayda-verified owner's phone once verified) so the two + // can diverge without the backend's eTrade-authenticity check misfiring. + etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), vatNumber: z .string() @@ -124,6 +128,7 @@ export const stepFields: Record = { "companyEmail", "companyPhone", "companyAddress", + "etradePhone", "tinNumber", "vatNumber", "ownerPassportNumber", diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 1477b7189..775d9fe41 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -9,7 +9,6 @@ import { Divider, Group, Loader, - Modal, Paper, SimpleGrid, Stack, @@ -34,13 +33,7 @@ import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/component import { saveBlob } from "@/utils/download"; import { formatCurrency } from "@/lib/currency"; import { BORDER, INK, MUTED } from "../contracts/contract-ui"; -import { - billedTo, - fmtDate, - InvoiceStatusBadge, - isPayable, - titleCase, -} from "./invoice-ui"; +import { billedTo, fmtDate, InvoiceStatusBadge, isPayable, titleCase } from "./invoice-ui"; function MetaItem({ label, value }: { label: string; value: string }) { return ( @@ -70,12 +63,6 @@ export default function InvoiceDetailPage() { } = useQuery(api.invoices.get.queryOptions({ input: { id } })); const [payModalOpen, setPayModalOpen] = useState(false); - // CBE bill payment: the bill reference to pay at any CBE channel (no redirect). - const [billAction, setBillAction] = useState<{ - billReference?: string; - instructions?: string; - expiresAt?: string; - } | null>(null); // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges // one of the signed-in customer's own invoices (unlike the admin-facing @@ -368,7 +355,7 @@ export default function InvoiceDetailPage() { { if (!pay.processing) { setPayModalOpen(false); @@ -380,66 +367,11 @@ export default function InvoiceDetailPage() { processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={(method, payerAccount) => pay.pay(id, method, payerAccount) } /> - - {/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */} - setBillAction(null)} - centered - radius={18} - size={440} - title={Pay at CBE} - > - - - {billAction?.instructions ?? - "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} - - - - {billAction?.billReference} - - - - - Amount due:{" "} - - {formatCurrency(amountDue, invoice.currency)} - - - {billAction?.expiresAt && ( - - Pay before:{" "} - - {fmtDate(billAction.expiresAt)} - - - )} - - The invoice updates automatically once CBE confirms your payment. - - - ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ac211b5be..e8fa01118 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -295,6 +295,7 @@ export function ReadonlyBookingView({ processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={pay.confirm} /> {viewer} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 2e9477f80..a57dd0a9d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -26,7 +26,7 @@ interface ProviderOption { accent: string; } -// Only Telebirr, Waafi and CBE bill payment are enabled for now. +// Only Telebirr, Waafi, CAC Bank and CBE bill payment are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -63,6 +63,10 @@ const PROVIDERS: ProviderOption[] = [ /** Providers that debit against an SMS OTP instead of redirecting to a page. */ const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; +/** CAC Bank SMS codes are 4 digits. */ +const OTP_LENGTH = 4; +/** Providers that settle asynchronously via a bill reference instead of a redirect. */ +const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL"; /** * Pick the provider that settles in the booking's currency. USD → Waafi, @@ -180,6 +184,7 @@ export function PaymentMethodModal({ processing, error, otp, + bill, }: { opened: boolean; onClose: () => void; @@ -192,14 +197,21 @@ export function PaymentMethodModal({ error?: string | null; /** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */ otp?: InvoicePaymentFlow["otp"]; + /** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */ + bill?: InvoicePaymentFlow["bill"]; }) { const providers = useMemo( - () => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)), - [currency, otp], + () => + providersForCurrency(currency).filter( + (p) => + (otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)), + ), + [currency, otp, bill], ); const [method, setMethod] = useState(providers[0].method); const [mobile, setMobile] = useState(""); const [code, setCode] = useState(""); + const [copied, setCopied] = useState(false); // Keep the selection valid when the currency (and therefore provider list) changes. useEffect(() => { @@ -217,6 +229,101 @@ export function PaymentMethodModal({ const needsMobile = isOtpMethod(method); const canSubmit = !needsMobile || mobile.trim().length > 0; + if (bill?.open) { + return ( + + + + Pay at CBE + + + {bill.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} + + + + + {bill.billReference} + + + + + {amountLabel && ( + + Amount due: {amountLabel} + + )} + {bill.expiresAt && ( + + Pay before:{" "} + + {new Date(bill.expiresAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + )} + + + This page updates automatically once CBE confirms your payment. + + + + + + ); + } + if (otp?.open) { return ( - + + + Verification code + otp.submit(value)} aria-label="One-time password" /> - + {otp.error && ( @@ -276,7 +388,7 @@ export function PaymentMethodModal({ radius={12} color="edr-green" loading={otp.submitting} - disabled={otp.submitting || code.trim().length === 0} + disabled={otp.submitting || code.trim().length !== OTP_LENGTH} onClick={() => otp.submit(code.trim())} styles={{ root: { height: 46, flex: 1 }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index a146dfb11..d3bbc55e9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -243,6 +243,7 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index 7a9949e8a..2eb07a0b1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -56,6 +56,7 @@ export function PayNowButton({ processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={pay.confirm} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts index bb1bd33ab..eb6e170d1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts @@ -45,6 +45,7 @@ export function useBookingPayment(bookingId: string) { ? "No payable invoice found for this booking yet. Please refresh or contact support." : flow.error, otp: flow.otp, + bill: flow.bill, confirm: (method: PaymentMethod, payerAccount?: string) => { if (!payableInvoiceId) { setNoInvoice(true); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index c525b03c4..8f13e1466 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -33,8 +33,7 @@ import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import { extractApiError } from "@/utils/result"; -const CONSENT_TEXT = - "I have read the entire contract and agree to its terms."; +const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; /** * Customer contract preview + sign. Customers must scroll through the full @@ -224,7 +223,10 @@ export default function ContractViewPage() { } return ( - +