diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 5a145a0db..d2359dd97 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,4 +1,10 @@ # Copy to .env for local/docker compose (not committed). + +# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted), +# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda +# (canned verified profile, no eSignet call). Leave unset in production. +ENV= + PORT=3001 # @tria-plc/auditlog's client interceptor stamps every AuditLog row's # `application` from this env var directly, bypassing MezgebModule.forRoot's diff --git a/apps/edr-freight-api/src/common/dev-bypass.util.ts b/apps/edr-freight-api/src/common/dev-bypass.util.ts new file mode 100644 index 000000000..5e6e39e8d --- /dev/null +++ b/apps/edr-freight-api/src/common/dev-bypass.util.ts @@ -0,0 +1,13 @@ +/** + * Dev/staging bypass gate for OTP, payment and Fayda verification. + * + * Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be + * mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in + * production, so this is always false there. + */ +export function isBypassEnv(): boolean { + return ["dev", "staging"].includes(process.env.ENV ?? ""); +} + +/** Fixed code accepted in addition to the real one when isBypassEnv(). */ +export const DEV_BYPASS_OTP = "000000"; 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 42d46226a..b27520c25 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository"; import { NotificationsService } from "../notifications/notifications.service"; import { EmailClientService } from "../notifications/email-client.service"; +import { isBypassEnv, DEV_BYPASS_OTP } from "../../common/dev-bypass.util"; /** * Where a code goes. At least one of phone/email must be set — enforced by the @@ -146,6 +147,21 @@ export class OtpService { `otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`, ); + // Dev/staging only: the row above still exists (so a real code would + // still verify), but skip the real SMS/email send — no carrier cost, no + // dependency on RabbitMQ/the mail relay being up. Verify with the fixed + // DEV_BYPASS_OTP code instead of whatever landed in the row. + if (isBypassEnv()) { + this.logger.warn( + `otp.dispatch.bypassed target=${label} — dev/staging, no real SMS/email sent (verify with ${DEV_BYPASS_OTP})`, + ); + return { + success: true, + delivered: true, + message: "OTP sent successfully", + }; + } + // NOTE: do NOT reset the brute-force attempt counter on send. Clearing it // here let an attacker wipe the per-target guess budget just by calling // /otp/send between guesses. The counter is cleared only when the code is @@ -394,7 +410,10 @@ export class OtpService { // invalid otp — per-target attempt cap so a 6-digit code can't be // brute-forced within its TTL; the code is burned once the budget is spent. - if (otpData.otp !== otp) { + // Dev/staging only: a fixed code verifies any pending OTP row without + // knowing the real one — the row still has to exist (sendOtp still runs). + const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP; + if (otpData.otp !== otp && !bypassed) { const attempts = (this.actionAttempts.get(key) ?? 0) + 1; if (attempts >= this.MAX_ACTION_ATTEMPTS) { await this.otpRepository.deleteOtp(otpData); @@ -482,7 +501,10 @@ export class OtpService { ); } - if (otpData.otp !== otp) { + // Dev/staging only: a fixed code verifies any pending OTP row without + // knowing the real one — the row still has to exist (sendOtp still runs). + const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP; + if (otpData.otp !== otp && !bypassed) { const attempts = (this.actionAttempts.get(key) ?? 0) + 1; if (attempts >= this.MAX_ACTION_ATTEMPTS) { 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 037e188f8..168fa3df4 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -31,6 +31,7 @@ import { IntentStatusDto, PaymentPlatformDto, } from "./payments.dto"; +import { isBypassEnv } from "../../common/dev-bypass.util"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by * the caller (billing) — this service never derives them from a domain record. */ @@ -256,34 +257,52 @@ export class PaymentService { ); } - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: PaymentReferenceType.SHIPMENT, - referenceId: input.referenceId, - orderRef: input.orderRef, - // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was - // 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, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - payerName: input.payerName, - expiresAt: input.expiresAt, - // bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING). - returnUrl: - input.returnUrl ?? - `https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`, - failureUrl: - input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); + // Dev/staging only: skip the real gateway call entirely and report an + // immediate SUCCEEDED snapshot — everything below (upsert, settle, + // billing notify) runs exactly as it would for a real synchronous + // provider success. + const snapshot: PaymentIntentSnapshot = isBypassEnv() + ? { + intentId: `bypass-${input.referenceId}`, + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + merchantOrderId: input.orderRef, + provider: input.method as ProviderMethod, + status: ProviderPaymentStatus.SUCCEEDED, + amountMinor: input.amountMinor, + currency: input.currency, + providerTxnId: `bypass-${input.referenceId}`, + paidAt: new Date().toISOString(), + } + : await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was + // 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, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + payerName: input.payerName, + expiresAt: input.expiresAt, + // bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING). + returnUrl: + input.returnUrl ?? + `https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`, + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; 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 af627f25a..298cae11e 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -28,6 +28,11 @@ import { NormalizedFaydaUserInfo, VerifaydaPurpose, } from './verifayda.types'; +import { randomUUID } from 'node:crypto'; +import { isBypassEnv } from '../../common/dev-bypass.util'; + +/** Sentinel `code` that skips the real eSignet exchange in dev/staging. */ +export const DEV_BYPASS_FAYDA_CODE = 'DEV_BYPASS'; export interface StartVerificationInput { purpose: VerifaydaPurpose; @@ -143,6 +148,26 @@ export class VerifaydaService { async completeVerification( query: VerifaydaCallbackDto, ): Promise { + // Dev/staging only: caller sends the sentinel code instead of a real + // eSignet redirect — skip the token exchange/session entirely and hand + // back a canned VERIFY result. `sub` is unique per call so binding both + // owner and PoA in the same bypass session doesn't collide. + if (isBypassEnv() && query.code === DEV_BYPASS_FAYDA_CODE) { + this.logger.warn('Fayda verification BYPASSED (dev/staging)'); + return { + purpose: 'VERIFY', + verified: true, + sub: `dev-bypass-${randomUUID()}`, + fullName: 'Dev Bypass User', + email: 'dev-bypass@example.com', + phoneNumber: '+251900000000', + birthdate: '1990-01-01', + gender: 'M', + address: 'Dev Bypass Address', + userDataSaved: false, + }; + } + if (query.error) { this.logger.warn(`Fayda callback returned error: ${query.error}`); if (query.state) { diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index 4e6921e14..71cef57da 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -25,6 +25,10 @@ import { type IdentitySubject, type IdentityVerificationState, } from "@/services/verifayda.service"; +import { isBypassEnv } from "@/utils/dev-bypass"; + +/** Sentinel code that skips the real eSignet exchange in dev/staging (see api's DEV_BYPASS_FAYDA_CODE). */ +const DEV_BYPASS_FAYDA_CODE = "DEV_BYPASS"; interface FaydaVerifyPanelProps { subject: IdentitySubject; @@ -80,6 +84,23 @@ export default function FaydaVerifyPanel({ setError(null); setLoading(true); try { + // Dev/staging only: send the tab straight to /fayda/callback with the + // sentinel code instead of round-tripping through eSignet — that page's + // existing completeIdentity()/navigate-back logic runs unchanged. + if (isBypassEnv()) { + stashPendingVerification({ + subject, + returnTo: + window.location.pathname + + window.location.search + + window.location.hash, + }); + window.location.assign( + `/fayda/callback?code=${DEV_BYPASS_FAYDA_CODE}&state=bypass`, + ); + return; + } + const authorizationUrl = await verifaydaService.start(); // Record who is being verified and where to come back to before the tab // leaves — /fayda/callback has no other way to know either. diff --git a/apps/edr-freight-web/portal/src/utils/dev-bypass.ts b/apps/edr-freight-web/portal/src/utils/dev-bypass.ts new file mode 100644 index 000000000..69eb23242 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/dev-bypass.ts @@ -0,0 +1,4 @@ +/** True when VITE_ENV is "dev" or "staging" — mirrors the API's ENV gate. */ +export function isBypassEnv(): boolean { + return ["dev", "staging"].includes(import.meta.env.VITE_ENV ?? ""); +} diff --git a/apps/edr-freight-web/portal/src/vite-env.d.ts b/apps/edr-freight-web/portal/src/vite-env.d.ts index a4955ecee..6a93b9e75 100644 --- a/apps/edr-freight-web/portal/src/vite-env.d.ts +++ b/apps/edr-freight-web/portal/src/vite-env.d.ts @@ -23,6 +23,8 @@ interface ImportMetaEnv { readonly VITE_POSTHOG_KEY?: string; /** Self-hosted PostHog instance URL. */ readonly VITE_POSTHOG_HOST?: string; + /** "dev" | "staging" — enables the OTP/payment/Fayda bypass. Unset in prod. */ + readonly VITE_ENV?: string; } interface ImportMeta {