# Deploying the PCEA ChMS

Written for a parish administrator with shell access, not a platform engineer.
Follow it in order; each step assumes the one before it.

Budget half a day for the first deploy, and do it on a **Monday or Tuesday** —
never a Saturday. If something goes wrong you want the week ahead of you, not a
service in fourteen hours.

---

## 0. Before you touch a server

- [ ] A domain, and TLS. Let's Encrypt is free and fine.
- [ ] The paybill's Daraja credentials from the Safaricom developer portal.
- [ ] Safaricom's current egress IP ranges (email `apisupport@safaricom.co.ke` —
      they change, and this is the one item with a lead time).
- [ ] An Africa's Talking account and an approved sender ID, if you want SMS
      receipts on day one. You can go live without it.
- [ ] **Registration with the ODPC as a data controller.** Member contribution
      records are personal data under Kenya's Data Protection Act 2019. This is
      not optional and it is not instant — start it before anything else here.
- [ ] The real district names and each district's annual budget for the current
      financial year. Without budgets, half the reports have nothing to measure
      against.

---

## Just trying it out?

See **`deploy/LOCALHOST.md`** — PHP's built-in server, fifteen minutes, no
Apache configuration. This document is for a real parish server.

## The short version — installing from a browser

If you are on cPanel or any shared host, you do not need the rest of this
document.

1. Create an **empty** database and a user with `SELECT, INSERT, UPDATE` on it
   (cPanel → MySQL Databases). Do not use the root account.
2. Upload the release and point the domain's document root at **`public/`**.
3. Visit `https://your-domain/install.php` and follow the four steps.
4. **Delete `public/install.php`.** The installer locks itself once an
   administrator exists, but a file that shows an installer to anyone who finds
   it should not stay on a live server.
5. Run `php tools/preflight.php` if you have shell access, or work down the list
   the installer shows you on its last page.

The installer creates the tables, the parish, its districts, the financial year
and every Sunday in it, the fellowship groups, and your administrator account.
It writes `.env` for you.

The rest of this document is the manual route, and the operational detail —
backups, cron, M-Pesa registration — that applies either way.

## 1. Server

Minimum for a single parish: 1 vCPU, 2 GB RAM, 20 GB disk. This is a small
application; 6,000 contributions a year is nothing. Anything larger is money
better spent elsewhere.

```bash
sudo apt update
sudo apt install -y nginx mariadb-server php8.3-fpm php8.3-mysql \
                    php8.3-mbstring php8.3-curl php8.3-xml certbot python3-certbot-nginx
sudo mysql_secure_installation
```

Kenyan hosting works well and keeps latency and data local. Any provider with a
Nairobi region is fine — this does not need a hyperscaler.

---

## 2. Files

```bash
sudo mkdir -p /var/www/pcea
sudo chown -R "$USER":www-data /var/www/pcea
# copy the project in, then:
cd /var/www/pcea
mkdir -p storage/backups
chmod 750 storage storage/backups
sudo chown -R www-data:www-data storage
```

**The document root is `public/`, and nothing above it may be reachable over
HTTP.** `src/`, `sql/`, `tools/` and `.env` all contain things that must not be
served. The pre-flight check in step 6 verifies this.

---

## 3. Database

```bash
sudo mysql <<'SQL'
CREATE DATABASE pcea_chms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'chms_app'@'localhost' IDENTIFIED BY 'GENERATE-A-LONG-RANDOM-ONE';
GRANT SELECT, INSERT, UPDATE ON pcea_chms.* TO 'chms_app'@'localhost';
-- The audit log and the callback log are append-only. The application can add
-- to them and read them, and cannot alter or remove a line.
REVOKE UPDATE ON pcea_chms.audit_log FROM 'chms_app'@'localhost';
REVOKE UPDATE ON pcea_chms.mpesa_callback_log FROM 'chms_app'@'localhost';
FLUSH PRIVILEGES;
SQL

# Fresh install — one file, generated from the migrations so it is provably
# identical to what an upgraded parish has.
#
# Import AS the app's own database user (chms_app above), never as root or
# a broader admin account — even though install.sql itself carries no
# DEFINER clause, MySQL/MariaDB silently assigns whichever user is
# CONNECTED as the implicit definer of every trigger it creates. Import as
# root here and every trigger in the live database quietly gets
# DEFINER=root baked in; the very first backup restore-drill run under the
# app's own narrower credentials then fails on each one with "Access
# denied; you need SUPER... privilege" — found only by actually running a
# full backup-and-restore cycle against a properly scoped user, not by
# reading this file and assuming importing as root was harmless.
mysql -u chms_app -p pcea_chms < sql/install.sql

# EXISTING installation being upgraded — the numbered migrations instead,
# in order, same rule: as the app's own user, not root. Do not run
# install.sql over live data — it is a fresh schema, not an upgrade path.
#   for f in 01_schema 02_views 03_upgrade 04_operations 05_reconcile 06_whatsapp; do
#     mysql -u chms_app -p pcea_chms < sql/$f.sql || break
#   done
```

Note the app account has **no DELETE grant anywhere**. Combined with the
triggers, financial rows cannot be removed even by a compromised application.

---

## 4. Configuration

