Skip to main content

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 dashboard
  • promo_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.

ContextDetails
AggregateBilling::Product (root) + Billing::ProductPrice (child)
LayerCatalog & Pricing
Upstream dependenciesBilling::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 dependentsBilling::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.

FromToTriggerNotes
(new)activeUC-1 / UC-5: row createdDefault status on creation
activeinactiveUC-2 / UC-6: admin deactivatesReversible. Hidden from employer dashboard. Historical invoices unaffected.
inactiveactiveUC-3 / UC-7: admin reactivatesReversible. Returns to default views.
activearchivedUC-4 / UC-8: admin archivesIrreversible. Hidden from default admin views. Allowed regardless of references.
inactivearchivedUC-4 / UC-8: admin archivesIrreversible. Same as above.
archived(any)RejectedTerminal state. To bring a retired catalog row back, create a new row.

What the three states mean

StatusWhat it means for salesReversible?Visible in employer dashboardVisible in admin default view
:activeCurrently sellableYesYes
:inactivePaused — not selling right now, may bring back laterYes ↔ :activeNoYes
:archivedRetired permanently — hidden from default viewsNo (terminal)NoNo (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

IDUse CaseTriggerActor
UC-1Create a product as a global catalog entryNew entitlement type needs a sellable productAdmin (ops)
UC-2Deactivate a productProduct temporarily not being soldAdmin (ops)
UC-3Reactivate a productResume selling after a pauseAdmin (ops)
UC-4Archive a product permanentlyProduct retired, will never be sold againAdmin (ops)
UC-5Create a product price for a specific marketNew market launch, new package, or starting a promoAdmin (ops)
UC-6Deactivate a product priceStop selling at this price (reversible)Admin (ops)
UC-7Reactivate a product priceResume selling at this priceAdmin (ops)
UC-8Archive a product price permanentlyPrice retired, will never be used againAdmin (ops)
UC-9Replace a product price atomically (deactivate-and-create)End of promo, tax-rate cutover, price changeAdmin (ops)
UC-10Resolve the applicable product price for a companyInvoice creation or employer dashboard needs pricingSystem
UC-11View active product prices for purchaseEmployer browsing available packagesEmployer
UC-12View product catalog with all pricesAdmin reviewing catalog and pricing configurationAdmin

UC-1: Create a product as a global catalog entry

FieldDetails
ActorIdentities::Admin (ops)
TriggerNew entitlement type needs a sellable product

Preconditions:

  • Billing::Entitlement exists for the target instrument

System Behavior:

  1. Admin enters: name, description
  2. Admin selects the Billing::Entitlement instrument it grants
  3. Admin sets grants_units_per_quantity (e.g., 1000 for a package, 1 for per-unit)
  4. System creates the product with status: :active
  5. Admin audit trail recorded

Business Rules:

  • name is a free-text human-readable label. It is not enforced unique — admin search and operational lookup tolerate similar names because identity is provided by id and uuid. Sales is expected to use clear, distinct names by convention.
  • grants_units_per_quantity must be a positive integer
  • billing_entitlement_id must 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

FieldDetails
ActorIdentities::Admin (ops)
TriggerProduct is being temporarily paused (not retired)

Preconditions:

  • Product exists and status: :active

System Behavior:

  1. Admin deactivates the product
  2. System sets status: :inactive
  3. Admin audit trail recorded

Business Rules:

  • Existing invoices are NOT affected (values were snapshotted)
  • All Billing::ProductPrice children 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

FieldDetails
ActorIdentities::Admin (ops)
TriggerResume selling after a pause

Preconditions:

  • Product exists and status: :inactive

System Behavior:

  1. Admin reactivates the product
  2. System sets status: :active
  3. Admin audit trail recorded

Business Rules:

  • Child Billing::ProductPrice rows that are themselves :active become 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

FieldDetails
ActorIdentities::Admin (ops)
TriggerProduct is being retired and will not be sold again

Preconditions:

  • Product exists in :active or :inactive

System Behavior:

  1. Admin archives the product (UI confirms "this is permanent")
  2. System sets status: :archived
  3. 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::ProductPrice rows 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

FieldDetails
ActorIdentities::Admin (ops)
TriggerNew market launch, a new package size, or the start of a promotion

Preconditions:

  • Billing::Product exists and status: :active
  • Billing::LegalEntity exists and is active for the seller jurisdiction
  • Geo::Country exists for the target market
  • No other :active Billing::ProductPrice exists for the same (billing_product_id, billing_legal_entity_id, billing_account_id) tuple — see WYSIWYG rule below

System Behavior:

  1. Admin selects the product, legal entity, and target country
  2. Admin optionally selects a Billing::Account to make this a private price for that specific client
  3. Admin enters pricing details:
    • pricing_modelpackage or per_unit
    • unit_price_cents — price in minor currency units
    • tax_code and tax_rate — applicable tax classification
    • currency is automatically taken from the selected Geo::Country's standard currency (geo_countries.currency) and snapshotted at creation — admin does not enter this field
  4. Admin optionally enters promo decoration:
    • compare_at_price_cents — anchor / "regular" price for strikethrough display (must be > unit_price_cents if set)
    • promo_label — free-text marketing copy ("Limited Time", "Launch Offer")
  5. For gig products: admin sets platform_fee_rate_bps (e.g. 2000 for a 20% platform fee)
  6. System validates and creates the product price with status: :active
  7. Admin audit trail recorded

Business Rules:

  • Gig product prices MUST have platform_fee_rate_bps set — this is the platform fee Jod charges, in basis points (e.g. 2000 = 20%)
  • unit_price_cents must be a positive integer
  • compare_at_price_cents, when set, must be > unit_price_cents
  • currency is always snapshotted from geo_countries.currency for the selected country — it is not editable after creation
  • tax_code must be valid for the legal entity's tax_regime (e.g. SR for sg_gst, PPN_STD for id_vat)
  • A product price cannot be created for a :inactive or :archived product
  • If billing_account_id is 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::ProductPrice with status = :active already 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: :active and is immediately resolvable
  • If billing_account_id is set, the price is only visible in the admin portal for that client

UC-6: Deactivate a product price

FieldDetails
ActorIdentities::Admin (ops)
TriggerStop selling at this price temporarily (the offer is on pause)

Preconditions:

  • Product price exists and status: :active

System Behavior:

  1. Admin deactivates the product price
  2. System sets status: :inactive
  3. 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

FieldDetails
ActorIdentities::Admin (ops)
TriggerResume selling at this price after a pause

Preconditions:

  • Product price exists and status: :inactive
  • No other :active Billing::ProductPrice exists for the same (billing_product_id, billing_legal_entity_id, billing_account_id) tuple (WYSIWYG uniqueness rule)

System Behavior:

  1. Admin reactivates the product price
  2. System validates the WYSIWYG uniqueness rule
  3. System sets status: :active
  4. 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

FieldDetails
ActorIdentities::Admin (ops)
TriggerPrice is retired and will never be activated again

Preconditions:

  • Product price exists in :active or :inactive

System Behavior:

  1. Admin archives the product price (UI confirms "this is permanent")
  2. System sets status: :archived
  3. 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)

