LexQLexQ
Back to patterns
SaaSProrationTrustIntermediate

Deciding Subscription Proration with a Time-Travel Audit

Proration treatments move into versioned rules, so a disputed charge is proven against the policy in force that day.

Sanghyun Park·July 7, 202615 min read15 min

The problem

A subscription business lets a customer change plans mid-cycle. The arithmetic that follows is settled: credit the unused share of the old plan, charge the same share of the new one, and the difference is the prorated amount. That formula is an identity. It has not changed since the first metered invoice, and no finance meeting will change it.

What changes is everything around it. Whether a downgrade takes effect now or at renewal. Whether a sub-dollar charge is worth invoicing at all. Whether a change made two days before renewal deserves its own invoice or folds into the next one. These are treatment decisions. Finance owns them and revisits them — quarterly, per market, after every billing-support review.

An earlier pattern, Centralizing Plan-Tier Feature Entitlement with BLOCK Rules, answered what a plan may use. This pattern answers what a plan change costs, and it carries a burden the entitlement check does not: the decision gets disputed months later. A proration line is the kind of charge a customer questions long after it posts — on an annual true-up, in a finance review, in a chargeback. By the time the question arrives, the treatment policy has moved, and the team must explain a May invoice under May's policy while July's policy is live.

The question this pattern answers: how does a team decide the treatment of every mid-cycle change under knobs finance keeps moving, and prove months later exactly which policy produced a disputed line — even after that policy has changed twice since.

The naive approach

The first version puts the knobs next to the arithmetic, as constants in the billing service.

public class ProrationService {

    // Treatment knobs — owned by finance.
    private static final BigDecimal MIN_CHARGE_USD   = new BigDecimal("1.00");
    private static final int        FOLD_WINDOW_DAYS = 3;

    public ProrationResult onPlanChange(PlanChange change) {
        // The proration identity. This part never changes.
        BigDecimal charge = change.newDailyRate()
                .subtract(change.oldDailyRate())
                .multiply(BigDecimal.valueOf(change.daysRemaining()))
                .setScale(2, RoundingMode.HALF_UP);

        // The treatment policy. Every branch is a finance decision,
        // and none of them is recorded as one.
        if (change.isDowngrade()) {
            return ProrationResult.deferToRenewal();
        }
        if (change.daysRemaining() <= FOLD_WINDOW_DAYS) {
            return ProrationResult.foldIntoRenewal(charge);
        }
        if (charge.compareTo(MIN_CHARGE_USD) < 0) {
            return ProrationResult.waive();
        }
        return ProrationResult.chargeNow(charge);
    }
}

It works, and each branch encodes a decision finance actually made. The flaw is not in the branches. It is in what the branches fail to leave behind, and it shows up in three places.

  • The policy's history is the deploy history. "What was the minimum charge on May 12" is a git blame on a constant, a release calendar, and a guess about which build was actually serving during a rolling deploy at 09:41 UTC. Three sources, none of them the record.
  • The invoice carries the amount, not the treatment. A waived charge, a folded charge, and a deferred downgrade all produce the same visible result: no line item today. Three different policies, one indistinguishable absence. Which branch returned is knowledge that lived for one stack frame.
  • A policy change silently rewrites the explanation of the past. After the minimum moves, support opens today's code to answer a question about May — and gives July's answer with full confidence. Nothing in the source marks where the old policy ends and the new one begins.

Defining the pattern

The fix starts with a split. The proration identity stays in billing code — it is mathematics, not policy, and a rule engine adds nothing to a formula that never changes. What moves out is the treatment: the layer finance keeps editing, and the layer disputes are about. Billing computes the raw prorated amount; the engine decides what happens to it, and records the decision.

In LexQ terms, this maps to three concepts.

  • Fact: the input the engine reads. change_type, days_remaining, and prorated_charge_usd — the raw amount billing computed.
  • Rule: one rule per treatment. Each has a condition and SET_FACT actions that write the treatment and its reason. The treatment is a category, not an arithmetic result, so the actions write strings rather than mutating the number.
  • Mutex Group: the field that guarantees every change receives exactly one treatment.

