Skip to main content

Billing::OutletBudget

Purpose

An Outlet Budget partitions a company's entitlement balance into per-outlet spending buckets, allowing enterprise clients to delegate spending authority to individual outlets. When an outlet has an active budget, credit reservations and consumption at that outlet draw from its dedicated sub-pool rather than the company's unallocated pool.

This solves a core enterprise need: HQ allocates credits to outlets so that outlet/area managers can hire independently without coordinating with HQ for every job. Analysis of production data (March 2026) shows that 75.3% of recent completed jobs and 72.9% of recent salary volume flow through outlet-level credit deduction — this is the dominant spending pattern for the platform's largest clients (NTUC, McDonald's, Unity Pharmacy, Cheers).

Outlet budgets are a partitioning layer on top of the existing ledger, not a replacement. The main financial ledger (Billing::LedgerEntry), company-level balance (Billing::EntitlementBalance), and FIFO lot system (Billing::EntitlementLot) are unchanged. The outlet budget tracks how the company's credit pool is distributed across outlets.

This aggregate consists of two models:

  • Billing::OutletBudget — a projection table (like Billing::EntitlementBalance) that tracks per-outlet available and reserved credit counts. Its existence signals that the outlet is budget-controlled.
  • Billing::OutletBudgetTransfer — an append-only log that records every allocation and deallocation of credits between the company's unallocated pool and an outlet budget. This is the source of truth for budget movements, analogous to how Billing::LedgerEntry is the source of truth for financial events.

Model Context

ContextDetails
AggregateBilling::OutletBudget (root) + Billing::OutletBudgetTransfer (child)
LayerEntitlements Engine
Upstream dependenciesBilling::Account (must exist), Billing::Entitlement (budget scoped to instrument), Billing::EntitlementBalance (partitioning source — unallocated pool derived from it), Org::Outlet (must exist and belong to the same company)
Downstream dependentsNone — this is a leaf entity. Existing flows (reserve/consume/release) are modified to update the budget.

Entitlement Type Considerations

Outlet budgets are not a universal billing capability — they only apply to entitlement types where the consumption event is outlet-scoped.

EntitlementInstrumentConsumption EventOutlet-Scoped?Outlet Budget Supported?
Gig Credits:gigGig::Shift completed at Org::OutletYesYes
Placement Credits:placementCareers::Job posted by Org::CompanyNoNo
Workforce Seats:workforceSubscription per Org::CompanyNoNo

Why Only Gig Credits?

Gig credits are consumed by shifts, which happen at specific physical outlets — the talent clocks in at FairPrice Vivo, not at "FairPrice East" abstractly. This is why enterprise clients need per-outlet spending control. In contrast:

  • Placement credits are consumed by career job postings and ad campaigns, which are company-level activities. There is no outlet reference on careers_jobs.
  • Workforce seats are subscription-based and scoped to the company, not individual outlets.

Future Extensibility

The schema is generic — billing_outlet_budgets has a billing_entitlement_id FK that can reference any entitlement instrument. If a future entitlement type introduces outlet-scoped consumption (e.g., per-outlet ad campaigns), outlet budgets can be enabled for that instrument without schema changes. The restriction is enforced at the application layer, not the schema:

# Billing::OutletBudgets::CreateService
# Only allow outlet budgets for entitlements with outlet-scoped consumption.
# Currently: gig only. Extend this list if new outlet-scoped instruments are added.
OUTLET_BUDGET_INSTRUMENTS = [:gig].freeze

def validate_entitlement!
unless OUTLET_BUDGET_INSTRUMENTS.include?(entitlement.instrument.to_sym)
raise Billing::InvalidEntitlement, "Outlet budgets are not supported for #{entitlement.instrument}"
end
end

Conceptual Model: Credit Partitioning

The company's EntitlementBalance is partitioned into outlet sub-pools and an unallocated pool:

