substate
Subscription lifecycle for Python: trials, renewals, grace periods, promo codes and referrals, lifted out of your application into a core you can actually test.
Why
Wrappers around payment providers are everywhere. None of them solve the part that actually breaks: subscription state over time.
A trial that converts. A renewal that lands three days late. A grace period that ends on a Sunday. A monthly plan billed on the 31st. A webhook delivered twice.
Most codebases handle this with if user.expires_at < datetime.now() sprinkled across a dozen handlers, and nobody tests it, because you cannot fast-forward a month in a test.
substate is that state machine, isolated, provider-agnostic and covered by tests.
Install
pip install substate
Zero required dependencies. Payment and storage adapters are optional extras.
Quickstart
import asyncio
from substate import SubscriptionEngine, MemoryStorage, FrozenClock, Plan, Payment, Period
async def main():
clock = FrozenClock("2026-01-01") # the real clock in production;
# frozen here so the output is exact
engine = SubscriptionEngine(storage=MemoryStorage(), clock=clock)
engine.register_plan(Plan(
id="pro_month",
price=5_000_000, # 5.00 USDT, minor units
currency="USDT",
period=Period.days(30),
trial_days=3,
))
sub = await engine.subscribe("user_1", "pro_month")
print(sub.state, sub.access_until) # State.TRIAL 2026-01-04
await engine.apply_payment(Payment(
provider="cryptobot",
external_id="inv_12345", # calling this twice changes nothing
user_id="user_1",
amount=5_000_000,
))
sub = await engine.get_subscription("user_1")
print(sub.state, sub.expires_at) # State.ACTIVE 2026-02-03
for event in await engine.tick(): # advance the world, collect what happened
print(event)
asyncio.run(main())
The clock is injectable, and that is the whole point
datetime.now() does not appear anywhere in the core. Every date comes from a clock you pass in.
from substate import SubscriptionEngine, MemoryStorage, FrozenClock
clock = FrozenClock("2026-01-01")
engine = SubscriptionEngine(storage=MemoryStorage(), clock=clock)
await engine.subscribe("user_1", "pro_month") # TRIAL, 3 days
clock.advance(days=4)
events = await engine.tick()
# [SubscriptionExpired(user_id='user_1', reason='trial_not_converted')]
In production you pass the real clock and never think about it again. In tests you fast-forward a year in milliseconds, which means the awkward cases are covered by actual tests rather than a comment saying it should probably work:
- payment arriving in the middle of a grace period
- a plan change requested on the last day of a paid period
- a renewal on the 31st when the next month has 30 days
- a promo code that outlives the subscription it was applied to
- the same webhook delivered twice, four seconds apart
State machine
subscribe ──> TRIAL ─────────────────────────> EXPIRED
│ ▲
payment │ │
▼ │
ACTIVE ─────────> GRACE ────────────┘
▲ │
└─────────────────┘
payment
A trial that never converts goes straight to EXPIRED. Grace is a courtesy to a
paying customer, not a free trial extension.
Inside grace the clock keeps running: paying on day three of a grace period
renews from the old expires_at, so those three days are spent. After expiry the
rule flips and the new period starts from the payment date, because a lapsed
subscription renewed from a two-month-old date would land entirely in the past.
CANCELLED is reachable from any state and keeps access until expires_at.
Transitions are driven by six calls and the clock: subscribe(), apply_payment(),
redeem(), change_plan(), cancel() and tick(). Nothing changes state behind your
back.
Checking access
if await engine.is_active("user_1"):
...
engine.is_active() compares the subscription's boundary against the injected clock,
so it is correct the moment a period ends rather than the next time tick() runs.
That gap is not theoretical: tick() usually runs from cron every few minutes, and
without this a cancelled subscription would keep access until the next run.
Subscription.is_active also exists, as a pure predicate over state with no notion
of time, and Subscription.access_until gives the boundary for whatever state the
subscription is in. Reach for the engine method unless you specifically want the
state check.
Events
The core knows nothing about notifications. It emits events, your application decides what to do with them.
subscription.created subscription.activated
subscription.renewed subscription.entering_grace
subscription.expired subscription.cancelled
subscription.plan_changed
promo.redeemed referral.accrued
payment.recorded payment.duplicate
payment.underpaid payment.unmatched
Every event carries user_id and an occurred_at set to the moment the transition
logically happened, not the moment you called into the engine. Fast-forward a month
and the grace period still starts on the day it was due.
Pass a sink and it sees everything, including events from calls that hand back a subscription rather than a list:
journal: list[Event] = []
engine = SubscriptionEngine(storage, clock=clock, on_event=journal.append)
The sink runs after the new state has been persisted, so a failing notification cannot roll back a transition. Keep it cheap: push onto a queue, do not do IO in it.
apply_payment() and tick() also return their events directly, because a payment
or a jump in the clock can set off a chain the caller cannot predict. subscribe(),
cancel() and change_plan() return the subscription instead: they produce at most
one event of their own, and you already know which one. The sink may additionally
see boundaries the call caught up on.
Money
Integers in minor units, everywhere, including discount math. No floats, no Decimal surprises. Rounding is explicit and covered by a test.
A plan is priced in the unit its provider actually pays in — USDT through CryptoBot, XTR through Stars — and nothing is ever converted. Plan.currency is a label the core does not interpret, the scale behind it belongs to the adapter, and a second provider means a second plan rather than an exchange rate.
Promo codes
Three kinds. PERCENT and FIXED attach to the subscription and come off the
payments that follow; PLUS_DAYS is spent the moment it is claimed and moves
whichever boundary the subscription is running on.
engine.register_promo_code(PromoCode(
code="SORRY",
kind=PromoKind.PLUS_DAYS,
value=3,
))
# the service was down for a day: give everyone their three days back
for user_id in affected:
await engine.redeem(user_id, "SORRY")
That is what redeem() exists for. Without it there is no way to extend a
subscription that is already running, which is the one thing you need at the
moment you least want to be writing code.
Limits (max_redemptions, max_per_user) count redemptions rather than
payments, and they are checked atomically: somebody who claims a code and never
pays has still spent their slot.
Referrals come with programs
Most referral code hardcodes one percentage. Real programs are not one percentage: a blogger you signed a deal with takes a cut of every renewal, someone who shared a link with a friend gets paid once, on the first payment.
That is two parameters differing by group, so they live in a program rather than in
an if:
engine.register_referral_program(ReferralProgram(
id="bloggers",
percent=30,
accrual=Accrual.EVERY_PAYMENT,
))
await engine.assign_program("user_42", "bloggers")
Attribution is recorded once, at subscribe(), and never moves. Accrual is computed
from money actually received, in minor units. A trial that never converted pays
nobody. One level only, by design.
Adapters
Payments, behind one protocol:
| Adapter | Status |
|---|---|
| CryptoBot | v0.1 |
| Telegram Stars | planned |
| YooKassa | planned |
Storage, behind one protocol:
| Adapter | Status |
|---|---|
| In-memory | v0.1, ships with the core |
| SQLAlchemy (async) | planned |
Both are plain protocols. Writing your own is roughly forty lines.
Taking money
An adapter turns a signed webhook into a payment. It creates no invoices and opens no sockets:
from substate.adapters.cryptobot import CryptoBotWebhook
webhook = CryptoBotWebhook(token=CRYPTO_PAY_TOKEN)
parsed = webhook.parse(body, signature) # raw request bytes, header value
events = await engine.apply_payment(parsed.payment)
body must be the bytes the socket delivered: the signature covers those, and a
framework that parses the JSON and serialises it again will break the check.
Amounts are read into minor units with Decimal, never float, and the invoice
must carry the subscriber's id in its payload.
Answer an AdapterError with 400 and do not retry it — a body that will never
verify will never verify — and keep 500 for your own failures, which are worth
retrying.
What substate does not do
Deliberately, so the scope stays small enough to finish:
- It is not a payment gateway. It never moves money. It records that money moved.
- No UI, no admin panel. It is a library.
- No framework coupling. No aiogram, no FastAPI, no Django. Bring your own.
- No background scheduler. You call
tick()from your own cron, worker or startup hook. - No proration. A plan change takes effect at the end of the paid period, and moves no money. Prorated switching is v0.2.
- No currency reconciliation. A plan is denominated in one unit, the payment arrives in that same unit, and nothing checks one against the other because there is nothing to check. Taking a second provider means registering a second plan.
- No refunds or chargebacks.
- No invoicing, receipts, tax or accounting.
License
MIT
Metadata
Release files for substate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| substate-0.1.0.tar.gz | 56.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| substate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 87.0 kB
Release files / substate-0.1.0.tar.gz
| Download URL | substate-0.1.0.tar.gz |
|---|---|
| Size | 56.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ddb7c37ed0b2fa1927f867331a5032821ddd686b6b5d0777153ce491ea79233d
|
|
BLAKE2b-256 checksum How to use checksums |
28f959fd63d5627f5fd27cbfe28b2996411a02e21f906075243be1053fc10bd6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency logRelease files / substate-0.1.0-py3-none-any.whl
| Download URL | substate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 30.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6b01c985a697359796a9cd4b7a0bbe62c91b9dd179a5b27b367d895a0d72cee8
|
|
BLAKE2b-256 checksum How to use checksums |
2982dc45a0dbf252d5245f6ec6f7f825b65a71714cd8ddd3ff71f71a30e32131
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency log