Applications
The monorepo contains separate runtime surfaces for API, operations, customer web, public landing, mobile, and docs.
| Workspace | Package | Stack | Local URL | Role |
|---|---|---|---|---|
apps/api | @activation-sys/api | Hono + TypeScript on Node.js | http://localhost:3001 | REST 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/admin | Next.js 16 | http://localhost:3000 | Admin operations and B2B company portal (quotes for prepaid; /b2b/catalog + /b2b/picks + /b2b/funding for flexible pay) |
apps/web-b2c | @activation-sys/web-b2c | Next.js 16 | http://localhost:3002 | Customer-facing browser activation flow |
apps/site | @activation-sys/site | Vite + React | http://localhost:3003 | Public landing page and product entry point |
apps/mobile | Flutter app | Flutter | N/A until Dart sources are added | Primary native B2C mobile experience |
docs | @activation-sys/docs | VitePress | VitePress dev URL | Internal 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 sayroster. 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'sactivatedflag before its link status: an activated person shows an "Activated" chip, and everyone else keeps the link-status label, whereusedreads "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 answersLINK_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 whoseneeds.fundingSaris 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 asrejectedorexpiredthe page offers a rebuild CTA rather than dead-ending: "Build a new quote" to/b2b/quotes/new, or "Declare a new amount" to/b2b/fundingfor 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 inlocalStorageper 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 theb2barea, 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/syncfor super admins. Store providers are operators rows withservice_type = 'esim'.GET /admin/store/providersreturns{ data: [{ id, slug, nameEn, nameAr, isNameArFallback, isActive, paymentMode, health }] }(health is the provider's latest reading,nullfor unregistered rows), andPATCH /admin/store/providers/:idis the audited enable/disable toggle (store.provider_toggled, eSIM rows only,404 STORE_PROVIDER_NOT_FOUNDotherwise) 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 }, includeisActiveCustomized, and page byretailUsd|productId. Admin order lists use the same envelope, are newest-first on(createdAt, orderId), expose owneruserIdplus fulfillmentfailedReason/issuedAt/issuanceState/externalOrderId, and acceptstuck=trueto 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/requeuere-enqueues a stuck fulfillment job, audited asstore.order_requeued, with409 STORE_ORDER_NOT_REQUEUEABLEwhen the order is not in the stuck state), view the live FX rates fetched from the provider (GET /admin/settings/fx-ratesreturns{ 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 returns202when queued and503 STORE_SYNC_UNAVAILABLEwhen 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.yamlincludesapps/web-b2candapps/siteso pnpm resolves them as first-class workspaces.turbo.jsonbuild 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, andpnpm build:site. pnpm devstill runs every workspacedevtask through Turbo, so fixed ports prevent app collisions.
Local port map
| Port | Surface |
|---|---|
3000 | Admin/B2B Next.js app |
3001 | API |
3002 | B2C web Next.js app |
3003 | Landing site Vite app |
Environment keys
Frontend public configuration is documented in .env.example:
NEXT_PUBLIC_API_BASE_URLfor Next.js browser-facing API calls.NEXT_PUBLIC_SITE_URLfor links from Next.js apps back to the public site.VITE_API_BASE_URLfor Vite browser-facing API calls.VITE_B2C_WEB_URLfor the landing site CTA into the B2C web app.VITE_SITE_URLfor 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 whenFEATURE_ESIMis off (ACTIVATION_ESIM_DISABLED) or when the resolved operator plugin did not explicitly declaresupportsEsim: 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 ascreatedAt|orderId. Admin order list paging follows the samecreatedAt|orderIdkeyset and surfaces the stuck filter via?stuck=true(status = paid AND issued_at IS NULL); admin order rows surfaceuserIdandfailedReasonfor triage. - Device-compatibility advisory list —
GET /store/device-compatibilityserves 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 thecurrencyfield while preserving theamountSarwire 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/adminunless a future split is explicitly planned.