Justin

David

All work
In progress2026·7 months · ongoing

Pastura

A multi-tenant SaaS for Nigerian Pentecostal churches. Twenty modules spanning auth, financial infrastructure, scheduling, content delivery, and messaging — built solo over seven months, with AI as a planning partner rather than just a code generator.

Pastura screenshot

Role

Founder & Sole Developer

Duration

7 months · ongoing

Core stack

Turborepo · pnpm workspaces · Shared api-contracts package

Context

Overview

Pastura is a church management platform built for Nigerian Pentecostal churches with 2–20 branches. It handles the operational core — members, attendance, giving, remittances, schedules, events, tasks — and adds a content/LMS module for discipleship training, a messaging system with a branch-scoped credit ledger, and a payment layer that reconciles through Paystack. The codebase is a Turborepo monorepo: Fastify 5 API, Next.js frontend running in CSR mode (a Tauri desktop shell is planned), Prisma on Neon, and a shared api-contracts package that keeps the frontend and backend from drifting. Twenty modules. One developer.

The Brief

Church management software in the Nigerian market mostly looks like CRUD with a coat of paint. The ambition here was different: build an operational system that helps leaders understand what's happening in their church and what to do about it. That means every decision is made with the assumption that the platform will eventually carry real financial data, real pastoral notes, and multi-branch hierarchies — even in v1, where the test set is a handful of churches and the smallest is around twenty members.

Constraints

  • Multi-tenant from day one — organization and branch isolation enforced at every API boundary, not just at login
  • Financial data (giving, remittances) must be immutable once recorded, with a full audit trail and void-and-replace correction
  • Monetary values stored as BigInt in kobo — floating-point arithmetic on money is not negotiable
  • The role model must not explode as features arrive — department leaders stay MEMBER in their JWT and receive scoped permissions instead of a new global role
  • Feature entitlements live at the service boundary (`requireFeatureAccess`) rather than being scattered through feature code — free tier and paid tier share one implementation
  • Certificate issuance, remittance reconciliation, and task synchronization all need strong idempotency — a retry must never produce a duplicate
  • Tauri desktop shell is a hard constraint that dictates a pure-CSR Next.js frontend and a Fastify API independent of the Next.js runtime
  • The API must be provider-agnostic — swapping Paystack or Termii should touch one directory, not the whole codebase

01

Multi-tenancy without leaks

The critical security boundary is Organization A ╳ Organization B. A user authenticated into one org must never be able to manipulate another org's members, giving, attendance, or financial records — even if they know the resource ID. That rules out the pattern most CRUD apps fall into: `if (user.role === 'GO') { allow }`. Role checks alone are insufficient because the same role exists in every org. Every read and write is scoped by `orgId` and, where relevant, `branchId` — enforced in the service layer, not just route middleware.

The role model was deliberately kept small. Five global roles: `GENERAL_OVERSEER`, `REGIONAL_HEAD`, `BRANCH_PASTOR`, `FINANCIAL_SECRETARY`, `MEMBER`. A department leader stays `MEMBER` in their JWT and receives a scoped permission grant instead. This avoids the failure mode where every new capability adds a new global role — and it keeps authorization tractable as the platform grows. Feature entitlements follow the same principle: `requireFeatureAccess(orgId, 'JOURNEY')` is called once at the service boundary, and the feature implementation below it has no idea whether the org is on the free tier or the paid tier.

Every domain table carries `orgId` and, where relevant, `branchId` as explicit columns — never derived at query time from a joined member record. Scheduled jobs and webhooks bypass user-level auth but use their own secret and scope, and never touch tenant data without an explicit `orgId` from the request. Invite tokens expose only masked information so users can accept an invitation without seeing sensitive org data first.

  • Three audit log tables — `GivingAuditLog`, `RemittanceAuditLog`, `MembershipAuditLog` — share the same shape and the same deliberate choice: plain indexed string columns for actor IDs instead of Prisma relations, so an audit trail outlives the record it describes and is never blocked by referential integrity on a deleted member.
  • `Transaction.providerEventId` is non-nullable with a `""` default. In Postgres, `NULL != NULL`, so a nullable composite unique would silently allow unlimited rows with `(provider=X, providerEventId=NULL)`. The sentinel gives every row a real string value from the first write.
  • The `Provider Naming Rule` is stated once and enforced by review: the strings `paystack` and `termii` appear in exactly three places — the concrete provider folders and the one `getProvider()` switch that maps a provider string to an implementation. Everywhere else in the codebase is provider-agnostic.