The mutex is not decoration here. In Enforcing KYC-Tiered Transfer Limits with BLOCK Rules, a request can match at most one rule, so the rules never compete. Here they do: an upgrade two days before renewal with a 0.80 prorated amount is simultaneously inside the fold window, below the minimum charge, and a standard upgrade. Three rules match, and each names a different treatment. Something has to pick one and record what happened to the others — the same single-decision-point structure as Adjudicating Credit Applications with a Priority-Ordered Mutex Group, applied to money instead of risk.

Every treatment rule carries the mutexGroup key proration-treatment, with mutexMode set to EXCLUSIVE and mutexStrategy set to HIGHEST_PRIORITY. The priority order is itself the finance policy. The fold rule sits above the waive rule: a below-minimum charge inside the final days is not forgiven, it is collected with the renewal. Both sit above the standard charge. And priority is not a value set at creation — it is the 1..N order within the version, changed only by reorder, so "which treatment wins" is visible as list position in the console.

The fold rule and the waive rule are the two that compete.

{
  "name": "Fold — within final 3 days",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "change_type",
        "operator": "EQUALS",
        "value": "UPGRADE",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "days_remaining",
        "operator": "LESS_THAN_OR_EQUAL",
        "value": 3,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "SET_FACT",
      "parameters": { "key": "proration_treatment", "value": "FOLD_INTO_RENEWAL" }
    },
    {
      "type": "SET_FACT",
      "parameters": { "key": "treatment_reason", "value": "Change within final 3 days folds into the renewal invoice" }
    }
  ],
  "mutexGroup": "proration-treatment",
  "mutexMode": "EXCLUSIVE",
  "mutexStrategy": "HIGHEST_PRIORITY",
  "mutexLimit": 1,
  "isEnabled": true
}

{
  "name": "Waive — below 1.00 minimum",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "change_type",
        "operator": "EQUALS",
        "value": "UPGRADE",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "prorated_charge_usd",
        "operator": "LESS_THAN",
        "value": 1.00,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "SET_FACT",
      "parameters": { "key": "proration_treatment", "value": "WAIVE" }
    },
    {
      "type": "SET_FACT",
      "parameters": { "key": "treatment_reason", "value": "Prorated charge below the 1.00 minimum" }
    }
  ],
  "mutexGroup": "proration-treatment",
  "mutexMode": "EXCLUSIVE",
  "mutexStrategy": "HIGHEST_PRIORITY",
  "mutexLimit": 1,
  "isEnabled": true
}

Two more rules complete the group, same shape. The defer rule matches change_type equal to DOWNGRADE, writes DEFER_TO_RENEWAL with the reason Downgrade takes effect at next renewal, and sits first — a downgrade matches nothing else, so it decides alone. The standard rule matches any UPGRADE, writes CHARGE_NOW with the reason Standard mid-cycle proration, and sits last: the treatment that applies when nothing more specific outranks it. With these four, every change whose facts are present receives exactly one treatment, and the constraint lives in a field, mutexMode, not in the order four if-blocks happened to be typed.

Four treatment rules in one EXCLUSIVE mutex group — defer, fold, waive, and charge, ordered by collection preference

The knobs are now data with a history. The fold window, the minimum, the downgrade treatment — each is one value in a draft version, and every edit that ships becomes a version with a deployment record. The rest of this pattern stands on that history.

Impact Simulation strategy

Finance raises the minimum charge from 1.00 to 5.00. Before that value touches a live plan change, the move has a measurable blast radius: how many upgrades stop producing a standalone charge, and which treatment they land in instead. Duplicate the live version and edit the one value — the result is a candidate (the Target Version, in the console's terms) carrying no traffic. The question is count-shaped, so no metric fact is needed; includeRuleStats is enough.

lexq analytics simulation start --json '{
  "policyVersionId": "<candidate-version-id>",
  "dataset": {
    "type": "HISTORICAL",
    "source": "EXECUTION_LOGS",
    "from": "2026-06-01",
    "to": "2026-06-30"
  },
  "options": {
    "baselinePolicyVersionId": "<baseline-version-id>",
    "includeRuleStats": true,
    "maxRecords": 50000
  }
}'

