SolidPeer — Billing schema
The mechanical contract: when CC are granted, when they expire, what happens at upgrade / downgrade / cancellation, how 0-balance is handled, what's recorded. Tier prices, CC quotas, and per-method costs live in PRICING.md and gateway/src/config/methods/* — this doc only describes the rules around them. The on-chain settlement mechanics (HD wallet, deposit addresses, BCH price feed, refund/change handling) live in PAYMENTS.md — this doc treats every customer payment as an instantaneous "subscribe" / "upgrade" / "topup" / "renewal" event whose effects are described below.
1. Model in one sentence
Subscription tiers grant CC quotas tied to a 30-day cycle; CCs expire at cycle end; charges (network-rate-scaled) burn the quota down; payment is crypto-only, so there's no auto-overage — requests hit HTTP 429 at zero balance and the customer either waits for renewal, tops up, or upgrades.
2. Account state
Account
├── account_id: string
├── status: 'active' | 'expired' | 'suspended'
├── suspended_reason: string|null # set iff status='suspended'; e.g. "abuse:tx-spam", "tos:fraud", "ops:investigation"
├── suspended_at: timestamp|null
├── tier: 'hobby' | 'build' | 'scale' | 'business' | 'dedicated'
├── subscription_term: 'monthly' | 'annual' # cycle length 30 vs 365 days; see §7a
├── tier_rate_per_cc: number # USD per CC, locked at last bundle purchase (depends on tier AND term AND discount)
├── cycle_discount_pct: number # 0..1, fraction off the undiscounted price; locked at last bundle purchase. 0 for monthly with no promo; 1/6 for default annual; promos/per-tier overrides extend this. Stored explicitly so we can reconstruct what the customer paid even if the global ANNUAL_DISCOUNT_PCT or tier prices change later.
├── balance_cc: integer # CC remaining in current cycle
├── cycle_started_at: timestamp # last reset (subscribe / upgrade / renewal)
├── cycle_ends_at: timestamp # cycle_started_at + (30 or 365) days
├── scheduled_downgrade_to: tier|null # set when customer requests downgrade
├── scheduled_term_change: term|null # set when customer requests monthly↔annual switch
├── routing_policy: shared | dedicated:<id>
├── rps_cap: integer # tier-derived
├── max_concurrent_subs: integer # tier-derived
└── max_tokens: integer # tier-derived
Three time-derived fields (tier_rate_per_cc, cycle_started_at, cycle_ends_at) are the load-bearing ones; everything else is either tier-derived (looked up from the tier definition) or part of the status group. Persistence: Postgres accounts row; cached projection in Redis for the hot path.
Status semantics:
active— in a valid cycle on a paid tier. Service operates normally;balance_ccmay transiently be 0 if CC exhausted mid-cycle (this is not a status change — see §8.1).expired— last cycle ended without renewal. Service blocked; customer can self-service re-subscribe to return toactive.suspended— platform-initiated block. Service blocked regardless of cycle/balance.suspended_reasonrecords why; only an operator action lifts it. Distinct fromexpiredbecause it requires support intervention, not self-service.
3. CC lifecycle
balance_cc timeline
│
cycle_started_at ──┤ grant: balance_cc = tier.cc_quota_monthly
│
│ ─── per-request decrement ───
│ balance_cc -= chargedAmount(method_cost, network)
│
(mid-cycle) │ optional top-up: balance_cc += cc_purchased
│
│
cycle_ends_at ──┤ expire: balance_cc = 0 (whatever's left, lost)
│
│ if subscription renews: cycle_started_at = now,
│ balance_cc = tier.cc_quota_monthly (back to top)
│ else: status = 'expired', balance_cc stays 0
▼
CC are use-it-or-lose-it. No carry-over across cycles, no rollover policy, no per-CC purchase-time tracking. Every CC in the balance has the same expiry: cycle_ends_at.
4. Network rate multiplier
charge = round(method_cost_cc × NETWORK_RATE[network])
| Network | Rate | Note |
|---|---|---|
| mainnet | 1.0 | reference |
| chipnet | 0.5 | testnet, half rate |
| testnet4 | 0.5 | testnet, half rate |
| regtest | 0.5 | dev/test, half rate |
To make a network free, set its rate to 0 in gateway/src/types.ts NETWORK_RATE. The charge happens at every billing site (reservation, commit, cache hit, push-batch) — the multiplier is centralized so a one-line change covers the whole system.
5. Tier upgrade (immediate)
Customer requests an upgrade to a higher tier. The system:
credit_usd = balance_cc * tier_rate_per_cc
charge_usd = new_tier.price_usd - credit_usd # cannot go negative; clamp at 0
# customer pays charge_usd in crypto (BCH / stablecoin)
# state transitions, atomically:
tier = new_tier
tier_rate_per_cc = new_tier.price_usd / new_tier.cc_quota_monthly
balance_cc = new_tier.cc_quota_monthly # fresh grant
rps_cap = new_tier.rps
max_concurrent_subs= new_tier.max_concurrent_subs
max_tokens = new_tier.max_tokens
cycle_started_at = now
cycle_ends_at = now + 30 days
scheduled_downgrade_to = null # cancel any queued downgrade
Properties this rule has:
- The customer pays exactly
(consumed-at-old-rate) + (new-bundle-price)over the whole transaction. No arbitrage on either tail (heavy use vs light use, day-1 vs day-28 upgrade). - Old CC are forfeited but their dollar value is already in the credit, so nothing is lost.
- Cycle resets so the customer always gets a full 30 days at the new tier — no awkward "I upgraded with 2 days left" situation.
Worked example. Hobby ($9.99 / 300M CC, $0.0333×10⁻⁶) → Build ($39.99 / 800M CC, $0.0500×10⁻⁶), customer has 200M CC unused:
credit_usd = 200_000_000 * 0.0333e-6 = $6.66
charge_usd = $39.99 - $6.66 = $33.33
balance_cc = 800_000_000
tier_rate_per_cc = 0.0500e-6
cycle_started_at = now
Total cash across both purchases: $9.99 + $33.33 = $43.32, exactly equal to (100M Hobby CC consumed at Hobby rate, $3.33) + (800M Build CC granted at Build rate, $39.99).
6. Tier downgrade (queued to end of cycle)
Customer requests a downgrade to a lower tier. The system:
scheduled_downgrade_to = lower_tier
# no other state changes — tier, balance_cc, rps_cap, etc. all stay
The downgrade is visible in the dashboard as a scheduled action ("downgrades to Build on Jun 30") so the customer knows it's queued, not refused.
At cycle_ends_at:
if scheduled_downgrade_to != null:
tier = scheduled_downgrade_to
tier_rate_per_cc = scheduled_downgrade_to.price_usd / scheduled_downgrade_to.cc_quota_monthly
rps_cap = scheduled_downgrade_to.rps
max_concurrent_subs= scheduled_downgrade_to.max_concurrent_subs
max_tokens = scheduled_downgrade_to.max_tokens
scheduled_downgrade_to = null
balance_cc = tier.cc_quota_monthly # fresh grant at the (possibly new) tier
cycle_started_at = now
cycle_ends_at = now + 30 days
Why end-of-cycle: keeps the customer on the tier they paid for until that money is fully spent, avoiding the "upgrade Friday, burn Saturday, downgrade Sunday for refund" arbitrage. The natural burn during the rest of the cycle absorbs whatever surplus CC remained.
Cancellation is the special case where scheduled_downgrade_to = 'expired' (or null tier). At cycle end the account moves to status = 'expired', balance_cc = 0, rps_cap = 0. No refunds.
7. Top-up (mid-cycle CC purchase)
Customer wants more CC before cycle end without changing tier. Pay USD/USDC, receive CC at the customer's current tier rate:
cc_purchased = floor(usd_paid / tier_rate_per_cc)
balance_cc += cc_purchased
# tier, rate, cycle_started_at, cycle_ends_at: unchanged
These CC expire at the existing cycle_ends_at like everything else in the balance. Surfacing the deadline at top-up time matters: "$10 buys 16M CC; expires in 2 days at cycle end" stops customers from buying CC they can't use.
The reason top-ups happen at the customer's tier rate (rather than a separate "overage" rate) is that we want one $/CC schedule per customer, not two — keeps the explanation simple and rewards tier upgrades naturally (heavy users who keep needing top-ups end up paying more than the next tier's monthly fee, prompting the upgrade).
Minimum top-up: $5 USD-equivalent. Below that, BCH-payment friction (waiting for confirmation, deposit address allocation, customer attention) outweighs the marginal CC bought. The quote layer rejects sub-$5 top-up requests with invalid_input before allocating an address; the customer either bumps the amount or upgrades the tier.
Dedicated tier top-up rate: negotiated at contract signing with a documented floor of $0.0200/M CC (matches Scale's published per-CC rate). Stops the per-contract negotiation race-to-bottom while preserving real custom flexibility on the bundle side. Stored on accounts.tier_rate_per_cc like any other tier — once set, the topup math is identical.
7a. Annual subscription term
Customers can choose monthly or annual billing for any paid tier. Annual is the same model with three substitutions:
| Field | Monthly | Annual |
|---|---|---|
| Cycle length | 30 days | 365 days |
cc_quota granted at cycle start |
tier.cc_quota_monthly |
tier.cc_quota_monthly × 12 |
| Price | tier.monthly_price |
tier.monthly_price × 12 × (1 − ANNUAL_DISCOUNT_PCT) |
tier_rate_per_cc |
monthly_price / cc_quota_monthly |
annual_price / (12 × cc_quota_monthly) |
ANNUAL_DISCOUNT_PCT is a single configurable knob, default 1/6 (≈16.67%), which makes the annual price exactly 10× the monthly (12 × 5/6 = 10) — the tidy number behind the "two months free" framing. Per-tier overrides are allowed (tier.annual_discount_pct); when absent, the default applies. Set to 0 to charge full price annually.
The discount is locked on the account at every bundle purchase, alongside tier_rate_per_cc. Concretely: the schema field cycle_discount_pct records the fraction discount applied at the last subscription / upgrade / renewal. This serves two purposes:
- Customer protection from policy changes. If we later change
ANNUAL_DISCOUNT_PCT(raise it, lower it, set per-tier overrides), the customer's already-purchased cycle keeps the rate they originally got. No retroactive re-pricing — same rule astier_rate_per_cc. - Reconstruction of paid USD. From
tier,subscription_term, andcycle_discount_pct, we can rebuild what the customer actually paid for the current cycle:paid_usd = tier.monthly_price_at_purchase × term_months × (1 − cycle_discount_pct)whereterm_monthsis 1 or 12. Useful for receipts, refund disputes, revenue reporting per discount cohort, and audits comparing the rate they got vs. the rate currently advertised. (If tier prices themselves are mutated over time, the operator should additionally snapshottier.monthly_priceinto a separate audit-log row at every bundle-purchase event — a per-accountbundle_purchasestable — so historical receipts don't drift; the schema here only lockscycle_discount_pctandtier_rate_per_cc, not the underlying tier price.)
cycle_discount_pct is rewritten only on bundle-purchase events (initial subscribe, upgrade, renewal). Top-ups (§7) inherit the customer's current tier_rate_per_cc (which already bakes in the discount) but do NOT change cycle_discount_pct — top-up CC are sold at the customer's current rate, not at a separately-tracked discount.
Worked rates at default discount:
| Tier | Monthly $/mo | Monthly $/CC | Annual $/yr | Annual CC | Annual $/CC |
|---|---|---|---|---|---|
| Hobby | $9.99 | $0.0333×10⁻⁶ | $99.90 | 3.6B | $0.0278×10⁻⁶ |
| Build | $39.99 | $0.0500×10⁻⁶ | $399.90 | 9.6B | $0.0417×10⁻⁶ |
| Scale | $199.99 | $0.0211×10⁻⁶ | $1999.90 | 114B | $0.0175×10⁻⁶ |
| Business | $599.99 | $0.0300×10⁻⁶ | $5999.90 | 240B | $0.0250×10⁻⁶ |
Everything else in this doc applies to annual subscribers unchanged. The cycle abstraction is the same — only the cycle length and grant size differ:
- CC lifecycle (§3) — grant at cycle_start, expire at cycle_end, no rollover. Annual cycle just runs longer.
- Upgrades (§5) — same formula. The customer's current
tier_rate_per_ccalready reflects monthly vs. annual, socredit_usd = balance_cc × tier_rate_per_ccworks both ways. After upgrade, the new tier's annual rate (or monthly rate, if downgrading the term) is locked in. Cycle resets to the new term's length. - Downgrades (§6) — queued to
cycle_ends_at. For annual customers that's up to a year out, which is intentional: they paid for the whole year, they get the whole year. Downgrading the term (annual → monthly) is queued the same way viascheduled_term_change. - Top-ups (§7) — at the customer's
tier_rate_per_cc, so annual subscribers pay the discounted rate on top-ups too. Topped-up CC expire at the samecycle_ends_at(year end for annual).
Term changes
Customers can switch between monthly and annual via the same flow as a tier downgrade (queued to cycle_ends_at) or a tier upgrade (immediate, with credit). The decision rule:
- Term upgrade (monthly → annual, same tier): immediate, treated like a §5 tier upgrade.
credit_usd = balance_cc × monthly_rate_per_cc;charge_usd = annual_price − credit_usd; cycle resets to 365 days;tier_rate_per_ccswitches to annual rate. The customer's commitment to the year starts now. - Term downgrade (annual → monthly, same tier): queued via
scheduled_term_change = 'monthly'; takes effect atcycle_ends_at(the year boundary). The customer keeps annual benefits and any remaining CC through the year; at year end, balance expires and the new monthly cycle begins.
A combined tier-and-term change (e.g., monthly Hobby → annual Build) is handled as a single upgrade if the new bundle's price is higher (immediate, with credit), or split into a tier change now + queued term change at cycle end if the customer wants both moves but only one is "up" — but in practice combined changes that are net upgrades dominate, so the immediate path is the common case.
Why one big cycle (not 12 monthly cycles)
Two reasonable schemas exist for annual:
- Schema X (chosen): annual is one 365-day cycle. Full 12× CC granted upfront, expires at year end.
- Schema Y (rejected): annual is 12 monthly cycles, each with
cc_quota_monthlygranted and expiring per cycle. Forces pacing.
We pick X for two reasons:
- Schema simplicity. One cycle abstraction, one
cycle_ends_atfield, one expiry rule. Schema Y needs a separateprepaid_cc_poolfield, monthly-cycle nested inside the annual term, and rules about whether top-ups carry across the inner cycles or only within them. - Customer ownership of upfront payment. The customer paid for the year; restricting access to only 1/12 per month would feel like artificial gating against money they've already given us. The downside (customer can burn the year's CC in one month) is bounded by
rps_cap(tier-level RPS cap throttles the burn rate), and topping up at the cheap annual rate is the customer's natural recourse if they over-consume early.
If usage data later shows that month-1 burn is a real abuse vector (e.g., customers gaming the upfront grant for a one-month spike then chargeback-attacking the rest), Schema Y is a clean migration: introduce prepaid_cc_pool, move grants to monthly cycle boundaries inside the annual term. The schema doc in this commit reflects X; the operational decision is left at X for now.
8. Account-state rejections
Three account-level reasons can block a request, with three distinct outcomes that map to three different customer recovery paths. The gateway must emit the right one — operations and dashboards use these to tell self-service-fixable problems apart from operator-action problems, and clients use them to drive the right UX (top-up modal vs. subscribe modal vs. contact-support).
| Status | Condition | HTTP | Outcome | Recovery |
|---|---|---|---|---|
active |
balance_cc = 0 |
429 | rejected:balance |
self-service: top-up / upgrade / wait for cycle |
expired |
cycle ended w/o renewal | 402 | rejected:expired |
self-service: re-subscribe |
suspended |
platform action | 403 | rejected:suspended |
needs operator action; not self-service |
The dispatcher checks them in this order: suspended first (dominant), then expired, then balance_cc. A suspended account that's also out of CC and past cycle end emits rejected:suspended only — the other reasons are masked.
8.1 Active with balance_cc = 0 (CC exhausted mid-cycle)
The account is on a valid tier, in an active cycle, but burned through its CC quota.
HTTP 429
X-RateLimit-Reason: balance
audit.outcome = 'rejected:balance'
audit.cc_charged= 0
Customer's three escape hatches:
- Wait for
cycle_ends_at— fresh grant arrives if subscription auto-renews (§3). - Top up — see §7. Mid-cycle CC at the customer's tier rate, expires with the rest at cycle end.
- Upgrade — see §5. Since
balance_cc = 0, the credit is $0 and the customer pays full new-tier price; gets fresh 30-day cycle and full new bundle.
No auto-overage. Auto-charging crypto wallets isn't in scope (we don't hold custody, and on-chain pulls require pre-authorization we don't model). Hard 429 is the substitute.
8.2 Expired (cycle ended without renewal)
The customer's last cycle ended and no renewal payment was processed. This is the natural "subscription lapsed" terminal state — distinct from §8.1 because the customer isn't on any tier anymore, and the fix is to re-subscribe (a different flow from topping up an active account).
status = 'expired'
balance_cc = 0
tier_rate_per_cc= last_tier.rate # remembered for UX, not used in any charge
HTTP 402 (Payment Required — semantically correct: a new subscription buy is required)
X-Account-Status: expired
audit.outcome = 'rejected:expired'
audit.cc_charged= 0
The customer can re-subscribe to any tier and the system treats the new purchase like a fresh subscription (cycle starts now, full bundle granted). No special "reactivation" pricing — they just buy a new bundle.
Cancellation (customer-initiated, queued via §6) results in status = 'expired' at the next cycle_ends_at. So does a non-renewed subscription where the customer simply stops sending payment.
8.3 Suspended (platform-initiated)
The platform has explicitly blocked the account. Reasons (suspended_reason):
abuse:*— TOS violations: spam, fraud rings, phishing, illegal content broadcast.tos:*— bulk policy violations (e.g., terms-of-service breach detected by ops review).ops:investigation— temporary block pending review (e.g., suspicious traffic pattern that needs human eyes; lifted on either side of the verdict).ops:chargeback— payment chargeback received; held until reversed or absorbed.legal:sanctions— jurisdictional / sanctions-screening hit; may be permanent.
HTTP 403 (Forbidden — access denied by platform, not a payment issue)
X-Account-Status: suspended
audit.outcome = 'rejected:suspended'
audit.cc_charged = 0
State preserved on suspension. tier, balance_cc, cycle_started_at, cycle_ends_at are kept exactly as they were at the moment of suspension. If the suspension is later lifted, the account resumes from that frozen state — so a customer with two weeks left in their cycle and 200M CC unburned gets those back if reinstated.
Cycle still ticks while suspended. Suspension does not pause time. cycle_ends_at fires on schedule; if it fires while suspended, balance_cc expires to 0 like in any other cycle. Auto-renewal is disabled while suspended (we don't accept payments from a suspended account), so a multi-cycle suspension naturally degrades through expired even if the operator never lifts the suspension.
Lifting suspension (operator action only):
- If
cycle_ends_atis still in the future:status = 'active', balance/cycle unchanged. Customer resumes mid-cycle with whatever CC are left. - If the cycle ended during suspension:
status = 'expired', balance is 0 (already expired), customer must re-subscribe.
Suspensions can be permanent (no lift, e.g. for sanctions hits). The schema doesn't model permanence explicitly — it's an operational discipline rather than a state field.
Why status is checked before balance_cc in the dispatcher: a suspended account that happens to have $0 balance shouldn't emit rejected:balance (which suggests "top up to fix") — it should emit rejected:suspended (which sends the customer to support). Hiding the suspension behind a balance message is a security/operations bug.
9. Audit log
Per-request record (see gateway/src/types.ts AuditRecord):
token_id, account_id, system, network, method,
req_bytes, resp_bytes, duration_ms,
cc_charged, # network-rate-scaled, the figure deducted from balance
outcome, # see §10
ts
cc_charged reflects the actually-burned amount (post NETWORK_RATE scaling). Both mainnet (full) and testnet/regtest (half) charges land in the same field, distinguished by network. Dashboards break down revenue per network from this.
10. Outcomes
| outcome | semantics | balance impact | HTTP |
|---|---|---|---|
executed |
request reached upstream and completed | cc_charged deducted |
200 |
cached:time_window |
served from gateway cache | cc_charged deducted (same as miss) |
200 |
rejected:rate |
RPS or concurrency cap hit | 0 | 429 |
rejected:complexity |
request shape exceeded complexity gate | 0 | 403 |
rejected:method |
method denied for this token's allowlist | 0 | 403 |
rejected:balance |
active account, balance_cc < required (see §8.1) |
0 | 429 |
rejected:expired |
subscription ended without renewal (see §8.2) | 0 | 402 |
rejected:suspended |
platform-suspended account (see §8.3) | 0 | 403 |
failed:upstream |
upstream errored | 0 (or write-cost on writes — see §11) | 502 |
11. Write-method special case
Writes (sendrawtransaction, transaction.broadcast, chaingraph mutations) charge cc_charged even on failed:upstream because the gateway can't tell whether the transaction was accepted by the network — paying for the attempt removes the incentive for retry-storms when an upstream is wobbly. Reads refund the reservation on upstream failure.
This is enforced in gateway/src/listeners/http.ts (isWrite branch).
12. Forbidden operations
- Side-grades — no concept of "swap one perk for another at same price." Tier transitions are along the monotonic tier list (Hobby < Build < Scale < Business < Dedicated). If side-grades become a product need later, the rule in §5 stops being symmetric and needs explicit policy.
- Mid-cycle downgrades — always queued (§6).
- Refunds for unused CC — the upgrade-credit path (§5) is the only place the system pays customers back for unused CC, and that's a credit applied immediately to a higher-tier purchase, not a withdrawal.
- Auto-overage — explicitly out of scope; we hard-429 (§8).
- Carrying CC across cycle boundaries — every CC has the same expiry (
cycle_ends_at). No bucket of "rolled-over" CC. - Retroactive re-pricing — the $/CC rate a customer paid for already-purchased CC is locked at purchase time (
tier_rate_per_cc). Tier changes only affect future purchases and the credit calculation in §5.
13. State transitions
The lifecycle has two orthogonal axes: the cycle/tier axis (active → expired with paid-tier transitions on each renewal) and the suspension axis (any state can be suspended by operator action, regardless of cycle state).
Cycle/tier axis
┌─────────────┐
sign-up ───▶ │ active │ ◀─── renew ─┐
│ (paid tier) │ │
└─────────────┘ │
│ │
┌─────────┼──────────┐ │
│ │ │ │
upgrade downgrade cancel │
│ │ │ │
│ (queued)│ (queued) │ (queued)│
▼ ▼ ▼ │
┌────────┐ ┌────────┐ ┌────────┐ │
│ next │ │ end-of-│ │ end-of-│ │
│ cycle │ │ cycle: │ │ cycle: │ │
│ starts │ │ tier │ │ status │ │
│ at new │ │ swaps │ │ → │ │
│ tier │ │ │ │ expired│ │
└────────┘ └────────┘ └────────┘ │
│ │ │ │
└─────────┴──────────┘ │
│
┌─────────┴──────┐
▼ ▼
renewal no payment ──▶ ┌────────────┐
│ expired │ ─── re-subscribe ─┐
└────────────┘ │
▲ │
└────────────────────────────┘
Upgrade is the only transition that fires immediately and resets the cycle. Downgrade and cancel both wait for cycle_ends_at. Re-subscribe from expired is a fresh subscription (cycle starts now with full bundle).
Suspension axis (orthogonal)
any status ─── operator suspend ──▶ status='suspended'
▲ │
│ │
│ cycle ticks normally
│ (no auto-renew while suspended)
│ │
│ ▼
│ at cycle_ends_at: balance → 0
│ (status stays 'suspended')
│ │
└─── operator lift ──────┘
│
▼
status returns to:
- 'active' if cycle still valid
- 'expired' if cycle ended during suspension
Suspension dominates: while status='suspended', all gateway requests return rejected:suspended regardless of underlying cycle/balance state. Suspension is the only state in the schema that requires operator action to enter or exit — every other transition is either time-driven or customer-initiated.
14. End-to-end worked examples
A — Pure burn-down on Hobby. Customer subscribes Hobby ($9.99 / 300M CC, $0.0333e-6/CC). Day 12: balance 90M after burning 210M. Day 30: balance expires (or renewal grants 300M fresh, depending on auto-renew flag). No surprises.
B — Light user on Hobby, upgrade to Build mid-cycle. Day 10, balance 240M unused. Upgrade requested:
credit_usd = 240M * $0.0333e-6 = $7.99
charge_usd = $39.99 - $7.99 = $32.00
balance_cc = 800M (Build's quota)
tier_rate_per_cc = $39.99 / 800M = $0.04999e-6
cycle resets, ends now+30d
Customer paid $9.99 + $32.00 = $41.99, received 60M Hobby calls + 800M Build calls. Math checks out: 60M × $0.0333e-6 + 800M × $0.04999e-6 = $2.00 + $39.99 = $41.99.
C — Heavy user on Build, hits 0 mid-cycle, top up. Day 18, balance hit 0. Customer tops up $10:
cc_purchased = floor($10 / $0.04999e-6) = 200_040_008 CC (~200M)
balance_cc += 200_040_008
# everything else unchanged; expires at original cycle_ends_at (12 days away)
D — Build customer queues downgrade to Hobby. Day 5: requests downgrade. scheduled_downgrade_to = 'hobby'. Days 5–30: full Build access (75 RPS, 800M CC original quota minus burns). Day 30: tier swaps to Hobby, fresh 300M Hobby grant, RPS drops to 25, cycle restarts.
E — Build customer cancels. Same shape as D with scheduled_downgrade_to set to a sentinel meaning "expire". At day 30: status = 'expired', balance_cc = 0, requests start returning HTTP 402 with rejected:expired (see §8.2). Customer can re-subscribe to revive.
G — Suspension covers a cycle boundary. Build customer at day 10 of cycle, balance 350M, gets suspended for abuse:tx-spam. Days 10–30: all requests return HTTP 403 rejected:suspended; balance/cycle untouched. Day 30: cycle ends naturally, balance expires to 0; auto-renew is disabled while suspended, so status stays suspended and the cycle effectively ends. Day 45: operator lifts suspension. Since the cycle ended during suspension: status = 'expired', balance_cc = 0. Customer must re-subscribe to use the service again.
H — Suspension lifted mid-cycle. Build customer at day 5 of cycle, balance 480M, suspended for ops:investigation. Day 8: investigation clears, operator lifts. status = 'active', balance_cc = 480M, cycle_ends_at unchanged (still 22 days remaining). Customer resumes exactly where they were. From the customer's audit log perspective: 3 days of rejected:suspended followed by normal operation; no money or CC lost.
I — Annual Hobby subscription. Customer subscribes to annual Hobby. With default ANNUAL_DISCOUNT_PCT = 1/6:
charge_usd = $9.99 × 12 × 5/6 = $99.90
balance_cc = 3_600_000_000 (12 × 300M)
tier = 'hobby'
subscription_term= 'annual'
tier_rate_per_cc = $99.90 / 3.6B = $0.0278×10⁻⁶
cycle_discount_pct = 1/6
cycle_started_at = now
cycle_ends_at = now + 365 days
Customer can burn at up to 25 RPS through the year. If they burn early, top-ups happen at $0.0278×10⁻⁶/CC (the cheap annual rate they locked in).
J — Mid-annual upgrade Hobby → Build. Day 100 of an annual Hobby subscription, balance 1.8B unused. Customer upgrades to annual Build:
credit_usd = 1.8B × $0.0278×10⁻⁶ = $50.04
charge_usd = $399.90 − $50.04 = $349.86 # annual Build = $39.99 × 12 × 5/6
balance_cc = 9_600_000_000 # 12 × 800M
tier = 'build'
tier_rate_per_cc = $399.90 / 9.6B = $0.04166×10⁻⁶
cycle_discount_pct = 1/6
cycle_started_at = now
cycle_ends_at = now + 365 days
Customer paid $99.90 + $349.86 = $449.76 total. The 1.8B annual-Hobby CC are forfeited (their $50.04 dollar-equivalent already credited toward the upgrade). Fresh year of annual Build begins.
K — Term change monthly Hobby → annual Hobby. Day 8 of monthly Hobby, balance 210M unused. Customer wants to switch to annual to get the discount. Treated as a §5-style upgrade (price goes from $9.99 → $99.90, an upgrade direction):
credit_usd = 210M × $0.0333×10⁻⁶ = $6.99
charge_usd = $99.90 − $6.99 = $92.91
balance_cc = 3_600_000_000
subscription_term = 'annual'
tier_rate_per_cc = $0.0278×10⁻⁶
cycle_discount_pct = 1/6
cycle_started_at = now
cycle_ends_at = now + 365 days
Customer paid $9.99 + $92.91 = $102.90 total, received 90M monthly Hobby CC consumed at monthly rate ($3.00) plus a fresh annual Hobby year ($99.90). Math: $3.00 + $99.90 = $102.90. ✓
15. Open
These are tracked in TODO.md:
- Multi-currency. Tier prices are USD-denominated; the customer pays in BCH/stablecoin at a quoted exchange rate. Quote freshness, slippage handling, and what happens if BCH price moves between quote and confirmation are commercial decisions.
- Suspension audit / appeal flow. §8.3 specifies the gateway behavior; the operator-side workflow (who can suspend, what evidence is logged, how customers appeal, how lifts are authorized) is an ops/policy decision separate from the schema.
- Permanent vs temporary suspension. Schema treats them identically (
status='suspended', lift via operator action). Whether to add asuspension_permanent: boolfield, or rely purely on operational discipline, is open. Permanent flag would let the dashboard render a clear "this account cannot be reactivated" message vs ambiguous "suspended." - Promo-code discounts beyond annual.
cycle_discount_pctis general enough to carry promo discounts (e.g., 25% off first month for a launch promo, friends-of-team discount, etc.). The schema doesn't currently model the source of a discount (annual vs promo vs partner) — adding acycle_discount_sourceenum field is a future option if revenue reporting needs the breakdown.