02

Financial infrastructure as a first-class concern

Most of Pastura's modules are interesting. The financial modules are the ones where mistakes cost real money, and they're designed accordingly. Giving records are immutable once created — to correct an error, you void the original and create a replacement, preserving the audit trail. Remittance periods are enforced sequentially in SERIALIZABLE transactions: you can't submit period 5 if period 4 hasn't closed, and the rate is snapshotted at submission time inside the transaction so a concurrent rate change can't produce a snapshot inconsistent with what the branch saw.

The messaging credit system is where the financial discipline shows most clearly. A branch buys credits through Paystack, spends them on SMS and WhatsApp sends, and gets refunds under a policy that reads like it was written by someone who has thought carefully about failure modes. Credits live in an append-only ledger. `BranchMessagingAccount.creditsAvailable` is a denormalized cache, and the ledger is the source of truth. In any discrepancy, the ledger wins. The write order enforces this: ledger entry first, cache update second. A crash between the two leaves a stale cache that a future reconciliation job can repair by summing the ledger — and `getBalance()` can switch to ledger-sum mode without any other code changing.

Idempotency is layered three independent ways. `Transaction.reference` is unique — a duplicate payment intent can't create two transactions. `@@unique([provider, providerEventId])` prevents a webhook replay from being processed twice. `MessageCreditLedgerEntry.idempotencyKey` is unique — a worker retry can't double-credit a branch. Any one of these alone would prevent most duplicate processing. All three together make the system safe under concurrent webhook delivery, worker retries, and cron repair runs happening simultaneously.

The provider abstraction is what makes any of this portable. Every SMS and WhatsApp send returns a `DeliveryResult` — a three-state union of `ACCEPTED`, `REJECTED`, or `UNKNOWN`. Each provider implementation translates its own chaotic HTTP responses into one of those three states, and the worker reads only `result.status`. It never inspects `result.providerResponse` fields. Adding a second SMS provider means implementing the interface and adding a routing case — the worker doesn't change. The payment side follows the same pattern.

ts
type DeliveryResult =
  | { status: "ACCEPTED"; providerMessageId: string; providerResponse: Record<string, unknown> }
  | { status: "REJECTED"; providerResponse: Record<string, unknown> }
  | { status: "UNKNOWN";  providerResponse: Record<string, unknown> };

// Termii classification, lives only inside termii.sms.provider.ts:
//   HTTP 2xx + message ID     → ACCEPTED
//   HTTP 2xx, no message ID   → UNKNOWN
//   HTTP 4xx                  → REJECTED
//   HTTP 5xx                  → UNKNOWN
//   Network error / timeout   → UNKNOWN
Three states, one honest answer. The default is UNKNOWN because classifying something as ACCEPTED or REJECTED when you're not sure is worse than admitting you don't know.
  • The refund policy is conservative by design: refund only on explicit `REJECTED`. Never on `UNKNOWN` (the message may have been accepted). Never on worker crash (delivery state is ambiguous). Over-refunding is exploitable and silent; under-refunding is visible and recoverable via manual adjustment.
  • `attemptCount` on a message recipient counts lock acquisitions, not confirmed provider calls. A crashed worker burns one retry slot. This is deliberate — the alternative would allow infinite retry loops under crash conditions, which is the more dangerous failure mode.
  • Remittance uses SERIALIZABLE isolation on every state transition because two concurrent submissions could both pass the sequential-period check independently and then interleave writes that break the gapless timeline. Postgres serialization failures are caught and returned as `409 Conflict`.
  • All money is stored as `BigInt` in kobo. `0.1 + 0.2 !== 0.3` is not an acceptable bug in a financial system, and the choice was made before the first line of giving code was written.

03

A synchronization engine for scheduled tasks

