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.

Phase 3 (8 wks — biggest)Money-handling

The simplification

v1v2
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.OnSavingOutbox 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)

  1. Schemas, enums, RLS policies, indexes (especially the unique constraint enabling idempotent monthly run).
  2. Money helpers — keep custom impl or switch to dinero.js (see payments).
  3. Transition state machine + audit log table.
  4. Bill generation worker (BullMQ scheduler, monthly).
  5. Payment application service (called from QPay webhook, bank recon, manual cash entry).
  6. React-PDF templates for invoice + statement.
  7. Hono routes — heavy: ~20 endpoints.
  8. Web: invoice list (filters: status, period, contract), detail (items table, payment history), bulk generate UI, PDF preview.
  9. Backfill: v1 Bill + InvoiceBill + Sale + Discount → single invoices table. Map kinds. Carry over code for accountant continuity.
  10. Regression test: parity vs v1 — same input contracts produce same invoice amounts.

Open questions