Billing::Product
Purpose
A Product defines what Jod sells (e.g., "Placement Credits", "Gig Credits"). It specifies which type of entitlement the customer receives when they purchase, but does not include pricing. The same product exists across all countries.
A ProductPrice defines how much a product costs in a specific country. It includes the currency, unit price, tax rules, and for gig products, the platform fee rate. Employers see active prices as purchasable packages in their dashboard.
Together, Product and ProductPrice make up the pricing catalog that invoices are built from. When an invoice is created, the product name, price, and tax values are copied into the invoice line items. Changes to the catalog after that do not affect invoices that were already created.
The unit of measurement for what the customer receives (cents, credits, seats) is a property of Billing::Entitlement, not Billing::Product. A product is just a package — it grants N units of an entitlement per quantity bought (via grants_units_per_quantity). The entitlement defines what a "unit" means. This keeps the boundaries clean: the entitlement owns the measurement, the product owns the packaging, and the price owns the cost.
Immutability rule (single most important rule on this page)
All fields on Billing::Product and Billing::ProductPrice are immutable after creation, with one exception: status. To correct a typo, change a price, run a promo, or react to a tax-rate change, the workflow is always the same — archive the existing row and create a new one. There is no edit endpoint, no "edit while no invoices reference this row" exception, no field-level mutability matrix.
This rule is the foundation for everything else on this page. It removes an entire class of "did this row mean X or Y at time T" ambiguity, simplifies the audit trail, and lets us snapshot freely from these rows into invoice items without ever worrying that the source row might mutate later.
Promotions
Promotions are not modelled as a first-class entity. They are decoration on the active price row, expressed via two columns:
compare_at_price_cents— the anchor "regular" price shown as strikethrough on the employer dashboardpromo_label— optional marketing copy ("Limited Time", "Launch Offer")
To run a promo: create a new ProductPrice with the discounted unit_price_cents, set compare_at_price_cents to the original price, and optionally set promo_label. Activate it. The employer dashboard renders strikethrough + computed savings + label automatically.
To end a promo: see UC-9 (Replace a product price).
This design covers today's full surface — strikethrough pricing, computed % off and Save $X badges, marketing copy, per-customer promos (via billing_account_id). Time-bounded promos and coupon codes are documented in Future Enhancements for when actual operational pain demands them.
Model Context
Legend: Lavender nodes belong to the Billing domain. Grey nodes are external references (other domains). The yellow node is the subject of this spec. The dashed box is the aggregate boundary — the root (
Billing::Product) and its child (Billing::ProductPrice) are managed as a single unit.
| Context | Details |
|---|---|
| Aggregate | Billing::Product (root) + Billing::ProductPrice (child) |
| Layer | Catalog & Pricing |
| Upstream dependencies | Billing::Entitlement (which entitlement type -- placement, gig, workforce -- the product gives the customer), Billing::LegalEntity (the Jod entity that sells the product in each country), Geo::Country (which country the price applies to), Billing::Account (optional -- scopes a price to a specific client for private deals) |
| Downstream dependents | Billing::InvoiceItem (copies product and price details when the invoice is created) |
State Machine
Billing::Product and Billing::ProductPrice share the same three-state status enum. Identical transitions apply to both.
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | active | UC-1 / UC-5: row created | Default status on creation |
active | inactive | UC-2 / UC-6: admin deactivates | Reversible. Hidden from employer dashboard. Historical invoices unaffected. |
inactive | active | UC-3 / UC-7: admin reactivates | Reversible. Returns to default views. |
active | archived | UC-4 / UC-8: admin archives | Irreversible. Hidden from default admin views. Allowed regardless of references. |
inactive | archived | UC-4 / UC-8: admin archives | Irreversible. Same as above. |
archived | (any) | Rejected | Terminal state. To bring a retired catalog row back, create a new row. |
What the three states mean
| Status | What it means for sales | Reversible? | Visible in employer dashboard | Visible in admin default view |
|---|---|---|---|---|
:active | Currently sellable | — | Yes | Yes |
:inactive | Paused — not selling right now, may bring back later | Yes ↔ :active | No | Yes |
:archived | Retired permanently — hidden from default views | No (terminal) | No | No (only when admin opens "Archived" tab) |
Cascading rule for resolution: a Billing::ProductPrice resolves in the employer dashboard / invoice creation only when both the parent product AND the price have status = :active. The resolver expresses this as WHERE product.status = 'active' AND price.status = 'active' — no implicit walk, no derived state.
Archive has no precondition. A row may be archived at any time, regardless of whether Billing::InvoiceItem rows reference it. The row is preserved for historical invoice rendering and audit; archive only changes the status value and hides it from default admin views.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Create a product as a global catalog entry | New entitlement type needs a sellable product | Admin (ops) |
| UC-2 | Deactivate a product | Product temporarily not being sold | Admin (ops) |
| UC-3 | Reactivate a product | Resume selling after a pause | Admin (ops) |
| UC-4 | Archive a product permanently | Product retired, will never be sold again | Admin (ops) |
| UC-5 | Create a product price for a specific market | New market launch, new package, or starting a promo | Admin (ops) |
| UC-6 | Deactivate a product price | Stop selling at this price (reversible) | Admin (ops) |
| UC-7 | Reactivate a product price | Resume selling at this price | Admin (ops) |
| UC-8 | Archive a product price permanently | Price retired, will never be used again | Admin (ops) |
| UC-9 | Replace a product price atomically (deactivate-and-create) | End of promo, tax-rate cutover, price change | Admin (ops) |
| UC-10 | Resolve the applicable product price for a company | Invoice creation or employer dashboard needs pricing | System |
| UC-11 | View active product prices for purchase | Employer browsing available packages | Employer |
| UC-12 | View product catalog with all prices | Admin reviewing catalog and pricing configuration | Admin |
UC-1: Create a product as a global catalog entry
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | New entitlement type needs a sellable product |
Preconditions:
Billing::Entitlementexists for the target instrument
System Behavior:
- Admin enters:
name,description - Admin selects the
Billing::Entitlementinstrument it grants - Admin sets
grants_units_per_quantity(e.g., 1000 for a package, 1 for per-unit) - System creates the product with
status: :active - Admin audit trail recorded
Business Rules:
nameis a free-text human-readable label. It is not enforced unique — admin search and operational lookup tolerate similar names because identity is provided byidanduuid. Sales is expected to use clear, distinct names by convention.grants_units_per_quantitymust be a positive integerbilling_entitlement_idmust reference a valid entitlement instrument- Product is immediately active — no draft state
- All fields are immutable after creation. The admin form should warn the admin to double-check before submitting. Mistakes are corrected by archiving + recreating.
Postconditions:
- Product exists with
status: :active - No prices exist yet — prices are added separately (UC-5)
UC-2: Deactivate a product
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | Product is being temporarily paused (not retired) |
Preconditions:
- Product exists and
status: :active
System Behavior:
- Admin deactivates the product
- System sets
status: :inactive - Admin audit trail recorded
Business Rules:
- Existing invoices are NOT affected (values were snapshotted)
- All
Billing::ProductPricechildren stop resolving in the employer dashboard and during invoice creation while the parent is:inactive(cascading rule), regardless of their own status - Deactivation is reversible (see UC-3)
Postconditions:
- Product
status: :inactive - Product no longer appears in employer purchase flows or price resolution
UC-3: Reactivate a product
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | Resume selling after a pause |
Preconditions:
- Product exists and
status: :inactive
System Behavior:
- Admin reactivates the product
- System sets
status: :active - Admin audit trail recorded
Business Rules:
- Child
Billing::ProductPricerows that are themselves:activebecome resolvable again - Reactivation is rejected if the product is
:archived(irreversible)
Postconditions:
- Product
status: :active - Product is again available for price resolution and employer purchase flows
UC-4: Archive a product permanently
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | Product is being retired and will not be sold again |
Preconditions:
- Product exists in
:activeor:inactive
System Behavior:
- Admin archives the product (UI confirms "this is permanent")
- System sets
status: :archived - Admin audit trail recorded
Business Rules:
- Irreversible. Cannot be transitioned out of
:archived. To bring back a retired product, create a new row. - Allowed regardless of whether any invoice items reference children of this product. Invoices keep their snapshots; the row stays in the DB. Archive only hides it from default admin views.
- Child
Billing::ProductPricerows are NOT automatically archived (their own status is preserved for audit), but they stop resolving because the parent is archived.
Postconditions:
- Product
status: :archived - Product no longer appears in default admin views (visible only via "Archived" filter)
UC-5: Create a product price for a specific market
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | New market launch, a new package size, or the start of a promotion |
Preconditions:
Billing::Productexists andstatus: :activeBilling::LegalEntityexists and isactivefor the seller jurisdictionGeo::Countryexists for the target market- No other
:activeBilling::ProductPriceexists for the same(billing_product_id, billing_legal_entity_id, billing_account_id)tuple — see WYSIWYG rule below
System Behavior:
- Admin selects the product, legal entity, and target country
- Admin optionally selects a
Billing::Accountto make this a private price for that specific client - Admin enters pricing details:
pricing_model—packageorper_unitunit_price_cents— price in minor currency unitstax_codeandtax_rate— applicable tax classificationcurrencyis automatically taken from the selectedGeo::Country's standard currency (geo_countries.currency) and snapshotted at creation — admin does not enter this field
- Admin optionally enters promo decoration:
compare_at_price_cents— anchor / "regular" price for strikethrough display (must be> unit_price_centsif set)promo_label— free-text marketing copy ("Limited Time", "Launch Offer")
- For gig products: admin sets
platform_fee_rate_bps(e.g. 2000 for a 20% platform fee) - System validates and creates the product price with
status: :active - Admin audit trail recorded
Business Rules:
- Gig product prices MUST have
platform_fee_rate_bpsset — this is the platform fee Jod charges, in basis points (e.g. 2000 = 20%) unit_price_centsmust be a positive integercompare_at_price_cents, when set, must be> unit_price_centscurrencyis always snapshotted fromgeo_countries.currencyfor the selected country — it is not editable after creationtax_codemust be valid for the legal entity'stax_regime(e.g.SRforsg_gst,PPN_STDforid_vat)- A product price cannot be created for a
:inactiveor:archivedproduct - If
billing_account_idis set, this price is private — only available to admins when creating invoices for that specific account, and never visible in the employer dashboard - WYSIWYG uniqueness rule (application-layer): the system rejects creation if any other
Billing::ProductPricewithstatus = :activealready exists for the same(billing_product_id, billing_legal_entity_id, billing_account_id)tuple. The admin must deactivate the existing active price first, or use UC-9 (Replace) for atomic swap. This rule guarantees the resolver returns at most one row per privacy tier. - All fields are immutable after creation. To change anything, archive the row and create a new one.
Postconditions:
- Product price exists with
status: :activeand is immediately resolvable - If
billing_account_idis set, the price is only visible in the admin portal for that client
UC-6: Deactivate a product price
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | Stop selling at this price temporarily (the offer is on pause) |
Preconditions:
- Product price exists and
status: :active
System Behavior:
- Admin deactivates the product price
- System sets
status: :inactive - Admin audit trail recorded
Business Rules:
- Reversible (see UC-7)
- Existing invoices that snapshotted from this price are NOT affected
- The price stops resolving in the employer dashboard and invoice creation immediately
- Deactivation is allowed regardless of whether invoice items reference this price
Postconditions:
- Product price
status: :inactive
UC-7: Reactivate a product price
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | Resume selling at this price after a pause |
Preconditions:
- Product price exists and
status: :inactive - No other
:activeBilling::ProductPriceexists for the same(billing_product_id, billing_legal_entity_id, billing_account_id)tuple (WYSIWYG uniqueness rule)
System Behavior:
- Admin reactivates the product price
- System validates the WYSIWYG uniqueness rule
- System sets
status: :active - Admin audit trail recorded
Business Rules:
- Rejected if the price is
:archived(irreversible) - Rejected if another active price already exists for the same tuple — admin must deactivate that one first, or use UC-9 (Replace) for an atomic swap
Postconditions:
- Product price
status: :active - Resolver immediately returns this price for matching queries
UC-8: Archive a product price permanently
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | Price is retired and will never be activated again |
Preconditions:
- Product price exists in
:activeor:inactive
System Behavior:
- Admin archives the product price (UI confirms "this is permanent")
- System sets
status: :archived - Admin audit trail recorded
Business Rules:
- Irreversible. Cannot be transitioned out of
:archived. To bring back a retired price, create a new row (UC-5). - Allowed regardless of whether invoice items reference this price. The row is preserved for invoice rendering and audit; archive only hides it from default admin views.
- Archiving is the only way to clean up old / used prices that should never come back. Use UC-6 (Deactivate) for prices you might want to revive later.
Postconditions:
- Product price
status: :archived - Hidden from default admin views (visible only via "Archived" filter)
- Historical invoice items continue to render correctly via their snapshot fields
UC-9: Replace a product price atomically (deactivate-and-create)
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | End of a promo, tax-rate cutover, price increase / decrease, any "swap one price for another" |
Preconditions:
- An
:activeBilling::ProductPriceexists for the target(product, legal_entity, billing_account_id)tuple - Admin has the new price details ready (matching the UC-5 input set)
System Behavior:
- Admin opens the "Replace this price" action on the active price row
- Admin enters the new price details (full UC-5 input set, including optional
compare_at_price_centsandpromo_label) - System runs the replace as a single DB transaction:
- Set the old price
status: :inactive - Validate the new price (UC-5 rules including WYSIWYG uniqueness — which now passes because the old row was just deactivated in the same transaction)
- Insert the new price with
status: :active
- Set the old price
- Admin audit trail recorded against both rows
Business Rules:
- Atomicity is required: there must be no observable window in which the tuple has zero
:activeprices, otherwise the employer dashboard renders "no price available" briefly and the resolver returns nothing - The old row is left as
:inactive, NOT archived. If the admin wants to clean it up, they can archive it later (UC-8). Leaving as:inactivekeeps the row queryable in default admin views for follow-up reference - The new row gets a fresh row id, fresh audit timestamps, and is fully immutable from that point onward
Postconditions:
- Old price
status: :inactive, all its snapshot data preserved - New price
status: :activeand immediately resolvable
Concrete examples:
| Scenario | Old row | New row |
|---|---|---|
| Start a promo | unit=14900, compare_at=NULL (regular) | unit=9900, compare_at=14900, promo_label="Holiday Sale" |
| End a promo | unit=9900, compare_at=14900, promo_label="Holiday Sale" | unit=14900, compare_at=NULL |
| Tax-rate cutover (SG GST 9% → 10% on 1 Jan) | tax_rate=0.09 | tax_rate=0.10 |
| Permanent price increase | unit=14900 | unit=15900 |
UC-10: Resolve the applicable product price for a company
| Field | Details |
|---|---|
| Actor | System |
| Trigger | Invoice creation (UC-1 in billing-invoice.md) or employer dashboard needs pricing |
Preconditions:
Billing::Productexists andstatus: :active- At least one
Billing::ProductPriceexists for the product
System Behavior:
- System identifies the company's country via
Org::Company.country_id - System queries for matching product prices where:
billing_product_idmatches the desired productbilling_legal_entity.country_idmatches the company's country (joined throughbilling_legal_entity_id)status = :active- Parent product
status = :active billing_account_id IS NULL(standard) ORbilling_account_idmatches the company's billing account (private)
- If both a private and a standard match exist for this company, the private wins (per resolution rule below)
- System returns the resolved price, or none if no match
Business Rules:
- The application-layer WYSIWYG uniqueness rule (enforced on UC-5 create, UC-7 reactivate, and UC-9 replace) guarantees that for any given
(product, legal_entity, billing_account_id)tuple there is at most one:activerow. So at the privacy-tier level, resolution returns at most one row. The only multiplicity is "is there both a private match AND a standard match?" — handled by the resolution rule below. - For the employer dashboard (UC-11), only standard prices are returned (
billing_account_id IS NULL). For admin invoice creation, both standard and matching private prices are returned, and the resolver applies private-over-public. - This step finds the base catalog price. Negotiated terms from the company's
Billing::Agreement(like a custom platform fee rate or a discount) are applied separately when computing the invoice line items.
Resolution Priority:
The system uses a single rule:
- Private over public: a price with
billing_account_idmatching the buyer's billing account takes priority over a price withbilling_account_id IS NULL.
That's the entire rule. There is no time-window logic, no "most-specific-wins", no narrower-window-wins, no recency tiebreaker. The catalog is intentionally constrained so resolution stays this simple.
Fee Rate Resolution (gig only):
For gig invoices, the platform fee rate has two possible sources:
- If the company has an active
Billing::Agreementwith afee_rateterm scoped to the gig entitlement → use that rate (the negotiated rate, authoritative for that company) - Otherwise → use
ProductPrice.platform_fee_rate_bps(the list rate) AND auto-generate a newBilling::Agreementwith afee_rateterm capturing this rate (see use-cases.md section 3.1)
After a company has an agreement with a fee_rate term for gig, that agreement is authoritative for their fee rate forever. Subsequent invoices always use the agreement's rate, never the catalog list rate.
ProductPrice.platform_fee_rate_bps serves two roles, both of which apply only before a company has an agreement:
- Display rate shown to unauthenticated browsers and new sign-ups in the employer dashboard
- Template copied into the agreement's
fee_rateterm when the self-serve flow auto-creates an agreement
Per-client pricing implications:
- Per-company fee rate differences live in
Billing::Agreement— do not create a per-companybilling_product_priceto capture them. One standardProductPrice(withbilling_account_id IS NULL) serves all companies in that market; each company'sAgreementprovides their specific rate. - Per-company credit price differences (e.g., "Company X buys 1,000 credits for $80 instead of $100") do go in a private
ProductPricewithbilling_account_idset to that company's account.
This keeps Agreement as the source of truth for negotiated terms (fee rates, discounts, custom unit prices) and ProductPrice as the source of truth for catalog values (currency, base price, list rate). They meet at invoice time — the system reads both and combines them — without duplicating data across either side.
Legacy seeding rule: when migrating from the legacy database, seed one Billing::Agreement (with its fee_rate term) per legacy company. Do not create per-company billing_product_price rows for fee rate differences. The shared standard catalog of ProductPrice rows is created once, independent of how many companies exist.
Postconditions:
- Resolved price (or none) returned for invoice line item computation or employer display
UC-11: View active product prices for purchase
| Field | Details |
|---|---|
| Actor | Org::Membership (employer) |
| Trigger | Employer browsing available packages in their dashboard |
Preconditions:
- Employer is authenticated and belongs to an
Org::Company
System Behavior:
- System resolves the employer's company country
- System retrieves all standard product prices (
billing_account_id IS NULL) where both the parent product AND the price havestatus = :active - Employer sees available packages, with promo decoration rendered when set:
- Product name and description
unit_price_cents(current price, prominent)compare_at_price_centsrendered as strikethrough above the current price (when set)- Computed savings ("Save $X" or "X% off") derived from
compare_at_price_cents - unit_price_cents(whencompare_at_price_centsis set) promo_labelrendered alongside (when set)- Tax breakdown
- Employer can select a package to initiate a purchase
Business Rules:
- Only
:activeprices for:activeproducts in the employer's country are shown :inactiveand:archivedrows are never shown to employers regardless of any other field- Client-specific (private) prices are never shown — only the admin portal can use them when creating invoices for that specific account
- The purchase flow is handled by the Invoice domain — this UC only covers the catalog browsing
Postconditions:
- Read-only operation — no data changes
- Employer has visibility of available packages with appropriate promo decoration
UC-12: View product catalog with all prices
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin reviewing catalog and pricing configuration |
Preconditions:
- None
System Behavior:
- Admin navigates to the product catalog page
- Default view shows all products with
statusin (:active,:inactive), grouped, with their prices - Admin can switch to an "Archived" tab/filter to see archived products and prices
- Each product row shows: name, description, entitlement instrument, status
- Each price row shows: country, currency,
unit_price_cents,compare_at_price_cents(if set),promo_label(if set),pricing_model,tax_code,platform_fee_rate_bps(if gig), status
Business Rules:
- Admin filters available: by product, by country, by status (
:active,:inactive,:archived) - The "Archived" filter is the only way to see archived rows — they do not appear in the default view
Postconditions:
- Read-only operation — no data changes
Invariants
- A product must reference exactly one
Billing::Entitlementinstrument - All fields on
Billing::ProductandBilling::ProductPriceare immutable after creation, exceptstatus. To change any other field, archive the existing row and create a new one. - Products and prices are never hard-deleted — only set to
:archived(terminal state) grants_units_per_quantitymust be a positive integer- A product price must reference exactly one Product, one LegalEntity, and one Country
- Gig product prices (
product.billing_entitlement.instrument = :gig) must haveplatform_fee_rate_bpsset — this is required to calculate the platform fee line item on gig invoices unit_price_centsmust be a positive integercompare_at_price_cents, when set, must be> unit_price_cents- Invoice line items copy product and price values when created — changes to the catalog after that do not affect existing invoices
- A product whose status is
:inactiveor:archivedcannot have new prices created for it - Admin audit trail (
admin_created_by,admin_updated_by) is always populated on products and prices - Client-specific prices (
billing_account_idset) are never visible in the employer dashboard — they can only be used by admins when creating invoices for that specific account - WYSIWYG uniqueness (application-layer enforced): at any point in time, at most one
Billing::ProductPricewithstatus = :activeexists for a given(billing_product_id, billing_legal_entity_id, billing_account_id)tuple. Enforced on Create (UC-5), Reactivate (UC-7), and Replace (UC-9). Not a DB constraint — explicitly chosen for flexibility. - The
:archivedstatus is terminal. Transitions out of:archivedare rejected on every entry point.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::Entitlement | Product belongs_to Entitlement | Defines which entitlement type (placement, gig, workforce) the customer receives when they buy this product. The entitlement type also determines how credits are tracked — pooled for placement, or FIFO lots for gig. The entitlement also defines the unit of measurement (cent/credit/seat) that the product grants. |
Billing::LegalEntity | ProductPrice belongs_to LegalEntity | The Jod legal entity that sells in this country. The legal entity determines which tax rules apply to the price. |
Geo::Country | ProductPrice belongs_to Country | Market selector. Price is available to companies in this country. |
Billing::InvoiceItem | InvoiceItem references Product and ProductPrice | Copies the product name, grant quantity, unit price, tax, and (when set) compare_at_price_cents / promo_label into invoice line items when the invoice is created. Once copied, changes to the catalog do not affect the invoice. Archive of either parent does not affect invoice rendering. |
Billing::Agreement | Agreement terms inform pricing alongside ProductPrice | ProductPrice provides the standard price from the catalog. Agreement provides company-specific negotiated rates (fee rate, discount, unit price). Both are used together when calculating invoice line items. |
Billing::Account | ProductPrice optionally belongs_to Account | When set, the price is a private deal for that specific client. When NULL, the price is a standard catalog entry available to all employers. |
Identities::Admin | Product and ProductPrice track created_by / updated_by | Tracks which admin created or last changed the status of each product and price. |
Future Enhancements
These are deliberately out of scope today. Each is documented here so the design intent is preserved and the schema we ship now does not paint us into a corner when these arrive.
Scheduled price changes (declarative cutovers)
When the manual deactivate-and-create workflow (UC-9) becomes operationally painful — for example, a quarterly promo cadence or a tax-rate cutover scheduled months in advance — introduce a small scheduling primitive that is additive and does not change the shape of Billing::ProductPrice:
Table billing_scheduled_price_updates {
id bigint [pk, increment]
billing_product_price_id bigint [ref: > billing_product_prices.id]
apply_at timestamp [not null]
field_changes jsonb [not null, note: 'e.g. {"status": "active"} or {"status": "inactive"}']
applied_at timestamp [null]
applied_by_admin_id bigint [null, ref: > identities_admins.id]
idempotency_key string [unique]
created_at timestamp
updated_at timestamp
}
Properties of this design:
- A cron / scheduled job (Sidekiq, GoodJob) finds rows where
apply_at <= now AND applied_at IS NULLand applies thefield_changesviaUPDATE - After applying, the job sets
applied_atto make re-execution idempotent - Cancelling a scheduled change is
DELETE FROM billing_scheduled_price_updates WHERE id = ?— no edit semantics, no date-column manipulation - Multiple scheduled updates can target the same
apply_at(e.g., one inactivates the old price, one activates the new — they fire together for an atomic-feeling cutover) - The price row always represents "right now" — readers never deal with temporal predicates
Why this is not the same as a date-window design:
- A date-window design (
active_from,active_untilon the price row) fuses "what is the price" with "when does the price change", forcing every reader to apply temporal predicates and handle overlapping windows with a most-specific-wins rule - This design separates state (catalog) from change-schedule (operational concern). Readers stay simple. The scheduled-update job is the one place that knows about time.
When to build it: when sales reports the manual swap is hurting (typically after the third or fourth promo cycle, or the first tax-rate cutover with a hard deadline). Don't build preemptively — the manual workflow is fine for low-cadence cutovers.
Promotion codes, BOGO, customer segmentation
Today, promotions are decoration on the active price (compare_at_price_cents + promo_label), and per-customer promos use private prices via billing_account_id. When richer needs appear (BLACKFRIDAY-style coupon codes, buy-one-get-one, customer-tier pricing), introduce a first-class Billing::Promotion entity at that point.
The compare-at design does not paint us into a corner — coupon codes and BOGO live on a different axis (eligibility / cart-level rules) and don't need to share columns with ProductPrice. When that day comes, it'll be a separate table with its own state machine, and ProductPrice stays unchanged.
When to build it: when a stakeholder asks for a coupon code, a BOGO offer, or a customer-tier discount that cannot be expressed as a private price.
Schema Gaps
| Gap | Impact | Suggested Resolution |
|---|---|---|
| No composite index supporting the price resolution query | Finding prices by product, country, and status will get slower as more prices are added | Add index on (billing_product_id, billing_legal_entity_id, status) on billing_product_prices. Ensure country_id is indexed on billing_legal_entities to support the buyer-country join. |
No DB-level check that gig prices have platform_fee_rate_bps set | platform_fee_rate_bps is nullable in the DB, but Invariant 6 requires it to be set when the product's entitlement is :gig. A direct insert (raw SQL, fixture, bulk import) could create a gig price with no platform fee, producing silent zero-fee invoice lines | Enforced at application level via Billing::ProductPrices::TeamCreateValidator. Reject any create that produces a gig price without platform_fee_rate_bps. No DB constraint added — a cross-table CHECK would require denormalising the entitlement instrument onto billing_product_prices, which we deliberately avoided |
No DB-level check that tax_code matches the legal entity's tax_regime | UC-5 requires tax_code to be valid for the seller's tax_regime (e.g. SR only for sg_gst). The column is a free-form string — an admin or migration could record an Indonesian VAT code on a Singapore price, producing an incorrect tax classification on every downstream invoice and Xero export | Enforced at application level. Validator checks the (tax_regime, tax_code) pair against a known map of valid codes per regime at create time |
No DB-level check that currency matches the legal entity's default_currency | The currency column is documented as a snapshot of billing_legal_entities.default_currency, but the DB does not enforce the match. A direct insert could produce a Singapore legal entity price with currency = 'IDR', leading to invoices in the wrong currency | Enforced at application level. Manager copies currency from the selected legal entity at create time and rejects any user-supplied override |
No DB-level check that compare_at_price_cents > unit_price_cents | Without enforcement, a direct insert could produce compare_at < unit_price, which would render as a negative discount on the dashboard ("Save -$50") | Enforced at application level via Billing::ProductPrices::TeamCreateValidator. Could also be added as a Postgres CHECK constraint cheaply (compare_at_price_cents IS NULL OR compare_at_price_cents > unit_price_cents) — recommended belt-and-braces |
| WYSIWYG uniqueness is application-layer only — no DB partial unique index | A race condition between two simultaneous activate/replace requests could result in two :active rows for the same tuple. The resolver would then return non-deterministic results until an admin notices | Acknowledged and accepted. Decision recorded during design review: flexibility prioritised. Application-layer validator runs inside the same DB transaction as the activate/create, which makes the race window small but not zero. Revisit if WYSIWYG breakage is observed in practice |
| No change history for catalog status transitions | admin_updated_by only records the last editor — intermediate transitions (e.g. "who deactivated this price two weeks ago?") are lost. Becomes load-bearing the first time finance or a customer disputes a status change | Add a billing_audit_events append-only table (actor_type, actor_id, record_type, record_id, from_status, to_status, occurred_at) and write to it from every status-transition Manager. Do not use ActiveRecord callbacks — follow the codebase rule that side effects are explicit |