Church operations are recurring. A Sunday service needs someone on chairs, someone on sound, someone on the welcome desk. Pastura models this as Task Templates attached to Service Schedules: define the tasks once, and they regenerate every time the schedule produces a new service instance.

The engineering problem is that scheduled services and generated tasks are two independent state machines that need to stay in sync. A service can be edited (time changes), deleted (schedule pattern changed), or rescheduled. Tasks can be edited by users (detach from the template) or cancelled. When the schedule regenerates its instances, it must not destroy any service row that a task still points at — and it must update the tasks whose services were patched rather than recreated.

The reconciliation runs inside the same transaction as the service regeneration. When a schedule's pattern changes, the engine finds every upcoming service, checks which ones are still referenced by a task via a polymorphic `linkedType`/`linkedId` column pair, and either deletes or patches each one. That polymorphic link is deliberately *not* a Prisma relation — a task can link to a service, an event, or an event instance, and three nullable foreign keys would be worse than two plain string columns with an index. The cost is that reconciliation is a two-query intersection in application code instead of a nested relation filter. The benefit is that adding a fourth link target later requires zero schema migration.

The sync state machine has two states: `SYNCED` and `DETACHED`. A user editing a template-sourced task with scope `THIS` detaches it. Editing with `ALL_FUTURE` propagates the change to every synced task on every upcoming service. Template deletion cancels the synced tasks but leaves detached ones alone. The backfill dedup keys on 'any task row exists for this (template, service) pair regardless of status' — a cancelled-then-detached tombstone still counts as existing, which prevents resurrection after a hard delete. Get that rule wrong and you either resurrect killed tasks or under-generate new ones.

ts
const candidates = await tx.service.findMany({
  where: { scheduleId: schedule.id, isModified: false, status: "UPCOMING" },
  select: { id: true, date: true, startTime: true, endTime: true },
});

const linkedServiceIds = new Set(
  (await tx.task.findMany({
    where: { linkedType: "SERVICE", linkedId: { in: candidates.map(c => c.id) } },
    select: { linkedId: true },
    distinct: ["linkedId"],
  })).map(t => t.linkedId!),
);

const toDelete = candidates.filter(c => !linkedServiceIds.has(c.id));
const toPatch  = candidates.filter(c =>  linkedServiceIds.has(c.id));
Two-query intersection instead of a nested Prisma filter — `Task` has no relation field to `Service`, by design.
  • The implementation plan for this module went through three versions. The v2 plan assumed a Prisma relation that didn't exist. The v3 plan was produced by reading the actual schema and every route handler the plan would touch — three real discrepancies were caught at the plan level rather than becoming bugs in production.
  • Task creation authorization is centralized: standalone tasks go through `resolveTaskCreationScope` with a hierarchy-based matrix, while delegated tasks linked to a service or event inherit the write-authorization of the underlying resource instead of re-deriving a scope. One function, two entry points.

04

Content module: sequential gating without a rules engine

The content module lets a General Overseer author training courses — sections, typed lessons, quizzes, pricing — and lets org members browse, enroll, learn, earn certificates, and rate. Courses are org-scoped; the GO is the only author role, and they have structural access to every course in their org rather than appearing as an enrolled learner.

A lesson's content type (`ARTICLE`, `DOCUMENT`, `VIDEO`, `QUIZ`) is immutable after creation. Changing it requires delete-and-recreate. This looks restrictive, but the alternative would mean dropping old columns, populating new ones, deleting orphaned quiz rows, invalidating every learner's progress on that lesson, and potentially cascading to certificates. Every step is destructive and there's no clean rollback. The mitigation isn't a mutability flag — it's a deliberate friction at creation time, followed by a `Duplicate as` action if the user picked wrong.

Sequential lesson gating is flattened across the whole course, not per-section. Lesson N is fetchable only if lesson N−1 has a `completedAt`. This avoids the reasoning trap of 'finish Section 1, unlock all of Section 2' and keeps the ordering rule trivial to reason about: there is one list, in one order.