FieldDetails
ActorIdentities::Admin (ops)
TriggerEnd of a promo, tax-rate cutover, price increase / decrease, any "swap one price for another"

Preconditions:

  • An :active Billing::ProductPrice exists 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:

  1. Admin opens the "Replace this price" action on the active price row
  2. Admin enters the new price details (full UC-5 input set, including optional compare_at_price_cents and promo_label)
  3. 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
  4. Admin audit trail recorded against both rows

Business Rules:

  • Atomicity is required: there must be no observable window in which the tuple has zero :active prices, 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 :inactive keeps 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: :active and immediately resolvable

Concrete examples:

ScenarioOld rowNew row
Start a promounit=14900, compare_at=NULL (regular)unit=9900, compare_at=14900, promo_label="Holiday Sale"
End a promounit=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.09tax_rate=0.10
Permanent price increaseunit=14900unit=15900

UC-10: Resolve the applicable product price for a company

FieldDetails
ActorSystem
TriggerInvoice creation (UC-1 in billing-invoice.md) or employer dashboard needs pricing

Preconditions:

  • Billing::Product exists and status: :active
  • At least one Billing::ProductPrice exists for the product

System Behavior:

  1. System identifies the company's country via Org::Company.country_id
  2. System queries for matching product prices where:
    • billing_product_id matches the desired product
    • billing_legal_entity.country_id matches the company's country (joined through billing_legal_entity_id)
    • status = :active
    • Parent product status = :active
    • billing_account_id IS NULL (standard) OR billing_account_id matches the company's billing account (private)
  3. If both a private and a standard match exist for this company, the private wins (per resolution rule below)
  4. 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 :active row. 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:

  1. Private over public: a price with billing_account_id matching the buyer's billing account takes priority over a price with billing_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:

  1. If the company has an active Billing::Agreement with a fee_rate term scoped to the gig entitlement → use that rate (the negotiated rate, authoritative for that company)
  2. Otherwise → use ProductPrice.platform_fee_rate_bps (the list rate) AND auto-generate a new Billing::Agreement with a fee_rate term 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_rate term 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-company billing_product_price to capture them. One standard ProductPrice (with billing_account_id IS NULL) serves all companies in that market; each company's Agreement provides 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 ProductPrice with billing_account_id set 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

