How E-Earn works, and how to run it
This page is for developers and anyone curious about the project: what E-Earn is for, how it's built, how to extend it to a new platform, and how to operate it day to day. It doubles as the onboarding doc for whoever maintains this next.
Scope & problem
essentialNigeria runs 60+ separate platforms (eProperties, eHotels, eBudget, eShortlet, SpareRoom, and more). Each one was built by a different developer, at a different time, and each one grew its own referral/commission system — different database schemas, different auth mechanisms, different response shapes, different rules for how commission is earned and paid out.
E-Earn does not replace any of those systems. It is a thin layer on top: one login for an affiliate, one dashboard that shows their referral code, link, and earnings on every platform they're active on, pulled live from each platform's own API. Money movement, referral rules, and commission logic all stay exactly where they already live.
Out of scope for v1: onboarding all 60+ platforms (2 are wired up as a proof of the pattern — eProperties and eHotels), and handling withdrawals centrally (each platform keeps its own withdrawal flow; E-Earn just links out to it).
Key decisions
Affiliates connect by pasting their existing platform login
E-Earn logs into the platform's own /login endpoint on the affiliate's behalf and stores the resulting JWT (plus the credentials, encrypted) so it can silently re-authenticate when the token expires. This works today against all 60+ backends with zero code changes on their end — the alternative (each backend pushing events to E-Earn via webhook) would require touching every one of them.
Withdrawals stay on the platform
E-Earn shows the balance and links out to the platform's real withdrawal flow (e.g. eProperties' Paystack-backed admin-approved withdrawal). Centralizing payouts would mean E-Earn owns real money movement, reconciliation, and compliance across every platform — out of scope until the read-only aggregation is proven useful.
Next.js + TypeScript, not another Laravel app
E-Earn's actual job is defining and enforcing one clean contract across dozens of divergent APIs. TypeScript's type system is well suited to that (the PlatformConnector interface below), and Next.js server actions make a natural place to hold per-platform auth logic without exposing it to the browser.
Architecture
E-Earn never talks to a shared database. Every platform is reached over its own public HTTP API, through a small adapter ("connector") that knows that platform's login endpoint and dashboard endpoint shape.
Affiliate
Browser, one E-Earn login
E-Earn
Next.js server actions
eProperties API
Laravel · custom JWT
eHotels API
Laravel · tymon/jwt-auth
Platform #3…60
Not wired up yet
Tech stack
| Framework | Next.js 16 (App Router, Server Actions, Turbopack) |
| Language | TypeScript |
| Styling | Tailwind CSS v4 (CSS-first theme, no config file) |
| Database | SQLite via Prisma ORM (can be swapped for Postgres or any desired database in production if desires) |
| Hub auth | Custom — bcryptjs password hashing + jose-signed httpOnly session cookie (no NextAuth) |
| Credential encryption | AES-256-GCM (Node crypto), key from CREDENTIALS_ENCRYPTION_KEY |
Data model
Three tables, defined in prisma/schema.prisma:
- User — a hub account. Separate identity from any platform login.
- PlatformConnection — one row per (user, platform). Holds the AES-256-GCM encrypted login credentials, an encrypted cached JWT and its expiry, the affiliate's referral code/link on that platform, and a status (
connected/error). - DashboardSnapshot — the last successful, normalized dashboard fetch for a connection, so pages render instantly without a live round trip on every request.
Credentials are stored, not just the token, because these Laravel backends hand out JWTs with a short TTL (commonly ~60 minutes) and no refresh token. Re-authenticating silently is the only way to keep a dashboard in sync without asking the affiliate to log in again every hour.
The connector contract
Every platform-specific adapter implements the same interface (src/lib/connectors/types.ts), so nothing else in the app ever needs to know how any individual platform works:
export interface PlatformConnector {
key: string; // "eproperties"
name: string; // "eProperties"
description: string;
siteUrl: string;
withdrawUrl: string; // where the affiliate actually withdraws
currency: string;
authenticate(credentials: PlatformCredentials): Promise<PlatformAuthResult>;
getDashboard(token: string): Promise<NormalizedDashboard>;
}NormalizedDashboard is the common shape every connector maps its platform's response into: referral code/link, total/available/pending/ paid-out balances in minor currency units, and a short list of recent activity. The eProperties connector reads kobo integers directly; the eHotels connector converts naira decimals to kobo — that unit mismatch is exactly the kind of divergence this contract exists to absorb.
Request flows
Connect
Affiliate submits their platform email/password on /dashboard/platforms → connectPlatformAction calls the connector's authenticate() → on success, credentials and token are encrypted and upserted into PlatformConnection → the connector's getDashboard() is called immediately and cached into DashboardSnapshot.
View
Dashboard pages read straight from Prisma (getConnectionViewsForUser) — no live platform call on page load, so the UI is fast even if a platform is slow or down.
Refresh
Affiliate clicks "Refresh" → if the cached token still has more than 60 seconds of life, it's reused; otherwise the stored (decrypted) credentials are used to re-authenticate → either way, getDashboard() runs again and the snapshot is overwritten.
Disconnect
Deletes the PlatformConnection row (cascades to its snapshot). Nothing is sent to the platform itself — it never knew E-Earn existed.
What a new platform must provide
E-Earn never gets database or codebase access to any platform — it only ever calls that platform's own public HTTP API, the same way a browser would. So before a line of code is written in this repo for, say, eSchool or eShowa, that platform's own developer needs to hand over a small, fixed set of information. None of it requires them to build anything new if they already have a referral/commission system with a login endpoint — it's a documentation ask, not a dev ask.
| What to ask for | Why E-Earn needs it |
|---|---|
API base URL(s) | Local/.test, staging, and production hostnames for their API. Becomes an env var like ESCHOOL_API_URL. |
Login endpoint contract | Method, path, request body, and a full sample JSON response (redact real tokens) — specifically where the bearer token and its expiry live. |
Referral/commission dashboard endpoint(s) | Path(s), required auth header, and a full sample JSON response. Some platforms split this into two calls (eHotels returns the referral code from a separate endpoint than the earnings summary) — that's fine, just needs to be documented. |
Field mapping | Which field is the referral code/link, and which are total/available/pending/paid-out balances — plus whether amounts are minor units (kobo) or decimals (naira). eHotels returns naira; the connector converts it. |
Response envelope | Confirm it follows the ecosystem-wide { status, message, data } shape (see platformJsonRequest in src/lib/connectors/http.ts). If it doesn't, the connector will need custom unwrapping instead of using that helper. |
Public site URL + withdrawal URL | Where a non-affiliate signs up, and where a connected affiliate actually withdraws earnings on that platform — E-Earn only ever deep-links to these, it never handles money. |
Currency | ISO code (almost always NGN today) so the dashboard formats amounts correctly. |
One working staging login | A real affiliate email/password on their staging environment, so the connector can be built and verified against a live response before anyone connects a real account. |
The only hard requirement on their side is that login and dashboard data are reachable over JSON HTTP with a bearer token. Everything else (exact field names, units, whether it's one endpoint or three) is absorbed by the connector — see the eProperties/eHotels shapes below for how divergent two real examples already are.
POST /api/login
{ "email": "affiliate@example.com", "password": "..." }
-> 200
{
"status": "success",
"message": "Login successful",
"data": {
"authorization": { "token": "eyJ...", "type": "bearer", "expires_in": 3600 }
}
}GET /api/referral/dashboard
Authorization: Bearer eyJ...
-> 200
{
"status": "success",
"data": {
"referral_code": "ESC-4F21",
"referral_link": "https://eschool.ng/register?ref=ESC-4F21",
"summary": {
"total_referrals": 12,
"successful_referrals": 8,
"total_earned_kobo": 450000,
"available_kobo": 200000,
"pending_kobo": 100000,
"paid_out_kobo": 150000
},
"recent_commissions": [
{ "referred_name": "Jane D.", "plan_type": "Termly plan", "amount_kobo": 5000, "status": "paid", "earned_at": "2026-08-20T10:00:00Z" }
]
}
}Copy-paste this to send to the platform's dev team as-is:
Subject: E-Earn integration — info needed for <Platform>
To wire <Platform> into the E-Earn affiliate hub, could you send over:
1. API base URL — local/.test, staging, and production
2. Login endpoint: method, path, request body, and a full sample response JSON
(redact real tokens) — where is the bearer token and its expiry?
3. Referral/commission dashboard endpoint(s): path(s), required auth header,
and a full sample response JSON
4. Does the response follow { status, message, data }, or something else?
5. Public site URL (where a non-affiliate signs up)
6. Withdrawal page URL (where a connected affiliate cashes out)
7. Currency, and whether amounts are minor units (kobo) or decimal (naira)
8. One working staging login (email + password) we can build/test against
Nothing needs to change on your end beyond documenting what already exists —
E-Earn only calls your existing public API. iF you've further questions, kindly contact the developer, Adewale via +2348133169835Adding a platform
Once the platform's dev team has sent the details above, onboarding platform #3 (and eventually the rest of the 60+) on the E-Earn side is:
- Add its API URL(s) to
.envand.env.exampleas<KEY>_API_URL(and<KEY>_FRONTEND_URLif the public site/withdraw pages live on a different host than the API). - Create
src/lib/connectors/<key>.tsimplementingPlatformConnector, mapping its response intoNormalizedDashboard(template below). - Add it to
connectorRegistryinsrc/lib/connectors/registry.ts.
import {
ConnectorAuthError,
type NormalizedDashboard,
type PlatformAuthResult,
type PlatformConnector,
type PlatformCredentials,
} from "./types";
import { platformJsonRequest } from "./http";
const API_BASE = process.env.EXAMPLE_API_URL ?? "http://api-example.test/api";
interface LoginData {
authorization: { token: string; type: string; expires_in: number };
}
interface ExampleDashboardData {
referral_code: string;
referral_link: string;
summary: {
total_referrals: number;
successful_referrals: number;
total_earned_kobo: number;
available_kobo: number;
pending_kobo: number;
paid_out_kobo: number;
};
recent_commissions: Array<{
referred_name: string;
plan_type: string;
amount_kobo: number;
status: string;
earned_at: string;
}>;
}
export const exampleConnector: PlatformConnector = {
key: "example",
name: "Example",
description: "One-line description shown on the connect grid.",
siteUrl: "https://example.ng",
withdrawUrl: "https://example.ng/dashboard/referral/withdraw",
currency: "NGN",
async authenticate(credentials: PlatformCredentials): Promise<PlatformAuthResult> {
try {
const data = await platformJsonRequest<LoginData>(`${API_BASE}/login`, {
method: "POST",
body: JSON.stringify(credentials),
});
return {
token: data.authorization.token,
expiresAt: new Date(Date.now() + data.authorization.expires_in * 1000),
};
} catch (err) {
throw new ConnectorAuthError(
err instanceof Error ? err.message : "Invalid Example credentials.",
);
}
},
async getDashboard(token: string): Promise<NormalizedDashboard> {
const data = await platformJsonRequest<ExampleDashboardData>(
`${API_BASE}/referral/dashboard`,
{ headers: { Authorization: `Bearer ${token}` } },
);
return {
referralCode: data.referral_code,
referralLink: data.referral_link,
totalReferrals: data.summary.total_referrals,
successfulReferrals: data.summary.successful_referrals,
totalEarnedMinor: data.summary.total_earned_kobo,
availableMinor: data.summary.available_kobo,
pendingMinor: data.summary.pending_kobo,
paidOutMinor: data.summary.paid_out_kobo,
currency: "NGN",
recentActivity: (data.recent_commissions ?? []).map((c) => ({
label: `${c.referred_name} · ${c.plan_type}`,
amountMinor: c.amount_kobo,
status: c.status,
occurredAt: c.earned_at,
})),
};
},
};export const connectorRegistry: PlatformConnector[] = [
epropertiesConnector,
ehotelsConnector,
// exampleConnector,
];Nothing else changes — the connect form, dashboard, and detail page are all driven off the registry automatically. If the platform's response envelope doesn't match { status, message, data }, skip platformJsonRequest and write a small custom fetch inside that one connector instead — nothing else in the app depends on how a connector talks to its platform internally.
Environment variables
| Variable | Purpose |
|---|---|
DATABASE_URL | Prisma datasource. file:./dev.db locally; a Postgres URL in production. |
SESSION_SECRET | Signs the hub's httpOnly session cookie (jose/HS256). Rotate = every session invalidated. |
CREDENTIALS_ENCRYPTION_KEY | Derives the AES-256-GCM key for stored platform credentials/tokens. Rotate = every stored connection breaks and must be reconnected. |
EPROPERTIES_API_URL | Base API URL for the eProperties connector. Defaults to the local .test host. |
EHOTELS_API_URL | Base API URL for the eHotels connector (hotel-mgt-backend). |
EHOTELS_FRONTEND_URL | Public eHotels site — used for the withdraw link and default referral link host. |
All of these live in .env (gitignored). See .env.example for a template. New platforms follow the same convention: <KEY>_API_URL (required) and <KEY>_FRONTEND_URL (only if the site/withdraw pages live on a separate host from the API) — add both to .env and .env.example when wiring one up.
Running it locally
npm install
cp .env.example .env # fill in real secrets
npx prisma migrate dev # create/update the SQLite schema
npm run dev # http://localhost:3000 (or next free port)| npm run dev | Start the dev server (Turbopack) |
| npm run build / start | Production build and start |
| npm run lint | ESLint |
| npx tsc --noEmit | Type-check without emitting |
| npx prisma studio | Browse/edit the local database in a GUI |
| npx prisma migrate dev --name X | Create a new migration after editing schema.prisma |
Security notes
- Platform passwords are never logged and never rendered back to the client — they're encrypted immediately and only decrypted server-side, in memory, to make an outbound authenticate() call.
- Session cookies are httpOnly, sameSite=lax, and secure in production — not readable from client JS.
SESSION_SECRETandCREDENTIALS_ENCRYPTION_KEYin this repo are development placeholders. Generate strong random values (e.g.openssl rand -base64 32) before any real deployment, and keep them out of git.- Because credentials are stored (encrypted) rather than only a token, treat the database file itself as sensitive — back it up carefully, and encrypt it at rest if hosted.
Limitations & roadmap
- Only 2 of 60+ platforms are wired up; the rest need connectors written per the guide above.
- Dashboard data is only as fresh as the last manual refresh or connect — there's no background sync job yet.
- No password reset flow for the hub account yet.
- No admin view across all affiliates — each affiliate only sees their own data.
- Withdrawals are entirely deep-linked out; no in-app payout history beyond what each platform already tracks.
Project structure
src/
app/
page.tsx # landing page
docs/page.tsx # this page
login/, register/ # hub auth pages
dashboard/
layout.tsx # auth-gated shell
page.tsx # aggregated overview
platforms/page.tsx # connect/manage grid
platforms/[platform]/ # per-platform detail
actions/
auth.ts # register/login/logout server actions
platforms.ts # connect/refresh/disconnect server actions
components/ # shared UI (forms, buttons, badges, docs/*)
lib/
connectors/
types.ts # the PlatformConnector contract
eproperties.ts, ehotels.ts # per-platform adapters
registry.ts # the list every page reads from
auth.ts, session.ts # hub identity + cookie session
crypto.ts # AES-256-GCM helpers
db.ts # Prisma client singleton
connections.ts # merges registry + DB rows into view models
prisma/schema.prisma # User / PlatformConnection / DashboardSnapshot