Certificates are issued eagerly — inside the same transaction as the completion write that triggers them. The `(userId, courseId)` unique constraint is what prevents double issuance under concurrency: the second request catches its own `P2002` violation and returns `{ issued: false }` because the first request will have already emitted the notification. The catch is narrowed to check `err.meta.target` includes `userId_courseId` so a `verificationCode` collision (astronomically unlikely) still surfaces as a real error instead of being silently swallowed.

ts
try {
  const cert = await tx.contentCertificate.create({
    data: { userId, courseId, verificationCode: nanoid(12) },
  });
  return { issued: true, certificate: cert };
} catch (err) {
  if (
    err instanceof Prisma.PrismaClientKnownRequestError &&
    err.code === "P2002" &&
    (err.meta?.target as string[])?.includes("userId_courseId")
  ) {
    return { issued: false };
  }
  throw err;
}
Certificate issuance returns a discriminated union so the caller knows whether to emit the notification. The P2002 catch is narrow on purpose.
  • Curriculum locks at publish time, not at first certificate. Content freezes; metadata (title, cover, category, roles) and price stay editable. Course deletion is blocked on non-DRAFT status, and archived courses can't be deleted either — certificates must survive, and entitlements grant perpetual access.
  • Quiz grading is single-correct with no partial credit, enforced at both write time and grading time. `isCorrect` is never returned to learners in API responses until after submission — the frontend literally never receives which option is right.
  • Every user-visible noun comes from a single `copy.ts` file. The backend says 'Lesson' but the UI says 'Session', because 'Lesson' carries K-12 weight that doesn't fit adult discipleship training. One file changes if a term turns out wrong.

05

Solo development with AI as a planning partner

Pastura is built by one developer. The tooling that makes that feasible isn't a code generator — it's a discipline of writing implementation plans detailed enough to be reviewed, corrected, and handed off. Every module starts as a planning document that specifies schema changes, service-layer responsibilities, transaction boundaries, authorization rules, error shapes, test cases, and rollback path. The plan is reviewed for over-engineering, missing edge cases, and assumptions that contradict the actual codebase before a single line of production code is written.

The Task Synchronization plan went through three versions. The v2 plan made an assumption about the schema — that `Task` had a Prisma relation to `Service` — that turned out to be wrong. The v3 plan was produced by reading the actual `schema.prisma`, every service file the plan would touch, and every route handler that would consume the new functions. Three real discrepancies were found and fixed at the plan level. None of them became bugs, because the plan was audited before implementation.

This is what makes the AI-assisted workflow tractable at Pastura's scale. Models are good at generating code and bad at knowing whether the code fits. The discipline is: use the model to explore the shape of the solution, then reconcile the plan against the real code, then implement. The plan document is the artifact that bridges the two — it's what makes the workflow reproducible, reviewable, and correctable.

The honest version is that some modules were right the first time and some were corrected three times. Both are in the git history. That's the real story of building something this large solo, and it's more useful than pretending the first draft is always the last.

  • Planning is done with higher-reasoning models; implementation is done with cheaper ones. The plan is the artifact that makes the handoff work.
  • Every plan document specifies its dependency graph against other modules, its merge-conflict surfaces with parallel work, and its rollback path — because none of those are obvious once the code exists.
  • Reconciling a plan against the real codebase is a step, not an afterthought. It's what caught the `Task`→`Service` relation assumption before it shipped.

06

What's next: the Journey module

The direction Pastura is building toward is a module called Journey. It sits above members, attendance, events, giving, and content, and turns historical church activity into actionable follow-up workflows. The central question it answers is: which members need attention, why do they need attention, what should we do, who should do it, and what happened afterward?

The first trigger will be attendance-based: three consecutive missed main worship services creates a follow-up case, assigned to the member's department leader rather than the pastor. The system detects signals, not diagnoses — the trigger creates a follow-up opportunity, not a judgment. A missing member could be traveling, studying, sick, or attending another approved activity; the follow-up is how the church finds out which.

The architectural discipline is the same as everywhere else in Pastura: the attendance module emits a domain event, the Journey trigger evaluator listens, and the Journey module creates the case. Attendance never knows about Journey, and Journey never reimplements attendance logic. Strong idempotency at the database level — not just the application level — prevents the failure mode where a retried evaluation creates fourteen identical cases for the same member.

