Repository navigation
Description
Epic 004: Subscriptions — Stake2Pay (House of Stake) + Stripe Billing
Context
Epics 001–002 built a generic aggregator for one-time payments: every provider implements PaymentContract (metadata, ping, createCheckout, verifyWebhook, getSession), the API discovers providers dynamically, and the UI renders provider cards without build-time knowledge of them. Epic 003 adds webhook event persistence and merchant event delivery.
This epic adds subscriptions as a second product surface, with two providers to prove the contract is provider-agnostic:
- Stake2Pay — users lock NEAR in the House of Stake staking contract (
stake.dao, nearai/house-of-stake-contractsfeat/stake-daobranch); the locked NEAR is staked to the service provider's validator pool and the validator commission funds the provider — users pay with staking yield, not principal. Reference backend integration: nearai/chat-api PR #275 (Rust), ported here as an aggregator plugin. - Stripe Billing — recurring prices via Stripe Checkout (
mode: subscription) and the Subscriptions API, in the existingplugins/stripe/.
Subscriptions don't fit PaymentContract: stake2pay has no hosted checkout URL (the user's wallet signs a contract call from wallet intents) and no webhooks (state is verified by polling chain state via JSON-RPC view calls), and lifecycle verbs (cancel / resume / change plan) have no one-time equivalent. So subscriptions get their own SubscriptionContract, mirroring how Stripe separates Checkout from Billing. PaymentContract and the existing one-time flow are untouched.
Design
Flow mapping from the one-time model:
One-time (PaymentContract) |
Subscription (SubscriptionContract) |
Stake2Pay | Stripe Billing |
|---|---|---|---|
| — | listPlans |
on-chain catalog views (get_product / get_price) |
Stripe recurring Prices |
createCheckout → hosted URL |
createSubscription → action |
wallet_intent (contract call args + deposit) |
redirect (Checkout URL, mode: subscription) |
verifyWebhook (push) |
getSubscription (pull) |
RPC view get_subscription_for_price |
Subscriptions API |
| — | cancel / resume / changePlan |
wallet_intent (unlock / re-lock) |
executed (API call) |
createSubscription and lifecycle ops return a discriminated action: { kind: "wallet_intent" | "redirect" | "executed", ... } — wallet-based providers return signing instructions, hosted providers return a URL, API-based ops return the resulting state.
Key properties of the stake2pay plugin:
- No secrets. All chain access is read-only JSON-RPC view calls; the wallet does all writes. Config is
variablesonly:rpcUrl,networkId,contractId,productId. - Stateless v1. No local subscription rows; every read goes to chain. Persistence/entitlement caching is a follow-up — chat-api's experience shows local rows create a reconcile problem (their renewal-sync gap) we can avoid entirely at aggregator scope.
- Testnet already exists.
hos-e2e-0601144939.testnet(v1.0.3) has a seeded product (prod_5lklj46roIwKZK) with three recurring tiers (Starter 1–10 NEAR, Basic 10–40, Pro 40–400) — integration tests run against it read-only, no funding or keys required. - Wallet UX is already in the stack.
near-kitandbetter-near-authare in the Bun catalog and used by the UI; the subscriptions page signs thelocktransaction directly instead of redirecting.
The HoS contract also supports one-off pay purchases (credits) — a direct NEAR payment path, not staking: the deposit is spent into revenue_by_validator and creates no stake lock (staking-contract/src/payments.rs). As a stretch, the plugin can additionally implement PaymentContract so stake2pay appears among one-time providers.
Tickets
| # | Ticket | Issue | Dependency |
|---|---|---|---|
| 004-01 | SubscriptionContract definition + shared Zod schemas |
#28 | — |
| 004-02 | plugins/stake2pay/: NEAR RPC view client, catalog, intents, status |
#29 | 004-01 |
| 004-03 | API generic subscription routes + provider discovery | #30 | 004-01 |
| 004-04 | UI /subscriptions page: plans → wallet sign / redirect → status |
#31 | 004-02, 004-03 |
| 004-05 | E2E against HoS testnet contract + docs | #32 | 004-02, 004-03 |
| 004-06 | Stripe Billing implements SubscriptionContract |
#33 | 004-01, 004-03 |
| 004-07 | (stretch) PaymentContract implementation for HoS one-off pay purchases |
#34 | 004-02 |
Notes / decisions to confirm
- Provider key:
stake2pay(registered config-only underplugins.stake2payinbos.config.json,development: "local:plugins/stake2pay", nosecretsarray). - Subscription identity: chain subscriptions key on
(near_account, price_id). The aggregator must know the payer's NEAR account — frombetter-near-authsession when signed in with NEAR, else provided explicitly ingetSubscriptioninput. - Amounts are ranges, not fixed prices: an HoS plan is "lock between X and Y NEAR for period P" (
lock_factor_near_monthssufficiency check on-chain).PlanSchemaneedsminAmount/maxAmountrather than a singleunitAmount; Stripe plans setminAmount == maxAmount. - Cancel semantics: stake2pay "cancel" = unlock intent; funds stay staked until the epoch-based unstake pipeline settles. Status enum:
active,cancel_at_period_end,pending_unstake,ended,none. - The testnet contract is ephemeral (e2e instance, mock validator pool, dated June 1). Pin its id in test config, expect to swap it; production contract + real catalog don't exist yet.
- Stripe subscription webhooks (
customer.subscription.*,invoice.*) flow through the existingpaymentWebhookroute and Epic 003's event persistence — no new webhook surface.
Acceptance Criteria
-
SubscriptionContractlives alongsidePaymentContractand is imported by the API the same way (via plugin source) -
plugins/stake2payruns standalone viabun run devand passesbun run test(unit: chain-JSON → plan/status mapping; integration: live testnet views) - API subscription routes delegate dynamically — adding a second subscription provider is config-only (proven by 004-06)
- UI completes plan → wallet sign → active status against testnet, and plan → Stripe Checkout redirect in test mode
- Existing one-time flow (epics 001–003) untouched;
bun typecheckandbun lintpass
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fieldsDone
Activity
jaswinder6991 commentedon Jul 21, 2026
Epic 004 complete: all six core tickets merged — 004-01 (#28, PR #35), 004-02 (#29, PR #36), 004-03 (#30, PR #37), 004-04 (#31, PR #38), 004-05 (#32, PR #40), 004-06 (#33, PR #39). The stretch ticket 004-07 (#34) is closed as out of scope. Closing the epic.