EE-Earn
Documentation

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

session cookie

E-Earn

Next.js server actions

PlatformConnector

eProperties API

Laravel · custom JWT

eHotels API

Laravel · tymon/jwt-auth

Platform #3…60

Not wired up yet

Encrypted credentials + Prisma/SQLitesit between E-Earn and every platform — nothing is shared between platforms

Tech stack

FrameworkNext.js 16 (App Router, Server Actions, Turbopack)
LanguageTypeScript
StylingTailwind CSS v4 (CSS-first theme, no config file)
DatabaseSQLite via Prisma ORM (can be swapped for Postgres or any desired database in production if desires)
Hub authCustom — bcryptjs password hashing + jose-signed httpOnly session cookie (no NextAuth)
Credential encryptionAES-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:

src/lib/connectors/types.ts
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

1

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.

2

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.

3

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.

4

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 forWhy 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 contractMethod, 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 mappingWhich 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 envelopeConfirm 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 URLWhere 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.
CurrencyISO code (almost always NGN today) so the dashboard formats amounts correctly.
One working staging loginA 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.

example: login
POST /api/login
{ "email": "affiliate@example.com", "password": "..." }

-> 200
{
  "status": "success",
  "message": "Login successful",
  "data": {
    "authorization": { "token": "eyJ...", "type": "bearer", "expires_in": 3600 }
  }
}
example: referral dashboard
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:

message to send
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 +2348133169835

Adding 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:

  1. Add its API URL(s) to .env and .env.example as <KEY>_API_URL (and <KEY>_FRONTEND_URL if the public site/withdraw pages live on a different host than the API).
  2. Create src/lib/connectors/<key>.ts implementing PlatformConnector, mapping its response into NormalizedDashboard (template below).
  3. Add it to connectorRegistry in src/lib/connectors/registry.ts.
src/lib/connectors/<key>.ts (template)
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,
      })),
    };
  },
};
src/lib/connectors/registry.ts
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

VariablePurpose
DATABASE_URLPrisma datasource. file:./dev.db locally; a Postgres URL in production.
SESSION_SECRETSigns the hub's httpOnly session cookie (jose/HS256). Rotate = every session invalidated.
CREDENTIALS_ENCRYPTION_KEYDerives the AES-256-GCM key for stored platform credentials/tokens. Rotate = every stored connection breaks and must be reconnected.
EPROPERTIES_API_URLBase API URL for the eProperties connector. Defaults to the local .test host.
EHOTELS_API_URLBase API URL for the eHotels connector (hotel-mgt-backend).
EHOTELS_FRONTEND_URLPublic 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

setup
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 devStart the dev server (Turbopack)
npm run build / startProduction build and start
npm run lintESLint
npx tsc --noEmitType-check without emitting
npx prisma studioBrowse/edit the local database in a GUI
npx prisma migrate dev --name XCreate 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_SECRET and CREDENTIALS_ENCRYPTION_KEY in 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
Questions or want to wire up the next platform? Start from the Adding a platform section above — it's the whole job in three steps. Contact the developer, Adewale on whatsapp via +2348133169835 for issues that couldn't be resolved. Thanks