SolidPeer

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:


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:

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:

  1. 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 as tier_rate_per_cc.
  2. Reconstruction of paid USD. From tier, subscription_term, and cycle_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) where term_months is 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 snapshot tier.monthly_price into a separate audit-log row at every bundle-purchase event — a per-account bundle_purchases table — so historical receipts don't drift; the schema here only locks cycle_discount_pct and tier_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:

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:

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:

We pick X for two reasons:

  1. Schema simplicity. One cycle abstraction, one cycle_ends_at field, one expiry rule. Schema Y needs a separate prepaid_cc_pool field, monthly-cycle nested inside the annual term, and rules about whether top-ups carry across the inner cycles or only within them.
  2. 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:

  1. Wait for cycle_ends_at — fresh grant arrives if subscription auto-renews (§3).
  2. Top up — see §7. Mid-cycle CC at the customer's tier rate, expires with the rest at cycle end.
  3. 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):

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):

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


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:

  1. 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.
  2. 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.
  3. Permanent vs temporary suspension. Schema treats them identically (status='suspended', lift via operator action). Whether to add a suspension_permanent: bool field, 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."
  4. Promo-code discounts beyond annual. cycle_discount_pct is 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 a cycle_discount_source enum field is a future option if revenue reporting needs the breakdown.