# Hostledger — Project Plan

Working through this top to bottom, one phase at a time. Check items off as
we finish them. Nothing in a later phase should get built before the phases
above it are done, since each one depends on what came before (installer
needs the schema, billing needs auth, provisioning needs billing, etc).

**Scope update (2026-07-25):** payment gateways, domain registrar
integration, and an affiliate system are now core, not optional add-ons.
The bar for all three: the buyer of Hostledger should be able to turn them
on from the admin panel by pasting in API credentials — no code editing,
no developer needed. That means every gateway/registrar is built against a
shared interface (`GatewayInterface` / `RegistrarInterface`) so adding a new
one later is a single new class, not a rewrite.

## Security baseline (cross-cutting — applies to every phase below, not a
## phase you do once at the end)

- [x] Sensitive credentials (WHM API tokens, payment gateway keys,
      registrar keys) are encrypted at rest with AES-256-GCM
      (`classes/Crypto.php`), keyed off `APP_KEY` — never stored or logged
      in plaintext.
- [x] Every database query goes through PDO prepared statements — no string
      concatenation of user input into SQL, anywhere, ever.
- [x] All output escaped via `e()` before printing into HTML — no raw
      `echo $userInput`.
- [x] CSRF token required and verified on every state-changing POST
      (`csrf_field()` / `csrf_verify()`).
- [x] Passwords hashed with `password_hash()`/`password_verify()` (bcrypt),
      never reversibly encrypted, never stored in plaintext or logged.
- [x] Login brute-force protection: rate-limit/lock out repeated failed
      logins per account and per IP (admin login, client login) —
      `classes/Auth.php`, 5 attempts / 15 min, shared logic for both tables,
      constant-time-ish generic error on unknown email too.
- [x] Session hardening: httponly + samesite cookies (done in bootstrap),
      session ID regenerated on login (`login_as_admin()`/`login_as_client()`),
      session destroyed fully on logout (`logout()`).
- [x] Security headers on every response: `X-Content-Type-Options: nosniff`,
      `X-Frame-Options: DENY`, a baseline `Content-Security-Policy`,
      `Referrer-Policy: same-origin` (`apply_security_headers()`).
- [x] Strict admin/client isolation: a client can never query another
      client's data by guessing an ID (every query filtered by the
      logged-in client's own ID, checked server-side, not just hidden in
      the UI) — verified on invoices, services, domains, tickets, pay.php.
- [x] Every sensitive admin action (login, credential/config changes,
      manual invoice/service state changes) writes a row to `audit_log` —
      never silently mutate money- or access-related state. Webhook-driven
      payments also write an (adminId=null) audit row for traceability.
- [x] Stripe/PayPal/Paystack webhook signatures verified with each
      provider's own algorithm before trusting any webhook payload — never
      act on an unverified webhook body (Stripe: HMAC-SHA256 + 300s replay
      window; Paystack: HMAC-SHA512; PayPal: their verify-webhook-signature
      API call). See Phase 5b below.
- [x] `storage/` is not web-executable (document this requirement clearly
      in `docs/INSTALL.md` — `.htaccess` deny-all inside `storage/`,
      `classes/`, `includes/`, `database/`).
- [x] `config.php` permissions guidance in `docs/INSTALL.md` (640 or
      stricter where the host allows it).
- [x] Dependency-free by design (no Composer at runtime) means no
      third-party package supply-chain risk to audit — keep it that way.
      Every gateway/registrar integration (Stripe, PayPal, Paystack, eNom,
      WHM) is hand-rolled cURL, not an SDK.

## Phase 0 — Foundation
- [x] Decide tech stack: PHP + MySQL, no Composer/CLI dependency at runtime
- [x] Create organized project folder (`Hostledger/`) with real structure
- [x] Name the product "Hostledger" (checked clear of the real "HostBill"
      competitor at hostbillapp.com)