FieldDetails
ActorOrg::Membership (employer)
TriggerEmployer browsing available packages in their dashboard

Preconditions:

  • Employer is authenticated and belongs to an Org::Company

System Behavior:

  1. System resolves the employer's company country
  2. System retrieves all standard product prices (billing_account_id IS NULL) where both the parent product AND the price have status = :active
  3. Employer sees available packages, with promo decoration rendered when set:
    • Product name and description
    • unit_price_cents (current price, prominent)
    • compare_at_price_cents rendered as strikethrough above the current price (when set)
    • Computed savings ("Save $X" or "X% off") derived from compare_at_price_cents - unit_price_cents (when compare_at_price_cents is set)
    • promo_label rendered alongside (when set)
    • Tax breakdown
  4. Employer can select a package to initiate a purchase

Business Rules:

  • Only :active prices for :active products in the employer's country are shown
  • :inactive and :archived rows 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

FieldDetails
ActorIdentities::Admin
TriggerAdmin reviewing catalog and pricing configuration

Preconditions:

  • None

System Behavior:

  1. Admin navigates to the product catalog page
  2. Default view shows all products with status in (:active, :inactive), grouped, with their prices
  3. Admin can switch to an "Archived" tab/filter to see archived products and prices
  4. Each product row shows: name, description, entitlement instrument, status
  5. 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

  1. A product must reference exactly one Billing::Entitlement instrument
  2. All fields on Billing::Product and Billing::ProductPrice are immutable after creation, except status. To change any other field, archive the existing row and create a new one.
  3. Products and prices are never hard-deleted — only set to :archived (terminal state)
  4. grants_units_per_quantity must be a positive integer
  5. A product price must reference exactly one Product, one LegalEntity, and one Country
  6. Gig product prices (product.billing_entitlement.instrument = :gig) must have platform_fee_rate_bps set — this is required to calculate the platform fee line item on gig invoices
  7. unit_price_cents must be a positive integer
  8. compare_at_price_cents, when set, must be > unit_price_cents
  9. Invoice line items copy product and price values when created — changes to the catalog after that do not affect existing invoices
  10. A product whose status is :inactive or :archived cannot have new prices created for it
  11. Admin audit trail (admin_created_by, admin_updated_by) is always populated on products and prices
  12. Client-specific prices (billing_account_id set) are never visible in the employer dashboard — they can only be used by admins when creating invoices for that specific account
  13. WYSIWYG uniqueness (application-layer enforced): at any point in time, at most one Billing::ProductPrice with status = :active exists 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.
  14. The :archived status is terminal. Transitions out of :archived are rejected on every entry point.

Model Interactions

Related ModelRelationshipInteraction
Billing::EntitlementProduct belongs_to EntitlementDefines 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::LegalEntityProductPrice belongs_to LegalEntityThe Jod legal entity that sells in this country. The legal entity determines which tax rules apply to the price.
Geo::CountryProductPrice belongs_to CountryMarket selector. Price is available to companies in this country.
Billing::InvoiceItemInvoiceItem references Product and ProductPriceCopies 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::AgreementAgreement terms inform pricing alongside ProductPriceProductPrice 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::AccountProductPrice optionally belongs_to AccountWhen 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::AdminProduct and ProductPrice track created_by / updated_byTracks 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 NULL and applies the field_changes via UPDATE
  • After applying, the job sets applied_at to 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_until on 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

GapImpactSuggested Resolution
No composite index supporting the price resolution queryFinding prices by product, country, and status will get slower as more prices are addedAdd 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 setplatform_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 linesEnforced 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_regimeUC-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 exportEnforced 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_currencyThe 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 currencyEnforced 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_centsWithout 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 indexA 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 noticesAcknowledged 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 transitionsadmin_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 changeAdd 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