┌──────────────────────────────────────────────────────────┐
│ Billing::EntitlementBalance │
│ (company-level, driven by the ledger) │
│ │
│ units_available = 5,000 units_reserved = 1,000 │
│ │
│ ┌──────────────┬──────────────┬───────────────────┐ │
│ │ OutletBudget │ OutletBudget │ [Unallocated] │ │
│ │ Outlet A │ Outlet B │ │ │
│ │ avail: 1,500 │ avail: 800 │ avail: 2,700 │ │
│ │ rsv: 500 │ rsv: 200 │ rsv: 300 │ │
│ └──────────────┴──────────────┴───────────────────┘ │
└──────────────────────────────────────────────────────────┘

The unallocated pool is derived, not stored:

unallocated_available = company.units_available - SUM(outlet_budgets.units_available)
unallocated_reserved = company.units_reserved - SUM(outlet_budgets.units_reserved)

Outlets with budgets draw from their own sub-pool. Outlets without budgets draw from the unallocated pool. The existence of a Billing::OutletBudget record is the signal — no separate boolean toggle is needed.

Relationship to FIFO Lots

Outlet budgets and FIFO lots (Billing::EntitlementLot) are orthogonal concerns:

  • Lots answer: "Which purchase batch are these credits from?" — a finance concern for platform fee revenue recognition (SFRS(I) 15).
  • Outlet budgets answer: "Which branch's spending authority covers this?" — an operational concern for budget delegation.

A single lot can serve many outlets. A single outlet's budget can span multiple lots. FIFO lot consumption is always company-wide — the oldest lot is consumed first regardless of which outlet triggered the consumption. This is correct because:

  1. The platform fee rate is per-agreement, not per-outlet. Both outlets' lots have the same fee rate under the same agreement.
  2. If fee rates change between purchases, FIFO ensures older (potentially lower) rates are recognized first — the correct accounting treatment regardless of outlet.
  3. Tagging lots to outlets would create fragmentation where some lots can't be used even though the company has credits.

State Machine

FromToTriggerNotes
(new)activeUC-1: Outlet budgeting enabledDefault status on creation
activearchivedUC-5: Outlet budgeting disabledAll credits must be deallocated first (units = 0)

Billing::OutletBudgetTransfer has no state machine — it is an append-only log (like Billing::LedgerEntry).

Use Cases

IDUse CaseTriggerActor
UC-1Enable outlet budgeting for an outletAdmin/HQ decides outlet should manage its own creditsAdmin / HQ Manager
UC-2Allocate credits from company pool to outlet budgetAdmin/HQ wants to fund an outletAdmin / HQ Manager
UC-3Deallocate credits from outlet budget to company poolAdmin/HQ reclaims unused credits from outletAdmin / HQ Manager
UC-4Auto-allocate credits on invoice postingInvoice with outlet reference becomes paidSystem
UC-5Disable outlet budgeting for an outletAdmin decides outlet no longer needs its own budgetAdmin
UC-6View outlet budgets for a companyAdmin/HQ reviews credit distributionAdmin / HQ Manager
UC-7View transfer history for an outlet budgetAdmin/HQ needs audit trail of budget movementsAdmin / HQ Manager

UC-1: Enable outlet budgeting for an outlet

FieldDetails
ActorIdentities::Admin or Org::Membership (role: hq_manager)
TriggerAdmin or HQ decides an outlet should manage its own credit budget

Preconditions:

  • Billing::Account exists for the Org::Company
  • Org::Outlet exists, is active, and belongs to the same company as the billing account
  • No active OutletBudget already exists for this (account, entitlement, outlet) combination
  • The entitlement instrument supports outlet budgets (currently only :gig)

System Behaviour:

  1. Actor selects the outlet to enable budgeting for
  2. System validates preconditions
  3. System creates Billing::OutletBudget with:
    • status = active
    • units_available = 0
    • units_reserved = 0
  4. The outlet is now budget-controlled — reservations at this outlet will draw from its budget

Business Rules:

  • The budget starts at zero. Credits must be explicitly allocated (UC-2) or auto-allocated via invoice posting (UC-4)
  • Only one active budget per (account, entitlement, outlet) combination