- [x] Finalize DB schema (`database/schema.sql`) — clients, products,
      orders, services, invoices, invoice_items, payments, tickets,
      ticket_replies, servers, gateway_configs, registrar_configs,
      domains, affiliates, affiliate_referrals, affiliate_payouts,
      audit_log, settings — all with a configurable table prefix
- [x] `classes/Crypto.php` — AES-256-GCM encrypt/decrypt for secrets at rest
- [x] Bootstrap file (`includes/bootstrap.php`) — session start, error
      handling, config loader, PDO connection singleton, autoloader
- [x] `includes/Database.php` — PDO singleton + table-prefix helper
- [x] `includes/functions.php` — escaping, CSRF, flash messages, auth guards
- [x] `config.sample.php` — template the installer copies and fills in

## Phase 1 — Install wizard (`install/`) ✅
- [x] `install/functions.php` — requirements check, DB connect test, schema
      importer, config.php writer, lock file
- [x] `install/index.php` — the actual step-by-step controller (requirements
      → DB connection → schema import → admin account → finish/lock)
- [x] Refuse to re-run once `install.lock` + `config.php` both exist

## Phase 2 — Core framework & auth ✅
- [x] `classes/Auth.php` — shared brute-force lockout (5 attempts / 15 min)
      + generic error messages for both admins and clients tables
- [x] `classes/Audit.php` — audit_log writer (admin_id, action, details, ip)
- [x] Admin login/logout/session (`admin/login.php`, `admin/logout.php`)
      with brute-force lockout and audit_log entries on login/logout/failed
      login; `admin/index.php` stub dashboard behind `require_admin()`
- [x] Client login/register/session (`client/login.php`,
      `client/register.php`, `client/logout.php`) with brute-force lockout;
      `client/index.php` stub dashboard behind `require_client()`;
      register.php captures `?ref=CODE` into session for Phase 7's affiliate
      system to consume later
- [x] Security headers helper (`apply_security_headers()`), applied from
      `bootstrap.php` to every request
- [x] `includes/layout.php` — shared `render_page()` page shell (plain for
      now; Phase 9 handles real theming)

## Phase 3 — Client management ✅
- [x] `classes/Mailer.php` — hand-rolled SMTP (STARTTLS/implicit TLS + AUTH
      LOGIN) with a `mail()` fallback when SMTP isn't configured; also
      wired for Phase 8's ticket notifications and future order receipts
- [x] `classes/PasswordReset.php` + `password_resets` table — shared,
      single-use, sha256-hashed, 1-hour-expiry tokens for both admins and
      clients (only clients consume this yet; admin reset intentionally
      deferred, see note below)
- [x] Admin: list/search clients (`admin/clients.php`), view/edit a client,
      unlock a locked-out account, trigger a password reset email on their
      behalf (`admin/client-view.php`) — all state-changing actions logged
      to `audit_log`
- [x] Client: profile edit + change password (`client/profile.php`),
      forgot-password / reset-password flow (`client/forgot-password.php`,
      `client/reset-password.php`) — enumeration-safe (same response
      whether or not the email exists)

Note: admin-side self-service password reset (forgot-password for
*admins*) was not built — Phase 3 only asked for the client-facing flow.
`PasswordReset` already supports `user_type = 'admin'`, so adding
`admin/forgot-password.php` later is a small, low-risk addition whenever
it's wanted; flagging it here rather than building it unasked.

## Phase 4 — Products & orders ✅
- [x] `classes/Billing.php` — invoice number generation, creates the
      invoice + line item for a new order (setup fee + first cycle);
      Phase 5a will extend this same class with renewal invoices
- [x] Admin: create/edit hosting plans (`admin/products.php`,
      `admin/product-edit.php`) — price, billing cycle, setup fee, WHM
      package name, active/inactive toggle
- [x] Client: browse active plans and place an order with a domain
      (`client/plans.php`, `client/order.php`) — domain format validated
      server-side before an order is created
