Metadata-Version: 2.4
Name: larzsubscription
Version: 0.1.0
Summary: Subscription billing as a state machine: trials, renewals, dunning, proration, cancellation. Exact Decimal money, zero dependencies.
Author: larz-scripter
License: MIT
Project-URL: Homepage, https://github.com/larz-scripter/larzsubscription
Project-URL: Repository, https://github.com/larz-scripter/larzsubscription
Project-URL: Issues, https://github.com/larz-scripter/larzsubscription/issues
Keywords: subscription,billing,saas,recurring,proration,dunning,trial,state-machine,money,zero-dependency
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# larzsubscription

**Subscription billing as a state machine. Pure Python, zero dependencies.**

The lifecycle of a paid subscription is deceptively fiddly — trials that convert,
renewals that charge, failed payments that go *past due* and retry, plan changes
that need **proration**, cancellations now or at period end. larzsubscription
models all of it as one small, testable object with exact `Decimal` money.

```python
from larzsubscription import Plan, Subscription
from datetime import datetime

pro = Plan("pro", price="9.99", interval_days=30, trial_days=14)
sub = Subscription(pro, start=datetime(2026, 1, 1))

sub.status                                   # "trialing"
sub.renew(now=datetime(2026, 1, 15))         # trial converts -> "active", charges 9.99
credit = sub.change_plan(Plan("basic", "4.99"), now=datetime(2026, 1, 20))  # prorated
sub.cancel()                                 # at period end
```

## Why

- **The whole lifecycle, correctly.** trialing → active → past_due → canceled →
  expired, with trials converting, dunning attempts counted, and cancel-now vs
  cancel-at-period-end both handled.
- **Real proration.** `change_plan()` returns the exact net amount to charge (or
  credit) for the remainder of the current period.
- **Exact money.** `Decimal` throughout; floats rejected. Every charge is recorded
  as an invoice; `total_billed()` reconciles.
- **Deterministic.** Pass `now=` everywhere, so billing is testable without
  waiting or mocking the clock — and it composes with
  [larzmoney](https://github.com/larz-scripter/larzmoney) /
  [larzledger](https://github.com/larz-scripter/larzledger).

## Install

```bash
pip install larzsubscription
```

## API

```python
Plan(name, price, interval_days=30, trial_days=0)

sub = Subscription(plan, start=now, on_event=cb)
sub.renew(now)                 # bill next period (trial converts); returns amount
sub.fail_payment(now)          # -> past_due, dunning_attempts += 1
sub.recover(now)               # past_due -> active (payment recovered)
sub.cancel(immediately=False)  # at period end, or now
sub.change_plan(new_plan, now) # -> prorated net (charge>0 / credit<0)
sub.has_access(now); sub.next_renewal; sub.total_billed(); sub.invoices; sub.status
```

## Tests

```bash
python -m unittest discover -s tests -v   # 16 tests, zero deps
```

## The Larz stack

One of 30+ pure-Python, zero-dependency libraries at
[github.com/larz-scripter](https://github.com/larz-scripter) — pairs with
**[larzmoney](https://github.com/larz-scripter/larzmoney)**,
**[larzledger](https://github.com/larz-scripter/larzledger)**, and
**[larzpdf](https://github.com/larz-scripter/larzpdf)**.

## License

MIT © larz-scripter
