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