- [x] Order placement generates the initial invoice, and both client and
      admin can view invoices (`client/invoices.php` + `invoice-view.php`,
      `admin/invoices.php` + `invoice-view.php`) — read-only for now;
      Phase 5a adds "mark as paid" (admin) and Phase 5b adds "pay now"
      (client). Client invoice view enforces ownership (can't view another
      client's invoice by guessing the ID).
- [x] Affiliate referral capture (`Billing::captureAffiliateReferral()`):
      if `client/register.php?ref=CODE` set a referral code at signup, a
      matching active affiliate gets a `pending` row in
      `affiliate_referrals` with commission calculated at order time. No
      self-referral commission. Phase 5a credits it to the affiliate's
      balance once the invoice is actually paid; Phase 7 builds the UI to
      become an affiliate and see this.

## Phase 5a — Core billing engine ✅
- [x] `Billing::markInvoicePaid()` — idempotent (a second call on an
      already-paid invoice is a no-op), records a `payments` row, credits
      affiliate commission if the order was referred
- [x] Admin: "Mark as paid" button on `admin/invoice-view.php`
      (cash/bank transfer), audit-logged
- [x] `cron/billing.php` — generates renewal invoices for active services
      whose `next_due_date` has arrived, advances `next_due_date` by one
      billing cycle. CLI-only (refuses to run from a browser).
- [x] `cron/overdue.php` — marks unpaid-past-due invoices `overdue`, then
      flags the linked service `suspended` after a 5-day grace period.
      **DB bookkeeping only right now** — it does not yet call any control
      panel API to actually lock the account; that's Phase 6a wiring
      `classes/WHM.php` into `Billing::suspendOverdueServices()`.
- [x] On invoice paid: pending `affiliate_referrals` row (if any) flips to
      `approved` and its commission is added to the affiliate's balance —
      never credited at order-placement time, only on actual payment

Note: since Phase 6a (WHM provisioning) doesn't exist yet, no `services`
rows exist yet either in a fresh install — `cron/billing.php` and the
suspend half of `cron/overdue.php` will correctly do nothing until Phase 6a
starts creating services on payment. This is expected, not a bug.

## Phase 5b — Pluggable payment gateways ✅
- [x] `classes/Gateways/GatewayInterface.php` — common contract: create a
      checkout/payment for an invoice, verify a webhook, return paid/failed
- [x] `classes/Gateways/StripeGateway.php` — Checkout Sessions API via raw
      cURL; webhook verified via manual HMAC-SHA256 over `Stripe-Signature`
      with a 300s replay tolerance
- [x] `classes/Gateways/PaypalGateway.php` — Orders API v2 via raw cURL
      (OAuth2 client-credentials token, `custom_id` carries the invoice ID);
      webhook verified via PayPal's own `/v1/notifications/verify-webhook-signature`
      call (their documented method — no simple local HMAC like the others)
- [x] `classes/Gateways/PaystackGateway.php` — Transaction initialize API via
      raw cURL (amount in kobo/subunits); webhook verified via HMAC-SHA512
      over `X-Paystack-Signature` (important for Nigerian/African clients)
- [x] `classes/GatewayManager.php` — unlike registrars (one active at a
      time), multiple gateways can be enabled simultaneously so a client
      picks one at checkout; `enabled()` lists them, `get()` loads a
      specific one (only if enabled), `save()`/`configFor()` back the admin
      settings page
- [x] `admin/gateways.php` — Settings → Payment Gateways, one form per
      gateway, paste in credentials + enable checkbox (stored via
      `gateway_configs`, encrypted) — no code changes needed to turn one on;
      added to the admin nav in `includes/layout.php`
- [x] `api/webhooks.php` — one entrypoint (`?gateway=stripe|paypal|paystack`),
      routes to the right gateway class, never trusts a payload until
      `handleWebhook()` positively verifies it and returns an invoice ID;
      calls `Billing::markInvoicePaid()` (idempotent) and writes an
      `audit_log` row
- [x] `client/pay.php` — POST-only (CSRF-protected, ownership-checked)
      handler that redirects to the chosen gateway's hosted checkout;
      `client/invoice-view.php` now shows one "Pay with X" button per
      enabled gateway instead of the old placeholder text
- [x] `admin/invoice-view.php` — placeholder text replaced; explains that
      online payments are marked paid automatically via webhook, offline
      "Mark as paid" stays available for cash/bank transfer
- [x] `payments.stripe_payment_intent_id` renamed to the gateway-neutral
      `gateway_reference` in `database/schema.sql` (and
      `Billing::markInvoicePaid()`'s matching parameter), since that column
      now needs to hold a Stripe session ID, PayPal order ID, or Paystack
      reference interchangeably

Note: this phase was originally skipped over (jumped straight from 5a to
6a) and was caught and built afterward, once packaging work in Phase 10
surfaced the gap. Flagging that here for the record rather than quietly
backfilling it.

## Phase 6a — WHM/cPanel provisioning ✅
- [x] `classes/WHM.php` — createacct/suspendacct/unsuspendacct/removeacct/
      changepackage + a `testConnection()` used before saving a server.
      TLS certificate verification is ON by default and not exposed as a
      toggle (see the class docblock — fix a self-signed cert at the
      server with AutoSSL, don't disable verification).
- [x] `admin/servers.php` — register WHM servers; token is test-connected
      before it's encrypted (`Crypto`) and saved
- [x] `Billing::provisionOrder()` — creates the `services` row + calls WHM
      `createacct` the moment `markInvoicePaid()` runs; idempotent (safe to
      retry); refuses to run against an order with no paid invoice (a
      defense-in-depth check, since the only normal caller is
      `markInvoicePaid`); emails the client their new cPanel username +
      one-time-generated password via `Mailer` on success
- [x] `admin/orders.php` — order list with a "Provision now" retry button,
      shown only when a paid invoice actually exists for that order
- [x] `cron/overdue.php` → `Billing::suspendOverdueServices()` now calls
      real WHM `suspendacct`, not just a DB flag — a WHM failure is logged
      and skipped without blocking the rest of the sweep
- [x] `admin/services.php` — manual suspend/unsuspend/terminate against
      real WHM, `client/services.php` — read-only client view

## Phase 6b — Pluggable domain registrars ✅
- [x] `classes/Registrars/RegistrarInterface.php` — check availability,
      register, renew, get expiry
- [x] `classes/Registrars/EnomRegistrar.php` — eNom's classic reseller XML
      API via cURL (Check/Purchase/Extend/GetDomainExpDate) — field names
      should be double-checked against eNom's current docs before going
      live, since this was written from the long-stable API shape, not a
      dated snapshot verified against a live account
- [x] `classes/RegistrarManager.php` — loads whichever registrar is
      enabled + decrypts its config; adding a second registrar later is one
      new class + one line in its `$classMap`
- [x] `admin/registrars.php` — paste in eNom credentials, enable/disable
      (stored via `registrar_configs`, encrypted)
- [x] `client/domains.php` — checks live availability before creating a
      domain + invoice; `Billing::registerOrRenewDomainIfPending()` (called
      from `markInvoicePaid()`) does the actual registrar API call only
      once that invoice is paid — same pay-first pattern as hosting orders
- [x] `cron/domain-renewal-reminders.php` — emails clients 30 days before
      expiry; deliberately does NOT auto-invoice or auto-renew, per scope
- [x] Added `invoices.domain_id` + `classes/Settings.php` (small key/value
      helper, also backing `domain_annual_price`) to support the above —
      noted here since these weren't in the original Phase 0 schema list

## Phase 7 — Affiliate system ✅
- [x] `client/affiliate.php` — one-click opt-in generates a unique code
      (collision-checked against the UNIQUE constraint, not just trusted
      to random chance), shows the referral link, balance, commission
      rate, and a per-referral status table
- [x] Referral capture already wired since Phase 4 (`?ref=CODE` at
      registration → session → tagged at order time → credited at payment)
- [x] `admin/affiliates.php` — adjust each affiliate's commission rate,
      suspend/reactivate, and record a payout (wrapped in a DB transaction:
      deducts the balance, inserts an `affiliate_payouts` row, and marks
      the oldest `approved` referrals `paid` up to the payout amount)

## Phase 8 — Support tickets ✅
- [x] `client/tickets.php` (list + open new) and `client/ticket-view.php`
      (thread + reply) — ownership-checked, closed tickets go read-only
- [x] `admin/tickets.php` (list, customer replies surfaced first) and
      `admin/ticket-view.php` (thread, reply, set priority, close/reopen)
- [x] `classes/Ticket.php` + `Mailer` — staff notified (a configurable
      `support_notification_email` setting, falling back to
      `MAIL_FROM_ADDRESS`) on new ticket/customer reply; client notified on
      staff reply

## Phase 9 — UI/UX pass ✅
- [x] `includes/layout.php` rebuilt with a real top nav (every admin/client
      page linked, active page highlighted) instead of ad hoc links buried
      in each page's content, plus CSS custom properties for color/branding
- [x] Admin dashboard: stat tiles (unpaid/overdue invoices + total, open
      tickets, active/suspended services, affiliate payouts due + total)
- [x] Client dashboard: stat tiles (active services, unpaid invoices +
      total, open tickets, domain count, affiliate balance if they're an
      affiliate)
- [x] Branding: topbar label reads the `company_name` setting if set,
      falling back to "Hostledger". Logo support added post-build-complete:
      if `assets/img/logo.png` exists, `includes/layout.php` shows it (in a
      white pill, since the supplied logo has a light background, not a
      transparent one) instead of the text brand — drop a file in via
      FTP/File Manager, no code change needed. There is still no in-app
      upload UI (would need file-upload handling) — noted as a smaller
      remaining gap, not silently skipped.

## Phase 10 — Packaging for distribution ✅ (except one decision left for the business owner, see below)
- [x] `docs/INSTALL.md` — end-user instructions: upload via cPanel File
      Manager or FTP, create a MySQL database + user via cPanel, run the
      installer, set up cron jobs via cPanel's Cron Jobs screen, lock down
      `storage/` and `config.php` permissions
- [x] `scripts/build-release.sh` — stages a clean copy (rsync excludes
      `config.php`, `install/install.lock`, `storage/logs/*.log`,
      `storage/cache/*`, `PROJECT_PLAN.md`, the handover .docx files, `.git`,
      this scripts/ folder itself), sanity-checks the result (refuses to
      proceed if `config.php` somehow ended up staged, or if
      `config.sample.php`/`install/index.php` are missing), then zips it to
      `dist/hostledger-<version>.zip`. Test-ran it in this build: produced a
      95-file, ~246KB zip with no config.php/PROJECT_PLAN.md/docx files and
      all expected product files present.
- [x] `CHANGELOG.md` and a version constant (`includes/version.php` →
      `HOSTLEDGER_VERSION`, currently `0.1.0`) — bump both together per
      release, per the comment in each file

### Open decision — not mine to make
- [ ] **License-key gating for resale vs. sold as unlocked source.**
      Nothing in this codebase currently gates functionality behind a
      license key — anyone with the zip has the full working source. If you
      want to sell Hostledger as a licensed product (e.g. phone-home
      activation, a license table + periodic check against a license
      server you control, feature-locking without a valid key), that's a
      genuinely new subsystem — a licensing server/service is out of scope
      of "the product itself" and would need its own design decision on
      where it lives and how strict it is. Flagging this rather than
      guessing what you want here.

## Phase 11 — QA
**Standing caveat, read this first:** the build environment for this
project has no PHP runtime and no way to reach a real MySQL server or the
public internet (installing `php-cli` failed — no root/sudo; `npm`/`pip`
against public registries were blocked by the sandbox's proxy). Every file
in this project was written and then manually re-reviewed — logic traced
by hand, brace/paren/bracket balance verified with a small script, naming
consistency checked across files — but **none of it has actually been
executed.** Items below marked [x] mean "manually verified by static
review," not "observed to work at runtime." Items marked [ ] genuinely
need a real cPanel/MySQL environment and are the responsibility of
whoever runs the first real install.

- [ ] Fresh install test against a clean MySQL database — **not done,
      needs a real MySQL server.** Do this before any real customer touches
      it: run `install/index.php` start to finish on an empty database and
      confirm every step completes and `admin/`+`client/` both load after.
- [x] Full security pass against the checklist at the top of this document
      — every item re-verified this session by reading the actual
      implementing code (not just trusting earlier notes): PDO prepared
      statements throughout, `e()` escaping, CSRF on every state-changing
      form, bcrypt via `password_hash()`, brute-force lockout
      (`classes/Auth.php`), session regeneration on login +
      `session_destroy()` on logout, security headers
      (`apply_security_headers()`), ownership checks on every client page
      that takes an ID from the request (confirmed all 14 files under
      `client/` call `current_client_id()`), audit logging, verified
      gateway webhook signatures, `.htaccess` deny-all on
      `storage/`/`classes/`/`includes/`/`database/`.
- [ ] Walk the full lifecycle once end to end: register → order (with an
      affiliate referral) → pay via each of Stripe/PayPal/Paystack test
      modes → account auto-provisions → let it go overdue → confirm
      auto-suspend → register a test domain via eNom sandbox → open a
      ticket → reply → close → confirm affiliate commission accrued
      correctly — **not done, needs a real server + real (test-mode)
      gateway/registrar/WHM credentials.** This is the single most
      important test before selling or relying on this in production; a
      code review, however careful, is not a substitute for watching money
      and a hosting account actually move through the system once.

## Phase 12 — Post-launch additions (after Phase 11 was signed off) ✅
- [x] Real logo added (`assets/img/logo.png`, supplied by the project
      owner) — `includes/layout.php` now shows it in the topbar (wrapped in
      a white pill, since the source file has a light background rather
      than transparency) automatically whenever the file exists; no logo
      file falls back to the plain text brand exactly as before. This
      closes most of the "no logo" gap noted in Phase 9/11 — there's still
      no in-app *upload* UI, just automatic pickup of a file placed via
      FTP/File Manager.
- [x] `admin/settings.php` — a genuinely missing admin page: `company_name`
      previously had no UI at all (only ever set by the schema seed, so it
      was effectively uneditable without direct DB access). Added a small
      Settings page: company name, currency, timezone, and a checkbox for
      the item below. Added to the admin nav in `includes/layout.php`.
- [x] "Developed by SEO Boost Hosting" credit — added in three places per
      the project owner's explicit choice (all four options offered were
      selected): (1) README.md / this document, as the permanent
      authorship record; (2) a small footer line on every page, driven by
      the new `show_developer_credit` setting (defaults to `1`/on); (3) the
      install wizard's finish screen. Because Hostledger is meant to be
      resold/white-labeled, the footer credit is a per-install *setting*,
      not hard-coded — a buyer running their own instance can switch it off
      for their own customers via Admin → Settings, while SEO Boost
      Hosting's own installs keep it on by default.
- [x] Caught and fixed while touching the install wizard: the Step 5
      "finish" screen still referenced "Phase 10" and an "Admin → Settings
      → Payment Gateways" path that never matched the actual
      `admin/gateways.php` location, and only mentioned 2 of the 3 cron
      jobs. Corrected.
- [x] README.md fully rewritten — it had never been updated past its
      original Phase 0 "skeleton only, nothing is functional yet" draft.

### Known non-blocking loose ends noticed during this pass
- `assets/` and `templates/` exist as empty directories (scaffolded early,
  never used — `includes/layout.php` inlines all CSS instead). Harmless;
  fine to delete or fine to leave.
- Admin-side forgot-password isn't built (see Phase 3 note) —
  `PasswordReset` already supports it, it's a small addition if wanted.
- No logo/image upload for branding (Phase 9 note).
- License-key gating decision is still open (Phase 10) — needs your call,
  not a technical blocker.