Postconditions:

  • OutletBudget record exists in active status with zero balance
  • Reservations at this outlet will now check the outlet budget instead of the unallocated pool

UC-2: Allocate credits from company pool to outlet budget

FieldDetails
ActorIdentities::Admin or Org::Membership (role: hq_manager)
TriggerAdmin/HQ wants to fund an outlet's budget from the company's credits

Preconditions:

  • Active OutletBudget exists for the outlet
  • Company's unallocated pool has sufficient credits: unallocated_available >= allocation_amount

System Behaviour:

  1. Actor specifies the outlet and amount to allocate
  2. System computes unallocated_available = EntitlementBalance.units_available - SUM(active OutletBudgets.units_available)
  3. System validates unallocated_available >= allocation_amount
  4. Single DB transaction:
    • Create OutletBudgetTransfer:
      • transfer_type = :allocate
      • units = allocation_amount
      • actor_type / actor_id = the person performing the action
    • Update OutletBudget.units_available += allocation_amount

Business Rules:

  • Allocation must not cause the unallocated pool to go negative
  • allocation_amount must be > 0
  • No ledger entry is created — this is internal budget redistribution, not a financial event

Postconditions:

  • OutletBudget.units_available increased by allocation amount
  • Company's unallocated pool decreased by the same amount (derived, no explicit update needed)
  • OutletBudgetTransfer record created for audit trail

UC-3: Deallocate credits from outlet budget to company pool

FieldDetails
ActorIdentities::Admin or Org::Membership (role: hq_manager)
TriggerAdmin/HQ wants to reclaim unused credits from an outlet

Preconditions:

  • Active OutletBudget exists for the outlet
  • OutletBudget.units_available >= deallocation_amount (cannot deallocate reserved credits)

System Behaviour:

  1. Actor specifies the outlet and amount to deallocate
  2. System validates OutletBudget.units_available >= deallocation_amount
  3. Single DB transaction:
    • Create OutletBudgetTransfer:
      • transfer_type = :deallocate
      • units = deallocation_amount
      • actor_type / actor_id = the person performing the action
    • Update OutletBudget.units_available -= deallocation_amount

Business Rules:

  • Cannot deallocate more than the outlet's available credits
  • Cannot deallocate reserved credits — those are held for active shifts and must be released (shift cancellation) before deallocation
  • deallocation_amount must be > 0

Postconditions:

  • OutletBudget.units_available decreased by deallocation amount
  • Company's unallocated pool increased by the same amount (derived)
  • OutletBudgetTransfer record created for audit trail

UC-4: Auto-allocate credits on invoice posting

FieldDetails
ActorSystem (triggered as part of invoice posting — Billing::Invoice UC-7)
TriggerInvoice with org_outlet_id transitions to paid and posting executes

Preconditions:

  • Invoice has org_outlet_id set (enterprise pattern — outlet-specific purchase)
  • Active OutletBudget exists for the referenced outlet
  • Invoice posting is executing (within the same transaction)

