Allocating Transaction Price Across Bundled Plans

A plan that includes a subscription, an onboarding package, and a block of support hours for one price is one transaction and several promises. Revenue recognition requires the price to be split across those promises, because they are satisfied at different times — the subscription over the term, onboarding on delivery, support as consumed. This guide sits under Revenue Recognition & ASC 606 and covers performing that split in a billing system rather than a spreadsheet.

The reason to do it in the system is reproducibility. An allocation computed once in a spreadsheet is unauditable and will not be recomputed the same way next quarter by a different person. An allocation stored as rows, derived by a documented rule from stored inputs, can be re-derived years later and explained in one query.

The reason it feels difficult is that the inputs are estimates. A standalone selling price for something you have never sold separately is a judgement, and the accounting standard expects a documented method rather than a precise number.

Trade-offs

Standalone price method Defensibility Effort When it applies
Observable standalone sales Strongest Low, if the data exists The component is genuinely sold alone
Adjusted market assessment Good Medium Competitors sell something comparable
Expected cost plus margin Acceptable Medium Services with measurable delivery cost
Residual approach Limited, constrained Low Highly variable or unpriced components
Bundle allocation One bundled price is split across a subscription, an onboarding package, and support hours in proportion to their standalone selling prices. Bundle · 24,000 one price, three promises Subscription · 12 months recognised ratably Onboarding recognised on delivery Support hours recognised as consumed three schedules
The split is not cosmetic — each obligation releases revenue on its own schedule, and the allocation determines how much.

The residual approach is worth flagging as constrained: it is generally permitted only where a component’s selling price is highly variable or has never been established, and using it as a convenience for everything is not defensible.

Step-by-Step Implementation

1. Identify the obligations

A promise is distinct if the customer can benefit from it on its own and it is separately identifiable within the contract. In practice, for SaaS: the subscription is one obligation; implementation services usually are if another vendor could perform them; a discounted future renewal option may be one if the discount is material. Record the determination rather than inferring it at report time.

CREATE TABLE performance_obligations (
  obligation_id  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  contract_id    UUID NOT NULL,
  kind           TEXT NOT NULL,          -- 'subscription' | 'services' | 'support' | 'option'
  ssp_minor      BIGINT NOT NULL,        -- standalone selling price estimate
  ssp_method     TEXT NOT NULL,          -- 'observable' | 'market' | 'cost_plus' | 'residual'
  ssp_evidence   TEXT,                   -- link or note supporting the estimate
  recognition    TEXT NOT NULL,          -- 'ratable' | 'point_in_time' | 'as_consumed'
  allocated_minor BIGINT                 -- filled by the allocation step
);

ssp_method and ssp_evidence are what make the estimate defensible. An estimate with no recorded basis is the finding an auditor writes up.

2. Allocate in proportion

from decimal import Decimal

def allocate(transaction_minor: int, obligations: list[dict]) -> list[dict]:
    """Relative standalone-selling-price allocation, exact to the minor unit."""
    total_ssp = sum(o["ssp_minor"] for o in obligations)
    out, allocated = [], 0
    for i, o in enumerate(obligations):
        share = int(Decimal(transaction_minor) * Decimal(o["ssp_minor"]) / Decimal(total_ssp))
        if i == len(obligations) - 1:
            share = transaction_minor - allocated     # absorb rounding on the last
        out.append({**o, "allocated_minor": share})
        allocated += share
    return out

3. Attribute discounts deliberately

Discount attribution A bundle discount is spread across all obligations by default, but is attributed to a specific one where evidence shows the discount relates only to it. Spread proportionally the default treatment every obligation shares it no evidence needed Attribute to one requires evidence e.g. free onboarding record the basis
Attributing a discount to one obligation is permitted but must be evidenced — the default is to spread it.

Because a negotiated discount is usually recorded as a rate-card override on a specific price, the mapping from override to obligation is often already available — which makes evidenced attribution cheaper than it sounds.

4. Generate the recognition schedule

-- Ratable release for a subscription obligation across its service period.
INSERT INTO revenue_schedule (obligation_id, period_month, amount_minor)
SELECT o.obligation_id,
       gs::date,
       o.allocated_minor / c.term_months
  FROM performance_obligations o
  JOIN contracts c USING (contract_id)
  CROSS JOIN LATERAL generate_series(c.term_start, c.term_end - INTERVAL '1 month',
                                     INTERVAL '1 month') gs
 WHERE o.recognition = 'ratable';

Divide once and put the remainder in the final month rather than distributing fractions of a minor unit, so the schedule sums exactly to the allocated amount.

5. Store the allocation, do not recompute it

Once a contract’s allocation is set, it is fixed unless the contract is modified. Recomputing it later — after a standalone price estimate has been updated, for instance — silently changes historical revenue. Version the standalone price estimates and reference the version the allocation used.

Contract Modifications and When to Reallocate

