Skip to content

Architecture

Ndakita Build is a whitelabeled layer on ERPNext v15, delivered as a multi-tenant service. Each tenant is a construction small or medium enterprise running several concurrent projects.

Components

                 ┌──────────────────────────┐
  Browser  ───▶  │  ndakita_group_site       │   Marketing website and sign-up
                 │  (public web application)  │
                 └────────────┬─────────────┘
                              │  HTTPS, public endpoints (no secret)
                              ▼
                 ┌──────────────────────────┐
                 │  ndakita_build_platform   │   Provisioning, lifecycle, billing
                 │  (platform service)        │   Metadata database only
                 └────────────┬─────────────┘
                              │  bench CLI (subprocess)      │  Frappe REST (per tenant)
                              ▼                              ▼
                 ┌──────────────────────────────────────────────────┐
                 │  Frappe Bench (one site + dedicated database each) │
                 │  {slug}.<base-domain>   for every tenant           │
                 │  each site runs the ndakita_build app + ERPNext    │
                 └──────────────────────────────────────────────────┘
  • ndakita_group_site — the public website and the sign-up form. It calls only the platform's public endpoints and holds no secret. One deployment serves two hostnames, mapped by src/middleware.ts: ndakita.com is the company site, and build.ndakita.com serves the /build route tree with the prefix stripped, so build.ndakita.com/demo renders /build/demo. Requests to /build/* on the company host redirect across. Tenant ERPs sit one label further down at {slug}.build.ndakita.com, so a customer stays on one brand from the first marketing page to their live instance.