System Behaviour:

  1. During invoice posting (see Billing::Invoice UC-7), after granting credits:
    • System checks if invoice.org_outlet_id is present
    • System checks if an active OutletBudget exists for that outlet
  2. If both conditions are met, within the same posting transaction:
    • Create OutletBudgetTransfer:
      • transfer_type = :allocate
      • units = granted_amount (from the invoice item's units_to_grant)
      • actor_type = 'Billing::InvoicePosting', actor_id = posting.id
      • source_type = 'Billing::InvoicePosting', source_id = posting.id
    • Update OutletBudget.units_available += granted_amount
  3. If the outlet has no active OutletBudget, credits remain in the unallocated pool (standard behavior)

Business Rules:

  • Auto-allocation only occurs when the invoice explicitly references an outlet AND the outlet has an active budget
  • If auto-allocation would cause any inconsistency, the entire posting transaction rolls back
  • This is atomic with the grant — there is no window where credits are granted but not allocated

Postconditions:

  • Credits are granted to the company balance (via standard posting) AND allocated to the outlet budget — all in one transaction
  • OutletBudgetTransfer record created with source tracing back to the invoice posting

Context — Enterprise Invoice Pattern: Enterprise clients like NTUC have individual outlets that request and receive their own invoices. The invoice carries an org_outlet_id that identifies which outlet the credits are for. This use case eliminates the manual allocation step (UC-2) for these cases.

Without auto-allocate:  Invoice paid → Grant → Manual allocate (UC-2) → Outlet funded
With auto-allocate: Invoice paid → Grant + Allocate (same txn) → Outlet funded

UC-5: Disable outlet budgeting for an outlet

FieldDetails
ActorIdentities::Admin
TriggerAdmin decides an outlet no longer needs its own credit budget

Preconditions:

  • Active OutletBudget exists for the outlet
  • OutletBudget.units_available = 0
  • OutletBudget.units_reserved = 0

System Behaviour:

  1. Admin requests to disable outlet budgeting
  2. System validates the budget has zero balance (available and reserved both = 0)
  3. System transitions OutletBudget.status to archived

Business Rules:

  • Cannot archive a budget that has available or reserved credits — admin must deallocate (UC-3) first and wait for reserved credits to be consumed or released
  • Archiving is a soft operation — the record is preserved for audit
  • If the admin wants to re-enable budgeting later, a new OutletBudget is created (UC-1)

Postconditions:

  • OutletBudget.status = archived
  • Reservations at this outlet will now draw from the company's unallocated pool
  • The partial unique index allows a new active OutletBudget to be created for the same outlet later (UC-1)

Cross-Domain: Org::Outlet Deactivation

When an Org::Outlet is deactivated (is_active: false), the system should check whether an active OutletBudget with non-zero balance exists for that outlet.

Behaviour:

  • If OutletBudget.units_available > 0 or OutletBudget.units_reserved > 0: the UI displays a warning to the admin that credits must be deallocated (UC-3) and active shifts resolved before outlet deactivation. The deactivation is not blocked — the outlet can still be deactivated — but the warning ensures the admin is aware of stranded credits.
  • If OutletBudget.units_reserved > 0: reserved credits are held for active shifts. These must be consumed (shift completion) or released (shift cancellation) through normal Gig flows. The admin cannot force-deallocate reserved credits.
  • After all credits are deallocated and reservations resolved, the admin can archive the budget (UC-5).

Note: Org::Outlet UC-3 (deactivate outlet) should be updated to include this cross-domain check. The invariant that inactive outlets cannot reference new jobs (org-outlet.md) naturally prevents new reservations at the deactivated outlet, so the budget will drain over time as existing shifts complete.


UC-6: View outlet budgets for a company

FieldDetails
ActorIdentities::Admin or Org::Membership (role: hq_manager)
TriggerAdmin/HQ wants to see how credits are distributed across outlets

Preconditions:

  • Billing::Account exists

System Behaviour:

  1. Actor navigates to the billing account → Outlet Budgets tab
  2. System displays:
    • Company-level summary: total available, total reserved, unallocated pool
    • Per-outlet breakdown: outlet name, units_available, units_reserved, status
    • Ordered by outlet name or by units_available descending
  3. System computes unallocated_available and unallocated_reserved as derived values

Business Rules:

  • Archived budgets can be shown/hidden via filter
  • Unallocated pool is always displayed for context

Postconditions:

  • Read-only operation — no data changes

UC-7: View transfer history for an outlet budget

FieldDetails
ActorIdentities::Admin or Org::Membership (role: hq_manager)
TriggerAdmin/HQ needs to audit credit movements for a specific outlet

Preconditions:

  • OutletBudget exists (active or archived)

System Behaviour:

  1. Actor selects an outlet budget to view its history
  2. System displays all OutletBudgetTransfer records, ordered by occurred_at descending:
    • Transfer type (allocate / deallocate)
    • Units transferred
    • Who performed the action (actor)
    • Source (manual or invoice posting reference)
    • Timestamp

Business Rules:

  • Transfer history is immutable and always available (even for archived budgets)

Postconditions:

  • Read-only operation — no data changes

Modifications to Existing Entitlement Flows

The following existing flows (documented in use-cases.md) are modified to update the outlet budget when the outlet is budget-controlled. The main ledger flow is unchanged — the outlet budget update is an additional step within the same transaction.

Reserve at a Budgeted Outlet

Existing flow (unchanged): LedgerEntry(reserve)EntitlementBalance updated → EntitlementHold created → LotAllocation(s) via FIFO.

Additional step (new): If the outlet has an active OutletBudget:

  1. Pre-check: OutletBudget.units_available >= reservation_amount
  2. Update: OutletBudget.units_available -= amount, OutletBudget.units_reserved += amount

Both company balance and outlet budget shift from available to reserved. The reconciliation invariant holds.

If the outlet has no OutletBudget: check unallocated_available >= reservation_amount instead.

Consume at a Budgeted Outlet

Existing flow (unchanged): LedgerEntry(consume)EntitlementBalance updated → EntitlementHold closed → LotAllocation(consume) with platform fee recognition.

Additional step (new): If the outlet has an active OutletBudget:

  • Update: OutletBudget.units_reserved -= consumed_amount

If actual consumption is less than reserved (variance): the release flow (below) handles the difference, returning excess credits to the outlet budget.

Release at a Budgeted Outlet

Existing flow (unchanged): LedgerEntry(release)EntitlementBalance updated → EntitlementHold closed → LotAllocation(release).

Additional step (new): If the outlet has an active OutletBudget:

  • Update: OutletBudget.units_available += released_amount, OutletBudget.units_reserved -= released_amount

Released credits return to the outlet budget (not the unallocated pool), because the outlet originally reserved them.

Ledger Entry Enhancement

To support outlet-level SOA reporting, billing_ledger_entries gains an optional org_outlet_id field:

  • Set for gig reserve/consume/release entries where the shift is at a specific outlet
  • NULL for grants, adjustments, and non-outlet operations
  • This is a denormalized convenience field — the outlet can also be derived via reference → Gig::Shift → Gig::Job → org_outlet_id, but the denormalized field enables efficient queries without cross-domain joins

Invoice Enhancement

To support auto-allocation on posting (UC-4), billing_invoices gains an optional org_outlet_id field:

  • Set when the invoice is for an outlet-specific credit purchase (enterprise pattern)
  • NULL when the invoice is a company-level purchase (SME pattern)
  • Used during posting to determine whether to auto-allocate to an outlet budget

Invariants

Rules that must always hold, regardless of use case:

  1. An OutletBudget must belong to exactly one Billing::Account and reference exactly one Org::Outlet
  2. The Org::Outlet must belong to the same Org::Company as the Billing::Account — cross-company budget assignment is never allowed
  3. Only one active OutletBudget per (billing_account_id, billing_entitlement_id, org_outlet_id) combination
  4. OutletBudget.units_available >= 0 — outlet budgets do not support overdraft
  5. OutletBudget.units_reserved >= 0
  6. Partitioning invariant: SUM(active outlet_budgets.units_available) <= EntitlementBalance.units_available — the sum of all outlet budgets' available credits cannot exceed the company's total available credits
  7. Partitioning invariant: SUM(active outlet_budgets.units_reserved) <= EntitlementBalance.units_reserved — same for reserved credits
  8. The derived unallocated pool must be non-negative: unallocated_available >= 0 and unallocated_reserved >= 0
  9. An OutletBudget can only be created for entitlement instruments where consumption is outlet-scoped (currently :gig only)
  10. An OutletBudget can only be archived when units_available = 0 AND units_reserved = 0
  11. OutletBudget records are never hard-deleted — only archived
  12. OutletBudgetTransfer records are immutable and append-only — never updated or deleted
  13. OutletBudgetTransfer.units must be > 0
  14. Allocate transfers must not cause the unallocated pool to go negative
  15. Deallocate transfers must not cause OutletBudget.units_available to go negative
  16. Deallocate cannot reclaim reserved credits — only available credits can be deallocated
  17. FIFO lot consumption is always company-wide and outlet-unawareBilling::EntitlementLot records are not tagged or partitioned by outlet. The oldest lot is consumed first regardless of which outlet triggered the consumption. This is by design: lots serve finance (revenue recognition), outlet budgets serve operations (spending delegation).
  18. Invariant 2 (same-company check) is enforced at the application layer — the database cannot enforce cross-FK path consistency between billing_account_id → org_company_id and org_outlet_id → company_id

Model Interactions

Related ModelRelationshipInteraction
Billing::AccountOutletBudget belongs_to AccountAccount must exist. One account can have many outlet budgets (one per outlet per entitlement).
Billing::EntitlementOutletBudget belongs_to EntitlementScopes the budget to a specific instrument. Currently only :gig is supported.
Billing::EntitlementBalancePartitioning relationshipOutletBudgets partition the company-level balance. Sum of outlet budgets + unallocated = company total.
Billing::OutletBudgetTransferOutletBudget has_many TransfersAppend-only log of allocation/deallocation events. Source of truth for budget movements.
Billing::LedgerEntryIndirect — both updated in same transactionReserve/consume/release ledger entries trigger outlet budget updates when the outlet is budget-controlled.
Billing::EntitlementLotOrthogonal — no direct relationshipFIFO lot consumption is company-wide and outlet-unaware. Lots do not "belong" to outlets.
Billing::InvoiceInvoice may reference an outletWhen invoice.org_outlet_id is set, posting auto-allocates granted credits to the outlet budget (UC-4).
Billing::InvoicePostingSource for auto-allocationAuto-allocation transfer records the posting as its source for traceability.
Org::OutletOutletBudget references OutletThe outlet must belong to the same company as the billing account. Outlet deactivation requires manual deallocation.
Identities::AdminActor on transfersAdmin can allocate/deallocate credits. Recorded on OutletBudgetTransfer.actor_type/id.
Org::MembershipActor on transfersHQ managers can allocate/deallocate credits. Recorded on OutletBudgetTransfer.actor_type/id.

Schema

New Table: billing_outlet_budgets

Table billing_outlet_budgets {
Note: 'Partitions a company entitlement balance into per-outlet spending buckets. The existence of an active record signals that the outlet is budget-controlled.'

id bigint [pk, increment]
billing_account_id bigint [not null, ref: > billing_accounts.id]
billing_entitlement_id bigint [not null, ref: > billing_entitlements.id]
org_outlet_id bigint [not null, ref: > org_outlets.id]

status string [not null, default: 'active', note: 'rails_enum(:active, :archived)']
units_available bigint [not null, default: 0, note: 'Credits available for new reservations at this outlet. Must be >= 0.']
units_reserved bigint [not null, default: 0, note: 'Credits held for active shifts at this outlet. Must be >= 0.']

created_at timestamp
updated_at timestamp

indexes {
(billing_account_id, billing_entitlement_id, org_outlet_id) [unique, note: 'Partial unique index: WHERE status = active. Allows archived records to coexist with a new active record for the same outlet. PostgreSQL: CREATE UNIQUE INDEX ... WHERE status = active.']
(billing_account_id, billing_entitlement_id, status) [note: 'Fast lookup of all active budgets for a company and instrument. Used to compute unallocated pool.']
}
}

New Table: billing_outlet_budget_transfers

Table billing_outlet_budget_transfers {
Note: 'Append-only log of credit movements between the company unallocated pool and outlet budgets. Source of truth for allocation and deallocation events.'

id bigint [pk, increment]
billing_outlet_budget_id bigint [not null, ref: > billing_outlet_budgets.id]

transfer_type string [not null, note: 'rails_enum(:allocate, :deallocate). :allocate = company pool → outlet budget. :deallocate = outlet budget → company pool.']
units bigint [not null, note: 'Number of credits transferred. Must be > 0.']

occurred_at timestamp [not null]

actor_type string [not null, note: 'Polymorphic: Identities::Admin, Org::Membership, Billing::InvoicePosting (for auto-allocate)']
actor_id bigint [not null]

source_type string [null, note: 'Polymorphic reference to what triggered the transfer. NULL for manual allocations. Billing::InvoicePosting for auto-allocate.']
source_id bigint [null]

note string [null, note: 'Optional reason for the transfer, e.g. "Monthly budget top-up", "Reclaiming unused credits before outlet closure"']
idempotency_key string [unique, note: 'Prevents duplicate transfers. Required for auto-allocate (invoice posting idempotency).']

created_at timestamp
updated_at timestamp [note: 'Included for Rails timestamps convention. Transfers are append-only and never updated — this column will always equal created_at.']

indexes {
billing_outlet_budget_id [note: 'All transfers for a specific outlet budget (audit trail)']
(actor_type, actor_id) [note: 'All transfers performed by a specific actor']
(source_type, source_id) [note: 'Trace back to the source that triggered the transfer']
}
}

Modified Table: billing_invoices

// Add to existing billing_invoices table:
// org_outlet_id bigint [null, ref: > org_outlets.id,
// note: 'Set when invoice is for an outlet-specific credit purchase (enterprise pattern). NULL for company-level purchases. Used during posting to auto-allocate credits to the outlet budget.']

Modified Table: billing_ledger_entries

// Add to existing billing_ledger_entries table:
// org_outlet_id bigint [null, ref: > org_outlets.id,
// note: 'Denormalized outlet reference for outlet-level SOA reporting. Set on gig reserve/consume/release entries. NULL for grants, adjustments, and non-outlet operations. Immutable once written.']

Schema Gaps

GapImpactStatus / Resolution
Legacy org_outlets credit fields (available_credits, consumed_credits, min_credit_limit) still existConfusion about source of truth during migrationOpen. Remove after migration to billing_outlet_budgets. Noted as transitional in org.dbml.
is_job_credit_deduction on org_outletsLegacy toggle; replaced by existence of OutletBudget recordOpen. Remove after migration. Application checks for active OutletBudget instead.
external_reference on billing_invoicesStores Xero invoice number separately from internal numberResolved. Added to billing.dbml.
org_outlet_id on billing_invoicesAuto-allocate credits on posting for enterprise invoicesResolved. Added to billing.dbml.
org_outlet_id on billing_ledger_entriesOutlet-level SOA reporting without cross-domain joinsResolved. Added to billing.dbml.
Billing::Invoice UC-7 does not reference outlet budget auto-allocationImplementers reading Invoice spec will miss the outlet budget stepOpen. Update Invoice UC-7 to cross-reference OutletBudget UC-4.
billing-layers.md does not include outlet budget tablesNew tables not listed in the architecture layer diagramOpen. Add billing_outlet_budgets and billing_outlet_budget_transfers to the Entitlements Engine layer.
Billing overview glossary missing new terms"Outlet Budget", "Unallocated Pool", "Budget Transfer" not definedOpen. Add to the glossary in overview/index.md.
Org::Outlet UC-3 (deactivate) has no cross-domain checkAdmin can deactivate outlet without warning about stranded creditsOpen. Add warning check as documented in this spec's "Cross-Domain: Org::Outlet Deactivation" section.

Data Source

  • Usage statistics: jodgig_2026_clean (MySQL), dump date 10 March 2026
  • Legacy credit system: CreditService.php, JodJobService.php, JodJobValidator.php in /jodgig-api/
  • Legacy data: 681/2,315 locations (29.4%) use outlet-level deduction; 75.3% of last 3 months job volume
  • Design checkpoint: billing-entitlement-balance-outlet-level.md