v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
// Action: create a discount / referral code.
|
||||
//
|
||||
// A code can be percentage-off or fixed-sats-off. It can target a specific
|
||||
// product (or apply universally), have an optional expiry, an optional
|
||||
// usage cap, and a free-form referrer label for tracking ("twitter-launch",
|
||||
// "alice@example.com"). Codes are case-insensitive and normalized to
|
||||
// uppercase on create.
|
||||
//
|
||||
// Buyers redeem codes by passing `?code=FOUNDERS50` to the public purchase
|
||||
// flow. The discount is reserved atomically at purchase time and finalized
|
||||
// when the BTCPay invoice settles.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
code: Value.text({
|
||||
name: 'Code',
|
||||
description:
|
||||
'The redeemable string. Case-insensitive (will be uppercased). ' +
|
||||
'ASCII letters, digits, "-", and "_" only. E.g., "FOUNDERS50".',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^[A-Za-z0-9_-]{2,40}$',
|
||||
description:
|
||||
'letters, digits, dashes, underscores; 2 to 40 characters',
|
||||
},
|
||||
],
|
||||
}),
|
||||
kind: Value.select({
|
||||
name: 'Discount kind',
|
||||
description:
|
||||
'"Percent off" reduces the price by N%. ' +
|
||||
'"Fixed sats off" subtracts a fixed number of sats. ' +
|
||||
'"Free license" issues a license outright with no payment, ' +
|
||||
'redeemed via the public /v1/redeem endpoint.',
|
||||
default: 'percent',
|
||||
values: {
|
||||
percent: 'Percent off',
|
||||
fixed_sats: 'Fixed sats off',
|
||||
free_license: 'Free license (no payment)',
|
||||
},
|
||||
}),
|
||||
amount: Value.number({
|
||||
name: 'Amount',
|
||||
description:
|
||||
'For percent: 1..=100 (integer percentage). E.g., 50 = 50% off. ' +
|
||||
'For fixed sats off: any positive integer (sats). ' +
|
||||
'For free license: ignored (set to 0). ' +
|
||||
'Note: percent is converted to basis points server-side.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
}),
|
||||
max_uses: Value.number({
|
||||
name: 'Max uses',
|
||||
description: '0 = unlimited. Otherwise, max number of redemptions.',
|
||||
required: true,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
}),
|
||||
expires_at: Value.text({
|
||||
name: 'Expires at (ISO 8601)',
|
||||
description:
|
||||
'Optional cutoff date. RFC3339 / ISO 8601 UTC, e.g. ' +
|
||||
'"2026-12-31T23:59:59Z". Leave blank for no expiry.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
product_slug: Value.text({
|
||||
name: 'Product slug (optional)',
|
||||
description:
|
||||
'Restrict the code to one specific product. Leave blank to apply ' +
|
||||
'the discount to any product.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
policy_slug: Value.text({
|
||||
name: 'Policy slug (optional)',
|
||||
description:
|
||||
'Further restrict to a single policy of the chosen product. ' +
|
||||
'Requires "Product slug" to be set if used.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
referrer_label: Value.text({
|
||||
name: 'Referrer / campaign label (optional)',
|
||||
description:
|
||||
'Free-form tracking string. E.g., "twitter-launch", ' +
|
||||
'"partner-alice@example.com", "podcast-XYZ-spring-2026". Shown in ' +
|
||||
'usage reports; never visible to buyers.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
description: Value.textarea({
|
||||
name: 'Description (optional)',
|
||||
description: 'Internal note. E.g., "Founders rate, expires May 31."',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
})
|
||||
|
||||
export const createDiscountCode = sdk.Action.withInput(
|
||||
'create-discount-code',
|
||||
async () => ({
|
||||
name: 'Create discount code',
|
||||
description:
|
||||
'Add a redeemable discount / referral code. Buyers append ' +
|
||||
'?code=YOUR_CODE to the purchase URL to apply it.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Discount codes',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
if (formInput.policy_slug && !formInput.product_slug) {
|
||||
throw new Error('Policy slug requires Product slug to also be set.')
|
||||
}
|
||||
|
||||
// Convert UI percent (1..=100) to basis points (100..=10000).
|
||||
let amount = formInput.amount
|
||||
if (formInput.kind === 'percent') {
|
||||
if (amount < 1 || amount > 100) {
|
||||
throw new Error('Percent amount must be between 1 and 100.')
|
||||
}
|
||||
amount = amount * 100
|
||||
} else if (formInput.kind === 'fixed_sats') {
|
||||
if (amount < 1) {
|
||||
throw new Error('Fixed sats amount must be at least 1.')
|
||||
}
|
||||
} else if (formInput.kind === 'free_license') {
|
||||
// Amount is unused for free licenses; force to 0 so the server
|
||||
// accepts it.
|
||||
amount = 0
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
code: formInput.code,
|
||||
kind: formInput.kind,
|
||||
amount,
|
||||
description: formInput.description ?? '',
|
||||
}
|
||||
if (formInput.max_uses > 0) body.max_uses = formInput.max_uses
|
||||
if (formInput.expires_at) body.expires_at = formInput.expires_at
|
||||
if (formInput.product_slug) body.product_slug = formInput.product_slug
|
||||
if (formInput.policy_slug) body.policy_slug = formInput.policy_slug
|
||||
if (formInput.referrer_label) body.referrer_label = formInput.referrer_label
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/discount-codes',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Create discount code failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const code = (await resp.json()) as { id: string; code: string; kind: string; amount: number }
|
||||
const humanAmount =
|
||||
code.kind === 'percent'
|
||||
? `${code.amount / 100}% off`
|
||||
: code.kind === 'fixed_sats'
|
||||
? `${code.amount} sats off`
|
||||
: 'free license (no payment)'
|
||||
const redemptionHint =
|
||||
code.kind === 'free_license'
|
||||
? `Buyers redeem this code via the public /v1/redeem endpoint or via ` +
|
||||
`the "Redeem free license" buyer-side action — they receive the key ` +
|
||||
`directly, no BTCPay invoice.`
|
||||
: `Buyers can redeem this code by appending ?code=${code.code} to the ` +
|
||||
`public purchase URL.`
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Discount code created',
|
||||
message:
|
||||
`Created code "${code.code}" — ${humanAmount}.\n` +
|
||||
redemptionHint +
|
||||
`\n\nInternal id: ${code.id}`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user