The simulation tests the candidate against June's real plan changes. Two conditions decide whether to ship. First, the waive rule's match-rate shift — candidate minus baseline — is the volume of upgrades that will stop being charged, and it must sit inside the range finance agreed to absorb. Second, the rules the edit did not touch must hold their match rates: the change claimed to move one knob, and the per-rule statistics verify that nothing else moved with it. During a simulation, external calls are mocked; the run reads historical data and writes nothing.

Impact Simulation comparing the candidate's treatment mix against the baseline

If no production traffic exists yet, upload a representative dataset instead — changes clustered around both thresholds, charges just under and just over the minimum, day counts just inside and outside the window — and run the same comparison. The simulation answers the forward-looking question: what would this change do. The sections below answer the backward-looking one.

Decision Trace output

Run the case where three treatments collide, with Dry Run: an upgrade two days before renewal, prorated amount 0.80.

{
  "result": "SUCCESS",
  "data": {
    "traceId": "c7a2e9f4-...",
    "inputFacts": {
      "change_type": "UPGRADE",
      "days_remaining": 2,
      "prorated_charge_usd": 0.80
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "proration_treatment": "FOLD_INTO_RENEWAL",
      "treatment_reason": "Change within final 3 days folds into the renewal invoice"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Defer — downgrade to renewal",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Fold — within final 3 days",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      },
      {
        "ruleName": "Waive — below 1.00 minimum",
        "status": "BLOCKED",
        "reasonCode": "MUTEX_PRIORITY_LOST",
        "reasonDetail": "Winner=[Fold — within final 3 days], Strategy=HIGHEST_PRIORITY"
      },
      {
        "ruleName": "Charge — standard proration",
        "status": "BLOCKED",
        "reasonCode": "MUTEX_PRIORITY_LOST",
        "reasonDetail": "Winner=[Fold — within final 3 days], Strategy=HIGHEST_PRIORITY"
      }
    ]
  }
}

Dry Run of the folded change — waive and charge both matched and lost the mutex on priority

generatedVariables holds what billing reads: the treatment, and the reason string the rule wrote. mutatedFacts is empty and no __delta appears — SET_FACT creates new facts rather than changing prorated_charge_usd, and a categorical treatment moves no number. The trace is where the 0.80 is explained. The waive rule matched — the amount is genuinely below the minimum — and lost to the higher-priority fold, recorded as BLOCKED / MUTEX_PRIORITY_LOST with the winner named. The answer to "why wasn't this small charge waived" is in the record, not in anyone's memory: it fell inside the final-days window, where charges ride the renewal invoice.

Now the case that gets disputed. An upgrade with ten days remaining and a prorated amount of 3.20:

{
  "result": "SUCCESS",
  "data": {
    "traceId": "e1b8d3a6-...",
    "inputFacts": {
      "change_type": "UPGRADE",
      "days_remaining": 10,
      "prorated_charge_usd": 3.20
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "proration_treatment": "CHARGE_NOW",
      "treatment_reason": "Standard mid-cycle proration"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Defer — downgrade to renewal",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Fold — within final 3 days",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Waive — below 1.00 minimum",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Charge — standard proration",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      }
    ]
  }
}

Dry Run of the standard charge — no competitor matched

Suppose this execution ran in production on May 12, the customer was charged 3.20, and the minimum-charge change simulated above shipped in early July. Days later the customer disputes the line: under today's policy, 3.20 sits below the 5.00 minimum and would be waived. Why was it charged?

This is where the audit stops being a log and starts being time travel. Three places answer the question, and none of them answers by reconstruction.

The sealed record. Every production execution lands in the audit ledger with its full trace — the facts as they arrived, the treatment written, every competitor's outcome — and is stamped at write time with the deployment that was live. The stamp is a record, not a lookup: deploying a new version later does not rewrite it. The May 12 execution answers, unchanged, what the engine saw, what it decided, and which deployed version decided it.

