Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
11 KiB
Phase 27 — Subscriptions: recurring billing
Status: Draft v0.27 Date: 2026-05-22
Why
The most-requested feature for an operator-deployable payments stack that the workspace couldn't model until now. Vendors with SaaS, memberships, content subscriptions, or any other "charge the same customer on a schedule" pattern need plans, billing cycles, trials, dunning, and proration.
Final phase of the sequenced trio (25 → 26 → 27): crypto (Phase 25), then single-file persistence (Phase 26), now subscriptions.
What shipped
| # | Item | Where |
|---|---|---|
| 1 | op-subscriptions crate — sealed error, plan, subscription, scheduler, dunning, proration, store |
crates/op-subscriptions/ |
| 2 | Plan (id, name, amount, interval, interval_count, trial_days) with snapshot-at-creation semantics |
plan.rs |
| 3 | Interval enum: Day / Week / Month / Year (Month/Year calendar-aware) |
plan.rs |
| 4 | Subscription + Status state machine: Trialing → Active → PastDue/Paused/Canceled with transitions |
subscription.rs |
| 5 | BillingScheduler — pure-function period math: classify(sub, now) → DueState, tick(sub, now) → mutate |
scheduler.rs |
| 6 | Calendar-aware add_months — Jan 31 + 1 month = Feb 28/29 (no off-by-one bugs) |
scheduler.rs |
| 7 | DunningPolicy — pluggable retry schedule with decide(retry_count, failed_at, now) → DunningOutcome |
dunning.rs |
| 8 | proration::credit_remaining + proration::switch_charge — integer-exact proration math for mid-cycle plan changes |
proration.rs |
| 9 | SubscriptionStore trait + InMemorySubscriptionStore ref impl |
store.rs |
| 10 | GraphSubscriptionStore in op-graph — same single-file persistence as the other domain stores |
op-graph/src/subscription_store.rs |
| 11 | AppState.subscriptions: Arc<GraphSubscriptionStore> wired in |
op-server/src/state.rs |
| 12 | HTTP endpoints: POST /v1/subscriptions, GET /v1/subscriptions/{id}, GET /v1/subscriptions?customer_ref=, POST /v1/subscriptions/{id}/cancel, POST /v1/subscriptions/{id}/pause, POST /v1/subscriptions/{id}/resume |
op-server/src/handlers/subscription.rs, routes.rs |
| 13 | From<op_subscriptions::Error> for ApiError mapping (404 / 409 / 400) |
op-server/src/error.rs |
Workspace at the end of Phase 27:
| Check | Result |
|---|---|
cargo build --workspace --all-targets |
0 errors, 0 warnings |
cargo test --workspace |
1023 passing, 0 failing (+46 vs Phase 26) |
cargo clippy --workspace --all-targets |
0 warnings |
Test-count delta breakdown: op-subscriptions +41 (plan 3, dunning 5, proration 6, scheduler 9, store 6, subscription 12), GraphSubscriptionStore +4, op-server subscription HTTP +1.
Domain model
Plan (snapshot per-subscription)
│
▼
Subscription
├── customer_ref (operator's customer id)
├── method (PaymentMethod — vault token / crypto / A2A / ...)
├── status (Trialing/Active/PastDue/Paused/Canceled)
├── period [start, end)
└── cancel_at_period_end (bool flag)
Plan snapshots into Subscription at creation. Editing a plan
in the catalog does NOT re-price existing subscribers — that's the
universal SaaS convention and the right default for predictable
billing.
State machine
─────► Trialing ──promote_from_trial()──► Active
│ │
│ ├──pause()────► Paused
│ │ │
│ │ └─resume()──►
│ │
▼ ▼
(pause/cancel paths) record_billing_failure()
│
▼
PastDue
│
┌─record_billing_recovered()
│
▼
Active
Any state ──schedule_cancel_at_period_end()──► flag set;
scheduler tick at period end ──► Canceled (terminal)
Any state ──cancel_now()────────────► Canceled (terminal)
Illegal transitions return Error::InvalidTransition. Terminal
states refuse all further transitions.
Calendar-aware period math
advance(jan_31_unix, Interval::Month, 1) // → feb_29_unix (2024 leap)
advance(jan_15_unix, Interval::Year, 1) // → jan_15_next_year_unix
add_months uses the time crate to do day-clamping correctly
(Jan 31 → Feb 28/29, not Mar 3). Day / Week math is exact
arithmetic on unix seconds.
Scheduler
match BillingScheduler::classify(&sub, now_unix_secs) {
DueState::NotDue => /* keep waiting */,
DueState::TrialEnded => /* operator promotes via tick() */,
DueState::PeriodRollover => /* charge the new period */,
DueState::CancelAtPeriodEnd => /* sub flipped its flag; finalize */,
}
// Or do the mutation in one shot:
let state = BillingScheduler::tick(&mut sub, now_unix_secs)?;
tick is the action verb: it transitions the subscription and
returns the resulting DueState so the caller knows what to do
externally (typically: PeriodRollover → enqueue a charge via the
orchestrator).
Dunning
let policy = DunningPolicy::default(); // 1d → 3d → 5d → 7d → cancel
let policy = DunningPolicy::aggressive_daily(); // 7×1d → cancel
let policy = DunningPolicy::conservative(); // 1d → 7d → 30d → cancel
match policy.decide(retry_count, failed_at, now) {
DunningOutcome::RetryNow => /* charge again */,
DunningOutcome::Wait { next_attempt_at } => /* enqueue for that time */,
DunningOutcome::Cancel => /* subscription dies */,
}
The policy is data — schedules are Vec<u32> of per-retry delays
in days. Operators serialize/deserialize them; no need to re-deploy
to change retry behavior.
Proration
// Customer upgrades mid-cycle. Credit the unused portion of the
// old plan against the first charge on the new plan.
let credit = proration::credit_remaining(&sub, now_unix_secs)?;
let charge = proration::switch_charge(new_plan.amount, credit)?;
// charge clamps at zero — we don't model "we owe the customer money"
// via subscriptions (refund flow handles that).
Math is integer-exact: amount × seconds_remaining / period_length, with the integer floor biased toward the operator.
No floating point, no rounding surprises.
Single-file persistence
Same model as Phase 26: GraphSubscriptionStore stores one
subscription vertex per subscription with the full state JSON
plus indexed properties (external_id, customer_ref,
status_code, current_period_end). Reopening the .graph file
recovers every subscription record alongside refunds / disputes /
batches / idempotency / ledger / webhooks. No separate database
server.
HTTP surface
POST /v1/subscriptions
GET /v1/subscriptions/{id}
GET /v1/subscriptions?customer_ref=...
POST /v1/subscriptions/{id}/cancel # body: {at_period_end?, now_unix_secs}
POST /v1/subscriptions/{id}/pause # body: {now_unix_secs}
POST /v1/subscriptions/{id}/resume
Same {code, message, details} error envelope as the rest of the
server. 404 on unknown id, 409 on illegal transitions, 409
on external_id mismatch, 400 on bad input.
Honest concerns (carry-forward)
- No actual charging. The scheduler tells you a subscription
is due; the operator wires the charge through
op-orchestrator(or a direct adapter call) and feeds the result back viarecord_billing_failure/record_billing_recovered. The glue layer is operator-side. Subscriptiondoesn't implementPartialEq. Itsmethod: PaymentMethodfield carriesTokenbytes that intentionally don't have equality semantics. Compare onidwhen testing.- No usage-based billing. Plans are fixed-amount; metered /
per-unit-of-X pricing is a separate model that doesn't fit
cleanly into
Plan::amount. Future phase if operators need it. - No add-ons / coupons / discounts. Operators model these
via
metadataand adjust the orchestrator's charge call. Building them in would push opinion onto every operator. - No customer-side cancellation portal. That's UI, not backend. The HTTP endpoints support it; the portal is the operator's frontend job.
list_for_customer/list_due_atscan all vertices. Same limitation as the other graph-backed stores: Minigraf doesn't yet expose secondary indexes. For 100k subscriptions this scans in milliseconds; at 10M operators want a backend with proper indexing (the trait surface admits one drop-in).- No webhook events on subscription transitions. Operators
wanting "send email on charge-failed" wire that themselves via
the existing
op-webhookdispatcher.
Test totals
op-subscriptions 41 tests
plan 3
dunning 5
proration 6
scheduler 9
store 6
subscription 12
op-graph +4 tests (GraphSubscriptionStore)
op-server +1 test (subscription_create_pause_resume_cancel)
----
+46 net
cargo test --workspace: 1023 passing, 0 failing.
cargo build --workspace --all-targets: 0 warnings.
cargo clippy --workspace --all-targets: 0 warnings.
State of the project after Phase 27
OpenPay now covers, end-to-end, what a vendor needs to bring margin back from the card-network tax stack:
| Capability | Phase |
|---|---|
| Double-entry ledger | 12 |
| Graph-backed audit / fraud queries | 14, 18, 19, 21 |
| Bi-temporal time-travel | 17 |
| Cross-rail orchestrator with routing signals | 11, 18, 19, 20 |
| Reconciliation (CAMT.053/054, NACHA-aware) | 15, 20 |
| Refunds + disputes | 21 |
| Settlement + payout files | 22 |
| HTTP API server | 23 |
| Driver SDK + conformance harness | 24 |
| Crypto rail (USDC, EURC, PYUSD) | 25 |
| Single-file persistence across all stores | 26 |
| Subscriptions / recurring billing | 27 |
A vendor can write a driver against op-driver-sdk, plug it into
the orchestrator, deploy op-server pointed at one .graph file,
and run a production payments stack — card / A2A / crypto rails,
refunds, disputes, settlement, subscriptions, audit — without any
external database, queue, or auxiliary service.