Two rules everything else depends on
Adding payments to a training product creates two ways to ruin it. Both are avoidable, and both have to be decided now rather than patched later.
Money unlocks content. Accuracy unlocks tiers. A subscription buys access to Tiers 2–5. The 70% accuracy gate between tiers is not for sale, at any price, to anyone. A course you can buy your way through is worth nothing to the trainee or to whoever hires them.
The server is authoritative — now for money too. Scoring already lives server-side because trainees read your JavaScript. Prices, entitlement, and credit balances join it, because the client now has a financial reason to lie.
Practically, rule two means three things. Checkout sends a plan_code, never an
amount — the price is looked up server-side. Nothing is granted when the browser returns from
Paystack; the webhook is the only path that grants access. And every credit movement is an
append-only ledger row, with the balance treated as a cache of it.
Subscriptions and credits, doing different jobs
Two currencies only work if they never overlap. The subscription is a door; credits are a vending machine.
| Subscription | Credits | |
|---|---|---|
| Buys | Access to content (tiers) | Consumables and conveniences |
| Renews | Yes, via Paystack Plans | No — spend-down balance |
| Runs out | At period end | When the balance hits zero |
| Unlocks a tier? | Yes — the payment axis | Only a single-scenario pass |
| Skips the 70% gate? | Never | Never |
| Reveals an answer? | No | No |
Why credits exist at all
A wallet is only worth its complexity if it pays for something that genuinely costs you money or protects a friction you designed on purpose. Yours has four real jobs.
- Instructor review — the real one. The task ladder already says Tier 4–5 written outputs should be graded against a rubric with borderline submissions flagged for a human. That review is a person reading an escalation note. It is the only part of FixIT with a genuine cost per use, and exactly what a credit system is for.
- Protecting designed friction. The 30-minute retry cooldown is pedagogical. Removing it free would undermine the learning; removing it for credits makes the impatient trainee pay for their impatience and leaves the default intact.
- Giving free users one thing to buy. Someone who finished Tier 1 but is not ready to commit to a month can spend 5 credits on a single Tier 2 fault. A far better conversion ramp than a hard wall — and it tells you which faults actually sell.
- Making the subscription obviously better. Every plan grants a monthly credit allowance. Annual covers a certificate.
Credits must never buy: hints, root causes, or accepted fixes · score, badges, or leaderboard position · tier progression · certificate eligibility. Credits pay for a certificate's issuance, never for qualifying for one.
What credits cost
| Action | What it does | Credits | Who |
|---|---|---|---|
| instructor_review | Human review and written feedback on a Tier 4/5 submission, within 48h | 15 | Subscribers |
| certificate | Generate and issue the completion certificate | 25 | Completers |
| performance_report | Per-theme accuracy, command-efficiency trend, weak areas | 20 | All |
| scenario_pass | 24-hour unlock for one locked scenario | 5 | Free tier |
| instant_retry | Skip the 30-minute cooldown after a failed fault | 3 | All |
| extra_attempt | One attempt beyond the daily cap | 2 | All |
| hint_pack | Hints on a fault — subscribers take the score penalty and pay nothing | 1 | Free tier |
That last row matters. Hints already cost score. Charging subscribers credits as well is a double penalty and reads as gouging. All of these are editable from the admin console without a deploy.
Plans
These figures are placeholders to react to. I do not know your market, your competitors, or what your existing ADEBLED students already pay. The structure I stand behind; the numbers need your judgement.
| Plan | Price | Ceiling | Credits | Notes |
|---|---|---|---|---|
| Free | ₦0 | Tier 1 — 6 faults | 3 once | No card. Full engine, full realism. |
| Monthly | ₦6,500 | Tier 5 — all 30 | 20 / mo | The anchor; the curriculum is 30 days. |
| Quarterly | ₦16,500 | Tier 5 | 70 | ~15% off. The realistic completion window. |
| Annual | ₦48,000 | Tier 5 | 300 | ~38% off. Certificate included. |
The free tier has to be genuinely good — all six Tier 1 faults, the real terminal, byte-accurate output, streamed timings, Services console, Task Manager. A crippled demo converts nobody. Tier 1 ends at exactly the moment the product gets interesting, when distractors and wrong paths arrive in Tier 2, and that is the honest place to ask for money.
Granted credits expire at period end; purchased credits never do. Easy to explain, and it keeps your outstanding liability bounded.
Paystack
Four flows, one file that must be right, and two prerequisites with lead time you should start this week.
Live keys need a verified Nigerian business: CAC registration, a business bank account in the business name, director ID, BVN. Approval takes days. You can build and fully test everything on test keys meanwhile — but you cannot take a naira until it clears.
Confirm your host allows outbound HTTPS from PHP. Some shared cPanel plans block it by default, which silently breaks every Paystack call. It is a ten-minute check that can cost you a week if you leave it to the end.
The four flows
| Flow | Shape | The catch |
|---|---|---|
| A · First subscription | Initialize → Paystack-hosted card page → callback → webhook grants | You never see card data, which is what keeps you out of PCI scope |
| B · Renewal | Paystack charges the saved card on schedule | No user, no callback. Grant on callback only and every renewal after the first silently fails |
| C · Credit top-up | Same as A minus the plan parameter | Webhook writes a ledger row, not a subscription |
| D · Cancellation | Set cancel-at-period-end, let access run out | Cutting someone off mid-period for cancelling earns you a chargeback |
The webhook is the whole integration
billing/webhook.php is publicly reachable, unauthenticated, and grants access.
It is the most security-sensitive file in the application.
// NO session_start(). NO CSRF check. NO auth guard. Paystack is not a browser. $raw = file_get_contents('php://input'); // 1. Verify the signature BEFORE parsing anything. On cPanel read the header // from $_SERVER — apache_request_headers() is unreliable under PHP-FPM. $sig = $_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] ?? ''; if (!hash_equals(hash_hmac('sha512', $raw, $secret), $sig)) { http_response_code(401); exit; // log it, then stop. Never process. } $event = json_decode($raw, true); // 2. Idempotency. Paystack WILL deliver the same event more than once — // that is normal behaviour, not an error. UNIQUE(provider,event_id) // makes the second insert fail harmlessly. if (!webhook_claim('paystack', $event['data']['id'], $event)) { http_response_code(200); exit; // already handled } // 3. Dispatch inside a DB transaction: grant, ledger, and event-completion // all commit together or not at all. switch ($event['event']) { case 'charge.success': handle_charge_success(...); break; case 'subscription.create': handle_sub_create(...); break; case 'subscription.disable': handle_sub_disable(...); break; case 'invoice.payment_failed': handle_payment_failed(...); break; case 'refund.processed': handle_refund(...); break; } http_response_code(200);
Non-negotiable, in this order:
- Verify the HMAC before parsing. Compare with
hash_equals, not==. - Idempotency is mandatory, not defensive. Without the unique constraint, one retry grants two months of access or double-credits a wallet.
- Re-check the amount server-side. Trust the signature for authenticity, then still compare against the plan price.
- Never trust
metadatafor authorisation. Use it to find the user, then re-derive entitlement from your own tables. - Log every event, verified or rejected. When a trainee says "I paid and nothing happened", this is how you answer in thirty seconds.
- Exclude the path from maintenance mode and every rewrite rule. A 302 on your webhook URL means Paystack marks deliveries failed.
Webhooks get missed — a host wobble, a deploy, exhausted retries. An hourly reconciliation cron diffs Paystack's transactions against yours, verifies stale pendings, confirms expiries against Paystack before downgrading anyone, and recomputes every wallet balance from the ledger. Build it in the same phase as the webhook, not later. This is the piece that lets you sleep.
Grace, and what a downgrade must not do
A failed renewal moves the subscription to past_due with three days of
full access. Card declines from insufficient balance, expired debit cards and bank
downtime are routine in Nigeria and are not the trainee's fault; cutting access the instant a
charge fails punishes people for their bank's problems. Email on day 0, 1 and 3, then
downgrade.
Downgrading must be non-destructive. An expired trainee keeps their account, full attempt history, badges, leaderboard position, and unspent purchased credits. They lose access to Tier 2+ content and nothing else. Resubscribe three months later and everything is where they left it. Hiding progress on downgrade is the most common way subscription products lose returning customers.
The two-axis access model
There are two entirely separate questions to answer before showing a trainee a scenario. Conflating them is what turns a training product into a slot machine.
Can they pay for this?
- Active subscription tier ceiling
- Admin grant or cohort access
- Single-scenario credit pass
Have they earned it?
- 70% average accuracy on the tier below
- Curriculum day unlock
- Derived from attempts, never stored
Both checks live in one function, can_start_scenario(), and it returns two
distinct failure codes. locked_payment shows an upgrade button.
locked_progression shows "You need 70% on Tier 2. You're at 61% — replay
NET-202 to lift it." Showing an upgrade button to someone who simply has not earned the
tier yet is the single fastest way to make training feel like a shakedown.
If you need a demo account that skips progression, it is a bypass_progression
flag on the user — shown as a warning badge in admin, excluded from certificates and
leaderboards, and written to the audit log. Never a quiet side effect of an admin "grant
access" button.
Surface separation
Three front-ends share one database and never share assets. The admin console can load 200 KB of CSS and nobody cares — you use it on a laptop, a few times a day. The trainee app must never ship a byte the simulator does not need, which means zero payment logic in the JS bundle: no price table, no plan codes, no Paystack key. Checkout is an ordinary server-rendered page. A bug in billing can then never break a scenario mid-attempt.
Data
The original seven tables survive intact. Twenty-four more carry money, platform settings, and administration. The full schema is importable as-is.
| Group | Tables |
|---|---|
| Identity | users · cohorts · login_attempts · auth_tokens |
| Content | scenarios · scenario_versions · attempts · badges · user_badges · review_queue |
| Money | plans · subscriptions · entitlement_grants · transactions · wallet_ledger · credit_products · credit_costs · coupons · coupon_redemptions · refunds · webhook_events |
| Platform | settings · feature_flags · audit_log · impersonation_log · email_templates · email_queue · announcements · cron_runs · ip_blocklist · rate_limits · certificates |
Three decisions worth defending
Kobo, always
Every money column is INT UNSIGNED in kobo. Paystack's API works in kobo, so
integers matching the provider's unit mean zero conversion at the boundary — which is where
conversion bugs live. Never FLOAT for money: 0.1 + 0.2 != 0.3 in
binary floating point and the error compounds across a ledger. ₦6,500 is stored as 650000.
Ledger as truth, balance as cache
wallet_ledger is append-only — no UPDATE, no DELETE, ever. A correction is a new
compensating row. This is the only way to answer "why does this trainee have 7 credits?" six
months later, and the only defensible position in a dispute. The cached balance columns are
written in the same transaction and reconciled nightly.
// Without FOR UPDATE, two tabs both read balance=5, both spend 3, // and you have given away a credit. Trainees DO open two tabs — // it is already on your own pre-launch test list. $db->prepare('SELECT credits_purchased, credits_granted FROM users WHERE id = ? FOR UPDATE')->execute([$userId]); // Spend the perishable bucket first: granted credits expire, // purchased ones never do. $fromGranted = min($granted, $cost); $fromPurchased = $cost - $fromGranted; // idempotency_key is UNIQUE — a double-tapped button or a retried // request rolls back instead of charging twice. ledger_insert($db, $userId, 'granted', -$fromGranted, $action, $idemKey);
Derive skill, store money
The build plan is right that progress must never be a stored column — it drifts. Six rows from one indexed query give you attempts, solves, accuracy and average commands per tier. That last figure is what the task ladder calls the best available proxy for growing diagnostic instinct, and it arrives free, so put it on the profile and the certificate.
Money is the exception. Entitlement is stored, because a subscription's state depends on events — renewals, cancellations, grace periods — that cannot be reconstructed from a payment list alone.
Super admin console
Three roles, not one — because the day you hand instructor access to someone else, you want that boundary already enforced rather than bolted on.
| Role | Can | Cannot |
|---|---|---|
| instructor | View their cohort, grade the review queue, see stuck-point analytics, draft scenarios | Touch money, publish content, change settings |
| admin | The above, plus publish scenarios, manage users, issue refunds, run coupons | Change platform settings or scoring weights, manage roles, delete accounts |
| super_admin | Everything | — |
Every admin file calls require_role() on line one, before any output. Never guard
by hiding a nav link — a guard that lives only in the menu is not a guard.
Modules
Priority: P1 before the pilot · P2 before real money · P3 after, driven by what actually hurts.
Revenue, MRR, active subs, signups, live attempts — plus a health strip: last cron per job, failed webhooks, mail backlog, ledger drift, PHP errors. On shared hosting failure is silent; put this where you cannot miss it.
Search, detail view with everything on one page, suspend, force-verify, change role, grant or revoke entitlement, adjust credits — every adjustment writing a ledger row with a mandatory reason.
A form, not a JSON textarea, with server-side schema validation, a preview sandbox that records no stats, and versioning with diff and rollback. You will not get a fault's difficulty right without playing it.
Scoring weights, tier gate, cooldowns, maintenance mode with an allowlist, feature toggles, a billing master switch for when Paystack has an incident.
Actively tests outbound HTTPS, Paystack reachability, SMTP, DB writes, log writability. Turns a class of "broken and I have no SSH" problems into a page you can read.
Filterable list with raw payloads, a Sync-with-Paystack diff showing matched / Paystack-only / FixIT-only, one-click resolve, and refunds through the API.
Every event, signature status, payload, replay. This is how "I paid and nothing happened" gets answered in thirty seconds.
Permanent and unmissable when Paystack is in test mode. A fortnight of "live" signups that were all test-mode is a real and depressingly common outcome.
View queued and failed, retry, preview rendered output, send-test-to-me. Mail deliverability on cPanel is the most common silent failure.
Last run, duration, status per job, with an alert when one misses its window. Shared-hosting cron fails quietly.
Read-only, reason required, logged, red banner, 30-minute cap, super admins exempt. The most useful support tool you will build and the easiest to abuse.
Per fault: where they give up, off-path commands, recurring wrong diagnoses, which hint unblocks them. Tells you where to add hints where the data says, not where you guessed.
MRR, ARPU, new vs renewal, churn, failed-charge rate, plan mix, and credit liability outstanding — money taken but not yet delivered on.
Global search, audit log viewer, error log tail, announcements, data export, backups, session killer, rate-limit and IP management, cache buster, certificates with public verification.
Mandatory TOTP for admin and super admin (~60 lines of pure PHP, no library) · step-up re-auth for prices, refunds, grants, roles, deletions and settings · optional IP allowlist · every mutation writing before/after JSON to the audit log, especially your own · shorter idle timeout and a separate cookie name.
The last super admin cannot be deleted, suspended, or demoted. Enforce it with a count check in code. Locking yourself out of a no-SSH app means editing the database by hand through phpMyAdmin at 2am.
The ordering rule for everything else: build the tool the first time you need it manually, not before. The two exceptions are the audit log and the health strip — retrofitting an audit log means you have no record of exactly the period you most want one for.
cPanel deployment
Empty cPanel account to a live, HTTPS, payment-taking install. No SSH anywhere.
Push a hello-world index.php through the full pipeline before writing a line
of the engine. Every problem below is easier to solve against an empty app — and discovering
in week six that your host blocks outbound cURL is a genuinely bad week.
- PreflightPHP 8.1+, extensions, MySQL 5.7+, cron, Git Version Control — and the outbound HTTPS test.
- SubdomainDocument root at
fixit/public_htmlso the app sits one level above the web root. - DatabaseCreate, add user with all privileges, import
schema.sqlvia phpMyAdmin. - Code onto the servercPanel Git Version Control with a
.cpanel.yml, or File Manager zip. - Configuration
config.phpoutside the web root, chmod 600, gitignored. - .htaccessHTTPS force, security headers, gzip, long asset cache, error log to file.
- HTTPSAutoSSL, then HSTS and secure session cookies. Paystack rejects http webhooks.
- EmailAuthenticated SMTP, queue + cron, SPF and DKIM verified in Email Deliverability.
- CronMail queue 5-min, subscriptions hourly, maintenance and backup nightly.
- PaystackTest mode, plans created, webhook URL set, all three test cards run.
- Smoke testInfrastructure, application, money, admin — every line, every deploy.
- BackupsDB export before each deploy, nightly dump, weekly copy taken off the server.
The layout that makes secrets safe
When you create the subdomain, uncheck "share document root" and point it at
fixit/public_html. Then fixit/ itself is not web-accessible, and
that is where includes/, storage/, vendor/,
bin/ and scenarios/ live. Strictly better than the
.htaccess-deny fallback: no single misconfiguration can expose your Paystack
secret key.
Note that Composer is not available without SSH. Upload PHPMailer's
src/ by hand and require it directly. You need nothing else — the Paystack
integration is plain cURL and the app is deliberately framework-free.
Gotchas, with their causes
| Symptom | Cause | Fix |
|---|---|---|
| Paystack calls fail silently | Outbound HTTPS blocked | Host support ticket for api.paystack.co |
| Webhook shows 302 in Paystack | Your HTTPS or www rewrite catching it | Exclude the webhook path from rewrites |
| Webhook shows 403 | mod_security rule | Ask the host to whitelist the path |
| Signature header empty | apache_request_headers() under PHP-FPM | Read $_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] |
| Cron produces nothing | Wrong PHP binary path | Copy it from Select PHP Version |
| Mail lands in spam | Missing SPF / DKIM | Email Deliverability → repair |
| CSS changes invisible | One-year cache header | Bump the asset version from admin |
| Import fails on JSON columns | MariaDB older than 10.2 | Replace JSON with LONGTEXT |
| Random 508s | Resource limit — usually an N+1 query | Check Resource Usage; find the query |
| 500 after deploy | config.php overwritten or wrong permissions | Confirm it exists, is 600, returns an array |
The client-side engine and three-server-hits-per-scenario design mean shared hosting will carry several hundred concurrent trainees. The signals to move are sustained CPU throttling in Resource Usage, MySQL connection limits during cohort sessions, or the day you want SSH, Composer and Redis more than you want the simplicity. None of those are launch problems.
Roadmap
The source plan lays out roughly 25 days for engine, auth, content, admin basics and pilot. That is reasonable for what it covers. It does not include payments, a subscription lifecycle, a credit wallet, or a super admin console — realistically 20–25 further working days.
| Scope | Days, focused full-time | Elapsed, evenings & weekends |
|---|---|---|
| Original plan | ~25 | 8–10 weeks |
| + monetization + super admin | ~48 | 14–18 weeks |
Plan against the right-hand column unless FixIT is your full-time job. Nobody sustains eight focused hours a day on a side project, and the gap between those columns is where most projects quietly die.
Deploy pipeline
The most valuable change to the original plan: make deployment work before building anything. Preflight, subdomain, database, git deploy, HTTPS, error logging — and submit the Paystack business application.
Done when you can commit, click Deploy, and see it live in under a minute.
Engine
Unchanged from the source plan. Terminal, parser, output generators, byte-accurate layout, streamed timings, verbatim errors, DocumentFragment rendering. No database.
Done when you solve NET-101 end to end, it feels instant, and a working technician does not spot it as fake in the first ten seconds. Find one and watch their face.
Auth and persistence
Signup, verification, reset, sessions, CSRF, rate limiting, lockout, mail queue. Attempts recorded, autosave, resume. Scoring moved server-side.
Done when two accounts solve the same fault with separate correct progress, and a tampered client score is rejected.
Content and instructor admin
Fault builder, sandbox, versioning, audit log skeleton. All six Tier 1 faults plus one from each higher tier — NET-201, EP-301, MULTI-401, INC-501 — exactly as the task ladder recommends, to prove every mechanic before writing the other twenty.
Done when you can add a fault without touching code.
Monetization
Pricing page, checkout, verify, webhook with signature and idempotency, subscription lifecycle, wallet with row locking, reconciliation cron, receipts and dunning email, entitlement gating.
Done when a test card buys access, a replayed webhook grants nothing extra, and two tabs cannot double-spend a credit.
Super admin console
The P1 and P2 modules: health dashboard, users, payments and reconciliation, webhook inspector, settings and flags, email queue, cron monitor, TOTP and step-up auth.
Done when you can run a support request end to end without opening phpMyAdmin.
Content completion and pilot
Write out to 30 faults. Run a small live cohort. Watch where they stall and add hints where the data says. Expect what the pilot exposes to be pacing and copy, not code.
Done when a cohort clears Tiers 1–2 with no support request you could not answer from admin.
Expansion
In build order: the M365/Entra console — the highest-value module, precisely because nobody hands a trainee a live tenant — then Event Viewer, the instructor review queue, certificates, ticket-craft mode, timed incidents, and B2B seats if the demand shows up.
If you are running late, cut in this order
- Coupons, announcements, data export, certificates — post-pilot anyway
- Impersonation — useful, but you can read the database
- The credit wallet entirely. Subscriptions alone are a complete product. Ship subscription-only and add credits once you know which consumables people actually want. Biggest saving available, and it costs almost nothing because the schema is already there.
- Tiers 4–5 content — launch with 22 faults across three tiers
Server-side scoring · webhook signature verification · webhook idempotency · wallet row locking · the audit log · stripping answers from the client payload. Every one is a "we'll add it later" that turns into an incident.
Open questions
Grouped by when the answer actually blocks something. Everything above works under the stated assumptions; these are where a different answer changes the build.
Subdomain or subfolder?I have assumed fixit.adebled.com.ng with its own document root. A subfolder works but complicates cookie scope, .htaccess inheritance, and the webhook URL.
Which cPanel host and plan?Specifically whether it offers Git Version Control, cron, and outbound HTTPS from PHP. Send me the hostname or a screenshot of the cPanel home and I can tell you what to expect before you hit it.
Is ADEBLED SERVICES CAC-registered with a business bank account?Paystack live keys need both. If not, that application becomes your longest-lead item and everything else proceeds around it.
Does the domain already send email, with SPF and DKIM set up?If the training division already runs MailerLite or Mailjet, reusing it beats configuring cPanel SMTP.
Are the prices right?My figures have no basis beyond structure. What do your current ADEBLED students pay? What does a competing Nigerian IT-support course charge? This is the assumption I have least confidence in.
Should annual exist at launch?Best cash flow and most fee-efficient, but selling a year of access to a product with three weeks of history is a refund risk.
A free trial of the paid tiers, separate from the free tier?My default is no — free Tier 1 already does that job without a card.
What is the refund policy?It needs to be on the pricing page before the first payment, and it needs to be one you will actually honour.
Which payment channels?Card only is simplest. Bank transfer and USSD widen reach materially in Nigeria — but recurring charges require a saved card, so those users would need manual renewal. A product decision, not a technical one.
What happens when a subscriber finishes all 30 faults?They stop paying, which is correct. Is there anything after — refresher mode, new faults monthly, a community tier? Right now this is a course being billed like a subscription. Worth deciding early.
Who grades the Tier 4–5 written submissions?The 48-hour SLA is only credible if someone is on the other end. If it is only you, price the credits high enough that volume stays manageable.
What anchors curriculum day 1?Signup, first attempt, or cohort start. I default to first attempt, with the clock pausing during a lapsed subscription — nobody should lose curriculum days to a card decline.
Leaderboard: global, cohort-only, or opt-in?Self-serve means no cohorts by default. But public rankings discourage exactly the nervous beginners you most want to keep. Opt-in with a display name is the compromise.
Does the certificate need to mean something externally?If AQskill or an employer will ever verify one, you need the public verification page, a serial scheme, and a defensible standard for what earns it.
Is mobile first-class or a fallback?A Windows terminal on a 6-inch screen with a soft keyboard is a poor experience — but Nigerian trainees are more likely to be on a phone than a laptop. This affects a lot of CSS and is far cheaper to decide now.
Offline tolerance?The architecture is unusually well suited to it — the engine is already client-side. A service worker could let someone finish a scenario through a network drop. Not v1, but in Nigeria it may be a genuine differentiator.
Who maintains content accuracy?Windows changes, M365 renames things constantly, error strings drift. Byte-accurate output is a maintenance commitment, not a one-time build.
Do you care about account sharing?Randomisation stops answer-passing, but one account shared between five friends is a revenue problem the schema does not currently address.