Home EP09 - Why Your Subscription Billing Engine Needs More Than One Clock
Post
Cancel

EP09 - Why Your Subscription Billing Engine Needs More Than One Clock

Introduction

Alice runs a small design studio. In March 2022, she subscribed to the Pro plan of a project management SaaS on annual billing, at the equivalent of €19 per month. Since then, the public price went up to €29, then to €39. Alice still pays €19. That’s grandfathering, and it’s one of the most common pricing practices out there.

(The problem comes from real work. The billing scenario, names, prices and dates are made up to illustrate it.)

The first implementation is usually simple: store the subscription date, add an if. And it works, until a second rule that also changes over time shows up. A loyalty discount, for example. Then a tax rate change. Each of these rules has its own calendar, and none of them care about the others.

That’s the problem I want to look at: not one rule changing over time, but several, each at its own pace. I’ve run into it in more than one rule engine, and it took me a while to understand what was really going wrong.

The goal here isn’t to build a full billing framework or handle system-wide migrations—that depends heavily on your stack. It’s about shifting how we model time and state when business rules diverge.

TL;DR

  • Different business rules follow different clocks: the price is bound at subscription, the discount at business events, the tax at each invoice.
  • The rule-version context used to evaluate a subscription is a tuple, one version per axis — not the whole state, and not a date.
  • Compatibility between rule versions should be data you can list and test, not scattered if statements.
  • A new rule shouldn’t go live until someone decides what happens to the existing customers it affects.

1. The naive approach and why it collapses

1
2
3
4
5
6
7
8
9
10
public decimal GetMonthlyPrice(Subscription sub)
{
    if (sub.StartDate < new DateOnly(2024, 1, 1))
        return 19m;

    if (sub.StartDate < new DateOnly(2026, 1, 1))
        return 29m;

    return 39m;
}

This is where most billing code starts, and there’s nothing wrong with it. It’s readable, easy to test, and it matches what the business asked for.

Then the marketing team adds a loyalty discount: 10% off once the customer has been with us for 12 months. In July 2024 they change it: 15% off, but only after 24 months, and only for annual plans. Customers who renewed before the change keep the old rule until their next renewal. Meanwhile, the VAT rate goes from 20% to 21% in January 2025.

The method grows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public decimal GetMonthlyAmount(Subscription sub, DateOnly invoiceDate)
{
    var price = GetMonthlyPrice(sub);
    var months = sub.MonthsActiveAt(invoiceDate);

    if (sub.LastRenewalDate < new DateOnly(2024, 7, 1))
    {
        if (months >= 12)
            price *= 0.90m;
    }
    else if (months >= 24 && sub.IsAnnual) // monthly plans excluded, see BILL-412
    {
        price *= 0.85m;
    }

    var vatRate = invoiceDate < new DateOnly(2025, 1, 1) ? 0.20m : 0.21m;

    return price * (1 + vatRate);
}

Look at the dates this method uses. StartDate for the price (inside GetMonthlyPrice), LastRenewalDate for the discount, invoiceDate for the tax. Three different dates in the same method, and nothing in the code tells you which rule is supposed to read which date. That knowledge lives in the head of whoever wrote it.

The discount check is the most interesting one. What it really tries to answer is “which discount rule did this customer get at their last renewal?”. But the system never stored that answer, so the code tries to rebuild a past decision from the current state, using a date as a clue, every single time we send an invoice.

The IsAnnual condition has its own story. At some point, someone decided that the new discount only applies to annual plans, which is probably a good decision. But it sits inside an else if, next to a comment pointing to a ticket, and in six months nobody will remember it’s there. (We’ll come back to exactly where this condition belongs once the model has a proper place for it.)

And the combinations add up fast. Three price versions, two discount versions, two tax versions: that’s already 12 possible combinations. Add one new version per rule per year, and after a few years you’re in the hundreds. To be clear, nobody needs to implement all of them. The combinations form a Cartesian product: it’s the space of states your system could be in. Some of them are valid, some should be forbidden, some can simply never happen. This code can’t tell you which is which:

  • Which combinations exist in production today?
  • Which ones are allowed?
  • Which ones were never tested?

(Section 3 comes back to exactly this question, once each axis has its own clock.)

