Skip to main content
All Docs
FeaturesagentOS Block ManagerUpdated April 12, 2026

Service Charge Payments & Ground Rent Tracking

Service Charge Payments & Ground Rent Tracking

v0.12.24 introduces full payment recording for service charges, a separate ground rent demand and payment module, per-unit leaseholder ledgers, and portfolio-wide automatic arrears calculation.

Overview

The financial schema now covers the complete lifecycle from budget to payment:

serviceChargeBudgets
  └── serviceChargeBudgetLines
  └── serviceChargeDemands
        └── serviceChargePayments  ← NEW

groundRentDemands                  ← NEW
  └── groundRentPayments           ← NEW

Service Charge Payments

Recording a Payment

Use payment.recordServiceChargePayment to allocate a payment to a specific demand. The procedure:

  1. Loads and validates the demand (must belong to the caller's org; cannot be cancelled)
  2. Inserts an immutable payment record
  3. Recalculates amountPaidPence on the demand from the sum of all linked payments
  4. Updates demand status automatically (partially_paid, paid)
  5. Writes a full audit log entry
const result = await trpc.payment.recordServiceChargePayment.mutate({
  demandId: "dem_abc123",
  amountPence: 125000,          // £1,250.00
  paymentDate: "2024-04-01T00:00:00Z",
  paymentMethod: "bank_transfer",
  reference: "BACS-20240401-001",
  notes: "Q1 instalment",
});

// result.demandStatus   → "paid" | "partially_paid"
// result.totalPaidPence → total received against this demand
// result.remainingPence → outstanding balance

Supported payment methods: bank_transfer, standing_order, direct_debit, cheque, cash, card, calmony, other

Listing Payments

// All payments for a block
const { items, nextCursor } = await trpc.payment.listServiceChargePayments.query({
  blockId: "blk_xyz",
  limit: 25,
});

// All payments for a specific demand
const { items } = await trpc.payment.listServiceChargePayments.query({
  demandId: "dem_abc123",
});

Each row includes joined demandReference, leaseholderName, unitNumber, and blockName for display.


Per-Unit Leaseholder Ledger

payment.unitLedger returns the complete financial history for a single unit — every service charge demand, every SC payment, every ground rent demand, and every ground rent payment — with live arrears calculated from the data.

const ledger = await trpc.payment.unitLedger.query({ unitId: "unit_123" });

// ledger.serviceCharge.demands        → all SC demands for the unit
// ledger.serviceCharge.payments       → all SC payments for the unit
// ledger.serviceCharge.arrearsPence   → outstanding SC balance

// ledger.groundRent.demands           → all GR demands for the unit
// ledger.groundRent.payments          → all GR payments for the unit
// ledger.groundRent.arrearsPence      → outstanding GR balance

// ledger.totalArrearsPence            → combined SC + GR arrears

Arrears are calculated as max(0, totalDemanded - totalPaid) — cancelled demands are excluded from both figures.


Ground Rent Module

Ground rent is tracked entirely separately from service charges, with its own demand lifecycle and payment records.

Creating a Ground Rent Demand

const demand = await trpc.payment.createGroundRentDemand.mutate({
  unitId: "unit_456",
  demandPeriod: "2024/25",
  periodStart: "2024-04-01T00:00:00Z",
  periodEnd: "2025-03-31T23:59:59Z",
  amountPence: 30000,            // £300.00
  dueDate: "2024-06-24T00:00:00Z",
  notes: "Annual ground rent",
});

// demand.demandReference → auto-generated, e.g. "GR-MAN-2024-1A"
// demand.leaseholderName → snapshot from the unit record at creation time
// demand.status → "draft"

The demand reference is auto-generated as GR-{BLOCK_PREFIX}-{YEAR}-{UNIT_NUMBER}.

Leaseholder name and email are snapshotted from the unit record at the time the demand is created, so historical demands remain accurate if contact details change later.

Demand Status Lifecycle

draft → sent → paid
              └── partially_paid
              └── overdue
              └── cancelled

Use payment.updateGroundRentDemandStatus to manually transition status:

await trpc.payment.updateGroundRentDemandStatus.mutate({
  demandId: "grdem_789",
  status: "sent",   // sets sentAt automatically
});

Transitioning to sent sets sentAt; transitioning to paid sets paidAt.

When a payment is recorded via recordGroundRentPayment, status is also updated automatically using the same recalculation logic as service charge payments.

Recording a Ground Rent Payment

const result = await trpc.payment.recordGroundRentPayment.mutate({
  demandId: "grdem_789",
  amountPence: 30000,
  paymentDate: "2024-06-20T00:00:00Z",
  paymentMethod: "standing_order",
  reference: "SO-GR-2024",
});

Portfolio-Wide Arrears Summary

payment.arrearsSummary aggregates arrears across the entire organisation in a single query.

const summary = await trpc.payment.arrearsSummary.query();

// summary.serviceCharge.arrearsPence     → total SC arrears (pence)
// summary.serviceCharge.overdueCount     → number of overdue SC demands
// summary.groundRent.arrearsPence        → total GR arrears (pence)
// summary.groundRent.overdueCount        → number of overdue GR demands
// summary.totalArrearsPence              → combined total
// summary.totalOverdueCount              → combined overdue count

Cancelled demands are excluded from all totals.


Permissions

All procedures in the payment router enforce RBAC:

ActionRequired Permission
View payments, ledger, demands, arrears summaryfinancials.read
Record a paymentfinancials.create
Create a ground rent demandfinancials.create
Update ground rent demand statusfinancials.update

All mutation procedures use adminProcedure; all query procedures use orgProcedure.


Data Integrity Notes

  • Immutable payment records. Individual payment rows are never modified. amountPaidPence on the demand is always recalculated from the aggregate sum of linked payment rows, ensuring consistency even if records need to be voided in future.
  • Demand guards. Payments cannot be recorded against a cancelled demand — the procedure throws PRECONDITION_FAILED.
  • Pence-based amounts. All monetary values are stored as integers in pence to avoid floating-point rounding errors.
  • RLS. All three tables carry org_id and are protected by row-level security. Cross-organisation data access is not possible at the database level.
  • Audit trail. Every payment recording and demand status change is written to the audit log via logAudit.