Billing::InvoiceLine
This model was previously named Billing::InvoiceItem with table billing_invoice_items. Phase 2 renames it to Billing::InvoiceLine / billing_invoice_lines.
Why the rename? The term "line" is the industry standard for a row on a commercial document (Xero, Stripe, and QuickBooks all use "line"). The term "item" implies a standalone entity that exists independently, whereas a "line" unambiguously belongs to a document. This rename aligns Jod's terminology with the industry and prevents confusion when engineers work with external accounting systems.
Impact: Requires a migration to rename the table. All existing references to billing_invoice_items in code and documentation must be updated.
Purpose
A Billing::InvoiceLine is a single row on an invoice representing one sold item. Each line is a snapshot of what was sold — capturing the product description, quantity, unit price, applicable tax, and how many entitlement credits to grant on posting.
Lines are the bridge between the commercial sale (what appears on the customer's invoice) and entitlement granting (what the InvoicePosting uses to write ledger entries).
Responsibilities
- Snapshot pricing, tax, and grant quantities at invoice creation time — these values never change after creation, even if the underlying product or price is updated
- Encode
line_typeto distinguish the principal (what the customer is buying) from the platform fee (Jod's margin, gig credits only) - Hold
units_to_grantso the posting step knows exactly how many credits to grant without recomputing from the product catalog - Store
platform_fee_rate_bps(gig only) so the lot creation step in posting has the frozen rate
Model Context
| Context | Details |
|---|---|
| Aggregate | Child of Billing::Invoice |
| Layer | Commercial Documents |
| Upstream | Billing::Invoice (parent), Billing::Product (snapshot source), Billing::ProductPrice (snapshot source) |
| Downstream | Billing::InvoicePosting reads line_type and units_to_grant to decide grant behavior |
Table Schema
Table name: billing_invoice_lines (renamed from billing_invoice_items in Phase 2)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | bigint | PK, auto-increment | |
uuid | string | NOT NULL, UNIQUE | External-safe identifier |
billing_invoice_id | bigint | NOT NULL, FK → billing_invoices.id | Parent invoice |
billing_product_id | bigint | NOT NULL, FK → billing_products.id | What product was sold — snapshot reference |
billing_product_price_id | bigint | NOT NULL, FK → billing_product_prices.id | Which price was used — snapshot reference |
description | string | — | :principal snapshots billing_products.name; :platform_fee is always the literal string "Platform Fee" |
quantity | integer | — | How many units purchased |
unit_price_cents | integer | — | Snapshot from billing_product_prices.unit_price_cents — never overridden by an agreement |
amount_cents | integer | — | quantity × unit_price_cents (gross — never reduced by a discount; see discount_cents) |
tax_cents | integer | — | See tax policy below |
units_to_grant | integer | — | quantity × billing_products.grants_units_per_quantity. Always 0 for :platform_fee lines |
line_type | enum string | NOT NULL | rails_enum(:principal, :platform_fee) |
platform_fee_rate_bps | integer | nullable | Gig only — the frozen platform fee rate. NULL for placement and all non-gig products |
discount_cents | integer | NOT NULL, default 0 | Amount discounted off this line via the agreement's discount_rate term. Lands on whichever line is Jod's own revenue: the :platform_fee line for gig, the :principal line for placement (see Tax Policy). Always 0 on the gig :principal line. |
discount_rate_bps | integer | nullable | The discount_rate term's basis points used to compute discount_cents. NULL when no discount applies |
created_at | timestamptz | ||
updated_at | timestamptz |
Line Types
line_type | What it is | units_to_grant | Tax |
|---|---|---|---|
:principal | The entitlement being purchased (credits, stored value) | > 0 | Placement: full GST on amount_cents. Gig: 0% (tax-exempt) |
:platform_fee | Jod's platform margin (gig credits only) | 0 (money only) | Full GST on amount_cents |
A Placement Credits invoice has exactly 1 line: :principal
A Gig Credits invoice has exactly 2 lines: 1 × :principal + 1 × :platform_fee
Tax Policy
| Product | Line Type | Tax Base | Tax Rate |
|---|---|---|---|
| Placement Credits (SG) | :principal | amount_cents - discount_cents | 9% GST (SR code) |
| Gig Credits — principal (SG) | :principal | 0 | 0% (tax-exempt stored value) |
| Gig Credits — platform fee (SG) | :platform_fee | amount_cents - discount_cents | 9% GST (SR code) |
When an agreement's discount_rate term applies, tax is computed on the post-discount amount of whichever line carries the discount — the :platform_fee line for gig, the :principal line for placement — not that line's gross amount_cents. The line that does not carry the discount is unaffected (gig :principal is always tax-exempt regardless; placement has no :platform_fee line).
For Indonesia (future):
- Placement: 11% PPN (
PPN_STD) - Gig principal: exempt
- Gig platform fee: 11% PPN
Key Indexes
| Index | Columns | Type | Purpose |
|---|---|---|---|
| — | uuid | UNIQUE | External lookup |
| — | billing_invoice_id | — | All lines for an invoice |
Snapshot Invariants
All values on an invoice line are immutable after creation. They represent the commercial truth at the moment of sale.
| Field | Snapshotted From | Why snapshot? |
|---|---|---|
description | billing_products.name (:principal) or the literal "Platform Fee" (:platform_fee) | Product name could change; invoice must show what was sold at the time |
unit_price_cents | billing_product_prices.unit_price_cents — always the catalog price, agreements never override it | Price could change; invoice must show the contracted price |
tax_cents | Computed from the post-discount amount × tax_rate | Tax rate could change; invoice must show tax at time of sale |
units_to_grant | quantity × grants_units_per_quantity | Grant rate could change; posting must use the rate at sale time |
platform_fee_rate_bps | Agreement's fee_rate term OR ProductPrice.platform_fee_rate_bps | Fee rate is negotiated per agreement; must be frozen at invoice creation |
discount_cents / discount_rate_bps | Agreement's discount_rate term, applied to the :platform_fee line for gig or the :principal line for placement | Discount is negotiated per agreement; must be frozen at invoice creation. nil/0 when no discount_rate term exists for the entitlement — there is no catalog fallback |
How InvoicePosting Uses InvoiceLine
When an invoice is posted (after payment is verified and invoice reaches paid), the InvoicePosting service iterates over all InvoiceLines and acts based on line_type:
For each InvoiceLine:
if line_type == :principal AND entitlement == :placement:
→ LedgerEntry(grant, available_delta: +units_to_grant, deferred_revenue_delta: +amount_cents)
if line_type == :principal AND entitlement == :gig: [Phase 3]
→ LedgerEntry(grant) + EntitlementLot(platform_fee_rate_bps: platform_fee_rate_bps)
if line_type == :platform_fee AND entitlement == :gig: [Phase 3]
→ LedgerEntry(platform_fee_deferred_delta: +amount_cents) — no units granted
Invariants
- Every
InvoiceLinemust belong to exactly oneBilling::Invoice units_to_grant == 0for all:platform_feelines (enforced by DB CHECK constraintcheck_platform_fee_lines_grant_no_units, since Phase 3 posting grants credits straight from this column — a violated row means free credits for a customer)platform_fee_rate_bps IS NOT NULLfor gig lines;IS NULLfor all other linesamount_cents == quantity × unit_price_cents(enforced at creation)tax_centsfollows the tax policy for the product's entitlement type and the seller's tax regime- All snapshot fields are immutable after creation — changes to products, prices, or agreements cannot mutate existing lines
- A Placement Credits invoice has exactly 1 line (
:principal) - A Gig Credits invoice has exactly 2 lines (1 ×
:principal, 1 ×:platform_fee) — enforced byTeamCreateValidator/TeamUpdateValidatorrejecting any invoice with more than one submitted line param (BuildLinesServicestill expands a single gig line param into 2 persisted rows) discount_centsis always0on the gig:principalline — the discount reduces Jod's own platform fee margin, never the wage value owed to the buyer/company. For placement (no:platform_feeline), the discount lands on:principalinstead.
Design Deviation from Source
| What Changed | Source It Differs From | Rationale |
|---|---|---|
Rename billing_invoice_items → billing_invoice_lines (table) | DBML, Ali's use cases | Industry-standard term. See rename notice at top of this page. |
Rename Billing::InvoiceItem → Billing::InvoiceLine (model) | Existing model naming | Consistency with table rename |