mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// Data repair — not reversible (the original malformed shape isn't worth restoring).
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN paid_at TYPE timestamp
|
||||
USING paid_at::timestamp;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN paid_at TYPE date
|
||||
USING paid_at::date;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -928,7 +928,7 @@ export class CompaniesService {
|
||||
): Promise<ProfileResponseDto> {
|
||||
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<void> {
|
||||
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<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
||||
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
||||
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<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DispatchOutcome> {
|
||||
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",
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentService.markIntentSucceeded", () => {
|
||||
const build = (rows: Record<string, unknown>[]) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string> {
|
||||
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)
|
||||
|
||||
@@ -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<string, unknown> | 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<string, string>();
|
||||
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<string, unknown> | undefined,
|
||||
'en',
|
||||
);
|
||||
const addressAm = this.formatFaydaAddress(
|
||||
raw['address#am'] as Record<string, unknown> | undefined,
|
||||
'am',
|
||||
);
|
||||
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,8 @@ export interface FaydaUserInfo {
|
||||
gender?: string;
|
||||
birthdate?: string;
|
||||
picture?: string;
|
||||
address?: Record<string, unknown>;
|
||||
'address#en'?: Record<string, unknown>;
|
||||
'address#am'?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(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<number | null>(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current !== null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
||||
@@ -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({
|
||||
<Group gap="sm">
|
||||
<ShieldCheck size={18} />
|
||||
<Text fw={600} c="edr-text">
|
||||
{title} identity
|
||||
{title}
|
||||
</Text>
|
||||
{verified ? (
|
||||
<Badge
|
||||
@@ -191,13 +232,21 @@ export default function FaydaVerifyPanel({
|
||||
)}
|
||||
|
||||
{verified && state && (
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<VerifiedField label="Name" value={state.name} />
|
||||
<VerifiedField label="Phone" value={state.phone} />
|
||||
<VerifiedField label="Email" value={state.email} />
|
||||
<VerifiedField label="Address" value={state.address} />
|
||||
<VerifiedField label="Verified" value={formatDate(state.verifiedAt)} />
|
||||
</SimpleGrid>
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<Avatar radius="xl" size={44} color="edr-green" variant="light">
|
||||
{getInitials(state.name)}
|
||||
</Avatar>
|
||||
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={600} size="sm" c="edr-text" truncate>
|
||||
{state.name}
|
||||
</Text>
|
||||
<Group gap="md" wrap="wrap">
|
||||
<DataRow icon={<Phone size={13} />} value={state.phone} />
|
||||
<DataRow icon={<Mail size={13} />} value={state.email} />
|
||||
</Group>
|
||||
<DataRow icon={<MapPin size={13} />} value={state.address} />
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="edr-muted">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<span style={{ color: "var(--mantine-color-edr-muted-6)", display: "flex", flexShrink: 0 }}>
|
||||
{icon}
|
||||
</span>
|
||||
<Text size="xs" c="edr-muted" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-start" grow>
|
||||
<div className="max-sm:flex-col! max-sm: grow flex items-start gap-4">
|
||||
<TextInput
|
||||
label={
|
||||
<>
|
||||
TIN Number (10 digits){" "}
|
||||
<span style={{ color: "var(--mantine-color-red-6)" }}>*</span>
|
||||
</>
|
||||
}
|
||||
aria-label="TIN Number (10 digits), required"
|
||||
placeholder="0012345678"
|
||||
maxLength={10}
|
||||
error={error}
|
||||
{...register}
|
||||
/>
|
||||
{showLoading && (
|
||||
<Button
|
||||
className="max-w-none"
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
disabled
|
||||
leftSection={<Loader size={16} />}
|
||||
>
|
||||
Getting...
|
||||
</Button>
|
||||
)}
|
||||
{showRetry && (
|
||||
<Button
|
||||
className="max-w-none"
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!isValidTin(tin) || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
)}
|
||||
{status === "verified" && mutation.data && !mutation.data.tinTaken && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => downloadTinRecord(tin, mutation.data!)}
|
||||
disabled={!isValidTin(tin)}
|
||||
leftSection={<Download size={16} />}
|
||||
mt="24px"
|
||||
>
|
||||
Download
|
||||
Get Data
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{notFound && (
|
||||
<Alert
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlertTriangle, ArrowRight, CheckCircle2, Clock } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
|
||||
import { api } from "@/services/api";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { OnboardingRequirements } from "@/services/companies.service";
|
||||
@@ -28,7 +28,8 @@ function getCopy(
|
||||
if (!requirements || requirements.progress.completed === 0) {
|
||||
return {
|
||||
title: "Set up your company profile",
|
||||
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
|
||||
subtitle:
|
||||
"Unlock bookings, tracking and billing — it only takes a minute.",
|
||||
cta: "Start onboarding",
|
||||
};
|
||||
}
|
||||
@@ -46,9 +47,8 @@ function getCopy(
|
||||
if (remaining <= 2) {
|
||||
return {
|
||||
title: `Almost done — you're ${pct}% set up`,
|
||||
subtitle: `Just ${remaining} more ${
|
||||
remaining === 1 ? "item" : "items"
|
||||
} to finish: ${requirements.outstanding.join(", ")}.`,
|
||||
subtitle: `Just ${remaining} more ${remaining === 1 ? "item" : "items"
|
||||
} to finish: ${requirements.outstanding.join(", ")}.`,
|
||||
cta: "Finish onboarding",
|
||||
};
|
||||
}
|
||||
@@ -301,26 +301,24 @@ export function AccountReviewBanner() {
|
||||
// 5. Per-operational-profile approval (existing behaviour).
|
||||
if (pending.length === 0) {
|
||||
// Nothing outstanding — a quiet confirmation that the account is live.
|
||||
if (companyStatus === "active") {
|
||||
return (
|
||||
<div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<CheckCircle2 size={18} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-emerald-900">
|
||||
Your account is approved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// if (companyStatus === "active") {
|
||||
// return (
|
||||
// <div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
|
||||
// <div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
// <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
// <CheckCircle2 size={18} />
|
||||
// </span>
|
||||
// <span className="text-sm font-semibold text-emerald-900">
|
||||
// Your account is approved
|
||||
// </span>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingLabel = pending
|
||||
.map((p) => p.type.replace(/_/g, " "))
|
||||
.join(", ");
|
||||
const pendingLabel = pending.map((p) => p.type.replace(/_/g, " ")).join(", ");
|
||||
|
||||
return (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [otpMessage, setOtpMessage] = useState<string | undefined>();
|
||||
const [billAction, setBillAction] = useState<BillAction | null>(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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
aria-label="VAT Number"
|
||||
placeholder="0012345678"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
@@ -661,9 +689,9 @@ export default function CompanyProfileForm({
|
||||
index={2}
|
||||
title="Owner identity"
|
||||
subtitle={
|
||||
verifiedIdentity
|
||||
? "Verify the company owner with Fayda — their name, phone, email and address come from the verification."
|
||||
: "Provide the company owner's passport number."
|
||||
!identity?.owner.verified && !verifiedIdentity
|
||||
? "Provide the company owner's passport number."
|
||||
: undefined
|
||||
}
|
||||
status={
|
||||
verifiedIdentity
|
||||
@@ -683,7 +711,7 @@ export default function CompanyProfileForm({
|
||||
<>
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Company owner"
|
||||
title="Owner"
|
||||
state={identity.owner}
|
||||
required={identity.faydaRequired}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
@@ -718,6 +746,7 @@ export default function CompanyProfileForm({
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
onStatusChange={setTinStatus}
|
||||
onReset={handleETradeReset}
|
||||
/>
|
||||
{tinVerified && (
|
||||
<ETradeCompanyCard
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ReadOnlyField({
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text" fw={500}>
|
||||
<Text className="wrap-break-word" size="sm" c="edr-text" fw={500}>
|
||||
{value && value.trim() ? value : "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
@@ -75,7 +77,7 @@ export default function StepSection({
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<div style={{ paddingLeft: 34 }}>
|
||||
<div style={{ paddingLeft: isMobile ? 0 : 34 }}>
|
||||
<Stack gap="sm">{children}</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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<CompanyStep, (keyof FormData)[]> = {
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyAddress",
|
||||
"etradePhone",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"ownerPassportNumber",
|
||||
|
||||
@@ -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() {
|
||||
</Paper>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
opened={payModalOpen || pay.bill.open}
|
||||
onClose={() => {
|
||||
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 */}
|
||||
<Modal
|
||||
opened={!!billAction}
|
||||
onClose={() => setBillAction(null)}
|
||||
centered
|
||||
radius={18}
|
||||
size={440}
|
||||
title={<Text fw={800}>Pay at CBE</Text>}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c={MUTED}>
|
||||
{billAction?.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Text ff="monospace" fz={24} fw={800} c={INK} style={{ letterSpacing: 3 }}>
|
||||
{billAction?.billReference}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (billAction?.billReference) {
|
||||
navigator.clipboard?.writeText(billAction.billReference);
|
||||
toast.success("Bill number copied");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Amount due:{" "}
|
||||
<Text span fw={700} c={INK}>
|
||||
{formatCurrency(amountDue, invoice.currency)}
|
||||
</Text>
|
||||
</Text>
|
||||
{billAction?.expiresAt && (
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Pay before:{" "}
|
||||
<Text span fw={700} c={INK}>
|
||||
{fmtDate(billAction.expiresAt)}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Text fz="xs" c={MUTED}>
|
||||
The invoice updates automatically once CBE confirms your payment.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -295,6 +295,7 @@ export function ReadonlyBookingView({
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
bill={pay.bill}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
{viewer}
|
||||
|
||||
@@ -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<PaymentMethod>(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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={bill.close}
|
||||
centered
|
||||
radius={18}
|
||||
size={440}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||
>
|
||||
<Box px={24} py={24}>
|
||||
<Text fw={800} fz="18px" c="#10202F">
|
||||
Pay at CBE
|
||||
</Text>
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
{bill.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</Text>
|
||||
|
||||
<Group
|
||||
mt={18}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{ borderRadius: 12, border: "1.5px solid #E6ECF1" }}
|
||||
>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fz="22px"
|
||||
fw={800}
|
||||
c="#10202F"
|
||||
style={{ letterSpacing: 2 }}
|
||||
>
|
||||
{bill.billReference}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={copied ? <Check size={14} /> : undefined}
|
||||
onClick={() => {
|
||||
if (bill.billReference) {
|
||||
navigator.clipboard?.writeText(bill.billReference);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{amountLabel && (
|
||||
<Text mt={12} fz="13px" c="#7A8794">
|
||||
Amount due: <Text span fw={700} c="#10202F">{amountLabel}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{bill.expiresAt && (
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
Pay before:{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{new Date(bill.expiresAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text mt={14} fz="11.5px" c="#9AA8B5">
|
||||
This page updates automatically once CBE confirms your payment.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
mt={18}
|
||||
variant="default"
|
||||
radius={12}
|
||||
onClick={bill.close}
|
||||
styles={{
|
||||
root: { height: 44 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#475569" },
|
||||
}}
|
||||
>
|
||||
Close (pay later)
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
if (otp?.open) {
|
||||
return (
|
||||
<Modal
|
||||
@@ -240,18 +347,23 @@ export function PaymentMethodModal({
|
||||
{otp.message}
|
||||
</Text>
|
||||
|
||||
<Box mt={18}>
|
||||
<Stack mt={18} gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
length={OTP_LENGTH}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
oneTimeCode
|
||||
value={code}
|
||||
placeholder="0"
|
||||
disabled={otp.submitting}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setCode}
|
||||
onComplete={(value) => otp.submit(value)}
|
||||
aria-label="One-time password"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{otp.error && (
|
||||
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
|
||||
@@ -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 },
|
||||
|
||||
@@ -243,6 +243,7 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
bill={pay.bill}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ export function PayNowButton({
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
bill={pay.bill}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<Box p={{ base: "md", md: "xl" }} pb={data.canSignCustomer ? 120 : undefined}>
|
||||
<Box
|
||||
p={{ base: "md", md: "xl" }}
|
||||
pb={data.canSignCustomer ? 120 : undefined}
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
||||
<Button
|
||||
@@ -297,6 +299,7 @@ export default function ContractViewPage() {
|
||||
zIndex: 100,
|
||||
borderTop: "1px solid var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-body)",
|
||||
paddingBottom: 32,
|
||||
}}
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
@@ -423,10 +426,7 @@ export default function ContractViewPage() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ShieldCheck
|
||||
size={20}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
<ShieldCheck size={20} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
For security, enter the 6-digit code we sent to your registered
|
||||
|
||||
@@ -21,6 +21,7 @@ export default registerAs("cbeBill", () => ({
|
||||
process.env.PASSENGER_API_BASE_URL || "http://localhost:3002"
|
||||
).replace(/\/$/, ""),
|
||||
freightApiBaseUrl: (
|
||||
process.env.FREIGHT_API_BASE_URL || "http://localhost:3001"
|
||||
process.env.FREIGHT_API_BASE_URL ||
|
||||
"https://edrfreightapi-staging.edrsc.com/api"
|
||||
).replace(/\/$/, ""),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user