open-standards/openpay/docs/27-subscriptions-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
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.
2026-05-23 07:58:09 -04:00

11 KiB
Raw Permalink Blame History

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 via record_billing_failure / record_billing_recovered. The glue layer is operator-side.
  • Subscription doesn't implement PartialEq. Its method: PaymentMethod field carries Token bytes that intentionally don't have equality semantics. Compare on id when 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 metadata and 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_at scan 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-webhook dispatcher.

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.