I didn’t get this right the first time. The first version I designed of an engine like this was built on a simple idea: at any given date, exactly one version of each rule is in force. On paper, it was clean and easy to explain, and I defended it for a while.

It held until the first transition period. New terms were announced, but for several months the old terms and the new ones were both valid at the same time, and which one applied depended on the customer’s situation, not on the date. My model had no way to say that. Then came a second problem: a promise that no existing customer would lose a benefit they already had because of the change. For some customers, that meant looking at the old rule and the new one, and keeping whichever was better for them. At that point, “which rule is in force today?” was clearly the wrong question. The right one was: which version is this customer bound to, and why?

That’s when I understood that the number of dates was never really the issue. What hurts is treating “the date” as one shared variable, when these rules have nothing to do with each other and each one needs its own clock.


2. One axis, one binding policy

None of this is new territory. Martin Fowler’s temporal patterns (Effectivity, Temporal Property, Snapshot) answer “what was true at date X?” very well. What they leave open is which date each rule should use, and what happens when several rules, each on its own clock, have to live together. That’s the part I want to focus on.

Let’s call each independent rule (price, discount, tax) an axis. Each axis has a policy that says which moment decides its version. I’ve found that three patterns cover a lot of real cases.

  1. Binding at subscription. The version is chosen once, when the customer subscribes, and never moves. That’s the base price: Alice’s was decided in March 2022. (A plan change counts as a new agreement, with a new snapshot.)

  2. Resolution at evaluation. The version is looked up at each calculation, using the calculation date. That’s how I’m treating tax here, on purpose: nobody gets a grandfathered rate, the invoice date decides — real tax rules often depend on more than just a date. Strictly speaking, nothing is bound here, but “use the version in force at evaluation time” is still a decision.

  3. Binding at trigger event. The version is chosen when a business event happens, and stays until the next one. That’s the loyalty discount: it’s picked again at each renewal or tier change.

Three words get mixed up a lot here, and I’ve seen teams confuse them for a long time. Discount v2 has a validity period: it’s in force from July 2024. Alice is bound to v1 since her March 2024 renewal. Her November 2024 invoice is evaluated at that date. In November 2024, v2 is in force and Alice is still on v1. Both are true.

A rule being in force doesn’t mean every customer is bound to it. Or, more simply: a rule can change over time without changing for everyone.

Three clocks, one customer

Here’s Alice (annual plan, renewed every March). The boxes are the versions in the catalogue, the red line is what Alice actually gets, and each red dot is a moment where her version is chosen:

Three clocks, one customer: the price is bound at subscription, the discount at each renewal, and the tax is resolved at each invoice

Same customer, three axes, three different clocks.

Same seniority, different rules

Now take Bob: same plan, also on annual billing, same start date. In October 2024 he went from 5 to 10 seats, a trigger event, so his discount was bound again, this time to v2. Alice had no event until March 2025. In November 2024, they have the same plan, the same seniority, and different discount rules. Nothing is broken, but if nobody on the team expects it, it will come back as a support ticket.

So the state of a subscription can’t be described by one date, not even one “effective date”. It’s a tuple of versions, one per axis:

Customer (Nov 2024)PriceDiscountTax
Alicev1 (€19)v1 (10% after 12 months)v1 (20%)
Bobv1 (€19)v2 (15% after 24 months)v1 (20%)

The tuple only answers one question: which versions are used for this evaluation? Whether that particular combination is one the business would sign off on is a different question — section 3 is about that. For now, let’s see how the tuple itself gets represented.

Modeling it

It’s tempting to write something generic here:

1
RuleVersion Resolve(RuleType type, DateOnly effectiveDate);

One method for every rule looks clean, but it brings back the original mistake: it assumes every rule answers to the same kind of date. The caller has to know which date to pass for each rule (subscription date? invoice date? last renewal or last tier change?), and the business meaning ends up scattered across call sites again. I’d rather make each binding policy visible where it happens.

The code here shows the model, not a production implementation. With large volumes of data, you’ll make infrastructure trade-offs (storage, indexing, read models, caching). They matter, but they don’t change the model, so I’m leaving them out.

