PastCare billing system — individual subscriptions, tier upgrades, proration, denominational licenses, payment flow, and billing state machines. Use when writing or reviewing any code related to subscriptions, billing, Paystack payments, or tier changes.
Two parallel billing models exist side by side:
| Model | Entity | Who pays | Pricing basis |
|---|---|---|---|
| Individual | ChurchSubscription | Church (via Paystack) | Member count tiers |
| Denominational | DenominationalLicense | Denomination HQ (via Paystack) | Branch count tiers |
A church's ChurchSubscription.licenseType distinguishes the two:
INDIVIDUAL — church pays its own subscriptionDENOMINATIONAL — covered by the denomination's DenominationalLicense[New Church] → NEW_CHURCH (no subscription record)
↓ selects plan & pays
[ACTIVE] ← successful payment / renewal
↓ payment fails
[PAST_DUE] → grace period active (gracePeriodDays > 0)
↓ grace period expires
[SUSPENDED] → data retained for 90 days, then deleted
↓ manually or via payment
[ACTIVE]
[ACTIVE] → user cancels → [CANCELED] (access until currentPeriodEnd)
[CANCELED] + currentPeriodEnd passed → [SUSPENDED]
ChurchSubscription| Field | Meaning |
|---|---|
status | ACTIVE, PAST_DUE, SUSPENDED, CANCELED |
licenseType | INDIVIDUAL or DENOMINATIONAL |
nextBillingDate | LocalDate of next charge |
currentPeriodStart / currentPeriodEnd | LocalDate bounds |
gracePeriodDays | Days remaining in grace period (0 = no grace) |
autoRenew | Whether Paystack should auto-charge |
paystackAuthorizationCode | Card token for recurring charges — NEVER expose in API responses |
promotionalCreditMonths | Free months remaining |
The billing-page component resolves to one of four scenarios:
| Scenario | Condition |
|---|---|
LOADING | Data not yet fetched |
NO_ACCESS | MEMBER role or no SUBSCRIPTION_VIEW permission |
NEW_CHURCH | No subscription record (404 from API) |
EXPIRED | Status is PAST_DUE, SUSPENDED, or CANCELED |
ACTIVE | Status is ACTIVE (includes grace period) |
Stored in CongregationPricingTier. Prices in USD, displayed in GHS.
| Tier | Members | USD/month | GHS/month (at 12.00 rate) |
|---|---|---|---|
| Small | 1–200 | $5.99 | GHS 72 |
| Standard | 201–500 | $9.99 | GHS 120 |
| Professional | 501–1,000 | $13.99 | GHS 168 |
| Enterprise | 1,001+ | $17.99 | GHS 216 |
GHS formula: ceil(usdPrice × exchangeRate) — always round UP.
Billing intervals: MONTHLY, QUARTERLY, BIANNUAL, ANNUAL. Annual saves ~2 months vs monthly.
licenseType must be INDIVIDUAL — DENOMINATIONAL churches CANNOT self-upgradeSUBSCRIPTION_MANAGE permissionPOST /api/billing/tier-upgrade/preview) — returns prorated breakdown, no payment createdPOST /api/billing/tier-upgrade/initiate) — creates Paystack payment session, returns URLProrationCalculationService)daysRemaining = nextBillingDate - today
(capped at totalDaysInPeriod to handle edge cases)
daysUsed = totalDaysInPeriod - daysRemaining
credit = (oldAnnualPrice / totalDaysInPeriod) × daysRemaining [negative charge]
newPlanCost = (newAnnualPrice / totalDaysInPeriod) × daysRemaining
dueToday = newPlanCost - credit
= (newDailyRate - oldDailyRate) × daysRemaining
Three calculation paths:
calculateTierOnlyUpgrade — same interval, different tiercalculateIntervalOnlyChange — same tier, different intervalcalculateCombinedUpgrade — both tier and interval changedaysRemaining > totalDaysInPeriod → cap at totalDaysInPeriod (handles test data / billing date drift)dueToday ≤ 0 → no charge; upgrade is freeTierUpgradeService.completeUpgrade() is called from the webhook or verify endpoint:
ChurchSubscription.pricingTier and billingIntervalnextBillingDate to the new periodTierChangeHistory as COMPLETEDPayment as COMPLETED[Created] → status = SUSPENDED (no payment yet)
↓ payment verified
[ACTIVE] → currentPeriodEnd set, branches activated
↓ payment fails on renewal
[PAST_DUE] → grace period active
↓ grace expires
[SUSPENDED] → data retained 180 days
↓ canceled by admin
[CANCELED] → access until currentPeriodEnd, then SUSPENDED
ScheduledTasks)processLicenseRenewals() — daily, charges auto-renew licenses before expirysuspendPastDueLicenses() — daily, moves PAST_DUE → SUSPENDED after graceprocessExpiredCanceledLicenses() — daily, moves CANCELED → SUSPENDED after currentPeriodEnd| Aspect | Individual | Denominational |
|---|---|---|
| Data retention | 90 days | 180 days |
| Pricing | Member count | Branch count |
| Branch effect | N/A | All branches ACTIVE when license active; all revert to SUSPENDED INDIVIDUAL when license expires |
| Tier change | Self-service via billing page | Via denomination HQ admin → SUPERADMIN |
All payments (individual + denominational) land in the Payment entity.
DenominationPaymentResponse)id, amount, currency, status, paymentType, description, cardLast4, cardBrand, paymentMethodType, paymentDate, createdAt
paystackAuthorizationCode — live card token for recurring charges
ipAddress, userAgent — PII
metadata — internal JSON with system IDs
failureReason — may leak system internals
paystackTransactionId, paystackReference — internal Paystack IDs
| Service | Responsibility |
|---|---|
BillingService | Individual subscription management (create, renew, cancel, status) |
TierUpgradeService | Preview + initiate + complete tier upgrades |
ProrationCalculationService | Proration math for tier/interval changes |
TierValidationService | Validates tier selection against member count |
DenominationalBillingService | Denominational license billing (create, renew, cancel, pricing tiers) |
DenominationService | Denomination CRUD + branch management |
SubscriptionCheckService | Determines whether a church has active access |
TierEnforcementService | Enforces tier limits (blocks features when exceeded) |
// Individual
subscriptionRepository.findByChurchId(churchId)
subscriptionRepository.findByChurchIdAndStatus(churchId, "ACTIVE")
subscriptionRepository.findActiveByChurchIdForUpdate(churchId) // PESSIMISTIC_WRITE
// Denominational
licenseRepository.findByDenominationId(denominationId)
denominationRepository.findByInviteCode(code)
denominationRepository.findByHeadquartersChurchId(churchId)
| Permission | Who has it | Controls |
|---|---|---|
SUBSCRIPTION_VIEW | ADMIN, PASTOR, TREASURER | View billing page |
SUBSCRIPTION_MANAGE | ADMIN | Subscribe, cancel, tier upgrade |
DENOMINATION_VIEW | SUPERADMIN | View denominations |
DENOMINATION_MANAGE | SUPERADMIN | Create/edit denominations, manage licenses |
DENOMINATION_HQ_VIEW | ADMIN (HQ church only) | View denomination HQ page |
DENOMINATION_HQ_MANAGE | ADMIN (HQ church only) | Subscribe, verify payment |
DENOMINATION_HQ_MANAGE_BRANCHES | ADMIN (HQ church only) | Add/remove branches |
Payment entity — use DenominationPaymentResponse DTOisDenominational() in TierUpgradeServicedaysRemaining at totalDaysInPeriod before computing daysUseddenominationId to verifyAndActivateLicense() from HQ controller — prevents cross-denomination payment replay