Modifications are where allocations go wrong, because the intuitive response is to redo the arithmetic and the correct response depends on what changed.

If the modification adds a distinct obligation at its standalone selling price, it is treated as a separate contract. Nothing about the existing allocation changes; the new obligation gets its own allocation from its own price. This is the common case for an add-on purchased mid-term at list.

If the modification adds obligations at a discount, or changes the scope or price of remaining obligations, the remaining unrecognised amount plus the new consideration is reallocated across the remaining obligations prospectively. Revenue already recognised is untouched — the change affects the future, not the past.

If the modification changes an obligation that has already been partly satisfied in a way that is not distinct, a cumulative catch-up adjustment may be required. This is the case that most needs an accountant rather than an engineer, and the practical instruction is to flag such modifications for review rather than to encode a rule.

The engineering implication is that the schema must support a new allocation version per modification, with an effective date, rather than mutating the original. Storing allocation_version alongside each obligation’s allocated amount, and dating each version, makes both the prospective and the catch-up treatments expressible and keeps every historical schedule reproducible.

Finally, model contract termination explicitly. Early termination stops future recognition and may trigger recognition of remaining deferred amounts depending on the terms. Because that decision depends on contract language rather than system state, the system’s job is to surface the affected balances clearly and let a human make the call — not to guess.

Modifications and reallocation Adding a distinct obligation at standalone price leaves the allocation untouched, a discounted addition triggers prospective reallocation, and a non-distinct change may require a catch-up. Added at standalone price treated as a new contract existing allocation unchanged Added at a discount remaining amount reallocated prospective only past revenue fixed Not distinct partly satisfied already flag for review catch-up may apply
Three modification shapes with three different treatments — the system's job is to distinguish them and escalate the third.

Keeping Allocations Reproducible Years Later

The property that makes an allocation defensible is that anyone can re-derive it from stored inputs and reach the same answer. Three practices deliver it.

Version the standalone selling price estimates rather than updating them in place. Estimates legitimately improve as more observable data accumulates, and a contract allocated in 2025 must continue to reference the 2025 estimate rather than silently inheriting a better one made later. Store the estimate version on each obligation.

Store the allocation output, not only the inputs. Recomputing on read is tempting and fragile: a change to the rounding rule, the allocation function, or the estimate set would alter historical revenue with no record. Persist the allocated amounts and treat the function as a way to produce them once.

Record who approved the judgements. Which promises are distinct obligations, which estimation method applies, and whether a discount is attributable are all judgements, and an auditor will ask who made them on what basis. A short note and an approver on the contract’s allocation version is enough, and reconstructing it later from memory is not.

Keep the schedules queryable by period and obligation, so that a question like “how much of this customer’s revenue in Q3 came from services versus subscription” is a query rather than a project. That view is also what makes the reconciliation against the metrics layer tractable, since it lets you separate recurring from non-recurring revenue on the ledger side.

Verification & Testing

Assert the allocation sums exactly to the transaction price for every input, including standalone price sets that do not divide evenly. Property-test it over random inputs, since the rounding-absorption rule is easy to break during a refactor.

Assert that the recognition schedule for each obligation sums exactly to its allocated amount, and that the sum across all obligations equals the transaction price. Two independent totals that must agree is a cheap and effective control.

Test a modification: add an obligation at standalone price and assert the original allocation is unchanged; add one at a discount and assert the remaining unrecognised amount is reallocated prospectively while recognised revenue stays fixed. Both behaviours are easy to state and easy to get backwards.

Gotchas & Production Pitfalls

  • Allocating by invoiced amount rather than standalone price. Defeats the purpose — the whole point is that the invoice’s split does not reflect the economics.
  • Recomputing historical allocations. Changes prior-period revenue silently. Version the inputs and freeze the allocation.
  • Discounts always spread proportionally. Sometimes correct, but attributing an evidenced discount to its actual obligation gives a truer schedule.
  • Rounding distributed as fractions. Produces schedules that do not sum to the allocated amount, and the difference surfaces at year end.
  • Obligations inferred at report time. The determination is a judgement made once and recorded; re-deriving it from product data will drift.

Frequently Asked Questions

Does every bundle need allocation? Only where the components are distinct obligations satisfied at different times. A plan whose components are all delivered ratably over the same term needs no split for recognition purposes.

Where do standalone price estimates come from if we never sell components separately? From an adjusted market assessment or expected cost plus a margin, documented with the reasoning. The standard expects a method, not an observation you do not have.

How does this interact with usage-based components? Variable consideration has its own rules and is generally recognised as consumed. Keep metered revenue out of the allocation for fixed obligations and recognise it in the period the usage occurred.

Who should own this in the team? Finance owns the method and the estimates; engineering owns the schema, the arithmetic, and the reproducibility. Splitting it any other way produces either an unimplementable policy or an unauditable implementation.