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.comis the company site, andbuild.ndakita.comserves the/buildroute tree with the prefix stripped, sobuild.ndakita.com/demorenders/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¶
- A visitor completes the sign-up form on the marketing website.
POST /public/signupcreates a tenant record with statusawaiting_verificationand emails a single-use confirmation link. No ERP site is built at this point, so an unverified request cannot trigger provisioning.- The visitor opens the link, which calls
POST /public/verify-email. On a valid, unexpired token the tenant moves toprovisioningand provisioning starts asynchronously so the request returns immediately. - 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. - The verification page then polls the status endpoint until
trialand presents the tenant'shttps://{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.). - 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.