Because one deployment answers on two hostnames, every page declares its own canonical and og:url against the host that actually serves it (companyCanonical() / buildCanonical() in src/lib/urls.ts, and socialMeta() in src/lib/metadata.ts). A canonical set on the root layout is inherited by every route and tells crawlers each page duplicates the home page, and a relative one resolves against metadataBase, which always names the company host, so Build pages would canonical to ndakita.com/build/* — a 308 redirect rather than the page. For the same reason, links from the Build tree to company pages must go through companyUrl(): a bare /erp/comparison is rewritten into the /build tree on the Build host and 404s. Next shallow-merges metadata, so a page-level openGraph replaces the root object wholesale rather than merging into it; socialMeta() exists to rebuild the whole thing.

The lead funnel is home → /build → book a demo: the home hero and a "Ndakita Build" teaser section both point at /build, and both pages embed an animated product demo (src/components/build/ProductAnimation.tsx) that cycles the four headline features. Analytics is Vercel Web Analytics, enabled per project in the Vercel dashboard and mounted by <Analytics /> in app/layout.tsx, with no environment variable. It is chosen for being cookieless: the privacy policy states we set no cookies and that this is why there is no consent banner, so a cookie-based product such as GA4 would make that false and require consent UI. Custom events via trackEvent() need the Pro plan; page views do not. Demo data in the animation uses fictional supplier names only. All lead capture (contact, demo requests, early access, and the assessment fallback) goes through Formspree forms defined in formspree.json — the ndakita.com mailboxes are not wired up, so the site must never link to mailto:. Submissions are proxied through /api/lead, which verifies an optional Cloudflare Turnstile token server-side (protecting the Formspree quota from bots) and keeps the project ID out of the client bundle. Site copy never uses em or en dashes. The site is hosted on Vercel. - ndakita_build_platform — orchestrates the system: creates tenant sites, tracks lifecycle and billing, and serves payment webhooks. Its database stores only tenant metadata, never ERP business data. - ndakita_build — the construction ERP itself (DocTypes, custom fields, reports, labour and payment workflows). Installed on every tenant site.

Tenancy model

Each tenant is provisioned as a dedicated Frappe site with its own database. This provides database-level isolation rather than row-level filtering, so no query can span tenants. Tenants are not represented as companies inside a shared site.

Branding follows a product-brand model: every tenant's ERP interface displays the Ndakita Build identity (login page, application header, favicon, loading screen). A tenant's own logo appears only on generated documents. Branding is applied automatically during provisioning.

Platform API surface

Endpoint Authentication Purpose
POST /public/signup none (rate limited) Create a tenant record and email a verification link
POST /public/verify-email none (rate limited) Confirm the email and release provisioning
POST /public/resend-verification none (rate limited) Re-send the verification link
GET /public/slug-availability none (rate limited) Live availability check in the sign-up form
GET /public/tenants/{id}/status none Poll provisioning status; reveals the site URL when ready
GET/POST /tenants/* API key Administrative lifecycle: list, suspend, restore, reprovision
POST /webhooks/{mpesa,stripe} signature Payment callbacks

Sign-up to login flow

  1. A visitor completes the sign-up form on the marketing website.
  2. POST /public/signup creates a tenant record with status awaiting_verification and emails a single-use confirmation link. No ERP site is built at this point, so an unverified request cannot trigger provisioning.
  3. The visitor opens the link, which calls POST /public/verify-email. On a valid, unexpired token the tenant moves to provisioning and provisioning starts asynchronously so the request returns immediately.
  4. Provisioning runs the standard sequence: create the site, install ERPNext, the Ndakita Build application, and the whitelabel layer; run the setup wizard with the correct localisation; migrate (which seeds construction defaults); verify the installation; and configure web routing. On success the tenant moves to trial.
  5. The verification page then polls the status endpoint until trial and presents the tenant's https://{slug}.<base-domain> address. While provisioning, the status endpoint reports the current step, so the page shows a live progress checklist rather than an indefinite spinner — the internal steps are mapped to friendly, stack-free labels (the customer never sees "ERPNext", "SSL", etc.).
  6. The tenant opens the address and signs in as their administrator.

Tenant lifecycle

awaiting_verification → provisioning → trial → pending → active → grace → suspended → cancelled

A separate provisioning_failed state captures build failures. The states are defined in the platform models and mirrored in the marketing site's types. Suspension blocks login while preserving data; cancellation exports data before removing the site.

Pricing is a single managed plan billed on active projects: KES 50,000/month covers up to five active projects, and each additional project adds KES 6,000/month. A separate usage sweep reads each tenant's active-project count from its ERP site and stores it on the tenant, so the recurring amount tracks real usage without a live call at charge time. Onboarding (migration, setup, training) is a one-time fee quoted per client, separate from this amount.

A scheduled billing sweep advances the unpaid path automatically: it emails trial-expiry reminders, moves an expired trial to pending with a pay-by deadline, drops a lapsed renewal to grace, dispatches daily dunning, and suspends once the deadline passes. It never cancels — after a long suspension it only flags the tenant for a human export/cancel decision, because cancellation deletes data.

Payment runs over M-Pesa (Safaricom Daraja) for mobile money and Paystack for cards. When a tenant first becomes unpaid, the sweep sends an M-Pesa STK Push (a PIN prompt) for the plan price; Safaricom posts the result to a signed-boundary webhook, which confirms the amount and activates the subscription — restoring access if the site was suspended and resetting the billing clock. Card payers instead go through Paystack's hosted checkout, whose charge.success webhook (HMAC-verified, then re-verified via Paystack's API) reaches the same activation. The platform never trusts an unsolicited callback: it acts only on a payment it initiated. Stripe is not used — it isn't available to Kenyan businesses for payouts. Production Go-Live for both providers is the remaining piece.

Asynchronous execution

Provisioning and the scheduled tenant backup run outside the request path. A single-node deployment can run provisioning in a background thread; production routes both to Celery workers backed by Redis, so work survives an API restart and scales across workers. The same Redis instance backs the shared rate-limit store, the backup schedule, and the billing sweep. These are configuration switches: the default is the single-node path, so development needs no broker — the sweeps can also be run on demand from the CLI.

Design principles

  • ERPNext and Frappe core are never modified; the system extends them through hooks, fixtures, and overrides.
  • The platform never accesses tenant databases directly; it uses the bench CLI for provisioning and the Frappe REST API for tenant data.
  • Provisioning is asynchronous and idempotent; a failed tenant can be re-provisioned safely.
  • Tenant-facing interfaces contain no references to the underlying frameworks.