Billing & invoices
Biggest phase. Collapses v1's 4-way hierarchy (Bill / InvoiceBill / Sale / Discount) into a single Invoice table with a kind discriminator. BigInt money throughout — KNOWN_ISSUES #2 dies by construction.
The simplification
| v1 | v2 |
|---|---|
Invoice (abstract) + Bill + InvoiceBill + Sale + Discount — 4 subclasses, 4 tables (TPT) | Single invoices table, kind: enum('rent'|'utility'|'sale'|'discount'|'fee') |
Two status enums per entity (BillStatus + BillStatusType) | One invoiceStatus enum, transition fn |
Strict-equality payment checks (amount == paid) | compare(money(amount), money(paid)) === 0 |
Post-save fire-and-forget eBarimt push from Sale.OnSaving | Outbox row inserted in same tx → worker drains |
| InvoiceBillItem.SortOrder MAX() recompute (4195ms avg in v1 prod) | Single GROUP BY in line-item insert |
Data model
// packages/db/src/schema/invoices.ts
export const invoiceKind = pgEnum("invoice_kind", ["rent","utility","sale","discount","fee","correction"]);
export const invoiceStatus = pgEnum("invoice_status", ["draft","issued","partially_paid","paid","overdue","void","refunded"]);
export const invoices = pgTable("invoices", {
id: uuid().primaryKey().defaultRandom(),
ownerId: uuid("owner_id").notNull().references(() => propertyOwners.id),
code: text().notNull(), // human ref: "INV-2026-001234"
kind: invoiceKind().notNull(),
status: invoiceStatus().notNull().default("draft"),
contractId: uuid("contract_id").references(() => contracts.id),
tenantUserId: uuid("tenant_user_id").references(() => users.id),
customerNameSnapshot: text("customer_name_snapshot").notNull(),
customerTinSnapshot: text("customer_tin_snapshot"),
periodStart: date("period_start"), // for rent/utility kind
periodEnd: date("period_end"),
issueDate: date("issue_date").notNull().defaultNow(),
dueDate: date("due_date").notNull(),
subtotal: bigint({ mode: "bigint" }).notNull(),
vatAmount: bigint("vat_amount", { mode: "bigint" }).notNull().default(0n),
cityTaxAmount: bigint("city_tax_amount", { mode: "bigint" }).notNull().default(0n),
total: bigint({ mode: "bigint" }).notNull(),
paidAmount: bigint("paid_amount", { mode: "bigint" }).notNull().default(0n),
currency: text().notNull().default("MNT"),
ebarimtReceiptId: text("ebarimt_receipt_id"), // set after eBarimt push
ebarimtLottery: text("ebarimt_lottery"),
ebarimtQrData: text("ebarimt_qr_data"),
pdfUrl: text("pdf_url"), // R2 url after generation
notes: text(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
}, (t) => [
unique().on(t.ownerId, t.code),
index("inv_owner_status").on(t.ownerId, t.status),
index("inv_contract").on(t.contractId),
index("inv_due_unpaid").on(t.dueDate).where(sql`status in ('issued','partially_paid','overdue')`),
unique("inv_period_contract").on(t.contractId, t.kind, t.periodStart)
.where(sql`kind in ('rent','utility') and period_start is not null`),
pgPolicy("invoice_visibility", { /* owner OR tenant OR admin — see identity.html */ }),
]);
export const invoiceItems = pgTable("invoice_items", {
id: uuid().primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id").notNull().references(() => invoices.id, { onDelete: "cascade" }),
ownerId: uuid("owner_id").notNull(),
sortOrder: integer("sort_order").notNull(), // assigned via single GROUP BY at insert
kind: text({ enum: ["rent","utility_water","utility_electricity","utility_heating","parking","internet","cleaning","late_fee","discount","other"] }).notNull(),
description: text().notNull(),
quantity: numeric({ precision: 14, scale: 4 }).notNull().default("1"),
unitPrice: bigint("unit_price", { mode: "bigint" }).notNull(),
amount: bigint({ mode: "bigint" }).notNull(),
vatRate: numeric("vat_rate", { precision: 5, scale: 2 }).notNull().default("0"),
vatAmount: bigint("vat_amount", { mode: "bigint" }).notNull().default(0n),
}, (t) => [
index("ii_invoice_sort").on(t.invoiceId, t.sortOrder),
index("ii_owner").on(t.ownerId),
]);
// Idempotency for monthly bill runs:
// unique(owner_id, kind, contract_id, period_start) on invoices
// → re-running the monthly job is safe.
Money handling
BigInt minor units everywhere. See payments page for the dinero.js decision.
import { money, add, sub, compare, fromMajor, toMajor, format } from "@spacehub/shared/money";
const subtotal = items.reduce((acc, it) => add(acc, money(it.amount, "MNT")), money(0n, "MNT"));
const isPaid = compare(money(invoice.paidAmount, "MNT"), money(invoice.total, "MNT")) >= 0;
Never compare BigInt money with
==, ===, <, > against numbers. Always go through compare() or arithmetic helpers. ESLint rule: ban == 0n on money fields (custom rule, add later).
Bill generation worker
Monthly run, idempotent per (contract_id, kind, period_start).
// apps/workers/src/billing.ts
import { Worker } from "bullmq";
new Worker("billing", async (job) => {
if (job.name === "run-monthly-bills") {
const period = job.data.period ?? currentPeriod();
const contracts = await db.query.contracts.findMany({
where: eq(contracts.status, "active"),
});
for (const c of contracts) {
await db.transaction(async (tx) => {
// Idempotent: unique(contract_id, kind, period_start) on insert
await tx.insert(invoices).values({
ownerId: c.ownerId,
code: nextInvoiceCode(c.ownerId, period),
kind: "rent",
contractId: c.id,
tenantUserId: c.tenantUserId,
customerNameSnapshot: c.tenantNameSnapshot,
periodStart: period.start,
periodEnd: period.end,
dueDate: period.dueDate(c.paymentDayOfMonth),
subtotal: c.rentAmount,
total: c.rentAmount,
status: "issued",
}).onConflictDoNothing({
target: [invoices.contractId, invoices.kind, invoices.periodStart],
});
});
}
}
}, { connection, concurrency: 4 });
Status transitions
const ALLOWED: Record<InvoiceStatus, InvoiceStatus[]> = {
draft: ["issued","void"],
issued: ["partially_paid","paid","overdue","void"],
partially_paid: ["paid","overdue","refunded"],
paid: ["refunded"],
overdue: ["partially_paid","paid","void","refunded"],
void: [],
refunded: [],
};
// transitionInvoice(tx, invoiceId, to, actorId, reason?) — same shape as contract
Payment application
When a payment lands (from QPay webhook, bank reconciliation, or cash), apply against invoice:
export async function applyPayment(tx: Tx, invoiceId: string, amount: bigint, source: string) {
const inv = await tx.query.invoices.findFirst({ where: eq(invoices.id, invoiceId), with: { /* lock */ } });
if (!inv) throw new HTTPException(404);
const newPaid = inv.paidAmount + amount;
const newStatus =
newPaid >= inv.total ? "paid" :
newPaid > 0n ? "partially_paid" :
inv.status;
await tx.update(invoices).set({ paidAmount: newPaid, status: newStatus }).where(eq(invoices.id, invoiceId));
await tx.insert(payments).values({ invoiceId, amount, source, appliedAt: new Date() });
if (newStatus === "paid") {
// outbox: ebarimt push, send-receipt-pdf, push notification
await tx.insert(outbox).values([
{ aggregateType: "invoice", aggregateId: inv.id, eventType: "ebarimt.push", payload: { invoiceId } },
{ aggregateType: "invoice", aggregateId: inv.id, eventType: "pdf.render", payload: { invoiceId } },
{ aggregateType: "invoice", aggregateId: inv.id, eventType: "push.send", payload: { userId: inv.tenantUserId, kind: "payment_confirmed" } },
]);
}
}
API surface
GET /v2/invoices?status=overdue&contract={id}&period={yyyy-mm}&cursor=&limit=
POST /v2/invoices # creates draft
GET /v2/invoices/{id}
PATCH /v2/invoices/{id} # status-gated
POST /v2/invoices/{id}/issue # draft → issued
POST /v2/invoices/{id}/void # any → void (with audit reason)
POST /v2/invoices/{id}/refund # paid → refunded
GET /v2/invoices/{id}/pdf # 307 → R2 url (renders on first request via queue if missing)
POST /v2/invoices/bulk-generate # admin / owner — kicks BullMQ monthly job
GET /v2/invoices/{id}/items
POST /v2/invoices/{id}/items
PATCH /v2/invoice-items/{id}
DELETE /v2/invoice-items/{id}
GET /v2/invoice-packages # batch send (email + SMS to tenants)
POST /v2/invoice-packages
POST /v2/invoice-packages/{id}/dispatch
Build steps (Phase 3, 8 wks)
- Schemas, enums, RLS policies, indexes (especially the unique constraint enabling idempotent monthly run).
- Money helpers — keep custom impl or switch to dinero.js (see payments).
- Transition state machine + audit log table.
- Bill generation worker (BullMQ scheduler, monthly).
- Payment application service (called from QPay webhook, bank recon, manual cash entry).
- React-PDF templates for invoice + statement.
- Hono routes — heavy: ~20 endpoints.
- Web: invoice list (filters: status, period, contract), detail (items table, payment history), bulk generate UI, PDF preview.
- Backfill: v1 Bill + InvoiceBill + Sale + Discount → single invoices table. Map kinds. Carry over
codefor accountant continuity. - Regression test: parity vs v1 — same input contracts produce same invoice amounts.
Open questions
- Invoice code format: keep v1's format or new sequence per owner per year (
INV-{owner}-{yyyy}-{seq})? Accountants care; ask before changing. - Late fees — auto-apply N days after due date as a separate invoice line item? Default yes, configurable per contract.
- Multi-currency contracts — defer. Keep MNT-only for v2 launch.
- Discount as separate invoice vs negative line item: line item simpler; lets us total-out per invoice. Recommended.