A multi-tenant church finance SaaS, built solo in five months. Four custom plugins, ~13,000 lines of PHP, multi-currency accounting in Naira, Dollars, and Pounds, and recurring Paystack subscriptions — running live on standard PHP infrastructure.
Most church finance tools fall into one of two camps. Either they're consumer accounting software like QuickBooks, retrofitted with a "Donations" category and called good — never engineered for the structural reality of how churches actually run their money. Or they're enterprise denominational tools that cost $300+ per month and require a finance director to operate.
Nigerian denominations sit awkwardly between both. A typical RCCG, Anglican, or Methodist network has a national HQ with oversight, regional districts, and dozens or hundreds of branches — each with their own accounts, their own income streams, their own approval cultures. Money moves in Naira locally, Dollars from international missions, Pounds from diaspora partners. Tax reporting straddles Nigerian PAYE, CIT, VAT and UK Gift Aid for branches abroad. None of the international tools speak this geography.
ChurchVault was built to fit that shape exactly. Multi-tenant from day one — HQ admins see aggregated views across all their branches, branch managers see only their own. Multi-currency native — every bank account stores its own currency and symbol, so a Lagos church with both ₦ and $ accounts gets accurate per-currency totals, never a confusing converted aggregate. Paystack-first billing — recurring subscriptions in Naira, retry logic, kill switches. Built on WordPress, hosted on Hostinger — because the alternative is asking Nigerian churches to learn AWS, and that's not the job.
The platform is live at seemychurch.org, has been onboarding paying customers since Q2 2026, and runs entirely on four interlocking custom plugins totaling roughly 13,000 lines of PHP — with zero JavaScript build pipeline.
The platform is built as four custom WordPress plugins, each owning a clear domain. They share a single database namespace — every table is prefixed cv_ — and a small library of helper functions. Each plugin is independently activatable, deactivatable, and upgradable. None depend on a third-party library beyond what WordPress already ships.
Inside churchvault-hq sit the operational sub-modules that keep the platform running: the signup pipeline, the auto-renewal cron, a demo seeder for sales demos, a Health Monitor for table-level diagnostics, a System Audit for orphan-record detection, and an Admin Tools surface with destructive operations gated behind explicit confirmation. Each is namespaced and self-contained — touching one never breaks another.
The architectural foundation. Every user belongs to a church via the cv_church_id user-meta key. Every church belongs either to itself (independent) or to an HQ network via parent_hq_id on the cv_churches table. Two roles enforce visibility:
| Role | Stored as | Sees |
|---|---|---|
| HQ Admin | cv_is_hq_admin = 1 | Aggregated view across all branches in their network |
| Branch Manager | cv_is_hq_admin = 0 | Their own branch only |
| Finance Volunteer | role = 'finance_volunteer' | Limited to assigned tasks within a branch |
| Subscriber | role = 'subscriber' | Read-only on their own church data |
The hardest part of multi-tenant SaaS isn't the schema. It's discipline — every single query that touches financial data must be filtered by which church is allowed to see it. One missed WHERE clause anywhere in the codebase, and a Lagos church's deposits show up on a Calabar branch's report. That's not a bug. That's an incident.
ChurchVault enforces this discipline by building tenancy into the query layer itself, not the application code. A small helper, cv_get_user_church_ids($user_id), returns the array of church IDs the requesting user is allowed to read — one ID for branch managers, many for HQ admins, every ID for super admins. Every financial query then filters against that array using a prepared IN clause:
// Resolve the church IDs this user is allowed to read. // HQ admin → all branches; branch manager → just one. $church_ids = cv_get_user_church_ids( get_current_user_id() ); if ( empty( $church_ids ) ) { return []; // no access, no rows } // Build a safe placeholder list for the IN clause. $placeholders = implode( ',', array_fill( 0, count( $church_ids ), '%d' ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}cv_payments WHERE church_id IN ($placeholders) ORDER BY paid_at DESC", ...$church_ids ) );
The pattern is repeated across every read in the platform. cv_deposits, cv_payments, cv_subscriptions, cv_budgets, cv_tax_records — all gated by the same helper, all using $wpdb->prepare(), all passing church IDs through prepared statements rather than string-concatenated SQL. A non-admin user cannot return another church's data. The database is what enforces that, not the controller.
The same helper that returns one church ID for branch managers returns many for HQ admins — every branch in their network, computed via parent_hq_id walks on the cv_churches table. The query layer doesn't know or care; it just receives an array. Aggregated reports across an entire denomination network use exactly the same code path as a single branch's report. One pattern. Zero special cases.
A Lagos church might hold a Naira account at GTBank, a Dollar account for missionary support, and a Pounds account for diaspora partners. Most accounting tools either force a single base currency (and convert everything to it daily, generating phantom FX swings) or pretend the multi-currency case doesn't exist.
ChurchVault took a third path. Each bank account stores its own account_currency and account_symbol at row level. The dashboard never converts. Reports aggregate per currency, presenting three independent totals side by side rather than one fictitious blended number:
CREATE TABLE wp_cv_accounts ( account_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, church_id INT UNSIGNED NOT NULL, account_name VARCHAR(120) NOT NULL, account_currency CHAR(3) NOT NULL DEFAULT 'NGN', account_symbol VARCHAR(8) NOT NULL DEFAULT '₦', opening_balance DECIMAL(14,2) NOT NULL DEFAULT 0, INDEX (church_id, account_currency) ); -- Aggregated report stays per-currency. No blending. SELECT a.account_currency, a.account_symbol, SUM(p.amount) AS total FROM wp_cv_payments p JOIN wp_cv_accounts a ON a.account_id = p.account_id WHERE a.church_id IN (...) GROUP BY a.account_currency, a.account_symbol;
The dashboard auto-displays the correct symbol per account — a ₦ next to Naira balances, a $ next to USD, a £ next to GBP. Tax calculators read the currency of each transaction and apply the right country's rules to the right rows. A Nigerian payroll line gets PAYE; a UK Gift Aid donation gets the UK calculation. No conversion tables, no FX feeds, no phantom volatility. Three currencies, three totals, side by side.
The signup flow is deliberately quiet. A visitor lands on /pricing/, picks a plan, fills their church details on /signup/, and Paystack's inline checkout pops over the page — no redirect to an external domain, no broken back-button experience. On success, a webhook lands, a cv_subscriptions row is written with the Paystack subscription_code for future recurring charges, the church record is provisioned with status pending_setup, and an email pings me to do manual onboarding before flipping the church to active.
| Tier | Monthly | Annual |
|---|---|---|
| Essential · single church | ₦9,900 | ₦99,000 |
| Professional · multi-branch | ₦19,900 | ₦199,000 |
| Premium Pro · denomination scale | ₦39,900 | ₦399,000 |
Recurring billing is the part of any SaaS most likely to silently misbehave. ChurchVault runs a dedicated WP-Cron job, cv_auto_renewal_cron, that fires once per day. It walks every active subscription, identifies those whose next_billing_date falls today, and charges them via Paystack's subscription codes.
The retry path matters as much as the happy path. A failed charge — declined card, network blip, Paystack outage — triggers up to three retry attempts spread across seven days. After the third failure, the subscription is marked suspended, the church status flips, and the customer receives a notification email with a one-click reactivation link. No silent terminations. No surprise reactivations.
Above all of that sits a kill switch: a single Admin Tools toggle that pauses the entire renewal cron globally. If something looks off — duplicate charges, webhook flapping, an API change — one click halts every recurring charge across the platform until I've diagnosed it. This is the single most important administrative control on the system, and it's saved at least one Saturday already.
The Partners plugin powers ChurchVault's growth strategy: regional church consultants, denomination-level liaison officers, and commercial referrers who introduce new churches to the platform. Two architectural decisions made it work.
The default partner commission is 30% of every subscription payment, paid for the lifetime of the referred subscription. But strategic partners — those bringing entire denomination networks rather than individual churches — negotiate higher rates. Hardcoding tiers wouldn't scale. So commission rate lives on the partner row itself:
ALTER TABLE wp_cv_partners ADD COLUMN commission_rate DECIMAL(5,2) NOT NULL DEFAULT 30.00; -- A single UPDATE activates a custom rate. -- The first strategic partner: 35% under bilingual agreement. UPDATE wp_cv_partners SET commission_rate = 35.00 WHERE partner_id = 7;
Every payout calculation reads commission_rate from the partner row at the moment of payout — never hardcoded, never inherited from a tier table. The first strategic partnership signed was activated this way, formalised in a 17-page bilingual agreement at a 35% rate, with a single SQL update flipping the bit.
This was the harder design decision. A partner refers a church. Years later, the partnership terminates — for whatever reason. Should the referred church stop generating commissions? Almost every affiliate platform says yes. ChurchVault deliberately says no. Once a referral is attributed, the church continues earning the original partner commissions for the lifetime of its subscription, unless fraud is proven.
Implementing this required careful handling in the renewal cron — payouts continue to terminated partners' referrals — and explicit guard logic in the dashboard so terminated partners can still see their lifetime referral revenue without being able to refer new churches. It's a small detail with disproportionate impact on partner trust: people don't sign up for an affiliate programme that can revoke their work retroactively.
Denominations like RCCG, Anglican, and Methodist need oversight on branch-level expenditure. ChurchVault's approval layer lets branch managers mark transactions pending_approval; HQ admins see a queue, approve or reject, and the transaction either commits to the ledger or is bounced back with a reason. Fully optional — independent churches turn it off. Enterprise-grade where it's needed.
Customers set their account opening balance exactly once, on first login, per account. After that the field is locked behind a per-account user-meta flag (cv_ob_set_{account_id}) to prevent the most common self-inflicted accounting injury: re-entering an opening balance and silently corrupting historical totals. Super admins can reset the lock from Admin Tools when a customer needs a correction — but the customer themselves can't.
Built into the platform: PAYE, CIT, and VAT calculations for Nigerian tax residence, plus Gift Aid handling for UK branches. Rate tables are hardcoded and editable in the plugin, no third-party tax API. Works offline, deploys instantly when rates change, and is country-aware — a transaction's tax treatment is determined by its account's currency and the church's registered locale.
Two complementary read-only diagnostics surfaces. Health Monitor shows table row counts and schema status — am I on the right migration version, do all expected tables exist. System Audit hunts for orphan records, balance reconciliation drift, cron health, and Paystack key validity. Both surface issues before customers notice them. Both refuse to mutate anything; they only report.
One bug worth memorialising. WordPress treats every PHP file containing a Plugin Name: header in a plugin's root folder as a separate plugin entry in the admin UI. Renaming a file to -old.php didn't help — WordPress still saw it. Worse, deleting one of the duplicate entries via the UI deleted the whole folder. Fix: rename inactive backup files via Hostinger's file manager only, never via the WordPress UI. Diagnosed once, never repeated.
From initial concept in Q4 2025 to first paying Nigerian denomination signup in April 2026 — a five-month solo build covering core finance engine, multi-tenancy, multi-currency accounting, recurring Paystack billing, partner programme infrastructure, public showcase site, and a 17-page bilingual strategic partnership agreement. All four plugins shipped. All running live. All on a single Hostinger Cloud plan.
Active roadmap items include expanding the public showcase directory (more church profile pages, denominational filtering), an admin reporting suite for HQ-level cross-branch financial review, a Premium Pro tier rollout for full denomination networks, and ongoing strategic partnership outreach to additional Nigerian denominations.