```bash
cp deploy/.env.example .env
chmod 600 .env
chown www-data:www-data .env
$EDITOR .env
```

Every value matters, but two decide whether the system is safe:

- `DB_PASS` — long and random, used nowhere else.
- `MPESA_ALLOWED_CIDRS` — blank means your webhook accepts a payment
  notification from anyone on the internet.

Leave `AT_API_KEY` blank for the first week. The SMS queue then runs dry: it
prints what it *would* send, with the per-message cost, and nothing reaches a
member. Read a few days of that output before turning it on.

---

## 5. Web server

```bash
sudo cp deploy/nginx.conf /etc/nginx/sites-available/pcea
sudo ln -s /etc/nginx/sites-available/pcea /etc/nginx/sites-enabled/
# add to /etc/nginx/nginx.conf inside http{}:
#   limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d parish.example.co.ke
```

On cPanel shared hosting: check first whether this domain's document root
can be pointed at `public/` directly (cPanel → Domains; usually yes for an
Addon Domain, often no for a primary domain on an entry-level plan). If it
can, the included `public/.htaccess` does the same job as the nginx
config above — nothing else needed, and `sql/`, `tools/`, `src/`, and
`storage/` are structurally outside the web root, not just blocked by a
rule.

If the document root cannot be moved and the whole project has to sit
inside `public_html/` — the common case on budget Kenyan shared hosting —
use `deploy/htaccess-if-document-root-cannot-be-moved/` instead; its own
`README.md` says exactly where each file goes. It's tested against a real
Apache instance with a genuinely privilege-scoped database user, not just
written and assumed correct.

---

## 6. Pre-flight

```bash
php tools/preflight.php
```

It exits non-zero and prints what to fix. **Do not go live until it passes.**
It checks the things that quietly go wrong: demo passwords still in place, the
app connecting as database root, MyISAM tables with no transactions, a `.env`
inside the document root, backups configured but never restored.

---

## 7. Real data

Sign in as the administrator, change the password immediately, then work down
the setup checklist the dashboard shows you. It is ordered as a dependency
chain — districts, then the financial year, then budgets, then members.

Do **not** run `tools/seed.php` on a live parish. It truncates every table.

If you have the historical workbook, import it before anyone starts entering by
hand, so the running balances are right from the first week.

---

## 8. Scheduled jobs

```cron
# SMS queue, every five minutes
*/5 * * * * cd /var/www/pcea && php tools/send_queue.php >> storage/sms.log 2>&1

# Backup at 01:15, verified by restoring into a scratch database
15 1 * * * /var/www/pcea/deploy/backup.sh >> /var/log/pcea-backup.log 2>&1

# Weekly pre-flight, mailed to the administrator — catches drift
0 6 * * 1 cd /var/www/pcea && php tools/preflight.php
```

---

## 9. M-Pesa callbacks

Register once, after TLS is working and `preflight` passes:

```bash
curl -X POST https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "ShortCode": "YOUR_PAYBILL",
    "ResponseType": "Completed",
    "ConfirmationURL": "https://parish.example.co.ke/api/mpesa/c2b.php?hook=confirmation",
    "ValidationURL":   "https://parish.example.co.ke/api/mpesa/c2b.php?hook=validation"
  }'
```

`ResponseType: Completed` means Safaricom completes a payment if your server is
unreachable. For a church that is the only defensible setting — an offering must
never bounce at the till because a server was restarting.

**Test with KES 10 from a real phone before announcing anything.** Then check
`mpesa_callback_log`: the row proves what Safaricom sent and when, and it is the
only thing that will answer the first "my payment is missing" question.

---

## 10. The first month

Run the old workbook and this system side by side for four weeks. Do not stop
the spreadsheet. Each Sunday, compare the district report totals; they must
agree to the shilling. When four consecutive weeks agree, retire the workbook —
and keep a copy of the final file forever.

Tell members about the paybill account number **after** that month, not before.
Reconciliation gets much easier once phone numbers are on file, and the first
weeks are when the roll is most incomplete.

---

## Rolling back

```bash
cd /var/www/pcea && git checkout <previous-tag>   # or restore the previous folder
sudo systemctl reload php8.3-fpm
```

Schema changes are additive — none of the four migrations drops a column or a
table — so an older application runs against a newer database. That is
deliberate: it means a bad deploy is a two-minute reversal rather than a restore.

Restoring data is the last resort, and it loses everything since the last
backup:

```bash
gunzip -c storage/backups/pcea-YYYYMMDD-HHMMSS.sql.gz | mysql pcea_chms
```

---

## When something is wrong

| Symptom | Look here first |
|---|---|
| A member's payment is missing | `mpesa_callback_log` — did Safaricom send it? |
| Payments arrive but nobody is matched | `member.phone` coverage; see the members page banner |
| No SMS reaching anyone | `AT_API_KEY` set? `notification_outbox.last_error`? |
| Report totals disagree with the sheet | `driftAfterSignoff()`, then `audit_log` |
| Someone cannot sign in | `login_attempt`, then `app_user.locked_until` |
| Page is blank | PHP error log — errors are never shown in the browser |

Every financial write is in `audit_log` with who, when and from where. It is
append-only at the database grant level, so it can be trusted.