First, the price. A plan can still expose a catalogue price; a subscription’s contractual price just can’t be derived from that mutable, current value, or it would change for everyone retroactively. So it’s captured as an immutable value object at subscription and attached to the Subscription aggregate. (Close to Fowler’s Snapshot, but not quite: his is a view of an object as at a given date, reading through to the underlying object; this one is a copy taken once, at binding time.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public sealed record PricingSnapshot(
    PlanCode Plan,
    RuleVersion PriceVersion,
    Money MonthlyPrice,
    DateOnly CapturedOn);

public sealed class Subscription
{
    public SubscriptionId Id { get; }

    public PricingSnapshot Pricing { get; }          // bound at subscription
    public RuleVersion DiscountVersion { get; private set; } // bound at trigger event
    public SeatTier Tier { get; private set; }

    // ...

    public void ChangeTier(SeatTier newTier, RuleVersion discountVersion)
    {
        Tier = newTier;
        DiscountVersion = discountVersion;
    }
}

Then the discount. The version is resolved once, when the trigger event happens, by the application layer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class ChangeTierHandler(
    ISubscriptionRepository subscriptions,
    IRuleCatalog<DiscountRule> discounts)
{
    public async Task Handle(ChangeTierCommand command)
    {
        var subscription = await subscriptions.GetAsync(command.SubscriptionId);

        // Trigger event: resolve the discount version here, once
        var discountVersion = discounts.VersionInForceOn(command.EffectiveOn);

        subscription.ChangeTier(command.NewTier, discountVersion);

        await subscriptions.SaveAsync(subscription);
    }
}

VersionInForceOn only answers when — every trigger resolves to the same standard version for everyone on that date. A policy that instead preserves an already-earned benefit, or picks a version by cohort, would need to look at the customer too, not just the catalogue and the date.

The aggregate never sees the rule catalogue: the application layer resolves the version, the aggregate stores it. And the version is resolved once, at the event, then persisted. That’s the real fix for the naive code: instead of rebuilding “which version did Alice get?” at every invoice, we answer it when it happens and write it down. Renewals work the same way.

At billing time, resolving the tuple becomes boring, which is what we want:

1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed record VersionTuple(
    RuleVersion Price,
    RuleVersion Discount,
    RuleVersion Tax);

public sealed class VersionResolver(IRuleCatalog<TaxRule> taxes)
{
    public VersionTuple Resolve(Subscription sub, DateOnly invoiceDate) => new(
        Price: sub.Pricing.PriceVersion,           // bound at subscription
        Discount: sub.DiscountVersion,             // bound at last trigger event
        Tax: taxes.VersionInForceOn(invoiceDate)   // in force on the invoice date
    );
}

(In practice, the tuple is resolved per evaluation period rather than per invoice: a mid-cycle tier change can give one invoice two periods, each with its own tuple — which assumes a history of bindings, not just the current one that DiscountVersion holds here. Resolving a period before the last trigger event needs effective-dated history this example leaves out.)

The engine never goes to “fetch the current price”. It reads what’s bound, or resolves with the axis’s own policy, and each axis becomes a lookup: version N in, parameters out. Adding a new version is then mostly a new row in a table, not a new if. “Mostly”, because a version that changes the shape of the calculation still needs code, but as a new strategy next to the old ones, without touching the customers bound to them.

That’s representation solved: each axis knows which version applies to it. It says nothing about whether a given combination across axes should exist at all.


3. When axes stop being independent

So far, each axis is resolved on its own. If the price has 5 versions, the discount 4 and the tax 3, the state space has 60 possible tuples. Are they all valid? Almost certainly not. Say discount v3 is a 30% launch offer, designed with the €39 price in mind. Combined with Alice’s grandfathered €19, it would bring her down to €13.30 a month for a plan now sold at €39. Nobody would approve that on purpose, but if each axis is resolved separately, nothing prevents it.

The usual reaction is another if somewhere in the business code:

1
2
if (tuple.Discount == Discounts.V3 && tuple.Price == Prices.V1)
    discount = Discount.None; // launch offer not for grandfathered prices

It works, but it’s invisible. You can’t list these rules, you can’t review them with the business, and you can’t test them all because you don’t even know how many there are. This is how you get billing bugs that nobody notices for months.

What worked better for me is to treat compatibility as a first-class concept. A CompatibilityRule is data, not a branch in the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public enum Verdict { Allowed, Forbidden, Undecided }

// A null version means "any version" on that axis

public sealed record CompatibilityRule(
    string Id,
    RuleVersion? Price,
    RuleVersion? Discount,
    RuleVersion? Tax,
    Verdict Verdict,
    string Reason)
{
    public bool Matches(VersionTuple t) =>
        (Price is null || Price == t.Price) &&
        (Discount is null || Discount == t.Discount) &&
        (Tax is null || Tax == t.Tax);
}

var rule = new CompatibilityRule(
    Id: "CR-007",
    Price: Prices.V1,
    Discount: Discounts.V3,
    Tax: null,
    Verdict: Verdict.Forbidden,
    Reason: "Launch offer was designed for the €39 price, not for grandfathered ones");

Matches only answers whether a rule applies to a tuple. What it says about that tuple — Allowed or Forbidden — is the separate job of Verdict.

There’s a case the model doesn’t spell out yet: what if no rule matches a tuple at all? Given the same discipline as everywhere else in this system, absence of a rule isn’t the same thing as Allowed — a tuple nothing has judged is undecided, and undecided doesn’t get to bill. The catalogue needs an explicit default for that gap, not a fallback baked into whichever code path happens to run first.

This is also where the IsAnnual condition from Section 1 finally has a real home. It was never really about the discount version alone — it was a compatibility constraint between the discount axis and the plan’s billing cadence. Cadence is a fourth axis in the full model; I leave it out of the record above to keep the example small. Written the same way — a rule that forbids Discounts.V2 for monthly plans, with a Reason instead of a ticket number in a comment — it stops being a stray branch and becomes something the business can actually see and review.

Worth naming what that move changes, not just where it lives: in the naive code a monthly customer just computed a zero discount, invoice unaffected. As a Forbidden rule, the same case blocks the binding outright at the next trigger event — a silent no-op becomes a blocked transition, and something now has to say what a monthly customer gets instead.

Conceptually, a compatibility rule is a predicate on the tuple that returns a verdict, and the tuple grows with each new axis (cadence, usage quotas, contract terms…). The rules can now be listed, reviewed by the business, and tested without generating a single invoice. This is, in effect, a decision table: for a handful of axes a plain list validated by tests is enough, and it’s only worth reaching for a dedicated rules engine once the number of axes and rules grows past what a team can review by eye.

It helps to draw it as a table: one column per price, one row per discount. Each cell is a combination, and the rules say which cells are allowed:

Which discount can go with which price: a table of combinations, with one cell not allowed and one cell nobody has decided on

Two moments of validation

When should you check? For a long time I only checked when the tuple was created, and that’s not enough.

At state transitions. When a customer subscribes, changes plan, renews or changes tier, the application service resolves the resulting tuple and checks it against the compatibility rules before saving the change — this is exactly the kind of check ChangeTierHandler from section 2 would need right before its SaveAsync call. The aggregate protects its own invariants, while the compatibility rules deal with the relationship between axes. This stops invalid combinations from being born.

At every billing run. Some axes move on their own. The tax is resolved at evaluation, so it changes with no event on the subscription. Imagine tax v3 requires every discount to appear as its own invoice line, and discount v1 was built as a silent multiplier on the price. A customer still bound to discount v1 was fine last month. This month their tuple is (Price v1, Discount v1, Tax v3), and nobody touched their account. Adding a new compatibility rule has the same effect. If you only validate at state transitions, these cases go straight to the invoice.

Because the rules are data, you can also audit them like a decision table, looking for gaps (possible tuples for which the catalogue has no answer) and overlaps (two or more rules matching the same tuple). An overlap where the rules disagree is a conflict, and I treat it as a configuration error to fix before activation, never as something the engine settles at runtime with a priority order — if two rules disagree, the system should stop, not pick one. An overlap where the rules agree isn’t that kind of problem — the rules are just redundant, worth flagging during catalogue review, but not a reason to block anything.

Replaying the past

The compatibility catalogue has versions too. Recalculate Alice’s September 2026 invoice in 2030 with the 2030 catalogue, and you may get a different amount. That’s no longer a replay — it’s a recalculation under a different policy context, useful for a simulation or correction, not a substitute for what was billed. An issued invoice never changes; replaying it with its own recorded versions is for checking it or preparing a credit note. So each invoice records its inputs and the versions it used:

1
2
3
4
5
6
Invoice date          2026-09-01
Seats                 5
Price version         v1
Discount version      v2   (bound at her March 2025 renewal)
Tax version           v2
Catalogue version     v7

Replaying it means using exactly those, never the current ones. Notice Seats sits in that list too, next to the versions — replay needs the business inputs frozen as well, not only which rule versions applied to them. A past result shouldn’t depend on today’s rules, or on today’s numbers.


4. The decision nobody wants to make

A new compatibility rule makes some existing tuples invalid. In the table, it’s a cell that gets a cross while customers are already in it. What happens to those subscriptions? I’ve seen teams avoid this question for a long time.

Three choices show up in practice:

  • Migrate automatically at the next renewal: move Alice to the current discount, or the current price, whatever makes her tuple valid again.
  • Freeze the affected bindings (her discount version, for example), and record them as an explicit exception.
  • Block billing for that subscription entirely, until someone resolves the case by hand.

I’ve gone back and forth on this one. Where I’ve landed: the engine never decides, and for anything contractual, the default is to freeze.

For contractual terms, like the price Alice agreed to or a commercial discount, freezing is the default. An automatic migration changes an agreement the customer already accepted, as a side effect of a rule written for other customers, without anyone deciding it for her. When Alice asks support why her invoice went up, the only honest answer is “a rule changed and your account got caught in it”.

A freeze can be undone: the business can still migrate her later, on purpose, with notice. A wrong invoice is much harder to undo: credit notes, support tickets, and a customer who trusts you a little less. Freezing has a cost too. Exceptions pile up, so each one needs an owner and a review date.

A freeze is only real if the check that runs on every invoice actually honors it — otherwise the billing-time validation from section 3 forbids the same tuple right back at the next invoice. So both that check and the state-transition one resolve a tuple’s verdict the same way, through one function that looks for an approved exception before it forbids anything:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static class CompatibilityEvaluator
{
    public static Verdict Evaluate(
        VersionTuple tuple,
        ICompatibilityCatalog catalog,
        IReadOnlyCollection<EvolutionDecision> decisions)
    {
        var rule = catalog.MatchingRule(tuple);

        if (rule is not { Verdict: Verdict.Forbidden })
            return rule?.Verdict ?? Verdict.Undecided;

        var exempted = decisions.Any(d =>
            d.AffectedState == tuple && d.RuleId == rule.Id
            && d.Kind == DecisionKind.Freeze);

        return exempted ? Verdict.Allowed : Verdict.Forbidden;
    }
}

A frozen customer’s next invoice resolves to Allowed — not because the rule changed, but because a specific, reviewable exception says so for that exact rule. MatchingRule leans on an invariant it doesn’t enforce itself: a validated catalogue has at most one matching rule per tuple, which is exactly what section 3’s gap/overlap/conflict review is there to guarantee before activation — not something the return type can check on its own.

For rules you don’t control, like tax, freezing may not be a valid option: the business has to apply the current rate. The decision then moves to the other axes. In the tax v3 example, the question is what happens to the old discount.

And for internal calculation details that don’t change what the customer pays or sees, automatic migration is usually fine.

There’s a third option, and it’s the one nobody likes: block billing for that specific subscription until a person resolves it by hand. Freeze and migrate both assume the system can safely pick a side — keep the old combination, or move to a new one. Sometimes it can’t. Say the old tax version wasn’t just superseded, it was withdrawn because it was calculated wrong. Freezing keeps billing someone under a rate that shouldn’t be applied anymore. Migrating changes what the customer agreed to, without anyone actually deciding that for them. Neither is safe enough to automate, so the invoice waits, and someone looks at the account before the next one goes out.

This isn’t the same thing as the Blocked activation result below. That one stops a rule from going live until every affected state has a decision — a system-wide gate. This is narrower: even after a decision exists, the decision itself can be “stop and ask” rather than “freeze” or “migrate” — a pause on one subscription, not on the rule.

So the rule isn’t “always freeze”. The rule is that the engine never decides whether existing customers get migrated, frozen, or blocked. That decision belongs to the business policy governing the affected state, just like the binding policy belongs to each axis. In the end, each axis has two policies:

1
2
3
4
5
6
Axis

├── Binding policy   → when is a version selected?

└── Evolution policy → what happens to existing bindings
                       when the rules change?

I started this article talking about clocks, and I think this is where it actually ends up: each axis needs its own clock, and each clock needs its own answer for the past.

On the architecture side, this has a direct consequence. Activating a new CompatibilityRule is not finished until someone has made an explicit decision about the existing tuples it invalidates. Until then, the rule should not be allowed to go live.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public sealed record EvolutionDecision(
    VersionTuple AffectedState,
    string RuleId,        // the rule this decision responds to
    DecisionKind Kind,    // Freeze | Migrate | Block | Authorize
    string DecidedBy,
    DateOnly ReviewBy);

public sealed class CompatibilityRuleActivation(
    ISubscriptionRepository subscriptions,
    ICompatibilityCatalog catalog)
{
    public ActivationResult Activate(
        CompatibilityRule rule,
        IReadOnlyCollection<EvolutionDecision> decisions)
    {
        var undecided = subscriptions
            .DistinctActiveTuples()
            .Where(rule.Matches)
            .Where(t => decisions.All(d => !(d.AffectedState == t && d.RuleId == rule.Id)))
            .ToList();

        if (undecided.Count > 0)
            return ActivationResult.Blocked(rule.Id, undecided); // doubt blocks, it never defaults

        catalog.Activate(rule, decisions);

        return ActivationResult.Activated(rule.Id);
    }
}

In practice, DistinctActiveTuples() targets a set of distinct tuples, not one row per subscription — at real volume that’s its own projection or read-model question, which I’m leaving aside here.

Notice the activation check doesn’t look at the rule’s own verdict: a rule that newly makes a tuple Allowed needs a decision too, not just one that forbids something — that’s what Authorize is for. It’s scoped to tuples as they exist today, though: a tax version taking effect next month won’t show up in DistinctActiveTuples() yet, so that case is still caught by the billing-time check instead of the activation gate — a cycle later, not before.

EvolutionDecision keys the affected state by version tuple, which is deliberately the simplest thing that could work here — the closing section comes back to a case where that stops being enough.

The decision can often be made per affected state, rather than per subscription. In many systems, a handful of these states can cover thousands of subscriptions, so the business gets a short list to go through, not an endless one.

When the system is in doubt, it should stop and ask, not quietly fall back to some default behavior. A blocked activation is annoying for a day. A silent default can be wrong for years.

Or, as I’ve started to put it:

A rule that has nothing to say about the past shouldn’t be allowed to change the future.

One more thing worth admitting: this model has a domain where it stops working cleanly. Suppose the business decides something reasonable-sounding: existing subscriptions keep using Tax v4, but every new subscription must use Tax v5. That’s not a Freeze or a Migrate decision made subscription by subscription — it’s a blanket policy based on when the subscription was created. And CompatibilityRule, as defined here, only takes (PriceVersion, DiscountVersion, TaxVersion) as input. It has no way to express “this combination is valid, but only for subscriptions bound before a certain date” — it would need to know something about the binding itself, not just which versions are in play.

That’s a real limit, not a detail I’m glossing over — and it runs deeper than the compatibility layer. A CompatibilityRule that only sees version numbers works well when compatibility is about which versions get along with each other. But tax here is resolved fresh at evaluation for everyone alike — it was never bound to anything per-subscription to begin with. Making it depend on when a subscription started isn’t just a bigger tuple; it’s a different binding policy for that axis, and the tuple isn’t the only thing that would need to grow.

This is where the “multiple clocks” problem leads to a broader question. Once each rule has its own binding and evolution policy, you are no longer asking only “what is valid now?” You also need to answer “what was this customer bound to, and why?”

That question opens the door to a larger set of problems: temporal modeling, policy versioning, grandfathering, and historical reconstruction.

For this article, the model stops here. The important part is the shift in perspective: time is not a single input to the billing engine. Each business rule owns the meaning of time that applies to it.

That's all folks!

This post is licensed under CC BY 4.0 by the author.