Billing accounts and invoices
Multiple leases share a billing account while each invoice keeps its own lease and payment applications.
- Domain
- Services
- Kind
- Go HTTP service
- Lifecycle
- Active
- flowctl project
- None
- Stacks
- Runs on its own
- Source
- services/billing
Billing owns accounts, scheduled charges, invoices, registered receipts, and payment applications. A rental account can serve multiple leases. Each lease uses one account, and each lease-related invoice contains charges from exactly one lease. Charges without a lease are billed separately.
Account and lease associations
billing_account_lease records associations in Billing, with one unique lease_id and a
billing_account_id, creation time, and actor. Leases retains the confirmed account ID in its own
database. Account responses and new account events expose lease_ids; they no longer expose a
singular account lease_id. Earlier events keep their original payloads.
POST /v1/billing-accounts/resolve-rental associates an explicitly supplied account or creates one.
An existing account must be active, rental, and match the lease's legal entity, currency, and set
of responsible parties. Authorized payers and viewers are preserved and are not compared with
leaseholders. Linking a lease does not add or remove account parties. A different responsible-party
set requires another account. Ending a lease preserves its association and history.
Associations are idempotent and a lease cannot be reassigned through this operation. Leases persists a stable creation attempt before contacting Billing so a retry can recover after a local failure. See Leases.
Invoice boundaries
Both scheduled and manual issuance select charges by account, lease, exact bill_on date, and
whether the charge type is a refundable deposit. POST /v1/invoices/issue accepts lease_id. Omit
it (or pass null) to issue only charges without a lease; omission never means all leases. Charges on
the same date for two leases produce two invoices. Late-added charges produce another invoice
without editing an issued one.
A refundable deposit is money held for the resident and never shares an invoice with anything else,
so one call covers one side of that split. refundable_deposit on the body picks the side; the
scheduler issues both. Automatic allocation covers a deposit invoice like any other invoice, and
the security deposit is paid first on its due date by its order number.
Invoices expose an optional lease_id, also present in new invoice events.
GET /v1/invoices supports billing_account_id and lease_id filters with bracket pagination.
No invoice may mix a lease's charges with another lease or with charges lacking a lease.
Foreign keys validate charge membership and invoice scope. Generated lease_scope columns normalize
null solely for database enforcement; they are not API fields or lease identities.
An account fixes currency and legal entity. Payment terms remain account defaults, copied into the invoice's due date when issued. Issuance freezes the total and Accounting request; posting retries preserve that request and its idempotency keys. Accounting still receives one journal entry per invoice.
Cancelling the last invoice charge
POST /v1/charges/{id}/cancel takes the charge's amount out of the invoice total. For an
ordinary charge the total goes down. A concession lowered the total when the invoice was
issued, so cancelling it raises the total by the same amount, and reinstating it lowers the
total again.
Legacy concessions without a recorded target do not identify which charge they discount, so Billing cannot tell when the rent under them is cancelled. For these concessions, Billing refuses a cancellation or reinstatement that would leave the total below zero, with the message "the invoice would owe a negative amount; cancel its concessions first". If other charges keep the total at zero or above, and not below the money already applied (see below), the rent is cancelled and the concession stays on the invoice, still lowering the total. When you cancel rent that a legacy concession discounts, cancel the concession as well. See targeted concessions for automatic withdrawal when a tied target is cancelled.
When its last charge is cancelled,
the same transaction withdraws the invoice: status and payment_status become void,
the invoice keeps its ID and number, and the response reports emptied: true.
Billing records the withdrawal time, actor, and cancellation reason and emits invoice.voided.
The free-text reason remains on the records and is not included in that event.
The withdrawn invoice cannot receive payments, deposit cover, or credits. Its cancelled charges remain attached for history and cannot be reinstated onto it. A cancellation that would reduce the total below money already applied is still refused; release that money before cancelling. The same applies to reinstating a concession after a payment has covered the higher total. Charges on a credit invoice, whose total is negative, cannot be cancelled or reinstated this way; the refusal says so.
Each cancelled charge corrects its own ledger posting. Billing distinguishes this withdrawal
from a full invoice void so it never queues an additional invoice reversal. If the original
invoice posting is pending, Billing finishes its frozen posting first, then posts the individual
corrections. A pending local status does not prove Accounting has no journal: a successful create or
post response may have been lost. Retrying the frozen request with the same idempotency keys
confirms that original before correcting it, rather than leaving an unrecorded posting behind.
The original and corrections can post in different months. Accounting failures retain the existing
retry and blocked-posting behavior. Completing a withdrawn invoice's original posting does not
emit invoice.posted; its lifecycle remains the withdrawal recorded by invoice.voided.
Apply migration 0050_withdraw_empty_invoice.sql before deploying the updated Billing API and
scheduler. Withdrawal also sets invoice reversal attempts one above the shared limit, so older replicas
exclude it from their reversal queue during a rolling deployment. Updated schedulers finish any
pending original posting and charge corrections. The down migration refuses to remove the
distinction while any such withdrawals exist.
See invoice reversal operations for the distinction between parked withdrawals and failed reversals.
Source: services/billing/internal/revising/cancel.go and services/billing/internal/voiding/void.go.
Open recurring charges
Fixed-term monthly lines are expanded through the lease end when Billing consumes the activation
event. An open month-to-month line is also stored in recurring_charge, with its full monthly amount,
start date, and optional stop dates. This rule is separate from the individual monthly rows in
charges.
An in-process loop runs at startup and then daily. It keeps open rules about three months ahead by inserting only periods after the latest active period. The source reference combines the lease charge ID and period start, so retries and multiple replicas cannot duplicate a month. Leases accepts only monthly. A weekly or biweekly line already stored is not expanded.
A once lease line is a one-time charge. On lease.activated Billing creates one charge for each,
and on lease.charge_added for an active lease it creates the charge for the added line. Nothing is
created while the lease is a draft. The charge is the full amount with no service period, and its
source reference is the lease charge ID followed by :once, so a redelivered event creates it once.
On activation it is billed on the line's date or the execution day, whichever is later, unless the
line has a bill_trigger (see below). A charge that names a draft lease sent straight to
POST /v1/charges is refused with 409.
Billing at signing and at move-in
A lease charge line may carry bill_trigger: signing, move_in, or absent. It says which lease
event decides the day Billing bills the line. Absent keeps the rules above. The accounting app sets
it from the lease form: rent, a refundable deposit, and a concession on the lease's first month are
billed at signing, and any other one-time charge on a draft is billed at move-in.
bill_trigger | One-time line | Monthly line |
|---|---|---|
| absent | Billed on its date, not before the execution day | Each period billed on its period start |
signing | Billed on the execution day | The first period only is billed on the execution day when that is earlier than the period start |
move_in | Billed on the move-in day. Nothing is created until the lease moves in | Not allowed |
A first period keeps its dates and amount, including a prorated part month and a concession that
starts with the rent. Billing creates move_in lines when it consumes lease.moved_in, or from
lease.activated when that event already carries a move-in date.
A charge billed at signing also gets its own due date: the later of its billing day and three days before move-in. Move-in is the recorded move-in day when there is one, otherwise the scheduled move-in day, otherwise the contractual start. When signing is less than three days before move-in the charge is due on the day it is billed. The due date is part of how charges are grouped into invoices, so the deposit and the first month's rent each get an invoice with exactly that date. It is fixed when the invoice is issued.
Payment order within one due date comes from order_number on the charge code, lower first.
Refundable deposit codes are 1 and rent is 2, so a payment that does not cover everything due that
day pays the deposit first and then the first month's rent. Codes with no number are paid last. See
docs/billing/allocation-policy.md.
Rent billed before the month it covers is not earned yet. The rent code names a deferral account,
23010-000 Prepaid Rent, and a charge is deferred when its period spans more than one month or
starts in a later month than the one it is billed in. A lease signed on 20 September for 1 October
credits Prepaid Rent on 20 September, and the release pass moves it to rent revenue on 1 October.
Rent billed in its own month credits revenue directly, as before.
The lease drawer in the accounting app lists what is due before move-in: the open invoices billed on the execution day, with what is still owed and when it is due. Residents cannot pay from a resident page yet, so staff take the payment on the Take payment page.
Notice records the intended end, and move-out records the actual end. Both dates are stored for the lease even when no recurring rule exists yet. A later event that still looks open uses that stored date, so it does not add periods after the cutoff. An earlier stop date is kept when a later event carries a later one. Both events also stop later extensions, cancel future unbilled periods, and shorten an unbilled period that crosses the cutoff. A percent or free-month concession is shortened from the credit of the rent line that covers it, and a half month stays half of that shortened period. Billing does not rewrite an already invoiced period.
When a recurring line is removed (lease.charge_removed with charge_id), Billing also credits back
days it had already invoiced: the unused part of an invoiced period that crosses the last day, and the
full amount of each invoiced period after it. Each credit is a one-time charge on the last day, of the
reversal type that charge_type_reversals pairs with the line's type, such as petrntrv for petrnt.
It debits the original type's income account and credits Accounts Receivable, and a redelivered event
posts nothing more. A line type with no pairing is not credited.
A recurring rent concession (conr) may be a fixed credit, a percent of rent, or a free month. A
percent or free-month credit is calculated separately for each month, from the monthly rent line in
effect on the first day of that month. When rent changes during the concession, for example at a
month-to-month conversion with a new rent, the credit changes from the month the new rent starts.
Two rent lines in effect on the same day are an error, because the credit cannot tell which one to
follow. A free month names its starting month and a length in half-month steps.
A whole month credits that period's rent, including a prorated stub, so the month nets to zero.
A half month credits half of that period's rent. A length of 1.5 credits the starting month in full
and half of the next month. An open percent concession stores the rule, and each month added later
takes its credit from the rent rule that covers it. Periods already written are left as they are. Issuance allows an invoice
total of zero. A negative total is allowed too, and is a credit invoice (see Credits below).
Cancelling an executed lease
When Leases cancels a lease that was executed but never moved into, lease.cancelled carries
executed_on. In one transaction Billing marks the lease cancelled, voids the lease's issued
invoices that have no payment, deposit, or credit applied, cancels every charge not on an invoice,
and ends open-ended rent rules. The void is the same code as the operator's void, and it checks
again under the invoice lock that nothing is applied. Paid and partly paid invoices, credit
invoices, and an invoice holding a separately cancelled charge are left as they are and logged as
a warning with the invoice ID. Nothing is refunded and nothing becomes revenue. An operator can
void a left invoice, cancel one charge on it, or refund a processor payment through Payments.
A lease.activated, lease.charge_added, lease.converted_to_mtm, or lease.notice_given event
that arrives after the cancellation is ignored with a warning. The full rules are in
docs/billing/billing.md, under Cancelling an executed lease.
Credits
A credit is a charge whose general ledger mapping credits Accounts Receivable, so it lowers what the
resident owes. Concessions and reversals (rent and add-ons) are credits. An issued invoice with a negative total is a
credit invoice. Its credit is applied to open invoices on the same billing account, the way an
unapplied receipt is. Invoices on the credit's own lease come first, then the rest of the account,
each by due date, then the lowest order number among the invoice's charges. A refundable deposit invoice is covered like any other. The scheduler does this on each poll,
before it allocates receipts, and records each use in invoice_credit_application. Nothing is posted
to the ledger, because posting the credit invoice already credited Accounts Receivable.
POST /v1/credits schedules a credit on an active or ended lease. It needs a credit charge type, an
amount, a description, a bill_on date of today or later, and a why with a detail. Controllers and
platform admins may call it. A credit dated today is applied within one poll. It lowers the lease balance by the part applied to that lease's own invoices; the rest covers other open invoices on the account or stays as unused credit.
Invoice reads add credit_applied_cents, credit_applications and, on a credit invoice,
available_credit_cents. The lease balance adds unapplied_credit_cents. Voiding an invoice releases
the credit applied to it. Voiding a credit invoice releases what it covered. The events are
invoice.credit_applied and invoice.credit_reversed. The design is in
docs/leases/designs/2026-10-05-operator-issue-credit-design.md.
Accounting integration
The scheduler uses Accounting's generated Go client to create and post journal entries: one per invoice, and one per released month of a deferred charge. Issuance freezes the Accounting request, and posting retries preserve that payload. HTTP calls happen outside database transactions.
The create request for an invoice uses billing-invoice-<invoice-id> as its idempotency key. The
post request appends -post. Billing saves the returned journal entry ID so subsequent attempts can
resume posting without creating another entry. A failed operation remains available for a later
scheduler poll; the generated client does not perform additional retries.
A revenue release uses billing-release-<schedule-row-id>-<YYYY-MM>. The period is part of the key
on purpose: when the month a release was earned in has closed, the entry moves to the next open
month, and that is a new command rather than the same key carrying a changed payload, which
Accounting rejects as an idempotency conflict.
When an invoice is voided, each month it already released is reversed under
billing-release-reverse-<schedule-row-id>-<YYYY-MM>, dated on the day the release posted and
rolled forward the same way. Billing saves the month it is trying before each call, so a retry
after a lost response sends the same key and Accounting replays it.
Billing constructs the client at startup using ACCOUNTING_URL, defaulting to
http://localhost:8080, and a ten-second HTTP timeout. The existing X-Dev-Actor: billing header is
preserved. The generated-client integration does not introduce production service authentication.
A create response must have status 201 and contain a valid, nonzero journal entry UUID before Billing attempts the post operation. A failure leaves the invoice available for a later retry.
A release that cannot find an open period within twelve months, or that has failed on fourteen days, is marked blocked with the reason recorded. The days do not have to be consecutive. Invoice posting has no such limit and retries a failed invoice on every poll.
Source: services/billing/internal/scheduler/scheduler.go, services/billing/internal/releasing/split.go and services/billing/cmd/api/main.go.
Payments and lease balances
Payments registers submissions while Pending. Billing records
and allocates each new lifecycle receipt in one transaction. Approval updates status without
allocating again. Legacy registrations that omit payment_status keep their explicit allocation
behavior. If initial allocation fails, registration and its idempotency claim roll back together;
Payments retries the durable submission. The scheduler handles later use of prepay and legacy
receipts rather than completing a partly committed lifecycle registration.
The payment's fee charge is considered first, then a selected invoice or deposit, then the normal charge order. A lease-scoped receipt stays within that lease on initial and later allocation. The remainder stays available as prepay. Targets must belong to the receipt's billing account and lease scope. An invoice without a lease is outside a lease-scoped receipt's scope; only an unscoped receipt can pay account-level invoices. Billing's validation endpoint checks a selection before Payments schedules collection. Cancelled or superseded charges and void invoices cannot be new selections. A selected charge must be invoiced; a selected invoice must be issued before the remaining base amount can leave prepay; cancellation does not silently redirect that money.
Declined, Voided, and Returned outcomes withdraw the receipt and reverse its applications. The original rows and events remain for audit, and a repeated command cannot reverse them twice. Posting references can be attached later without changing allocations. Reversing an individual application releases funds for reuse; it does not refund cash. Deleting a Billing-only correction preserves the receipt's processor status: a Pending payment remains Pending until real approval arrives, which still emits its status-change event. Approval received while a correction exists updates processor status without removing the correction.
Migration 0048_pending_receipt can be rolled back only while every receipt remains representable
by the old schema. Pending, declined, or voided receipts and receipts with allocation intent
prevent downgrade before any schema change. Keep a compatible application version when such data
exists; rollback never relabels financial outcomes or deletes receipts.
Prepaid rent
Account responses expose prepay_cents separately from amounts still owed. It is the unused
funds on live receipts; unapplied_cents remains the compatibility name for the same funds.
Do not add the two fields together. Credit invoices remain in unapplied_credit_cents.
A receipt funded by a deposit correction identifies its posted correction and initial prepay posting. It becomes available only after both postings are confirmed and is never registered as a new cash payment. Its funding records preserve the original accounts and dimensions for later allocation or transfer. It cannot be returned or deleted through ordinary payment operations.
Payments posts cash against Accounts Receivable. Billing reclassifies the unused portion by
debiting that same receivable account and crediting the liability configured in the prepay
charge type's deferral_account_id. Together these entries debit cash and credit Prepaid Rent
for the unused amount. The first reclassification preserves the original payment date in
occurred_on, along with the property, lease and billing account. A closed posting month
rolls forward to an open month. It is separate from deferred revenue recognition on
an invoiced prepay charge.
A charge posts for payment allocation when its invoice is issued. Posted charges are paid directly even if their charge month is in the future, including first rent billed at signing. A payment received before a charge posts remains unused cash in Prepaid Rent; no month-start reservation is created, for rent or other charge types. The scheduler allocates available receipt funds in the existing invoice and charge order. When those funds pay a new invoice, Billing debits Prepaid Rent and credits Accounts Receivable for the amount used, dated on the actual application day in the legal entity’s calendar. The date comes from Accounting’s current-period endpoint; no property timezone or UTC fallback is inferred. Payment allocation itself needs no Accounting or calendar call. Payment applications reduce the invoice's outstanding amount and the amount eligible for a late fee. Unspent funds remain prepaid rent. Withdrawing a receipt reverses its applications and releases the remaining liability; the Payments service handles the cash reversal. Deleting a reversal restores funds and reconciles their new applications.
Each adjustment is frozen in receipt_prepay_posting before contacting Accounting. A confirmed
period_closed refusal before journal creation advances and persists the posting month while
preserving occurred_on. Create keys include the target month; post keys remain stable for
the resulting journal. Network failures retry the same payload and key. If Accounting has
already created the journal, Billing retains that reference; a subsequently closed month must
be reopened before posting, rather than creating a duplicate journal.
A posted adjustment emits billing_account.prepay_posted. An Accounting outage changes neither
the receipt's funds nor its applications. Ledger posting remains pending until Accounting accepts
it; an incorrect mapping or closed posting period must be corrected before the posting can finish.
Reconciliation failures are recorded per receipt in receipt_prepay_retry. Refusals about a
payment's own data double the retry delay up to an hour; ten refusals emit the distinct
prepay reconciliation needs attention error. Reconciliation continues automatically after
that threshold, so correcting the cause restores the ledger without a manual retry reset.
Accounting HTTP errors, authentication faults, configuration faults and unavailable calendars
remain retryable and do not count as payment-data refusals. A changed desired prepay amount
bypasses the previous backoff and starts a new retry count, including when a reversal releases
the liability. Outages preserve the previous data-refusal reason in blocked_reason.
Successful reconciliation clears the retry row. The posting's durable payload and idempotency
keys remain unchanged.
Prepay posting requires Accounting journal creation to accept the payment_id relationship. Billing returns an explicit
prepay_journal_entry_ids array when a receipt is deleted. Later
application dates come from Accounting’s legal entity calendar. If Accounting is unavailable or
the calendar is unconfigured, ledger adjustments remain pending while payments continue to
allocate normally.
Receipt deletion checks every prepay adjustment's verified post_month as well as the payment
and invoice posting months. Transaction dates do not identify an Accounting posting month.
An in-flight adjustment blocks deletion until it is posted, so a concurrent
worker cannot leave an unknown journal behind. Successful deletion returns
prepay_journal_entry_ids and retains them alongside the original receipt snapshot in its audit
record. The deletion audit event is internal and contains PII. Payments removes those journals
along with the payment journal; deletion creates no replacement entry.
Deleting mistaken payments and reversals
DELETE /v1/billing-accounts/receipts/{payment_id} removes a receipt recorded by mistake and
its applications, so the charges become owed again. A reason is required. Billing keeps a
deletion record and rejects later registration of that payment ID, including concurrent requests.
A reversed receipt must have its reversal deleted first.
Each active application removed emits invoice.payment_reversed in the same transaction.
Applications already reversed do not emit another reversal event.
DELETE /v1/billing-accounts/receipts/{payment_id}/reversal removes a mistaken reversal and
allocates the restored payment against amounts still owed. It does not restore old applications
that another payment may have settled since. Both routes require their own permission and replay
successful requests under the same idempotency key.
If the stored response disappears before a retry can replay it, the request returns HTTP 409
and must be retried with a new key so the accounting periods are checked again.
Receipt deletion checks the payment's own posted month, even when it has no applications,
and the posted months of invoices it paid. Reversal deletion checks the reversal's posted month.
A Billing-only correction reversal with no journal reference has no Accounting posting to check
and can be undone without Accounting. A correction with a journal reference still requires its
posted month to be open. Refund and return reversals always require their posting reference.
Billing reads calendar dates from Accounting's posted journal entries, including rolled-forward
postings; it does not infer post months from UTC timestamps or invoice billing dates.
An imported legacy_posted invoice without a journal reference blocks receipt deletion with
HTTP 409 until its original posting period can be verified. Billing does not substitute its billing date.
Other missing posting references or unavailable Accounting return HTTP 503.
If the payment posting, invoices, or reversal change during the accounting check, Billing returns
HTTP 409; retry to check the current accounting periods.
These routes change Billing's records; the Payments service still needs its own deletion endpoint
to coordinate removing a payment.
Source: services/billing/internal/handlers/receipt_deletion.go, services/billing/internal/handlers/reversal_deletion.go and services/billing/queries/query.sql.
GET /v1/billing-accounts/{id}/leases/{lease_id}/balance returns currency and:
| Field | Meaning |
|---|---|
scheduled_cents | Unbilled scheduled charges for the lease |
outstanding_cents | Issued lease invoice totals minus active payment applications, unreversed deposit applications and applied credit |
unapplied_credit_cents | Credit on the lease's own credit invoices not yet applied to any invoice |
due_now_cents | Outstanding amounts on those invoices due today or earlier in UTC |
overdue_cents | Outstanding amounts on those invoices with a due date before today in UTC |
allow_partial_payments | Whether the lease currently accepts partial payment selections |
Partial payments are enabled by default. For an automatic lease, the balance endpoint computes
allow_partial_payments from the current overdue balance on every read. The scheduler stores that
value within about a minute; consumers must use the balance endpoint rather than read the stored
column. Staff can set the value with POST /v1/billing-accounts/{id}/leases/{lease_id}/partial-payments.
That changes the source to staff, which the automatic process leaves alone. Staff cannot reset the
source to automatic. Changes are recorded in the billing account event feed.
These totals query all matching records, independently of invoice pagination. Unapplied funds remain on the account; they are not credited to an arbitrary lease. The lease drawer uses these Billing totals. Account balances continue to aggregate all leases and non-lease activity.
Payment journal entries retain the payment's optional lease reference as transaction attribution. Applying a payment does not reclassify Accounting journal lines. Therefore a lease-filtered GL balance is not a substitute for Billing's lease outstanding amount. Account and ledger totals can also differ temporarily while posting or receipt registration is retried.
Move-out deposits
Billing opens one disposition per ended lease. Held money is the paid, posted refundable-deposit invoices for that lease. Damage and cleaning lines stay editable until complete, which issues them as ordinary invoices. After complete, an invoiced move-out fee cannot be changed on reopen until general invoice voiding is available on main. The operator can still add new lines and change unpaid-balance lines. Applying the deposit reduces outstanding receivable and is not a payment. The completion event is deposit_accounting.completed. A missing statutory policy means zero interest and no deadline. The due date stored at open is kept when the disposition is reopened. Statements are immutable JSON versions. Refund obligations and unapplied credits both appear as escheatment candidates. Only a deposit refund posts tenant accounts payable to unclaimed property.
The accounting app page is Move-out deposits.
Migration and deployment
This is a coordinated upgrade of Billing, Leases, Payments, and the accounting demo UI. Stop old Billing API writers, lease consumers, and schedulers, and pause dependent lease/payment writes before applying the migrations. Old Billing binaries reference the removed singular account column and cannot run against the new schema. Deploy the updated callers and API clients before resuming work.
Billing migration 0011 backfills associations and derives invoice lease IDs from actual charge
references. It rejects invoices mixing different leases or lease and non-lease charges. Membership
foreign keys also reject inconsistent charge/account associations. Investigate failed validation;
do not silently split or rewrite issued invoices. Historical amounts, applications, posting requests,
and journal references remain unchanged. Existing separate accounts are not merged automatically.
Leases migration 0007 replaces the unique account index with a non-unique index. Abandoned lease
creation attempts can leave an association on a shared account; reconcile the individual attempt
without deleting an account used by other leases. Existing command replays and historical events may
still contain the older response shape; integrations should tolerate those historical payloads and
use a fresh account read for lease_ids.
End-of-lease collections
Collections is a domain inside Billing. A lease has one collection case. The case records whether collections is open and whether an eviction is on hold. Those two facts move independently. A hold suspends ordinary collections work and does not close the case, and it does not refuse a payment.
Deposit accounting opens the case when the residual amount still owed is positive, and closes an open case when that residual is zero or negative. The statement version decides order. The same version with a different amount conflicts. An older version is ignored. A controller can close an open case. The case stores the statement reference, not a copy of invoices, charges, or payments. Reads calculate the live lease balance from Billing at request time.
The accounting app Collections page shows the case beside that live balance, the account balance, and the lease's invoices and charges.
The design is in docs/superpowers/specs/2026-10-02-end-of-lease-collections-design.md. services/billing/migrations/0025_collection.sql defines the case and its outbox.
Source and verification
services/billing/migrations/0011_multiple_leases.sql defines the database rules. services/billing/openapi.yaml defines the HTTP contract. docs/billing/model.html provides the model walkthrough.
Run go test ./services/billing/... ./services/leases/... ./services/payments/... with local Postgres
available. Integration coverage includes shared accounts, isolated invoice groups, partial applications
and reversals, concurrent issuance, lease cancellation, and historical migration validation.
Client generation
Billing also publishes its own generated client for callers of Billing's API. Generation commands, client construction, and compatibility limits are documented in generated service clients.
Receipt history after deletion
GET /v1/billing-accounts/receipts/{payment_id} uses billing-account read permission
and returns a receipt with status of active, reversed, or deleted. A deletion
retains the original receipt snapshot and its reason, actor, and date. The record
stays readable by staff after its live allocations are removed, and contributes
nothing to balances. Older deletion audits may lack the original recorded time.
Receipt deletion always returns prepay_journal_entry_ids, including an empty
array when none exist. Deploy this contract before enabling Payments deletion.
Automatic late fees
Automatic late fees describes approved per-entity configuration, submission-time eligibility, net-rent attribution, and monthly assessment.
Billing account transfers
The accounting app moves settled held receipt funds between active accounts in the same legal entity and currency. Source reservations prevent concurrent spending, and destination funds become available after Accounting posts the movement. Both accounts retain provenance and audited reversals and date corrections. See Billing account transfers for the commands, accounting behavior, and original-payment withdrawal rules.