Journey is still in planning. The specification is written; the implementation is next. The point of writing this case study now, before the module ships, is to make the trajectory visible — a church management platform is unremarkable, but a church operations system that turns historic activity into actionable care is a different product category entirely.

Progress

Shipped

  • Auth & Identity — dual-layer session model (platform identity + org-scoped) with rotating refresh tokens, session lineage, and transparent bcrypt→Argon2id migration on first login
  • Organization hierarchy — Region → Branch → Department with enforced multi-tenant isolation
  • Members, proxy members, and invitation flow with masked-email public endpoints
  • Giving — immutable records with void-and-replace corrections, BigInt kobo arithmetic, and locked accounting periods
  • Online Giving — Paystack integration with idempotent payment intent creation, webhook replay protection, and reconciliation cron
  • Remittances — SERIALIZABLE transactions, sequential period enforcement, rate snapshotting, and cross-boundary reconciliation
  • Attendance — unified session model for services and events with QR check-in and worker headcount
  • Schedules & Events — 31-day rolling window, `FOR UPDATE SKIP LOCKED` job queue, event instance normalization
  • Tasks — full template synchronization engine with SYNCED/DETACHED state, backfill dedup, and per-instance scope control
  • Calendar — read-only aggregator projecting services, events, and tasks into one feed
  • Content/LMS — authoring, purchase, sequential gating, quizzes, eager certificate issuance
  • Messaging — SMS + WhatsApp with branch-scoped credit ledger, three-layer idempotency, and a conservative refund policy
  • Payments — provider-agnostic abstraction over Paystack with a reconciliation cron as the safety net for lost webhooks
  • Notifications — EventBus + orchestrator + SSE/push/email channels with dedupe-key uniqueness
  • Dashboard and Insights — role-scoped views for GO, Branch Pastor, Financial Secretary, and Member

Next

  • Journey — attendance-based follow-up workflows with structured resolution, assignment hierarchy, and audit history
  • Church Secretary role — non-financial operational visibility without finance permissions
  • Registrations and custom forms for events, visitors, and membership applications
  • Tauri desktop shell — API-first architecture already supports it, packaging is the remaining work
  • Public Content Marketplace — long-term direction for church content discovery

Impact

Status

Pre-launch

v1 feature-complete, in testing

Modules shipped

20

Auth · Orgs · Regions · Branches · Departments · Members · Invites · Giving · Remittances · Attendance · Schedules · Events · Tasks · Calendar · Content · Messaging · Payments · Credits · Notifications · Dashboard

Platform

Multi-tenant

Organization and branch isolation at every boundary

Team

1

Solo developer · 7 months · AI-assisted planning workflow

Stack

Monorepo

  • Turborepo
  • pnpm workspaces
  • Shared api-contracts package

Backend

  • Fastify 5
  • TypeScript
  • Prisma 6
  • PostgreSQL (Neon)

Frontend

  • Next.js 16 (CSR)
  • TanStack Query
  • Zustand
  • Tailwind CSS
  • shadcn/ui

Storage

  • Cloudflare R2
  • Presigned uploads

Integrations

  • Paystack
  • Termii
  • Resend

Infrastructure

  • Railway (API)
  • Vercel (web)
  • Docker
  • Luxon
  • Argon2id

What I'd do differently

The single biggest lesson from building Pastura has been that the plan is the artifact. The code is downstream of it, and the workflow that works — for me, for the models I use, for the future maintainer of this codebase — is the one where the plan is explicit enough to be reviewed and corrected before it becomes code. The second lesson is that small architectural decisions compound. Choosing BigInt for money instead of number looked like a formality at the start; seven months later it's the reason the financial modules don't have a category of bug that most CRUD church software ships with. The third, and the one I'm most aware of as I move into Journey: the temptation to over-engineer is strongest right when you have the most momentum. Journey could be a workflow engine, a rules DSL, an AI-driven churn-scoring system. It should be none of those things in v1. It should be: detect a pattern, assign a person, record what happened, and let the church decide what it means.

Next case study

JacinthForge

Frontend Engineer • Production LMS