Add self-serve billing: tiers, credits, BTCPay and Zaprite
This commit is contained in:
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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 operator’s 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
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user