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
+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
},
)