Decision Provenance. From that record, the responsibility lineage reads in three layers — who authored the version, who published it, who deployed it, and when. "The minimum in force on May 12" is not an inference from git blame; it is the version the deployment stamp names, opened to the rule exactly as it read that day.

Decision Replay. The direct answer to "would today's policy decide differently" is to replay the stored trace against the current version. The baseline side is the stored production trace itself — it is never re-evaluated, because the question is about what happened, not what would happen again — and only the candidate side re-executes. The diff shows CHARGE_NOW then, WAIVE now, and the reason for the difference is the one knob that moved between the two versions. The dispute closes with a sentence support can stand behind: the charge was correct under the policy in force on May 12, and here is the policy, the deployment, and the decision, unchanged.

Decision Replay of the stored May trace against the current version — the treatment flips from charge to waive

Edge cases

The pattern is the single treatment point and the sealed record, not these specific knobs. A few adjacent cases call for a deliberate choice.

  • A change exactly on a boundary. LESS_THAN_OR_EQUAL puts day 3 inside the fold window — "final three days" read inclusively. LESS_THAN puts a charge of exactly 1.00 outside the waiver — "minimum" read as the smallest amount still invoiced. Both readings are defensible; neither should be accidental. Decide once, write it into the operator, and the recorded condition shows which reading was live for any past decision.
  • A missing fact. A payload without days_remaining does not get a treatment quietly. The engine throws and names the missing fact rather than substituting a default, and the caller must treat an errored execution as not decided — never as a silent charge or a silent waiver. The engine refuses to guess about money, and the application must not guess on its behalf.
  • The line that prints as nothing. WAIVE, FOLD_INTO_RENEWAL, and DEFER_TO_RENEWAL all produce no immediate line item. Without the treatment fact, three different policies collapse into one indistinguishable absence — which is exactly the naive version's second flaw. The treatment and its reason are written per decision so that an absence is as explainable as a charge.
  • A change that is neither an upgrade nor a downgrade. A billing-cycle switch or a seat-count change is a change type this version does not yet model. An unrecognized change_type matches no rule. The execution completes as NO_MATCH with no treatment fact written, and the caller must treat a missing proration_treatment exactly like an errored execution: not decided. Extending the coverage is adding a value and its rules in a draft, not overloading an existing branch.
  • A replay diff is evidence, not a re-bill. Replaying May's decision under July's policy shows what would differ; it does not amend the May charge. If the business decides to credit customers after a policy change, that is a new decision executed forward and recorded as its own execution — the past record stays sealed. The ledger explains history; it does not edit it.

Production rollout

A candidate that clears both simulation conditions goes to production with Deploy. The rule snapshot at deploy time is sealed with a hash and integrity-verified, and the record captures who deployed which version and when — the same stamp every future execution will carry.

A treatment change ships to all plan changes at once, not through a gradual traffic split. A split would give two customers making the identical change in the same week two different treatments, and the ledger would faithfully record a bucket assignment as the deciding fact — technically auditable, impossible to defend on an invoice. When two treatment policies genuinely need comparing, that comparison is what the Impact Simulation already performs — candidate against baseline, over the same historical changes, with no live invoice split. The confidence to deploy comes from the simulation, not from a canary; what deploy adds is live confirmation against the simulated match rates.

Either of two signals means roll back immediately:

  • The changed rule's live match rate drifts from the simulated match rate. The live population of plan changes differs from the historical window, and the blast radius that was signed off no longer holds.
  • Treatments appear in bands the simulation reported untouched. The facts arriving in production do not look like the dataset the candidate was measured against.
Deployment detail for the proration treatment version

A rollback returns the group to the previous version and leaves its own deployment record, so even the retreat stays in the audit trail. Once live, the standing answers stay cheap: "how many upgrades were waived in June" is a per-rule statistics query; "the minimum in force on May 12" is the version history plus the deployment record; and "this specific line on this specific invoice" is the sealed execution itself — which is the answer this whole pattern exists to keep.

Ready to move decisions out of your deploy pipeline?

Try LexQ free — no credit card required.

Start Free