Add self-serve billing: tiers, credits, BTCPay and Zaprite

This commit is contained in:
Keysat
2026-06-13 13:36:05 -05:00
parent 84d56c94c9
commit 0aa648706e
17 changed files with 3781 additions and 116 deletions
+108
View File
@@ -0,0 +1,108 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config.json'
const { InputSpec, Value } = sdk
// Connects the relay to the operator's BTCPay store so users can top
// up their credit balance via Lightning / on-chain payments. All
// four fields must be set together — leaving any blank disables the
// credit-purchase flow (the rest of the relay keeps working).
//
// To generate the API key in BTCPay:
// 1. Account → Manage Account → API Keys
// 2. "Generate Key" with scopes:
// - btcpay.store.cancreateinvoice
// - btcpay.store.canviewinvoices
// 3. Restrict to your Recap store
//
// To set up the webhook in BTCPay:
// 1. Open your "Recap" store → Settings → Webhooks
// 2. URL: https://<your-relay-host>/relay/btcpay/webhook
// 3. Subscribe to: "An invoice has been settled"
// 4. Set "Automatic redelivery" on
// 5. Copy the auto-generated secret into the field below
const inputSpec = InputSpec.of({
relay_btcpay_base_url: Value.text({
name: 'BTCPay Base URL',
description:
'Public URL of your BTCPay server. The relay POSTs invoice-create requests here, and BTCPay POSTs webhooks back to /relay/btcpay/webhook on your relay host. Example: https://btcpay.keysat.xyz',
required: false,
default: null,
minLength: 0,
maxLength: 256,
patterns: [
{
regex: '^(https?://.+)?$',
description: 'Must be empty or start with http:// or https://',
},
],
}),
relay_btcpay_store_id: Value.text({
name: 'BTCPay Store ID',
description:
'UUID of the BTCPay store invoices should be created against. Find it in BTCPay → Store Settings → General → Store ID.',
required: false,
default: null,
minLength: 0,
maxLength: 64,
}),
relay_btcpay_api_key: Value.text({
name: 'BTCPay API Key',
description:
'Greenfield API token with the canCreateInvoice + canViewInvoices scopes restricted to your Recap store. Generated under Account → Manage Account → API Keys.',
required: false,
default: null,
masked: true,
minLength: 0,
maxLength: 256,
}),
relay_btcpay_webhook_secret: Value.text({
name: 'BTCPay Webhook Secret',
description:
'Shared secret BTCPay uses to HMAC-sign webhook deliveries to /relay/btcpay/webhook. Get this from BTCPay → Store Settings → Webhooks after creating the webhook entry.',
required: false,
default: null,
masked: true,
minLength: 0,
maxLength: 256,
}),
})
export const setBtcpayConnection = sdk.Action.withInput(
'set-btcpay-connection',
async ({ effects }) => ({
name: 'Set BTCPay Connection (credit purchases)',
description:
'Wire the relay to your BTCPay store so users can buy credit top-ups via Lightning. Leave any field blank to disable the credit-purchase flow — the rest of the relay keeps working without it.',
warning: null,
allowedStatuses: 'any',
group: 'Tiers',
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const config = await configFile.read().once()
return {
relay_btcpay_base_url: config?.relay_btcpay_base_url || undefined,
relay_btcpay_store_id: config?.relay_btcpay_store_id || undefined,
relay_btcpay_api_key: config?.relay_btcpay_api_key || undefined,
relay_btcpay_webhook_secret:
config?.relay_btcpay_webhook_secret || undefined,
}
},
async ({ effects, input }) => {
await configFile.merge(effects, {
relay_btcpay_base_url: (input.relay_btcpay_base_url || '').trim(),
relay_btcpay_store_id: (input.relay_btcpay_store_id || '').trim(),
relay_btcpay_api_key: (input.relay_btcpay_api_key || '').trim(),
relay_btcpay_webhook_secret: (
input.relay_btcpay_webhook_secret || ''
).trim(),
})
return null
},
)
+110
View File
@@ -0,0 +1,110 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config.json'
const { InputSpec, Value } = sdk
// Operator-editable bundle pricing for credit purchases. The bundle
// SIZES are fixed (5, 10, 20 credits — three bundles fit cleanly in
// the buyer modal's single row). Only their sats prices move via
// this action. If you need different bundle sizes, edit
// relay_credit_packages_json directly on the relay's data volume.
const inputSpec = InputSpec.of({
pkg_5_sats: Value.number({
name: '5 credit bundle — price (sats)',
description: 'Price the buyer pays for a 5-credit top-up.',
required: true,
default: 4000,
min: 1,
max: 100_000_000,
integer: true,
step: 1,
units: 'sats',
placeholder: null,
}),
pkg_10_sats: Value.number({
name: '10 credit bundle — price (sats)',
description: 'Price the buyer pays for a 10-credit top-up.',
required: true,
default: 6000,
min: 1,
max: 100_000_000,
integer: true,
step: 1,
units: 'sats',
placeholder: null,
}),
pkg_20_sats: Value.number({
name: '20 credit bundle — price (sats)',
description: 'Price the buyer pays for a 20-credit top-up.',
required: true,
default: 10000,
min: 1,
max: 100_000_000,
integer: true,
step: 1,
units: 'sats',
placeholder: null,
}),
})
const FIXED_CREDIT_SIZES = [5, 10, 20] as const
export const setCreditPackages = sdk.Action.withInput(
'set-credit-packages',
async ({ effects }) => ({
name: 'Set Credit Bundle Prices',
description:
'Per-bundle sats prices shown to buyers in the Recap credit-purchase modal. Bundle sizes (5, 10, 20 credits) are fixed by this action; edit relay_credit_packages_json directly if you need different sizes. Changes apply to the next buyer immediately — no daemon restart.',
warning: null,
allowedStatuses: 'any',
group: 'Tiers',
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const config = await configFile.read().once()
// Translate the stored JSON array back into per-bundle inputs.
// We only care about the four fixed sizes; anything else in the
// stored array is preserved on save (see merge step below) but
// not exposed in the form.
let parsed: Array<{ credits: number; sats: number }> = []
try {
const raw = JSON.parse(config?.relay_credit_packages_json || '[]')
if (Array.isArray(raw)) {
parsed = raw
.map((p: any) => ({
credits: Number(p?.credits),
sats: Number(p?.sats),
}))
.filter((p) => Number.isFinite(p.credits) && Number.isFinite(p.sats))
}
} catch {
// ignored — fall back to defaults
}
const lookup = (n: number, fallback: number) =>
parsed.find((p) => p.credits === n)?.sats ?? fallback
return {
pkg_5_sats: lookup(5, 4000),
pkg_10_sats: lookup(10, 6000),
pkg_20_sats: lookup(20, 10000),
}
},
async ({ effects, input }) => {
const packages = [
{ credits: 5, sats: Number(input.pkg_5_sats) },
{ credits: 10, sats: Number(input.pkg_10_sats) },
{ credits: 20, sats: Number(input.pkg_20_sats) },
].filter(
(p) =>
Number.isFinite(p.sats) && p.sats > 0 && FIXED_CREDIT_SIZES.includes(p.credits as 5 | 10 | 20)
)
await configFile.merge(effects, {
relay_credit_packages_json: JSON.stringify(packages),
})
return null
},
)
+93
View File
@@ -0,0 +1,93 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config.json'
const { InputSpec, Value } = sdk
// Operator-set per-tier monthly subscription prices, in USD. Used by
// the dashboard to compute revenue and operating margin (Gemini cost
// already comes out of the audit log). Pure accounting — the relay
// itself does no billing.
const inputSpec = InputSpec.of({
core_price: Value.number({
name: 'Core (Free) — Monthly Price',
description:
'Monthly subscription price for the Core tier in USD. Typically $0 since Core is the free entry tier. Used by the dashboard to compute total revenue; leave at 0 unless you actually charge for Core.',
required: true,
default: 0,
min: 0,
max: 10_000,
integer: false,
step: 0.01,
units: 'USD',
placeholder: null,
}),
pro_price: Value.number({
name: 'Pro — Monthly Price',
description:
'Monthly subscription price for the Pro tier in USD. Should match what you actually charge Pro customers on the licensing side.',
required: true,
default: 5,
min: 0,
max: 10_000,
integer: false,
step: 0.01,
units: 'USD',
placeholder: null,
}),
max_price: Value.number({
name: 'Max — Monthly Price',
description:
'Monthly subscription price for the Max tier in USD. Should match what you actually charge Max customers on the licensing side.',
required: true,
default: 15,
min: 0,
max: 10_000,
integer: false,
step: 0.01,
units: 'USD',
placeholder: null,
}),
})
export const setTierPrices = sdk.Action.withInput(
'set-tier-prices',
async ({ effects }) => ({
name: 'Set Tier Prices (USD)',
description:
'Configure the monthly USD price you charge per tier. The dashboard uses these numbers to compute revenue and operating margin against Gemini API cost. Has no effect on actual billing — it is for the operators accounting view only.',
warning: null,
allowedStatuses: 'any',
group: 'Tiers',
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const config = await configFile.read().once()
let parsed: any = {}
try {
parsed = JSON.parse(config?.relay_tier_prices_usd_json || '{}')
} catch {
parsed = {}
}
return {
core_price: typeof parsed?.core === 'number' ? parsed.core : 0,
pro_price: typeof parsed?.pro === 'number' ? parsed.pro : 5,
max_price: typeof parsed?.max === 'number' ? parsed.max : 15,
}
},
async ({ effects, input }) => {
const prices = {
core: Number(input.core_price ?? 0),
pro: Number(input.pro_price ?? 5),
max: Number(input.max_price ?? 15),
}
await configFile.merge(effects, {
relay_tier_prices_usd_json: JSON.stringify(prices),
})
return null
},
)
+141
View File
@@ -0,0 +1,141 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config.json'
const { InputSpec, Value } = sdk
// Connects the relay to the operator's Zaprite account so users can buy a
// prepaid Pro/Max period with a CARD (the "Pay by card" rail alongside the
// Bitcoin/BTCPay rail). Leave the API key blank to disable the card rail —
// the app hides "Pay by card" and the Bitcoin rail keeps working.
//
// To get your Zaprite API key:
// 1. Zaprite → Settings → API → create a key
// 2. Paste it below (stored masked)
//
// To set up the webhook in Zaprite:
// 1. Zaprite → Settings → Webhooks → add endpoint
// 2. URL: https://<your-relay-host>/relay/zaprite/webhook
// 3. Subscribe to order paid / completed events
// (No webhook secret is needed — the relay re-fetches each order from
// Zaprite's authenticated API to confirm payment before granting.)
//
// Card prices are charged in the currency below (cents for USD). They're
// separate from the dashboard's "Set Tier Prices (USD)" accounting figure;
// these are the real amounts a card buyer pays. Default ≈ parity with the
// sat prices ($21 / $42) — raise them to add a premium for card fees.
const inputSpec = InputSpec.of({
relay_zaprite_api_key: Value.text({
name: 'Zaprite API Key',
description:
'API key from Zaprite → Settings → API. Used to create hosted card checkouts and to re-fetch orders for webhook verification. Leave blank to disable the card rail.',
required: false,
default: null,
masked: true,
minLength: 0,
maxLength: 256,
}),
relay_zaprite_base_url: Value.text({
name: 'Zaprite Base URL',
description:
'Zaprite API base URL. Leave as the default unless Zaprite tells you otherwise.',
required: false,
default: 'https://api.zaprite.com',
minLength: 0,
maxLength: 256,
patterns: [
{
regex: '^(https?://.+)?$',
description: 'Must be empty or start with http:// or https://',
},
],
}),
relay_zaprite_currency: Value.text({
name: 'Card Currency',
description:
'Fiat currency the card is charged in (ISO code, e.g. USD, EUR). The card prices below are in this currency.',
required: false,
default: 'USD',
minLength: 0,
maxLength: 8,
}),
pro_card_price: Value.number({
name: 'Pro — Card Price',
description:
'Amount a card buyer pays for one prepaid Pro period, in the card currency. Default ≈ parity with the 21,000-sat Bitcoin price.',
required: true,
default: 21,
min: 0,
max: 100_000,
integer: false,
step: 0.01,
placeholder: null,
}),
max_card_price: Value.number({
name: 'Max — Card Price',
description:
'Amount a card buyer pays for one prepaid Max period, in the card currency. Default ≈ parity with the 42,000-sat Bitcoin price.',
required: true,
default: 42,
min: 0,
max: 100_000,
integer: false,
step: 0.01,
placeholder: null,
}),
})
export const setZapriteConnection = sdk.Action.withInput(
'set-zaprite-connection',
async ({ effects }) => ({
name: 'Set Zaprite Connection (card purchases)',
description:
'Wire the relay to your Zaprite account so users can buy Pro/Max with a card. Leave the API key blank to disable the card rail — the Bitcoin rail keeps working without it. Remember to add the webhook in Zaprite pointing at https://<your-relay-host>/relay/zaprite/webhook',
warning: null,
allowedStatuses: 'any',
group: 'Tiers',
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const config = await configFile.read().once()
let cents: any = {}
try {
cents = JSON.parse(config?.relay_tier_prices_fiat_cents_json || '{}')
} catch {
cents = {}
}
const toMajor = (c: any, fallback: number) =>
typeof c === 'number' && Number.isFinite(c) ? c / 100 : fallback
return {
relay_zaprite_api_key: config?.relay_zaprite_api_key || undefined,
relay_zaprite_base_url:
config?.relay_zaprite_base_url || 'https://api.zaprite.com',
relay_zaprite_currency: config?.relay_zaprite_currency || 'USD',
pro_card_price: toMajor(cents?.pro, 21),
max_card_price: toMajor(cents?.max, 42),
}
},
async ({ effects, input }) => {
// Card prices are entered in major units (dollars) but Zaprite + the
// relay charge in the currency's smallest unit (cents), so ×100.
const fiatCents = {
pro: Math.round(Number(input.pro_card_price ?? 21) * 100),
max: Math.round(Number(input.max_card_price ?? 42) * 100),
}
await configFile.merge(effects, {
relay_zaprite_api_key: (input.relay_zaprite_api_key || '').trim(),
relay_zaprite_base_url: (
input.relay_zaprite_base_url || 'https://api.zaprite.com'
).trim(),
relay_zaprite_currency: (input.relay_zaprite_currency || 'USD')
.trim()
.toUpperCase(),
relay_tier_prices_fiat_cents_json: JSON.stringify(fiatCents),
})
return null
},
)