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 (likeBilling::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 howBilling::LedgerEntryis the source of truth for financial events.
Model Context
| Context | Details |
|---|---|
| Aggregate | Billing::OutletBudget (root) + Billing::OutletBudgetTransfer (child) |
| Layer | Entitlements Engine |
| Upstream dependencies | Billing::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 dependents | None — 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.
| Entitlement | Instrument | Consumption Event | Outlet-Scoped? | Outlet Budget Supported? |
|---|---|---|---|---|
| Gig Credits | :gig | Gig::Shift completed at Org::Outlet | Yes | Yes |
| Placement Credits | :placement | Careers::Job posted by Org::Company | No | No |
| Workforce Seats | :workforce | Subscription per Org::Company | No | No |
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:
- The platform fee rate is per-agreement, not per-outlet. Both outlets' lots have the same fee rate under the same agreement.
- If fee rates change between purchases, FIFO ensures older (potentially lower) rates are recognized first — the correct accounting treatment regardless of outlet.
- Tagging lots to outlets would create fragmentation where some lots can't be used even though the company has credits.
State Machine
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | active | UC-1: Outlet budgeting enabled | Default status on creation |
active | archived | UC-5: Outlet budgeting disabled | All credits must be deallocated first (units = 0) |
Billing::OutletBudgetTransfer has no state machine — it is an append-only log (like Billing::LedgerEntry).
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Enable outlet budgeting for an outlet | Admin/HQ decides outlet should manage its own credits | Admin / HQ Manager |
| UC-2 | Allocate credits from company pool to outlet budget | Admin/HQ wants to fund an outlet | Admin / HQ Manager |
| UC-3 | Deallocate credits from outlet budget to company pool | Admin/HQ reclaims unused credits from outlet | Admin / HQ Manager |
| UC-4 | Auto-allocate credits on invoice posting | Invoice with outlet reference becomes paid | System |
| UC-5 | Disable outlet budgeting for an outlet | Admin decides outlet no longer needs its own budget | Admin |
| UC-6 | View outlet budgets for a company | Admin/HQ reviews credit distribution | Admin / HQ Manager |
| UC-7 | View transfer history for an outlet budget | Admin/HQ needs audit trail of budget movements | Admin / HQ Manager |
UC-1: Enable outlet budgeting for an outlet
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership (role: hq_manager) |
| Trigger | Admin or HQ decides an outlet should manage its own credit budget |
Preconditions:
Billing::Accountexists for theOrg::CompanyOrg::Outletexists, is active, and belongs to the same company as the billing account- No active
OutletBudgetalready exists for this (account, entitlement, outlet) combination - The entitlement instrument supports outlet budgets (currently only
:gig)
System Behaviour:
- Actor selects the outlet to enable budgeting for
- System validates preconditions
- System creates
Billing::OutletBudgetwith:status = activeunits_available = 0units_reserved = 0
- 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:
OutletBudgetrecord exists inactivestatus 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
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership (role: hq_manager) |
| Trigger | Admin/HQ wants to fund an outlet's budget from the company's credits |
Preconditions:
- Active
OutletBudgetexists for the outlet - Company's unallocated pool has sufficient credits:
unallocated_available >= allocation_amount
System Behaviour:
- Actor specifies the outlet and amount to allocate
- System computes
unallocated_available = EntitlementBalance.units_available - SUM(active OutletBudgets.units_available) - System validates
unallocated_available >= allocation_amount - Single DB transaction:
- Create
OutletBudgetTransfer:transfer_type = :allocateunits = allocation_amountactor_type/actor_id= the person performing the action
- Update
OutletBudget.units_available += allocation_amount
- Create
Business Rules:
- Allocation must not cause the unallocated pool to go negative
allocation_amountmust be > 0- No ledger entry is created — this is internal budget redistribution, not a financial event
Postconditions:
OutletBudget.units_availableincreased by allocation amount- Company's unallocated pool decreased by the same amount (derived, no explicit update needed)
OutletBudgetTransferrecord created for audit trail
UC-3: Deallocate credits from outlet budget to company pool
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership (role: hq_manager) |
| Trigger | Admin/HQ wants to reclaim unused credits from an outlet |
Preconditions:
- Active
OutletBudgetexists for the outlet OutletBudget.units_available >= deallocation_amount(cannot deallocate reserved credits)
System Behaviour:
- Actor specifies the outlet and amount to deallocate
- System validates
OutletBudget.units_available >= deallocation_amount - Single DB transaction:
- Create
OutletBudgetTransfer:transfer_type = :deallocateunits = deallocation_amountactor_type/actor_id= the person performing the action
- Update
OutletBudget.units_available -= deallocation_amount
- Create
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_amountmust be > 0
Postconditions:
OutletBudget.units_availabledecreased by deallocation amount- Company's unallocated pool increased by the same amount (derived)
OutletBudgetTransferrecord created for audit trail
UC-4: Auto-allocate credits on invoice posting
| Field | Details |
|---|---|
| Actor | System (triggered as part of invoice posting — Billing::Invoice UC-7) |
| Trigger | Invoice with org_outlet_id transitions to paid and posting executes |
Preconditions:
- Invoice has
org_outlet_idset (enterprise pattern — outlet-specific purchase) - Active
OutletBudgetexists for the referenced outlet - Invoice posting is executing (within the same transaction)
System Behaviour:
- During invoice posting (see
Billing::InvoiceUC-7), after granting credits:- System checks if
invoice.org_outlet_idis present - System checks if an active
OutletBudgetexists for that outlet
- System checks if
- If both conditions are met, within the same posting transaction:
- Create
OutletBudgetTransfer:transfer_type = :allocateunits = granted_amount(from the invoice item'sunits_to_grant)actor_type = 'Billing::InvoicePosting',actor_id = posting.idsource_type = 'Billing::InvoicePosting',source_id = posting.id
- Update
OutletBudget.units_available += granted_amount
- Create
- 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
OutletBudgetTransferrecord 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
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin decides an outlet no longer needs its own credit budget |
Preconditions:
- Active
OutletBudgetexists for the outlet OutletBudget.units_available = 0OutletBudget.units_reserved = 0
System Behaviour:
- Admin requests to disable outlet budgeting
- System validates the budget has zero balance (available and reserved both = 0)
- System transitions
OutletBudget.statustoarchived
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
OutletBudgetis 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
OutletBudgetto 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 > 0orOutletBudget.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
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership (role: hq_manager) |
| Trigger | Admin/HQ wants to see how credits are distributed across outlets |
Preconditions:
Billing::Accountexists
System Behaviour:
- Actor navigates to the billing account → Outlet Budgets tab
- 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
- System computes
unallocated_availableandunallocated_reservedas 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
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership (role: hq_manager) |
| Trigger | Admin/HQ needs to audit credit movements for a specific outlet |
Preconditions:
OutletBudgetexists (active or archived)
System Behaviour:
- Actor selects an outlet budget to view its history
- System displays all
OutletBudgetTransferrecords, ordered byoccurred_atdescending:- 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:
- Pre-check:
OutletBudget.units_available >= reservation_amount - 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
NULLfor 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)
NULLwhen 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:
- An
OutletBudgetmust belong to exactly oneBilling::Accountand reference exactly oneOrg::Outlet - The
Org::Outletmust belong to the sameOrg::Companyas theBilling::Account— cross-company budget assignment is never allowed - Only one active
OutletBudgetper(billing_account_id, billing_entitlement_id, org_outlet_id)combination OutletBudget.units_available >= 0— outlet budgets do not support overdraftOutletBudget.units_reserved >= 0- 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 - Partitioning invariant:
SUM(active outlet_budgets.units_reserved) <= EntitlementBalance.units_reserved— same for reserved credits - The derived unallocated pool must be non-negative:
unallocated_available >= 0andunallocated_reserved >= 0 - An
OutletBudgetcan only be created for entitlement instruments where consumption is outlet-scoped (currently:gigonly) - An
OutletBudgetcan only be archived whenunits_available = 0ANDunits_reserved = 0 OutletBudgetrecords are never hard-deleted — only archivedOutletBudgetTransferrecords are immutable and append-only — never updated or deletedOutletBudgetTransfer.unitsmust be > 0- Allocate transfers must not cause the unallocated pool to go negative
- Deallocate transfers must not cause
OutletBudget.units_availableto go negative - Deallocate cannot reclaim reserved credits — only available credits can be deallocated
- FIFO lot consumption is always company-wide and outlet-unaware —
Billing::EntitlementLotrecords 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). - 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_idandorg_outlet_id → company_id
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::Account | OutletBudget belongs_to Account | Account must exist. One account can have many outlet budgets (one per outlet per entitlement). |
Billing::Entitlement | OutletBudget belongs_to Entitlement | Scopes the budget to a specific instrument. Currently only :gig is supported. |
Billing::EntitlementBalance | Partitioning relationship | OutletBudgets partition the company-level balance. Sum of outlet budgets + unallocated = company total. |
Billing::OutletBudgetTransfer | OutletBudget has_many Transfers | Append-only log of allocation/deallocation events. Source of truth for budget movements. |
Billing::LedgerEntry | Indirect — both updated in same transaction | Reserve/consume/release ledger entries trigger outlet budget updates when the outlet is budget-controlled. |
Billing::EntitlementLot | Orthogonal — no direct relationship | FIFO lot consumption is company-wide and outlet-unaware. Lots do not "belong" to outlets. |
Billing::Invoice | Invoice may reference an outlet | When invoice.org_outlet_id is set, posting auto-allocates granted credits to the outlet budget (UC-4). |
Billing::InvoicePosting | Source for auto-allocation | Auto-allocation transfer records the posting as its source for traceability. |
Org::Outlet | OutletBudget references Outlet | The outlet must belong to the same company as the billing account. Outlet deactivation requires manual deallocation. |
Identities::Admin | Actor on transfers | Admin can allocate/deallocate credits. Recorded on OutletBudgetTransfer.actor_type/id. |
Org::Membership | Actor on transfers | HQ 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
| Gap | Impact | Status / Resolution |
|---|---|---|
Legacy org_outlets credit fields (available_credits, consumed_credits, min_credit_limit) still exist | Confusion about source of truth during migration | Open. Remove after migration to billing_outlet_budgets. Noted as transitional in org.dbml. |
is_job_credit_deduction on org_outlets | Legacy toggle; replaced by existence of OutletBudget record | Open. Remove after migration. Application checks for active OutletBudget instead. |
external_reference on billing_invoices | Stores Xero invoice number separately from internal number | Resolved. Added to billing.dbml. |
org_outlet_id on billing_invoices | Auto-allocate credits on posting for enterprise invoices | Resolved. Added to billing.dbml. |
org_outlet_id on billing_ledger_entries | Outlet-level SOA reporting without cross-domain joins | Resolved. Added to billing.dbml. |
Billing::Invoice UC-7 does not reference outlet budget auto-allocation | Implementers reading Invoice spec will miss the outlet budget step | Open. Update Invoice UC-7 to cross-reference OutletBudget UC-4. |
billing-layers.md does not include outlet budget tables | New tables not listed in the architecture layer diagram | Open. 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 defined | Open. Add to the glossary in overview/index.md. |
Org::Outlet UC-3 (deactivate) has no cross-domain check | Admin can deactivate outlet without warning about stranded credits | Open. 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.phpin/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