[{"data":1,"prerenderedAt":42},["ShallowReactive",2],{"blog-post-subscriptions-are-licensing-that-bills-on-time":3},{"slug":4,"title":5,"description":6,"date":7,"updated":8,"tags":9,"pillar":15,"draft":16,"image":17,"imageAlt":18,"html":19,"markdown":20,"faq":21,"readingTimeText":40,"readingTimeMinutes":41},"subscriptions-are-licensing-that-bills-on-time","Subscriptions are just licensing that also has to bill you on time","A subscription is a state machine bolted to a billing clock. Dunning, grace periods, and proration are where it leaks money. Here is how I model each.","2026-05-20T00:00:00.000Z",null,[10,11,12,13,14],"subscriptions","licensing","billing","state-machine","laravel","revenue-critical-code",false,"/blog-covers/revenue-critical-code.png","Revenue-critical code cover","\u003Cdiv class=\"post-tldr\">\u003Cdiv class=\"post-tldr-label\">TL;DR\u003C/div>\u003Cp>A subscription is a licensing state machine with a billing clock bolted on, and it leaks money in three places: dunning, grace periods, and proration. Fix each by keeping access computed, history append-only, money in cents, and time on the server.\u003C/p>\u003C/div>\n\u003Cp>A subscription is a licensing system with a billing clock attached. The hard part was never “can this account use the product,” which is the licensing question I have answered many times in WordPress plugins. The hard part is keeping that answer correct while a payment clock ticks in the background and occasionally misfires. If you model a subscription as a status column instead of a state machine, it will leak money in three predictable places: dunning, grace periods, and proration. This post walks each one.\u003C/p>\n\u003Ch2 id=\"start-from-the-licensing-question%2C-then-add-the-clock\" tabindex=\"-1\">Start from the licensing question, then add the clock\u003C/h2>\n\u003Cp>I build license-key systems, so I am used to reducing entitlement to one function: given this account and this moment, is access allowed? A subscription is that same function with a payment obligation layered on. The mistake is letting billing events write access directly. A failed charge should not immediately flip the customer to “no access,” and a successful charge should not be the only thing that grants it. Access is derived from state. Billing events move the state.\u003C/p>\n\u003Cp>So the first thing I do is name the states honestly:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>trialing -> active -> past_due -> grace -> canceled\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan>                          \\-> active (recovered)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan>                grace     -> canceled (dunning exhausted)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Every transition is an event with a reason and a timestamp, written to a log, never an in-place overwrite of a \u003Ccode>status\u003C/code> string. When a customer disputes why they lost access, I want to replay the sequence, not guess from a single mutable field. This is the same discipline I use for license state: entitlement is computed, its history is append-only.\u003C/p>\n\u003Ch2 id=\"where-it-leaks%2C-part-one%3A-dunning\" tabindex=\"-1\">Where it leaks, part one: dunning\u003C/h2>\n\u003Cp>Dunning is the retry schedule for a failed renewal, and it is the single biggest revenue leak in naive subscription code. The failure mode is binary thinking: the charge failed, so cancel. But most failed renewals are not customers leaving. They are an expired card, a temporary hold, or a bank declining a foreign transaction. Cancel on the first failure and you are firing paying customers over a card that would have worked on Tuesday.\u003C/p>\n\u003Cp>The fix is a retry schedule with backoff, driven by a scheduled job, that only gives up after the schedule is exhausted:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// Retry a failed renewal on a widening schedule, in days.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">const\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> DUNNING_SCHEDULE\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> [\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">3\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">5\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">7\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">];\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> attemptRenewal\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Subscription\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $sub)\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">:\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> void\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $result \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> $this\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">gateway\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">charge\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">amount_cents, $sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">renewal_key);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ($result\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">succeeded\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">()) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        $sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">transitionTo\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'active'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">reason\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'renewal_paid'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        return\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $attempt \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">dunning_attempt \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">+\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 1\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ($attempt \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">>\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> count\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">self::\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">DUNNING_SCHEDULE\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">)) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">        $sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">transitionTo\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'canceled'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">reason\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'dunning_exhausted'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        return\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">update\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">([\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        'status'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">          =>\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'past_due'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        'dunning_attempt'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> =>\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $attempt,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        'next_retry_at'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">   =>\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> now\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">()\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">addDays\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">self::\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">DUNNING_SCHEDULE\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">[$attempt \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">-\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 1\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">]),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    ]);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">    $this\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">notifyPaymentFailed\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($sub, $attempt); \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// and give them a link to fix the card\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Two things earn their keep here. The widening schedule (1, 3, 5, 7 days) spreads retries across the customer’s likely payday and gives a declined bank time to relent. And \u003Ccode>renewal_key\u003C/code> is a \u003Ca href=\"/blog/six-payments-edge-cases-that-bite-at-2am\">stable idempotency key\u003C/a>, so if the charge actually succeeded but the response was lost, the next attempt does not bill them twice. Dunning that double-charges is worse than dunning that gives up early, because now you have created the chargeback yourself.\u003C/p>\n\u003Cp>The principle I hold is that dunning is a recovery mechanism, not an eviction notice. Widening the gaps between retries almost always recovers more than tightening them, because you are waiting for paydays to arrive and bank holds to clear, not punishing a customer for a card that would have worked in a few days.\u003C/p>\n\u003Ch2 id=\"where-it-leaks%2C-part-two%3A-grace-periods\" tabindex=\"-1\">Where it leaks, part two: grace periods\u003C/h2>\n\u003Cp>A grace period is the gap between “payment is late” and “access is gone.” Set it to zero and you punish good customers for a bank’s timing. Set it to infinite and you give the product away. This is the exact tradeoff I live with in offline license checks, where the activation server can be briefly unreachable and I still owe the paying customer a working product.\u003C/p>\n\u003Cp>The rule I use: fail open for a bounded window, then fail closed. Access stays on through \u003Ccode>past_due\u003C/code> and a defined \u003Ccode>grace\u003C/code> window, and only flips off when dunning is genuinely exhausted:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> hasAccess\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Subscription\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $sub, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Carbon\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $now)\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">:\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> bool\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> match\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ($sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">status) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        'trialing'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'active'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> =>\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> true\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        'past_due'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">           =>\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> true\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// still retrying, keep them in\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        'grace'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">              =>\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $now\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">lessThan\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">grace_ends_at),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        default\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">              =>\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> false\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// canceled, expired\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    };\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>The number in \u003Ccode>grace_ends_at\u003C/code> is a business decision, not an engineering one, and it should be a config value someone can change without a deploy. What is not negotiable is that the window is measured against server time, never the client’s clock. A license or subscription that reads the device clock for its grace math can be defeated by rolling the clock back, and I have seen that attempted. Server time makes the money decision. The client only caches the last answer.\u003C/p>\n\u003Ch2 id=\"where-it-leaks%2C-part-three%3A-proration\" tabindex=\"-1\">Where it leaks, part three: proration\u003C/h2>\n\u003Cp>Proration is what you owe when someone changes plans mid-cycle. Upgrade on day ten of a thirty-day month and you charge for the unused twenty days at the new rate, credit the unused twenty at the old rate, and the difference is what they pay today. Get the arithmetic wrong and every plan change either overcharges (support ticket) or undercharges (silent leak), and at scale the silent leak is the expensive one because nobody complains about being charged too little.\u003C/p>\n\u003Cp>The mistakes are always the same two: floating point, and counting the boundary day twice. I do proration in \u003Ca href=\"/blog/store-money-as-integer-cents-never-a-float\">integer cents\u003C/a>, on whole days, with the seconds truncated so a change at 11pm and a change at 1am on the same day behave identically:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> prorationCents\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Subscription\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $sub, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Plan\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $newPlan, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Carbon\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $now)\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">:\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> int\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $periodDays  \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">current_period_start\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">diffInDays\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">current_period_end);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $usedDays    \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">current_period_start\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">diffInDays\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($now); \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// whole days\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $unusedDays  \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> max\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">0\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, $periodDays \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">-\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $usedDays);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $creditCents \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> intdiv\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($sub\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">plan\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">amount_cents \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">*\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $unusedDays, $periodDays);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $chargeCents \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> intdiv\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($newPlan\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">amount_cents  \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">*\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $unusedDays, $periodDays);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $chargeCents \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">-\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $creditCents; \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// may be negative: that is a credit, not a charge\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>The negative case is the one juniors forget. A downgrade produces a credit, and a credit is not \u003Ca href=\"/blog/the-refund-path-is-where-i-read-a-codebases-soul\">a refund you push to a card\u003C/a>, it is a balance you carry to the next invoice. If your code assumes proration is always a positive charge, downgrades silently drop money on the floor or, worse, try to “refund” a credit that was never a real payment.\u003C/p>\n\u003Ch2 id=\"the-unifying-view\" tabindex=\"-1\">The unifying view\u003C/h2>\n\u003Cp>Licensing answers one question: is access allowed right now. Subscriptions answer the same question while a billing clock runs and sometimes fails. Every leak I have named is a place where a billing event was allowed to overwrite access instead of nudging a state machine that derives it. Keep access computed, keep the history append-only, keep money in integer cents, keep time on the server, and let dunning, grace, and proration each be an explicit, testable transition.\u003C/p>\n\u003Cp>Do that and a subscription stops being a fragile status column and becomes what it always was: licensing that also has to bill you on time, and get both right at once.\u003C/p>\n\u003Cdiv class=\"post-takeaway\">\u003Cdiv class=\"post-takeaway-label\">The takeaway\u003C/div>\u003Cp>Every subscription leak I have named comes from the same mistake: letting a billing event write access directly instead of nudging a state machine that derives it. Answer the licensing question with computed access, move the state with events you log, keep money in integer cents, and keep time on the server, and dunning, grace, and proration each become an explicit, testable transition. That is the difference between a subscription that quietly bleeds revenue and one that gets both jobs right at once.\u003C/p>\u003C/div>\u003Ch2 id=\"faq\" tabindex=\"-1\">FAQ\u003C/h2>\n\u003Cdiv class=\"post-faq\">\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Why treat a subscription as a state machine rather than a status column?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>Because access has to stay correct while a billing clock ticks and sometimes misfires, and a single mutable status field cannot record why a transition happened. Deriving access from logged state lets you replay the sequence when a customer disputes losing access.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Should a failed renewal charge cancel the subscription immediately?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>No. Most failed renewals are an expired card, a temporary hold, or a bank declining a foreign transaction, not a customer leaving, so cancel on the first failure and you fire paying customers over a card that would have worked days later.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">What stops dunning retries from double-charging?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>A stable idempotency key on the renewal, so if a charge actually succeeded but the response was lost, the next attempt is a no-op at the gateway. Dunning that double-charges is worse than dunning that gives up, because you create the chargeback yourself.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">How long should a grace period be?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>That is a business decision, so make it a config value someone can change without a deploy. The engineering rule is fixed: fail open for a bounded window, then fail closed, and measure the window against server time so it cannot be defeated by rolling the client clock back.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Is proration always a charge?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>No, and the negative case is the one juniors forget. A downgrade produces a credit, which is a balance you carry to the next invoice, not a refund you push to a card, so code that assumes a positive charge silently drops that money.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">How is this different from plain licensing?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>Licensing answers one question: is access allowed right now. A subscription answers the same question while a billing clock runs and occasionally fails, so it is licensing plus dunning, grace, and proration done correctly at the same time.\u003C/p>\u003C/div>\u003C/div>\u003C/div>","\n## TL;DR\n\nA subscription is a licensing state machine with a billing clock bolted on, and it leaks money in three places: dunning, grace periods, and proration. Fix each by keeping access computed, history append-only, money in cents, and time on the server.\n\nA subscription is a licensing system with a billing clock attached. The hard part was never \"can this account use the product,\" which is the licensing question I have answered many times in WordPress plugins. The hard part is keeping that answer correct while a payment clock ticks in the background and occasionally misfires. If you model a subscription as a status column instead of a state machine, it will leak money in three predictable places: dunning, grace periods, and proration. This post walks each one.\n\n## Start from the licensing question, then add the clock\n\nI build license-key systems, so I am used to reducing entitlement to one function: given this account and this moment, is access allowed? A subscription is that same function with a payment obligation layered on. The mistake is letting billing events write access directly. A failed charge should not immediately flip the customer to \"no access,\" and a successful charge should not be the only thing that grants it. Access is derived from state. Billing events move the state.\n\nSo the first thing I do is name the states honestly:\n\n```\ntrialing -> active -> past_due -> grace -> canceled\n                          \\-> active (recovered)\n                grace     -> canceled (dunning exhausted)\n```\n\nEvery transition is an event with a reason and a timestamp, written to a log, never an in-place overwrite of a `status` string. When a customer disputes why they lost access, I want to replay the sequence, not guess from a single mutable field. This is the same discipline I use for license state: entitlement is computed, its history is append-only.\n\n## Where it leaks, part one: dunning\n\nDunning is the retry schedule for a failed renewal, and it is the single biggest revenue leak in naive subscription code. The failure mode is binary thinking: the charge failed, so cancel. But most failed renewals are not customers leaving. They are an expired card, a temporary hold, or a bank declining a foreign transaction. Cancel on the first failure and you are firing paying customers over a card that would have worked on Tuesday.\n\nThe fix is a retry schedule with backoff, driven by a scheduled job, that only gives up after the schedule is exhausted:\n\n```php\n// Retry a failed renewal on a widening schedule, in days.\nconst DUNNING_SCHEDULE = [1, 3, 5, 7];\n\npublic function attemptRenewal(Subscription $sub): void\n{\n    $result = $this->gateway->charge($sub->amount_cents, $sub->renewal_key);\n\n    if ($result->succeeded()) {\n        $sub->transitionTo('active', reason: 'renewal_paid');\n        return;\n    }\n\n    $attempt = $sub->dunning_attempt + 1;\n\n    if ($attempt > count(self::DUNNING_SCHEDULE)) {\n        $sub->transitionTo('canceled', reason: 'dunning_exhausted');\n        return;\n    }\n\n    $sub->update([\n        'status'          => 'past_due',\n        'dunning_attempt' => $attempt,\n        'next_retry_at'   => now()->addDays(self::DUNNING_SCHEDULE[$attempt - 1]),\n    ]);\n    $this->notifyPaymentFailed($sub, $attempt); // and give them a link to fix the card\n}\n```\n\nTwo things earn their keep here. The widening schedule (1, 3, 5, 7 days) spreads retries across the customer's likely payday and gives a declined bank time to relent. And `renewal_key` is a [stable idempotency key](/blog/six-payments-edge-cases-that-bite-at-2am), so if the charge actually succeeded but the response was lost, the next attempt does not bill them twice. Dunning that double-charges is worse than dunning that gives up early, because now you have created the chargeback yourself.\n\nThe principle I hold is that dunning is a recovery mechanism, not an eviction notice. Widening the gaps between retries almost always recovers more than tightening them, because you are waiting for paydays to arrive and bank holds to clear, not punishing a customer for a card that would have worked in a few days.\n\n## Where it leaks, part two: grace periods\n\nA grace period is the gap between \"payment is late\" and \"access is gone.\" Set it to zero and you punish good customers for a bank's timing. Set it to infinite and you give the product away. This is the exact tradeoff I live with in offline license checks, where the activation server can be briefly unreachable and I still owe the paying customer a working product.\n\nThe rule I use: fail open for a bounded window, then fail closed. Access stays on through `past_due` and a defined `grace` window, and only flips off when dunning is genuinely exhausted:\n\n```php\npublic function hasAccess(Subscription $sub, Carbon $now): bool\n{\n    return match ($sub->status) {\n        'trialing', 'active' => true,\n        'past_due'           => true, // still retrying, keep them in\n        'grace'              => $now->lessThan($sub->grace_ends_at),\n        default              => false, // canceled, expired\n    };\n}\n```\n\nThe number in `grace_ends_at` is a business decision, not an engineering one, and it should be a config value someone can change without a deploy. What is not negotiable is that the window is measured against server time, never the client's clock. A license or subscription that reads the device clock for its grace math can be defeated by rolling the clock back, and I have seen that attempted. Server time makes the money decision. The client only caches the last answer.\n\n## Where it leaks, part three: proration\n\nProration is what you owe when someone changes plans mid-cycle. Upgrade on day ten of a thirty-day month and you charge for the unused twenty days at the new rate, credit the unused twenty at the old rate, and the difference is what they pay today. Get the arithmetic wrong and every plan change either overcharges (support ticket) or undercharges (silent leak), and at scale the silent leak is the expensive one because nobody complains about being charged too little.\n\nThe mistakes are always the same two: floating point, and counting the boundary day twice. I do proration in [integer cents](/blog/store-money-as-integer-cents-never-a-float), on whole days, with the seconds truncated so a change at 11pm and a change at 1am on the same day behave identically:\n\n```php\npublic function prorationCents(Subscription $sub, Plan $newPlan, Carbon $now): int\n{\n    $periodDays  = $sub->current_period_start->diffInDays($sub->current_period_end);\n    $usedDays    = $sub->current_period_start->diffInDays($now); // whole days\n    $unusedDays  = max(0, $periodDays - $usedDays);\n\n    $creditCents = intdiv($sub->plan->amount_cents * $unusedDays, $periodDays);\n    $chargeCents = intdiv($newPlan->amount_cents  * $unusedDays, $periodDays);\n\n    return $chargeCents - $creditCents; // may be negative: that is a credit, not a charge\n}\n```\n\nThe negative case is the one juniors forget. A downgrade produces a credit, and a credit is not [a refund you push to a card](/blog/the-refund-path-is-where-i-read-a-codebases-soul), it is a balance you carry to the next invoice. If your code assumes proration is always a positive charge, downgrades silently drop money on the floor or, worse, try to \"refund\" a credit that was never a real payment.\n\n## The unifying view\n\nLicensing answers one question: is access allowed right now. Subscriptions answer the same question while a billing clock runs and sometimes fails. Every leak I have named is a place where a billing event was allowed to overwrite access instead of nudging a state machine that derives it. Keep access computed, keep the history append-only, keep money in integer cents, keep time on the server, and let dunning, grace, and proration each be an explicit, testable transition.\n\nDo that and a subscription stops being a fragile status column and becomes what it always was: licensing that also has to bill you on time, and get both right at once.\n\n## The takeaway\n\nEvery subscription leak I have named comes from the same mistake: letting a billing event write access directly instead of nudging a state machine that derives it. Answer the licensing question with computed access, move the state with events you log, keep money in integer cents, and keep time on the server, and dunning, grace, and proration each become an explicit, testable transition. That is the difference between a subscription that quietly bleeds revenue and one that gets both jobs right at once.\n\n## FAQ\n\n**Why treat a subscription as a state machine rather than a status column?**\nBecause access has to stay correct while a billing clock ticks and sometimes misfires, and a single mutable status field cannot record why a transition happened. Deriving access from logged state lets you replay the sequence when a customer disputes losing access.\n\n**Should a failed renewal charge cancel the subscription immediately?**\nNo. Most failed renewals are an expired card, a temporary hold, or a bank declining a foreign transaction, not a customer leaving, so cancel on the first failure and you fire paying customers over a card that would have worked days later.\n\n**What stops dunning retries from double-charging?**\nA stable idempotency key on the renewal, so if a charge actually succeeded but the response was lost, the next attempt is a no-op at the gateway. Dunning that double-charges is worse than dunning that gives up, because you create the chargeback yourself.\n\n**How long should a grace period be?**\nThat is a business decision, so make it a config value someone can change without a deploy. The engineering rule is fixed: fail open for a bounded window, then fail closed, and measure the window against server time so it cannot be defeated by rolling the client clock back.\n\n**Is proration always a charge?**\nNo, and the negative case is the one juniors forget. A downgrade produces a credit, which is a balance you carry to the next invoice, not a refund you push to a card, so code that assumes a positive charge silently drops that money.\n\n**How is this different from plain licensing?**\nLicensing answers one question: is access allowed right now. A subscription answers the same question while a billing clock runs and occasionally fails, so it is licensing plus dunning, grace, and proration done correctly at the same time.\n",[22,25,28,31,34,37],{"q":23,"a":24},"Why treat a subscription as a state machine rather than a status column?","Because access has to stay correct while a billing clock ticks and sometimes misfires, and a single mutable status field cannot record why a transition happened. Deriving access from logged state lets you replay the sequence when a customer disputes losing access.",{"q":26,"a":27},"Should a failed renewal charge cancel the subscription immediately?","No. Most failed renewals are an expired card, a temporary hold, or a bank declining a foreign transaction, not a customer leaving, so cancel on the first failure and you fire paying customers over a card that would have worked days later.",{"q":29,"a":30},"What stops dunning retries from double-charging?","A stable idempotency key on the renewal, so if a charge actually succeeded but the response was lost, the next attempt is a no-op at the gateway. Dunning that double-charges is worse than dunning that gives up, because you create the chargeback yourself.",{"q":32,"a":33},"How long should a grace period be?","That is a business decision, so make it a config value someone can change without a deploy. The engineering rule is fixed: fail open for a bounded window, then fail closed, and measure the window against server time so it cannot be defeated by rolling the client clock back.",{"q":35,"a":36},"Is proration always a charge?","No, and the negative case is the one juniors forget. A downgrade produces a credit, which is a balance you carry to the next invoice, not a refund you push to a card, so code that assumes a positive charge silently drops that money.",{"q":38,"a":39},"How is this different from plain licensing?","Licensing answers one question: is access allowed right now. A subscription answers the same question while a billing clock runs and occasionally fails, so it is licensing plus dunning, grace, and proration done correctly at the same time.","9 min read",9,1785070532920]