Skip to content

Applications

The monorepo contains separate runtime surfaces for API, operations, customer web, public landing, mobile, and docs.

WorkspacePackageStackLocal URLRole
apps/api@activation-sys/apiHono + TypeScript on Node.jshttp://localhost:3001REST API, OpenAPI/Scalar docs, auth (OTP, magic links), activation, global eSIM store, payment, B2B companies/quotes/contracts, corporate billing, PDF documents, platform settings, compliance, webhooks
apps/admin@activation-sys/adminNext.js 16http://localhost:3000Admin operations and B2B company portal (quotes for prepaid; /b2b/catalog + /b2b/picks + /b2b/funding for flexible pay)
apps/web-b2c@activation-sys/web-b2cNext.js 16http://localhost:3002Customer-facing browser activation flow
apps/site@activation-sys/siteVite + Reacthttp://localhost:3003Public landing page and product entry point
apps/mobileFlutter appFlutterN/A until Dart sources are addedPrimary native B2C mobile experience
docs@activation-sys/docsVitePressVitePress dev URLInternal developer documentation

B2B portal screens

The company portal lives under apps/admin/src/app/[locale]/(portal)/b2b. Sidebar order is Dashboard, then Quotes (prepaid) or Catalog + Selected packages (flexible), then Beneficiaries, Contract, Billing, and Funding (flexible).

  • Beneficiaries (/b2b/roster) — the roster screen. "Beneficiaries" is the user-facing name in both languages. The route, the API and the database still say roster. The table carries a Package column (the assigned package's name, an em dash when unassigned, a muted "unavailable" when the package was delisted) and a checkbox column. The link-status chip reads the entry's activated flag before its link status: an activated person shows an "Activated" chip, and everyone else keeps the link-status label, where used reads "Link opened" because a spent token says nothing about whether the person finished. The filter tabs keep the broader "Used" wording, since they filter on link status and both cases belong under it. In the per-person sheet an activated beneficiary is offered no send at all (the API answers LINK_USER_ALREADY_ACTIVE) and the lock note says the SIM is live; for everyone else a resend confirms first, because minting kills the link already delivered. Selecting rows opens a bulk bar with Assign package, Send links, Export links, Revoke and Remove. Select-all spans the whole current filter, not just the visible page, so a selection can exceed the batch routes' 500-id cap. Send, export and revoke call the batch routes in sequential 500-id chunks and sum the per-chunk results into one summary. Assign and remove loop the single-entry routes with progress and a per-reason failure summary.
  • Quote detail (/b2b/quotes/[id]) — the version being negotiated, its items, its chain and the reviewer's note. An admin-authored counter still under review offers both answers side by side, Accept and Decline, each behind a confirm dialog; Decline takes an optional reason, which renders back on the page as "Your reason" and is held apart from the reviewer's note. A funding request — a version whose needs.fundingSar is set — shows the declared amount above the items, since a counter adds line items to a funding version rather than replacing it. Once the chain closes as rejected or expired the page offers a rebuild CTA rather than dead-ending: "Build a new quote" to /b2b/quotes/new, or "Declare a new amount" to /b2b/funding for a funding chain, which has no line items to rebuild.
  • Funding (/b2b/funding) — flexible mode only. Declare or top up the funding amount; the transfer itself stays offline. The pending card and every history row navigate to that request's quote detail, which is where a countered funding request gets answered.
  • Selected packages (/b2b/picks) — flexible mode only, with a nav count badge. Leads with what the company has actually assigned: the roster's package assignments, aggregated server-side per package from the same roster sweep the Beneficiaries screen reads, so every admin of the company sees the same numbers. Below it sits a clearly separated "planned on this device" cart held in localStorage per company, which the catalog's Continue writes and the bulk assign flow consumes. The badge counts the planned picks. The empty state shows only when both sections are empty.

Internal admin screens

  • Companies (admin/companies/[companyId]) — a full company detail page for internal admins with the b2b area, replacing the earlier row-click side sheet. The header carries lifecycle actions beside a contract quota meter, with company-profile and payment rail sections below.
  • eSIM store administration — the API exposes /admin/store/products, /admin/store/orders, /admin/store/orders/:id, /admin/store/orders/:id/requeue, /admin/store/providers, /admin/settings/fx-rates, and /admin/store/sync for super admins. Store providers are operators rows with service_type = 'esim'. GET /admin/store/providers returns { data: [{ id, slug, nameEn, nameAr, isNameArFallback, isActive, paymentMode, health }] } (health is the provider's latest reading, null for unregistered rows), and PATCH /admin/store/providers/:id is the audited enable/disable toggle (store.provider_toggled, eSIM rows only, 404 STORE_PROVIDER_NOT_FOUND otherwise) whose effect reaches the destinations aggregate, browse, checkout, and payment initiation in the same request. Admin product lists use the house pagination envelope { data, total, nextCursor, hasMore }, include isActiveCustomized, and page by retailUsd|productId. Admin order lists use the same envelope, are newest-first on (createdAt, orderId), expose owner userId plus fulfillment failedReason/issuedAt/issuanceState/externalOrderId, and accept stuck=true to filter to paid orders that have never been issued. The web admin UI can use these routes to review provider catalog rows, manage public store overrides, triage paid-but-unissued orders (POST /admin/store/orders/:id/requeue re-enqueues a stuck fulfillment job, audited as store.order_requeued, with 409 STORE_ORDER_NOT_REQUEUEABLE when the order is not in the stuck state), view the live FX rates fetched from the provider (GET /admin/settings/fx-rates returns { base, rates: [{currency, ratePerUsd, fetchedAt, source}] }), and trigger catalog syncs. The companion API reference pages record the Connect-Day Register and Currency & Market model. Manual sync returns 202 when queued and 503 STORE_SYNC_UNAVAILABLE when Redis is unavailable.

Catalog Sync Engine — a shared policy engine (catalog-sync-engine.ts) drives both the eSIM product sync (sync-esim-products.ts — queue esim-product-sync) and the local package sync (sync-packages.ts — queue package-sync). Each lane implements a thin adapter around the engine with identical policy behavior: per-item validation (zod) with invalid counts, empty-catalog guard, zero-usable-key guard, customization-preserving upserts (CASE WHEN is_*_customized THEN existing ELSE source END), catalog-absence deactivation, per-owner error containment (one operator/provider failing does not abort the rest), and per-owner DB transactions. The local lane gained is_active_customized with the same CASE WHEN semantics as the store lane, preventing the resurrection hazard where a sync silently reactivates an admin-deactivated package. The engine itself imports no domain tables, plugin managers, BullMQ, or env to remain policy-only. The sync adapters refuse slug collisions across service types at runtime. The store lane also persists each item's opaque providerPayload for issuance-time use, requires explicit country coverage on global items (global is never worldwide), and refreshes provider health readings after every run — the data behind the admin providers list and the checkout circuit breaker (422 STORE_PROVIDER_UNAVAILABLE). Store fulfillment runs a provider-agnostic issuance state machine (issuance_state with in-flight stamping, classified provider errors, reconciliation instead of blind re-issue, and async KYC verification states with a customer verificationUrl). eSIM provider registration is configuration-based via ESIM_STORE_PROVIDERS.

Turbo and workspace mapping

  • pnpm-workspace.yaml includes apps/web-b2c and apps/site so pnpm resolves them as first-class workspaces.
  • turbo.json build outputs include .next/**, dist/**, and .vitepress/dist/** for Next.js, Vite, and docs builds.
  • Root scripts expose filtered commands: pnpm dev:web-b2c, pnpm dev:site, pnpm build:web-b2c, and pnpm build:site.
  • pnpm dev still runs every workspace dev task through Turbo, so fixed ports prevent app collisions.

Local port map

PortSurface
3000Admin/B2B Next.js app
3001API
3002B2C web Next.js app
3003Landing site Vite app

Environment keys

Frontend public configuration is documented in .env.example:

  • NEXT_PUBLIC_API_BASE_URL for Next.js browser-facing API calls.
  • NEXT_PUBLIC_SITE_URL for links from Next.js apps back to the public site.
  • VITE_API_BASE_URL for Vite browser-facing API calls.
  • VITE_B2C_WEB_URL for the landing site CTA into the B2C web app.
  • VITE_SITE_URL for deployment-specific landing links.

The API CORS allowlist should include the deployed origins for apps/admin, apps/web-b2c, and apps/site when those surfaces call the API directly.

Product boundaries

  • B2C activation logic remains server-owned through apps/api; clients must not submit trusted ownership fields.
  • eSIM activation requests must send simType: "esim"; the API rejects eSIM attempts when FEATURE_ESIM is off (ACTIVATION_ESIM_DISABLED) or when the resolved operator plugin did not explicitly declare supportsEsim: true (ACTIVATION_ESIM_UNSUPPORTED_OPERATOR).
  • Public eSIM store paginated product browse uses { data, total, nextCursor, hasMore, currency }; the destinations aggregate (GET /store/destinations) is intentionally unpaginated { data, currency } (bounded by distinct destinations); owner/admin store lists use { data, total, nextCursor, hasMore }. Store-order cursors preserve full database timestamptz precision as createdAt|orderId. Admin order list paging follows the same createdAt|orderId keyset and surfaces the stuck filter via ?stuck=true (status = paid AND issued_at IS NULL); admin order rows surface userId and failedReason for triage.
  • Device-compatibility advisory listGET /store/device-compatibility serves a versioned hard-coded constant of eSIM-capable phone models (public, ungated by the store flag so any surface can use it; matching is client-side, exact beats prefix, unknown devices warn and never block).
  • Store lane charges are in USD. The local Saudi activation lane continues to charge in SAR. Store order payments (payments.currency = 'USD') and charges (chargeAmount.currency = 'USD') are always USD-denominated. Receipt responses carry the payment's actual currency in the currency field while preserving the amountSar wire alias for mobile compatibility (the field name is historical — its value is the payment amount in the payment's currency, which may be SAR or USD).
  • User-visible B2C and site copy must keep Arabic and English parity.
  • Security-sensitive operations, payment side effects, identity verification, eSIM store fulfillment, and activation state transitions stay in API/services/queues.
  • Admin and B2B workflows continue to live in apps/admin unless a future split is explicitly planned.

Internal documentation - Activation System