diff --git a/bun.lock b/bun.lock index 42971d10..2508f4e3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,10 @@ "": { "name": "smshub", "dependencies": { + "@profullstack/affiliates": "^0.2.0", "@profullstack/footer": "latest", "@profullstack/profile": "0.1.6", + "@profullstack/redeem-credits": "^0.2.0", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.5.0", "@profullstack/throttle": "^0.3.4", @@ -285,6 +287,8 @@ "@playwright/test": ["@playwright/test@1.64.1", "", { "dependencies": { "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg=="], + "@profullstack/affiliates": ["@profullstack/affiliates@0.2.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-fbP5uiS8Pa5g8mk5WFD79U4Cj7BUPH6Qy0tQP7ZXE9gfxHOHirHASmZ/rAk8MqnmzGOZTuwq9WwOHbmoFV2TSA=="], + "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], "@profullstack/footer": ["@profullstack/footer@0.2.6", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-BKwbDszAc7e0mYIBiPjgr/5WS8WyDt8IW718s/zW/8pCWOIB1BGwSNs6fmImRSo5kqIjQKUVbXp22jZQ5GEipA=="], @@ -295,6 +299,8 @@ "@profullstack/profile": ["@profullstack/profile@0.1.6", "", { "peerDependencies": { "@libsql/client": ">=0.6.0", "postgres": "^3.4.0", "react": ">=18" }, "optionalPeers": ["@libsql/client", "postgres", "react"], "bin": { "profile": "src/cli.js" } }, "sha512-xkn0P3ANz151QJdlBwcBXkHk813qW9qAmuTZKcuvpEZYkyHDC9J9KfvpVSRepfjECzz4ONGHWO5Zwc4BHpfK8g=="], + "@profullstack/redeem-credits": ["@profullstack/redeem-credits@0.2.0", "", { "bin": { "redeem-credits": "dist/cli.js" } }, "sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog=="], + "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], "@profullstack/stack": ["@profullstack/stack@0.5.0", "", { "dependencies": { "@profullstack/emailer": "^1.0.1", "@profullstack/form-guard": "^0.1.1", "@profullstack/referrals": "^0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-LbXovIhyzLEq+a+y3q8KWC+jN1tJjvSoUiFEhpiEBisQn5U+iutIGHDgg16Lul74kQaFZeFlbp7CMPAVJ8KL6g=="], diff --git a/cli/README.md b/cli/README.md index 945528bd..00a1c5a8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,7 +28,10 @@ Installed, the command is `smshub-cli` (`npm i -g @profullstack/smshub` works to | `numbers` | your numbers, with expiry for rented ones | | `rent [--area 415] [--months 1] [--chain USDC_POL] [--no-wait]` | open a checkout; waits until the number is ready | | `order ` | status of a rent/renew order | -| `renew [--months 1]` | extend a rented number | +| `renew [--months 1] [--credit]` | extend a rented number (`--credit` pays from store credit) | +| `redeem ` | redeem a credit code for store credit (pays for rentals: `rent --credit`) | +| `credits` | store credit balance and history | +| `invites [new --note ..]` | your 3 invites (an accepted one gives you both $2.50 credit) and your `?ref=` link | | `messages [--since ISO] [--limit 20]` | texts received, newest first, codes extracted | | `otp [--wait 60] [--recent 300]` | print the next one-time code (exit 2 if none) | | `send "" [--from ] [--media ]` | text someone from your own Twilio/Telnyx number (rented numbers receive only) | @@ -58,7 +61,7 @@ Local (stdio), e.g. for Claude Code: claude mcp add smshub -e SMSHUB_API_KEY=smshub_... -- npx -y @profullstack/smshub mcp ``` -Tools: `account_overview`, `list_numbers`, `list_conversations`, `get_conversation`, `send_message`, `list_lines`, `add_line_contact`, `update_line_contact`, `remove_line_contact`, `share_line_contacts`, `voice_menu`, `rent_number`, `renew_number`, `order_status`, +Tools: `account_overview`, `list_numbers`, `list_conversations`, `get_conversation`, `send_message`, `list_lines`, `add_line_contact`, `update_line_contact`, `remove_line_contact`, `share_line_contacts`, `voice_menu`, `rent_number`, `renew_number`, `redeem_code`, `credit_balance`, `list_invites`, `create_invite`, `order_status`, `list_orders`, `get_messages`, `wait_for_code`. Rented numbers receive texts only. Some services refuse virtual numbers for sign-up codes. diff --git a/cli/package.json b/cli/package.json index a6cbaa9f..94988fea 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/smshub", - "version": "1.6.0", + "version": "1.7.0", "description": "Rent a phone number and receive SMS / verification codes from your terminal, scripts and AI agents (CLI, TUI, MCP) via smshub.dev", "type": "module", "bin": { diff --git a/cli/smshub.mjs b/cli/smshub.mjs index e0f60344..93766bf1 100644 --- a/cli/smshub.mjs +++ b/cli/smshub.mjs @@ -3,9 +3,12 @@ // // smshub-cli login --key smshub_... save an API key (Settings > API Keys) // smshub-cli numbers your numbers -// smshub-cli rent [--area 415] [--months 1] [--chain USDC_POL] +// smshub-cli rent [--area 415] [--months 1] [--chain USDC_POL | --credit] --credit pays from store credit // smshub-cli order -// smshub-cli renew [--months 1] +// smshub-cli renew [--months 1] [--credit] +// smshub-cli redeem redeem a credit code for store credit +// smshub-cli credits store credit balance and history +// smshub-cli invites [new [--note "for Kim"]] your invites (3), make one, your ?ref= link // smshub-cli messages [--since ISO] [--limit 20] // smshub-cli otp [--wait 60] print the next code, nothing else // smshub-cli send "" [--from ] [--media ] text someone from your own number @@ -31,7 +34,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline"; -const VERSION = "1.6.0"; +const VERSION = "1.7.0"; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "smshub"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); @@ -143,12 +146,16 @@ const commands = { async rent(args) { const { order } = await api("/api/v1/numbers/orders", { method: "POST", - body: { area_code: args.area, months: Number(args.months || 1), chain: args.chain }, + body: { area_code: args.area, months: Number(args.months || 1), chain: args.credit ? "CREDIT" : args.chain }, }); if (args.json && args["no-wait"]) return console.log(JSON.stringify(order, null, 2)); if (!args.json) { console.log(`Order ${order.id}: $${Number(order.amount_usd).toFixed(2)} in ${order.chain}`); - console.log(`Pay here: ${order.pay_url}`); + if (order.pay_url) console.log(`Pay here: ${order.pay_url}`); + } + if (order.status === "active" || order.status === "failed") { + if (args.json) return console.log(JSON.stringify(order, null, 2)); + return console.log(order.status === "active" ? `Your number: ${order.number}` : `${order.status}: ${order.error || ""}`); } if (args["no-wait"]) return; if (!args.json) process.stdout.write("Waiting for the payment to confirm (Ctrl+C to stop; check later with: smshub-cli order " + order.id + ")"); @@ -173,9 +180,37 @@ const commands = { const id = await resolveNumberId(args._[1]); const { order } = await api(`/api/v1/numbers/${id}/renew`, { method: "POST", - body: { months: Number(args.months || 1), chain: args.chain }, + body: { months: Number(args.months || 1), chain: args.credit ? "CREDIT" : args.chain }, + }); + print(args, order, (o) => console.log(o.pay_url ? `Pay here: ${o.pay_url}` : `${o.status}${o.number ? ": " + o.number : ""}`)); + }, + + async redeem(args) { + const code = args._[1] || args.code; + if (!code) throw new ApiError("Usage: smshub-cli redeem "); + const r = await api("/api/v1/redeem", { method: "POST", body: { code } }); + print(args, r, (r) => console.log(`Added $${Number(r.credit_usd).toFixed(2)}. Store credit: $${Number(r.balance_usd).toFixed(2)}`)); + }, + + async credits(args) { + const d = await api("/api/v1/credits"); + print(args, d, (d) => { + console.log(`Store credit: $${d.balance_usd.toFixed(2)} (pay with it: smshub-cli rent --credit)`); + for (const h of d.history.slice(0, 10)) console.log(`${h.at.slice(0, 10)} ${h.kind === "spend" ? "-" : "+"}$${h.amount_usd.toFixed(2)} ${h.source}`); + }); + }, + + async invites(args) { + if (args._[1] === "new") { + const r = await api("/api/v1/invites", { method: "POST", body: { note: args.note } }); + return print(args, r, (r) => console.log(`Invite (shown once): ${r.url}`)); + } + const d = await api("/api/v1/invites"); + print(args, d, (d) => { + console.log(`${d.quota.left} of ${d.quota.total} invites left. Accepted invite: $${d.bonus_usd.toFixed(2)} credit each.`); + for (const i of d.invites) console.log(`${i.created_at.slice(0, 10)} ${i.used ? "accepted" : "open"} ${i.note || ""}`); + console.log(`Referral link: ${d.referral_link}`); }); - print(args, order, (o) => console.log(`Pay here: ${o.pay_url}`)); }, async lines(args) { diff --git a/package.json b/package.json index 9a7f449a..7015e8f7 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ ] }, "dependencies": { + "@profullstack/affiliates": "^0.2.0", "@profullstack/footer": "latest", "@profullstack/profile": "0.1.6", + "@profullstack/redeem-credits": "^0.2.0", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.5.0", "@profullstack/throttle": "^0.3.4", diff --git a/scripts/mint-credit-code.mjs b/scripts/mint-credit-code.mjs new file mode 100644 index 00000000..a93a8222 --- /dev/null +++ b/scripts/mint-credit-code.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Mint a smshub.dev credit code (store credit that pays for rented numbers). +// +// SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... node scripts/mint-credit-code.mjs --usd 10 --label "for Kim" +// [--max 1] how many accounts may redeem it (default 1) +// [--expires 30d] days until it stops working (default: never) +// [--by riotcoder] who minted it, for the audit trail +// ... node scripts/mint-credit-code.mjs --list +// ... node scripts/mint-credit-code.mjs --revoke crd_xxx +// +// On dev2 with the app's env: set -a; . /home/anthony/www/smshub.dev/app.env; set +a +// The plaintext code is printed once; only its sha256 is stored. + +import { createClient } from "@supabase/supabase-js"; +import { Credits, supabaseStore } from "@profullstack/redeem-credits"; + +const args = process.argv.slice(2); +const opt = (name) => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : undefined; +}; +const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY; +if (!url || !key) { + console.error("Set SUPABASE_URL (or NEXT_PUBLIC_SUPABASE_URL) and SUPABASE_SERVICE_ROLE_KEY"); + process.exit(1); +} +const credits = new Credits(supabaseStore(createClient(url, key, { auth: { persistSession: false } }))); + +if (args.includes("--list")) { + for (const c of await credits.list()) { + console.log(`${c.id} ${c.grant.unit}=${c.grant.amount} ${c.redemptions}/${c.maxRedemptions} ${c.revokedAt ? "revoked" : "live"} ${c.label}`); + } + process.exit(0); +} +if (opt("revoke")) { + await credits.revoke(opt("revoke")); + console.log(`revoked ${opt("revoke")}`); + process.exit(0); +} + +const usd = Number(opt("usd")); +if (!Number.isFinite(usd) || usd <= 0 || usd > 1000) { + console.error("--usd is required (0 < usd <= 1000)"); + process.exit(1); +} +const days = opt("expires") ? Number(String(opt("expires")).replace(/d$/, "")) : null; +const minted = await credits.mint({ + grant: { unit: "usd_cents", amount: Math.round(usd * 100) }, + label: opt("label") || `smshub $${usd}`, + maxRedemptions: Number(opt("max") || 1), + expiresAt: days ? Date.now() + days * 86400_000 : null, + createdBy: opt("by") || "admin", +}); +console.log(JSON.stringify({ code: minted.code, id: minted.id, usd, max: minted.maxRedemptions, expires_at: minted.expiresAt }, null, 2)); +console.log(`Redeem at https://smshub.dev/settings#redeem`); diff --git a/src/app/api/v1/credits/route.ts b/src/app/api/v1/credits/route.ts new file mode 100644 index 00000000..4dd74f6b --- /dev/null +++ b/src/app/api/v1/credits/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { resolveUser } from "@/lib/request-user"; +import { creditSummary } from "@/lib/rewards"; + +export const dynamic = "force-dynamic"; + +/** Store credit balance (USD) and the latest grants and spends. */ +export async function GET(request: Request) { + const who = await resolveUser(request); + if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status }); + try { + return NextResponse.json(await creditSummary(who.user.userId)); + } catch (e) { + console.error("credits error:", e); + return NextResponse.json({ error: "Could not load credit" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/invites/route.ts b/src/app/api/v1/invites/route.ts new file mode 100644 index 00000000..28ee3af6 --- /dev/null +++ b/src/app/api/v1/invites/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { resolveUser } from "@/lib/request-user"; +import { getBrandSiteUrl } from "@/lib/brand-server"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { createInvite, invitesSummary } from "@/lib/rewards"; + +export const dynamic = "force-dynamic"; + +/** Invite quota (3 per account), the invites made so far, and the ?ref= link. */ +export async function GET(request: Request) { + const who = await resolveUser(request); + if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status }); + try { + return NextResponse.json(await invitesSummary(who.user.userId, await getBrandSiteUrl(request))); + } catch (e) { + console.error("invites error:", e); + return NextResponse.json({ error: "Could not load invites" }, { status: 500 }); + } +} + +/** Create an invite: { note? } -> { code, url }. The code is shown once. */ +export async function POST(request: Request) { + const who = await resolveUser(request); + if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status }); + const rl = checkRateLimit(`invite:${who.user.userId}`, { limit: 10, windowMs: 60 * 60 * 1000 }); + if (!rl.allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + const body = (await request.json().catch(() => ({}))) as Record; + try { + const r = await createInvite(who.user.userId, await getBrandSiteUrl(request), body.note); + return NextResponse.json(r, { status: r.ok ? 201 : 403 }); + } catch (e) { + console.error("create invite error:", e); + return NextResponse.json({ error: "Could not create the invite" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/redeem/route.ts b/src/app/api/v1/redeem/route.ts new file mode 100644 index 00000000..01c1fd83 --- /dev/null +++ b/src/app/api/v1/redeem/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { resolveUser } from "@/lib/request-user"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { redeemCode } from "@/lib/rewards"; + +export const dynamic = "force-dynamic"; + +/** + * Redeem a credit code: { code } -> { ok, credit_usd, balance_usd }. + * Store credit pays for rented numbers (order chain "CREDIT"). + * Session (web app) or API key (CLI, scripts). + */ +export async function POST(request: Request) { + const who = await resolveUser(request); + if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status }); + const rl = checkRateLimit(`redeem:${who.user.userId}`, { limit: 10, windowMs: 15 * 60 * 1000 }); + if (!rl.allowed) return NextResponse.json({ error: "Too many tries, wait a few minutes" }, { status: 429 }); + const body = (await request.json().catch(() => ({}))) as Record; + try { + const r = await redeemCode(who.user.userId, body.code); + return NextResponse.json(r, { status: r.ok ? 200 : 400 }); + } catch (e) { + console.error("redeem error:", e); + return NextResponse.json({ error: "Could not redeem the code" }, { status: 500 }); + } +} diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 235bad03..7b66e186 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -2,7 +2,8 @@ import { NextResponse, type NextRequest } from "next/server"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getBrandSiteUrl } from "@/lib/brand-server"; import { safeNextPath } from "@/lib/safe-next"; -import { saveProfileFromMetadata } from "@/lib/profile"; +import { profileStore, saveProfileFromMetadata } from "@/lib/profile"; +import { INVITE_COOKIE, onConfirmedSignIn } from "@/lib/rewards"; export { safeNextPath }; @@ -32,6 +33,7 @@ export async function GET(request: NextRequest) { const next = safeNextPath(url.searchParams.get("next")); let signedIn = false; + const inviteCookie = request.cookies.get(INVITE_COOKIE)?.value ?? null; try { const supabase = await createServerSupabaseClient(); @@ -49,14 +51,26 @@ export async function GET(request: NextRequest) { } = await supabase.auth.getUser(); signedIn = Boolean(user); // First confirmation: store the /register profile fields (never blocks). - if (user) await saveProfileFromMetadata(user); + if (user) { + await saveProfileFromMetadata(user); + // A new, confirmed account: spend its invite (both people get credit) and + // record ?ref= first touch. Never blocks sign-in. + await onConfirmedSignIn(request, user, inviteCookie, () => + profileStore() + .get(user.id) + .then(Boolean) + .catch(() => false) + ); + } } catch (error) { console.error("Auth callback error:", error); signedIn = false; } if (signedIn) { - return NextResponse.redirect(new URL(next, site)); + const res = NextResponse.redirect(new URL(next, site)); + if (inviteCookie) res.cookies.delete(INVITE_COOKIE); + return res; } const failure = new URL("/login", site); diff --git a/src/app/i/[code]/route.ts b/src/app/i/[code]/route.ts new file mode 100644 index 00000000..9160509d --- /dev/null +++ b/src/app/i/[code]/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getBrandSiteUrl } from "@/lib/brand-server"; +import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE } from "@/lib/rewards"; + +export const dynamic = "force-dynamic"; + +/** + * /i/: an invite link. Keeps the code in a 7-day cookie (read on + * /auth/callback when the new account confirms its email) and lands on the + * sign-up page, which says what the invite gives and also carries the code in + * the account's metadata. The code is checked only when it is spent. + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ code: string }> }) { + const { code: raw } = await params; + const code = decodeURIComponent(raw || "").trim(); + const site = await getBrandSiteUrl(request); + if (!/^[A-Za-z0-9-]{4,40}$/.test(code)) return NextResponse.redirect(new URL("/register", site)); + const to = new URL("/register", site); + to.searchParams.set("invite", code); + to.searchParams.set("next", "/numbers"); + const res = NextResponse.redirect(to); + res.cookies.set(INVITE_COOKIE, code, { + httpOnly: true, + sameSite: "lax", + secure: site.startsWith("https://"), + maxAge: INVITE_COOKIE_MAX_AGE, + path: "/", + }); + return res; +} diff --git a/src/app/invites/page.tsx b/src/app/invites/page.tsx new file mode 100644 index 00000000..3d97193b --- /dev/null +++ b/src/app/invites/page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { CopyField } from "@/components/copy-field"; + +interface InviteRow { + id: string; + note: string; + created_at: string; + expires_at: string | null; + used: boolean; + used_at: string | null; +} + +interface Summary { + quota: { total: number; used: number; left: number }; + bonus_usd: number; + referral_link: string; + invites: InviteRow[]; +} + +function fmt(iso: string | null) { + return iso ? new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : ""; +} + +export default function InvitesPage() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [fresh, setFresh] = useState<{ code: string; url: string } | null>(null); + + const load = useCallback(async () => { + const r = await fetch("/api/v1/invites"); + if (r.status === 401) { + window.location.href = "/login?next=/invites"; + return; + } + if (!r.ok) { + setError("Could not load your invites"); + return; + } + setData(await r.json()); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const create = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + const r = await fetch("/api/v1/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note }), + }).catch(() => null); + const b = r ? await r.json().catch(() => ({})) : {}; + if (r?.ok && b.ok) { + setFresh({ code: b.code, url: b.url }); + setNote(""); + await load(); + } else { + setError(b.error || "Could not create the invite"); + } + setBusy(false); + }; + + if (!data) { + return
{error || "Loading..."}
; + } + + return ( +
+
+

Invites

+ + ← Settings + +
+ +
+

+ {data.quota.left} of {data.quota.total} invites left +

+

+ Invites are few on purpose: give them to people you want here. When someone signs up through your + invite and confirms their email, you each get ${data.bonus_usd.toFixed(2)} of store credit. Two accepted + invites pay for a month of a rented number. Each link works once and expires after 30 days. +

+ {fresh && ( +
+
New invite. Copy it now: the code is shown only once.
+ +
+ )} + {error &&

{error}

} + {data.quota.left > 0 && ( +
+ setNote(e.target.value.slice(0, 80))} + placeholder="Who is it for? (only you see this)" + className="flex-1 rounded-lg border border-gray-700 bg-gray-950 px-3 py-2" + /> + +
+ )} +
+ + {data.invites.length > 0 && ( +
+

Your invites

+
+ {data.invites.map((i) => ( +
+
+ {i.note || "Invite"} +
Created {fmt(i.created_at)}
+
+ + {i.used ? `Accepted ${fmt(i.used_at)}` : i.expires_at ? `Open until ${fmt(i.expires_at)}` : "Open"} + +
+ ))} +
+
+ )} + +
+

Your referral link

+

Share it anywhere. People who sign up through it are recorded as yours.

+ +
+
+ ); +} diff --git a/src/app/numbers/page.tsx b/src/app/numbers/page.tsx index d8f8a5d0..54492c43 100644 --- a/src/app/numbers/page.tsx +++ b/src/app/numbers/page.tsx @@ -36,6 +36,7 @@ interface Overview { plan: { plan: "free" | "pro"; limits: Record; usage: Record }; numbers: NumberRow[]; orders: OrderRow[]; + credit?: { balance_usd: number }; } interface Text { @@ -47,6 +48,7 @@ interface Text { } const CHAIN_LABELS: Record = { + CREDIT: "Store credit", PAYPAL: "PayPal", USDC_POL: "USDC (Polygon)", USDC_SOL: "USDC (Solana)", @@ -201,6 +203,9 @@ export default function NumbersPage() { const unfinished = !openCheckout && data.ordering.open ? unfinishedOrder(data.orders, now) : null; const coins = data.pricing.chains.filter((c) => c !== "PAYPAL"); const payByPaypal = chain === "PAYPAL"; + // Store credit from a redeemed code or an accepted invite pays the whole order. + const credit = data.credit?.balance_usd ?? 0; + const creditCovers = (m: number) => credit + 1e-9 >= price * m; return (
@@ -318,6 +323,15 @@ export default function NumbersPage() { > Renew {months} mo (${(price * months).toFixed(2)}) + {creditCovers(months) && ( + + )}
@@ -375,6 +389,16 @@ export default function NumbersPage() {
+
+ + Store credit: ${credit.toFixed(2)} + {credit > 0 && !creditCovers(months) && ` (not enough for ${months} month${months > 1 ? "s" : ""})`} + + + Have a code? + Invite friends + +
{!data.ordering.open ? (

{data.ordering.reason}

) : ( @@ -454,6 +478,16 @@ export default function NumbersPage() { {busy ? "Opening..." : `Pay $${(price * months).toFixed(2)}${payByPaypal ? " with PayPal" : ""}`} + {creditCovers(months) && ( + + )} )}

diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index a04d73cb..bff454dd 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -25,10 +25,14 @@ export default function RegisterPage() { const [confirmSent, setConfirmSent] = useState(false); // Where to land after signup, e.g. /numbers when they came to rent a number. const [next, setNext] = useState("/inbox"); + // Set by /i/: an invite gives both people store credit once this account is confirmed. + const [invite, setInvite] = useState(null); const router = useRouter(); useEffect(() => { setNext(nextFromLocation()); + const code = new URLSearchParams(window.location.search).get("invite"); + if (code && /^[A-Za-z0-9-]{4,40}$/.test(code)) setInvite(code); }, []); const renting = next.startsWith("/numbers"); @@ -77,7 +81,8 @@ export default function RegisterPage() { next === "/inbox" ? "" : `?next=${encodeURIComponent(next)}` }`, // Also saved on /auth/callback if the call below never lands. - data: Object.fromEntries(PROFILE_FIELDS.map((k) => [k, p[k] ?? null])), + // The invite rides along too, so it still counts when the link is opened in another browser. + data: { ...Object.fromEntries(PROFILE_FIELDS.map((k) => [k, p[k] ?? null])), ...(invite ? { invite_code: invite } : {}) }, }, }); @@ -116,6 +121,11 @@ export default function RegisterPage() { />

Create your account

+ {invite && ( +

+ You were invited. Confirm your email and you and your friend each get $2.50 of credit toward a rented number. +

+ )} {renting && (

Next you pick an area code and pay with crypto. Your number is ready once the payment confirms. diff --git a/src/app/robots.txt/route.ts b/src/app/robots.txt/route.ts index 319d762f..e66c6595 100644 --- a/src/app/robots.txt/route.ts +++ b/src/app/robots.txt/route.ts @@ -5,5 +5,5 @@ import { gateway } from "@/lib/crawl-gateway"; // crawlers are refused everywhere but /crawl (where they can buy a pass), // retrieval crawlers are named as welcome, everyone else gets the rules below. export const GET = robotsRoute(gateway, { - disallow: ["/api/"], + disallow: ["/api/", "/i/", "/invites"], }); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index b2b87cf5..5c443cb5 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { useToast } from "@/contexts/toast-context"; import { ProfileCard } from "@/components/profile-card"; +import { RedeemCard } from "@/components/redeem-card"; import { ProviderTestPanel, SETUP_GUIDES, @@ -209,6 +210,8 @@ export default function SettingsPage() { + + {/* Rented numbers */}

diff --git a/src/components/redeem-card.tsx b/src/components/redeem-card.tsx new file mode 100644 index 00000000..adff964c --- /dev/null +++ b/src/components/redeem-card.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +// "Have a code?": redeem a credit code for store credit that pays for rented +// numbers. Reads and writes /api/v1/credits and /api/v1/redeem. +export function RedeemCard() { + const [code, setCode] = useState(""); + const [balance, setBalance] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + fetch("/api/v1/credits") + .then((r) => (r.ok ? r.json() : null)) + .then((b) => setBalance(typeof b?.balance_usd === "number" ? b.balance_usd : null)) + .catch(() => null); + }, []); + + const redeem = async (e: React.FormEvent) => { + e.preventDefault(); + if (!code.trim()) return; + setBusy(true); + setMessage(null); + const r = await fetch("/api/v1/redeem", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }).catch(() => null); + const b = r ? await r.json().catch(() => ({})) : {}; + if (r?.ok && b.ok) { + setBalance(b.balance_usd); + setCode(""); + setMessage({ ok: true, text: `Added $${Number(b.credit_usd).toFixed(2)} of credit. You now have $${Number(b.balance_usd).toFixed(2)}.` }); + } else { + setMessage({ ok: false, text: b.error || "Could not redeem the code." }); + } + setBusy(false); + }; + + return ( +
+
+

Have a code?

+ {balance !== null && ( + + Store credit: ${balance.toFixed(2)} + + )} +
+

+ Credit pays for rented numbers: pick "Pay with store credit" on{" "} + Rented numbers. Got invites to give?{" "} + Invite friends. +

+
+ setCode(e.target.value)} + placeholder="CR-XXXX-XXXX-XXXX-XXXX" + autoComplete="off" + spellCheck={false} + aria-label="Credit code" + className="flex-1 rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 font-mono uppercase" + /> + +
+ {message && ( +

+ {message.text} +

+ )} +
+ ); +} diff --git a/src/lib/__tests__/rewards.test.ts b/src/lib/__tests__/rewards.test.ts new file mode 100644 index 00000000..d2fb6c5c --- /dev/null +++ b/src/lib/__tests__/rewards.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Credits, memoryStore as creditMemory } from "@profullstack/redeem-credits"; +import { Affiliates, memoryStore as affMemory } from "@profullstack/affiliates"; +import { + acceptInvite, + attributeReferral, + createInvite, + CREDIT_UNIT, + INVITE_BONUS_CENTS, + INVITE_NEW_ACCOUNT_MS, + inviteCodeFor, + invitesSummary, + passesSignupChecks, + redeemCode, +} from "../rewards"; + +const NOW = Date.UTC(2026, 9, 26, 12); +const fresh = (id: string, over: Record = {}) => ({ + id, + created_at: new Date(NOW - 60_000).toISOString(), + email_confirmed_at: new Date(NOW - 1000).toISOString(), + user_metadata: {}, + ...over, +}); + +describe("credit codes", () => { + let credits: Credits; + beforeEach(() => { + credits = new Credits(creditMemory()); + }); + + it("redeems a code into usd_cents and says why a code is refused", async () => { + const { code } = await credits.mint({ grant: { unit: CREDIT_UNIT, amount: 1000 }, label: "for Kim" }); + const r = await redeemCode("kim", code.toLowerCase().replace(/-/g, " "), credits); + expect(r).toEqual({ ok: true, unit: CREDIT_UNIT, amount: 1000, credit_usd: 10, balance_usd: 10 }); + expect(await redeemCode("kim", code, credits)).toMatchObject({ ok: false, reason: "already_redeemed" }); + expect(await redeemCode("lee", code, credits)).toMatchObject({ ok: false, reason: "used_up", error: "That code has already been used." }); + expect(await redeemCode("lee", "CR-NOPE-NOPE-NOPE-NOPE", credits)).toMatchObject({ ok: false, reason: "invalid" }); + expect(await redeemCode("lee", "", credits)).toMatchObject({ ok: false, reason: "invalid" }); + }); +}); + +describe("invites", () => { + let credits: Credits; + let aff: Affiliates; + beforeEach(() => { + credits = new Credits(creditMemory()); + aff = new Affiliates(affMemory(), { defaultQuota: 3, now: () => NOW }); + }); + const deps = () => ({ credits, affiliates: aff, now: NOW }); + + it("gives 3 invites, then refuses", async () => { + for (let i = 0; i < 3; i++) expect((await createInvite("alice", "https://smshub.dev", `n${i}`, aff)).ok).toBe(true); + expect(await createInvite("alice", "https://smshub.dev", "n4", aff)).toMatchObject({ ok: false }); + const s = await invitesSummary("alice", "https://smshub.dev", aff); + expect(s.quota).toEqual({ total: 3, used: 3, left: 0 }); + expect(s.referral_link).toMatch(/^https:\/\/smshub\.dev\/\?ref=/); + }); + + it("an accepted invite credits both people once", async () => { + const inv = await createInvite("alice", "https://smshub.dev/", "for Bob", aff); + if (!inv.ok) throw new Error("no invite"); + expect(inv.url).toBe(`https://smshub.dev/i/${inv.code}`); + const r = await acceptInvite(fresh("bob"), inv.code, true, deps()); + expect(r).toEqual({ accepted: true, inviterId: "alice", bonus_usd: 2.5 }); + expect(await credits.balance("bob", CREDIT_UNIT)).toBe(INVITE_BONUS_CENTS); + expect(await credits.balance("alice", CREDIT_UNIT)).toBe(INVITE_BONUS_CENTS); + + // Single use: a second account gets nothing, and Bob cannot cash it again. + expect(await acceptInvite(fresh("carol"), inv.code, true, deps())).toEqual({ accepted: false, reason: "used" }); + expect(await acceptInvite(fresh("bob"), inv.code, true, deps())).toMatchObject({ accepted: false }); + expect(await credits.balance("alice", CREDIT_UNIT)).toBe(INVITE_BONUS_CENTS); + }); + + it("pays nothing and keeps the invite until the account passes the signup checks", async () => { + const inv = await createInvite("alice", "x", "", aff); + if (!inv.ok) throw new Error("no invite"); + // Unconfirmed email, no signup profile, an old account, and the inviter themself. + expect(await acceptInvite(fresh("bob", { email_confirmed_at: null }), inv.code, true, deps())).toEqual({ accepted: false, reason: "checks" }); + expect(await acceptInvite(fresh("bob"), inv.code, false, deps())).toEqual({ accepted: false, reason: "checks" }); + const old = fresh("dave", { created_at: new Date(NOW - INVITE_NEW_ACCOUNT_MS - 1).toISOString() }); + expect(await acceptInvite(old, inv.code, true, deps())).toEqual({ accepted: false, reason: "checks" }); + expect(await acceptInvite(fresh("alice"), inv.code, true, deps())).toEqual({ accepted: false, reason: "self" }); + expect(await credits.balance("alice", CREDIT_UNIT)).toBe(0); + // Still unused: Bob, once he passes, gets it. + expect((await acceptInvite(fresh("bob"), inv.code, true, deps())).accepted).toBe(true); + }); + + it("takes the invite from the cookie, else from signup metadata", () => { + expect(inviteCodeFor("INV-AAAA-BBBB-CCCC", fresh("b"))).toBe("INV-AAAA-BBBB-CCCC"); + expect(inviteCodeFor(null, fresh("b", { user_metadata: { invite_code: "INV-1111-2222-3333" } }))).toBe("INV-1111-2222-3333"); + expect(inviteCodeFor(undefined, fresh("b"))).toBeNull(); + }); + + it("passesSignupChecks needs a confirmed, new account with a profile", () => { + expect(passesSignupChecks(fresh("b"), true, NOW)).toBe(true); + expect(passesSignupChecks(fresh("b"), false, NOW)).toBe(false); + expect(passesSignupChecks(fresh("b", { email_confirmed_at: null }), true, NOW)).toBe(false); + }); +}); + +describe("?ref= attribution", () => { + it("records first touch for a new account from the referral_code cookie, and leaves old referral codes alone", async () => { + const external = new Set(["OLDCODE1"]); + const aff = new Affiliates(affMemory(), { now: () => NOW, externalCodes: (c) => external.has(c) }); + const code = await aff.affiliateCode("alice"); + const req = (cookie: string) => new Request("https://smshub.dev/auth/callback?code=x", { headers: { cookie } }); + + expect(await attributeReferral(req(`referral_code=${code}`), fresh("bob"), { affiliates: aff, now: NOW })).toMatchObject({ ok: true, affiliateId: "alice" }); + expect(await aff.affiliateOf("bob")).toBe("alice"); + // First touch: a later code does not overwrite it. + const other = await aff.affiliateCode("zed"); + expect(await attributeReferral(req(`referral_code=${other}`), fresh("bob"), { affiliates: aff, now: NOW })).toMatchObject({ ok: false, affiliateId: "alice" }); + // A code from the old @profullstack/referrals tables: not recorded here. + expect(await attributeReferral(req("referral_code=OLDCODE1"), fresh("carol"), { affiliates: aff, now: NOW })).toMatchObject({ ok: false, external: true }); + expect(await aff.affiliateOf("carol")).toBeNull(); + // An old account is never attributed. + const old = fresh("dave", { created_at: new Date(NOW - INVITE_NEW_ACCOUNT_MS - 1).toISOString() }); + expect(await attributeReferral(req(`referral_code=${code}`), old, { affiliates: aff, now: NOW })).toBeNull(); + }); +}); diff --git a/src/lib/developer-api.ts b/src/lib/developer-api.ts index 3eebef09..6d0cb7c7 100644 --- a/src/lib/developer-api.ts +++ b/src/lib/developer-api.ts @@ -31,7 +31,7 @@ export const ENDPOINTS: Endpoint[] = [ params: [ { name: "area_code", in: "body", type: "string", description: "Preferred 3-digit area code." }, { name: "months", in: "body", type: "integer", description: "1 to 12, default 1." }, - { name: "chain", in: "body", type: "string", description: "Coin to pay with: USDC_POL (default), USDC_SOL, BTC, ETH..." }, + { name: "chain", in: "body", type: "string", description: "How to pay: CREDIT (store credit, no pay_url), PAYPAL, or a coin: USDC_POL (default), USDC_SOL, BTC, ETH..." }, ], cli: "rent", mcp: "rent_number", @@ -52,11 +52,29 @@ export const ENDPOINTS: Endpoint[] = [ params: [ id("Number id."), { name: "months", in: "body", type: "integer", description: "1 to 12, default 1." }, - { name: "chain", in: "body", type: "string", description: "Coin to pay with." }, + { name: "chain", in: "body", type: "string", description: "CREDIT, PAYPAL or a coin." }, ], cli: "renew", mcp: "renew_number", }, + { + method: "POST", + path: "/api/v1/redeem", + summary: "Redeem a credit code for store credit (USD). Store credit pays for rentals with chain CREDIT.", + params: [{ name: "code", in: "body", type: "string", required: true, description: "The code, e.g. CR-XXXX-XXXX-XXXX-XXXX." }], + cli: "redeem", + mcp: "redeem_code", + }, + { method: "GET", path: "/api/v1/credits", summary: "Store credit balance and recent grants and spends.", cli: "credits", mcp: "credit_balance" }, + { method: "GET", path: "/api/v1/invites", summary: "Invite quota (3), your invites and your ?ref= referral link.", cli: "invites", mcp: "list_invites" }, + { + method: "POST", + path: "/api/v1/invites", + summary: "Create a single-use invite link. An accepted invite gives both people $2.50 of store credit.", + params: [{ name: "note", in: "body", type: "string", description: "Who it is for; only you see it." }], + cli: "invites new", + mcp: "create_invite", + }, { method: "GET", path: "/api/v1/numbers/{id}/messages", diff --git a/src/lib/managed-numbers/__tests__/credit-orders.test.ts b/src/lib/managed-numbers/__tests__/credit-orders.test.ts new file mode 100644 index 00000000..b0888668 --- /dev/null +++ b/src/lib/managed-numbers/__tests__/credit-orders.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { Credits, memoryStore } from "@profullstack/redeem-credits"; +import { FakeDb } from "@/__tests__/fake-db"; +import { createOrder, OrderError, MAX_PROVISION_ATTEMPTS, sweep, CREDIT_METHOD } from "../service"; +import { CREDIT_UNIT } from "@/lib/rewards"; + +const USER = "user-1"; +const EMAIL = "tester@example.com"; + +function env(mode: "test" | "live" = "test"): NodeJS.ProcessEnv { + return { + MANAGED_NUMBERS_MODE: mode, + MANAGED_NUMBERS_TESTERS: EMAIL, + TELNYX_TEST_NUMBERS: "+14085550001", + TELNYX_API_KEY: "KEYtest", + TELNYX_MESSAGING_PROFILE_ID: "profile-1", + COINPAY_API_KEY: "cp_live_" + "0".repeat(32), + COINPAY_BUSINESS_ID: "biz-1", + COINPAY_WEBHOOK_SECRET: "whsec", + MANAGED_NUMBER_PRICE_USD: "5", + } as unknown as NodeJS.ProcessEnv; +} + +describe("paying for a rented number with store credit", () => { + let fdb: FakeDb; + let db: SupabaseClient; + let credits: Credits; + let fetchImpl: ReturnType; + + beforeEach(() => { + fdb = new FakeDb(); + db = fdb as unknown as SupabaseClient; + credits = new Credits(memoryStore()); + fetchImpl = vi.fn(async () => new Response("{}", { status: 500 })); + }); + + const deps = () => ({ db, env: env(), credits, fetchImpl: fetchImpl as unknown as typeof fetch }); + + it("refuses with 402 and opens no order when the credit does not cover it", async () => { + await credits.grant(USER, { unit: CREDIT_UNIT, amount: 250 }, "invite:x:joined"); + const err = await createOrder(deps(), { userId: USER, email: EMAIL, chain: "credit", months: 1, siteUrl: "https://smshub.dev" }).catch((e) => e); + expect(err).toBeInstanceOf(OrderError); + expect((err as OrderError).status).toBe(402); + expect((err as Error).message).toMatch(/\$2\.50 .* \$5\.00/); + expect(fdb.rows("number_orders")).toHaveLength(0); + expect(await credits.balance(USER, CREDIT_UNIT)).toBe(250); + }); + + it("spends the credit and provisions through the normal order path, with no CoinPay call", async () => { + await credits.grant(USER, { unit: CREDIT_UNIT, amount: 1200 }, "code:crd_1"); + const order = await createOrder(deps(), { userId: USER, email: EMAIL, chain: "CREDIT", months: 2, siteUrl: "https://smshub.dev" }); + expect(order).toMatchObject({ status: "active", chain: CREDIT_METHOD, number: "+14085550001" }); + expect(order.pay_url ?? null).toBeNull(); + expect(Number(order.amount_usd)).toBe(10); + expect(order.paid_at).toBeTruthy(); + expect(await credits.balance(USER, CREDIT_UNIT)).toBe(200); + expect(fetchImpl).not.toHaveBeenCalled(); + const pn = fdb.rows("phone_numbers")[0]; + expect(pn).toMatchObject({ user_id: USER, managed: true, status: "active" }); + const history = await credits.history(USER, CREDIT_UNIT); + expect(history.at(-1)).toMatchObject({ kind: "spend", amount: 1000, source: `order:${order.id}` }); + }); + + it("renews a rented number from credit, extending its expiry", async () => { + await credits.grant(USER, { unit: CREDIT_UNIT, amount: 1000 }, "code:crd_1"); + await createOrder(deps(), { userId: USER, email: EMAIL, chain: "CREDIT", months: 1, siteUrl: "x" }); + const pn = fdb.rows("phone_numbers")[0]; + const before = new Date(String(pn.expires_at)); + const renew = await createOrder(deps(), { userId: USER, email: EMAIL, kind: "renew", phoneNumberId: String(pn.id), chain: "CREDIT", months: 1, siteUrl: "x" }); + expect(renew.status).toBe("active"); + const expected = new Date(before); + expected.setUTCMonth(expected.getUTCMonth() + 1); + expect(String(fdb.rows("phone_numbers")[0].expires_at)).toBe(expected.toISOString()); + expect(await credits.balance(USER, CREDIT_UNIT)).toBe(0); + }); + + it("gives the credit back when provisioning gives up for good", async () => { + // The one pool number is already lent to someone else, so provisioning keeps failing. + fdb.rows("phone_numbers").push({ id: "other", user_id: "u2", number: "+14085550001", status: "active", managed: true }); + await credits.grant(USER, { unit: CREDIT_UNIT, amount: 500 }, "code:crd_1"); + const order = await createOrder(deps(), { userId: USER, email: EMAIL, chain: "CREDIT", siteUrl: "x" }); + expect(order.status).toBe("paid"); + expect(await credits.balance(USER, CREDIT_UNIT)).toBe(0); + for (let i = 1; i < MAX_PROVISION_ATTEMPTS; i++) await sweep(deps()); + expect(fdb.rows("number_orders")[0].status).toBe("failed"); + expect(await credits.balance(USER, CREDIT_UNIT)).toBe(500); + await sweep(deps()); + expect(await credits.balance(USER, CREDIT_UNIT)).toBe(500); + }); +}); diff --git a/src/lib/managed-numbers/api.ts b/src/lib/managed-numbers/api.ts index ee85eb39..ef394c8d 100644 --- a/src/lib/managed-numbers/api.ts +++ b/src/lib/managed-numbers/api.ts @@ -7,7 +7,8 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { availableChains, CHECKOUT_WINDOW_MINUTES, coinpayConfig, DEFAULT_CHAIN, methodsFor } from "@/lib/coinpay/checkout"; import { getPlanUsage, priceUsdPerMonth } from "@/lib/plans"; import { extractOtp } from "@/lib/otp"; -import { GRACE_DAYS, orderingStatus } from "./service"; +import { CREDIT_METHOD, GRACE_DAYS, orderingStatus } from "./service"; +import { centsToUsd, creditBalanceCents } from "@/lib/rewards"; // Lives in lib/otp so the browser can import it without the server side. export { extractOtp }; @@ -61,11 +62,12 @@ export async function getOrder(db: SupabaseClient, userId: string, id: string) { } export async function overview(db: SupabaseClient, userId: string, email: string | null) { - const [plan, numbers, orders, chains] = await Promise.all([ + const [plan, numbers, orders, chains, creditCents] = await Promise.all([ getPlanUsage(db, userId), listNumbers(db, userId), listOrders(db, userId), availableChains(coinpayConfig()), + creditBalanceCents(userId).catch(() => 0), ]); return { ordering: orderingStatus(email), @@ -78,7 +80,10 @@ export async function overview(db: SupabaseClient, userId: string, email: string grace_days: GRACE_DAYS, // CoinPay's pay page expires this long after the order is created. checkout_window_minutes: CHECKOUT_WINDOW_MINUTES, + // Pass chain "CREDIT" to pay an order from store credit (redeemed codes, accepted invites). + credit_method: CREDIT_METHOD, }, + credit: { balance_usd: centsToUsd(creditCents) }, plan, numbers, orders, diff --git a/src/lib/managed-numbers/service.ts b/src/lib/managed-numbers/service.ts index 8006e1ab..0300b89f 100644 --- a/src/lib/managed-numbers/service.ts +++ b/src/lib/managed-numbers/service.ts @@ -39,6 +39,19 @@ import { } from "@/lib/telnyx/numbers"; import { getPlanUsage, orderAmountUsd } from "@/lib/plans"; import { MANAGED_API_KEY } from "@/lib/providers"; +import type { Credits } from "@profullstack/redeem-credits"; +import { centsToUsd, CREDIT_UNIT, getCredits } from "@/lib/rewards"; + +/** + * Store credit (from a redeemed code or an accepted invite) pays for an order + * when the buyer picks this as the method. It goes through the same order row + * and the same pending -> paid -> provision transition as a CoinPay payment. + */ +export const CREDIT_METHOD = "CREDIT" as const; + +export function isCreditMethod(method: unknown): boolean { + return String(method ?? "").trim().toUpperCase() === CREDIT_METHOD; +} export const GRACE_DAYS = 3; /** @@ -58,6 +71,8 @@ export interface Deps { env?: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; now?: () => Date; + /** Store credit; defaults to the app's (lib/rewards). */ + credits?: Credits; } export interface NumberOrder { @@ -136,6 +151,7 @@ export async function createOrder( const cp = coinpayConfig(env) as CoinPayConfig; const kind = input.kind ?? "new"; const months = clampMonths(input.months); + const payWithCredit = isCreditMethod(input.chain); const chain = normalizeMethod(input.chain); const areaCode = cleanAreaCode(input.areaCode); const { db } = deps; @@ -161,6 +177,17 @@ export async function createOrder( } const amount = orderAmountUsd(months, env); + const amountCents = Math.round(amount * 100); + const credits = payWithCredit ? deps.credits ?? getCredits() : null; + if (credits) { + const have = Math.max(0, Math.floor(await credits.balance(input.userId, CREDIT_UNIT))); + if (have < amountCents) { + throw new OrderError( + `Not enough credit: you have $${centsToUsd(have).toFixed(2)} and this costs $${amount.toFixed(2)}.`, + 402 + ); + } + } const { data: order, error } = await db .from("number_orders") .insert({ @@ -171,12 +198,14 @@ export async function createOrder( area_code: areaCode, months, amount_usd: amount, - chain, + chain: payWithCredit ? CREDIT_METHOD : chain, }) .select() .single(); if (error || !order) throw new OrderError("Could not create the order", 500); + if (credits) return payOrderWithCredit(deps, credits, order as NumberOrder, amountCents); + try { const description = `smshub.dev: ${numberLabel}, ${months} month${months > 1 ? "s" : ""}`; const metadata = paymentMetadata({ orderId: order.id, userId: input.userId, kind, email: input.email }); @@ -279,21 +308,59 @@ export async function settlePayment(deps: Deps, ev: WebhookEvent): Promise paid, then + * provision. A late payment for a checkout we already expired is still + * honoured. Null when the order was already claimed. + */ +async function claimAndProvision( + deps: Deps, + orderId: string +): Promise<{ status: NumberOrder["status"]; number: string | null } | null> { const now = (deps.now?.() ?? new Date()).toISOString(); - const { data: claimed } = await db + const { data: claimed } = await deps.db .from("number_orders") .update({ status: "paid", paid_at: now, updated_at: now }) - .eq("id", order.id) + .eq("id", orderId) .in("status", ["pending", "expired"]) .select() .maybeSingle(); - if (!claimed) { - return { settled: true, orderId: order.id, status: order.status, number: order.number }; + if (!claimed) return null; + return provision(deps, claimed as NumberOrder); +} + +/** + * Pay a just-created order from store credit: spend it (a compare-and-swap in + * the credit ledger, so parallel orders cannot overdraw), then the same claim + + * provision a CoinPay webhook runs. If provisioning gives up for good, provision() + * gives the credit back. + */ +async function payOrderWithCredit( + deps: Deps, + credits: Credits, + order: NumberOrder, + amountCents: number +): Promise { + const { db } = deps; + const spent = await credits.spend(order.user_id, CREDIT_UNIT, amountCents, `order:${order.id}`); + if (!spent.ok) { + await db + .from("number_orders") + .update({ status: "failed", error: "credit: not enough credit", updated_at: new Date().toISOString() }) + .eq("id", order.id); + throw new OrderError( + `Not enough credit: you have $${centsToUsd(spent.balance).toFixed(2)} and this costs $${centsToUsd(amountCents).toFixed(2)}.`, + 402 + ); } - const result = await provision(deps, claimed as NumberOrder); - return { settled: true, orderId: order.id, status: result.status, number: result.number }; + await claimAndProvision(deps, order.id); + const { data: after } = await db.from("number_orders").select("*").eq("id", order.id).maybeSingle(); + return (after ?? order) as NumberOrder; } async function managedProviderId(db: SupabaseClient, userId: string): Promise { @@ -342,6 +409,13 @@ export async function provision( .update({ status, error: msg, attempts, updated_at: now.toISOString() }) .eq("id", order.id); console.error(`[managed-numbers] order ${order.id} attempt ${attempts}: ${msg}`); + if (status === "failed" && isCreditMethod(order.chain)) { + // Paid from credit and never delivered: the credit goes back. + const cents = Math.round(Number(order.amount_usd) * 100); + await (deps.credits ?? getCredits()) + .grant(order.user_id, { unit: CREDIT_UNIT, amount: cents }, `refund:order:${order.id}`) + .catch((e) => console.error(`[managed-numbers] credit refund for order ${order.id} failed`, e)); + } return { status: status as NumberOrder["status"], number: order.number }; }; diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index 6aca8104..1fc1a354 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -11,6 +11,7 @@ import { SendError, sendMessage } from "@/lib/messages/send"; import type { RequestUser } from "@/lib/request-user"; import { LINE_TOOLS, LineToolError } from "./line-tools"; import { AlertError, listAlerts, sendAlert } from "@/lib/alerts/service"; +import { createInvite, creditSummary, invitesSummary, redeemCode } from "@/lib/rewards"; export const PROTOCOL_VERSION = "2025-06-18"; const MAX_WAIT_SECONDS = 55; @@ -98,14 +99,14 @@ const TOOLS: Tool[] = [ inputSchema: obj({ area_code: { type: "string", description: "Preferred 3-digit US area code; nearest available if none." }, months: { type: "integer", minimum: 1, maximum: 12, default: 1 }, - chain: { type: "string", description: "How to pay: PAYPAL, or a coin such as USDC_POL (default), USDC_SOL, USDC_BASE, USDT_POL, BTC, ETH, SOL, DOGE, XRP, ADA, BNB, BCH. GET /api/v1/numbers lists what is live." }, + chain: { type: "string", description: "How to pay: CREDIT (store credit, set up at once, no pay_url), PAYPAL, or a coin such as USDC_POL (default), USDC_SOL, USDC_BASE, USDT_POL, BTC, ETH, SOL, DOGE, XRP, ADA, BNB, BCH. GET /api/v1/numbers lists what is live." }, }), run: (c, a) => createOrder({ db: c.db }, { userId: c.user.userId, email: c.user.email, kind: "new", areaCode: a.area_code, months: a.months, chain: a.chain, siteUrl: c.siteUrl }), }, { name: "renew_number", - description: "Extend a rented number. Returns an order with pay_url.", + description: "Extend a rented number. Returns an order with pay_url, or pass chain CREDIT to pay from store credit at once.", inputSchema: obj( { number_id: { type: "string" }, months: { type: "integer", minimum: 1, maximum: 12, default: 1 }, chain: { type: "string" } }, ["number_id"] @@ -113,6 +114,30 @@ const TOOLS: Tool[] = [ run: (c, a) => createOrder({ db: c.db }, { userId: c.user.userId, email: c.user.email, kind: "renew", phoneNumberId: String(a.number_id), months: a.months, chain: a.chain, siteUrl: c.siteUrl }), }, + { + name: "redeem_code", + description: "Redeem a credit code (CR-XXXX-XXXX-XXXX-XXXX) for store credit in USD. Store credit pays for rent_number/renew_number with chain CREDIT.", + inputSchema: obj({ code: { type: "string" } }, ["code"]), + run: (c, a) => redeemCode(c.user.userId, a.code), + }, + { + name: "credit_balance", + description: "Store credit balance in USD, with recent grants (codes, invites) and spends (orders).", + inputSchema: obj({}), + run: (c) => creditSummary(c.user.userId), + }, + { + name: "list_invites", + description: "Invite quota (3 per account), invites made so far, and your ?ref= referral link. An accepted invite gives you and the new account $2.50 of store credit each.", + inputSchema: obj({}), + run: (c) => invitesSummary(c.user.userId, c.siteUrl), + }, + { + name: "create_invite", + description: "Create one invite link (single use, 30 days). The code is returned only once.", + inputSchema: obj({ note: { type: "string", maxLength: 80, description: "Who it is for; only you see it." } }), + run: (c, a) => createInvite(c.user.userId, c.siteUrl, a.note), + }, { name: "order_status", description: "Status of a rent/renew order: pending (unpaid), paid (provisioning), active (done), failed, expired.", diff --git a/src/lib/rewards.ts b/src/lib/rewards.ts new file mode 100644 index 00000000..2638bdee --- /dev/null +++ b/src/lib/rewards.ts @@ -0,0 +1,238 @@ +/** + * Credit codes and invites. + * + * - A credit code (@profullstack/redeem-credits) grants unit `usd_cents`: store + * credit that pays for a rented number. It is spent through the same order + * path as a CoinPay payment (see createOrder with chain "CREDIT" in + * lib/managed-numbers/service.ts), so a number paid with credit is an ordinary + * number_orders row that went pending -> paid -> active. + * - Invites (@profullstack/affiliates): 3 per account. When a NEW account made + * through an invite confirms its email and has a valid signup profile, both + * people get INVITE_BONUS_CENTS of credit. Two accepted invites pay for a month. + * - ?ref= links reuse @profullstack/referrals' `referral_code` cookie (set by + * trackReferralCode in middleware.ts); a code that belongs to the old referrals + * tables comes back external and is not recorded twice. + * + * Server only: both stores run on the service-role client. + */ + +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { Credits, supabaseStore as creditStore, type RedeemResult } from "@profullstack/redeem-credits"; +import { Affiliates, supabaseStore as affStore, refFromRequest } from "@profullstack/affiliates"; + +export const CREDIT_UNIT = "usd_cents"; +/** What an accepted invite gives each side: $2.50, so two accepted invites pay for a $5 month. */ +export const INVITE_BONUS_CENTS = 250; +export const INVITE_QUOTA = 3; +export const INVITE_COOKIE = "smshub_invite"; +export const INVITE_COOKIE_MAX_AGE = 7 * 24 * 60 * 60; +/** An invite only counts for an account this young: an old account opening an invite link gets nothing. */ +export const INVITE_NEW_ACCOUNT_MS = 7 * 24 * 60 * 60 * 1000; + +// process.env[name] with a non-literal key: Next.js inlines process.env.LITERAL at build time. +function env(name: string): string { + const key = String(name); + return process.env[key] ?? ""; +} + +let serviceClient: SupabaseClient | undefined; +function client(): SupabaseClient { + return (serviceClient ??= createClient( + env("NEXT_PUBLIC_SUPABASE_URL") || env("SUPABASE_URL"), + env("SUPABASE_SERVICE_ROLE_KEY"), + { auth: { persistSession: false, autoRefreshToken: false } } + )); +} + +let credits: Credits | undefined; +let affiliates: Affiliates | undefined; + +export function getCredits(): Credits { + return (credits ??= new Credits(creditStore(client()))); +} + +export function getAffiliates(): Affiliates { + return (affiliates ??= new Affiliates(affStore(client()), { defaultQuota: INVITE_QUOTA })); +} + +/** Tests: swap in memory stores. */ +export function setRewardsForTests(c: Credits | undefined, a: Affiliates | undefined): void { + credits = c; + affiliates = a; +} + +export async function creditBalanceCents(userId: string, c: Credits = getCredits()): Promise { + return Math.max(0, Math.floor(await c.balance(userId, CREDIT_UNIT))); +} + +export function centsToUsd(cents: number): number { + return Math.round(cents) / 100; +} + +const REDEEM_MESSAGES: Record["reason"], string> = { + invalid: "That code is not valid. Check it and try again.", + expired: "That code has expired.", + used_up: "That code has already been used.", + already_redeemed: "You already redeemed that code.", + revoked: "That code is no longer valid.", +}; + +export type RedeemOutcome = + | { ok: true; unit: string; amount: number; credit_usd: number; balance_usd: number } + | { ok: false; reason: string; error: string }; + +/** Redeem a credit code for a user, with a message a person can read. */ +export async function redeemCode(userId: string, code: unknown, c: Credits = getCredits()): Promise { + const raw = String(code ?? "").trim(); + if (!raw || raw.length > 64) return { ok: false, reason: "invalid", error: REDEEM_MESSAGES.invalid }; + const r = await c.redeem(raw, userId); + if (!r.ok) return { ok: false, reason: r.reason, error: REDEEM_MESSAGES[r.reason] }; + const balance = await creditBalanceCents(userId, c); + return { + ok: true, + unit: r.grant.unit, + amount: r.grant.amount, + credit_usd: r.grant.unit === CREDIT_UNIT ? centsToUsd(r.grant.amount) : 0, + balance_usd: centsToUsd(balance), + }; +} + +export async function creditSummary(userId: string, c: Credits = getCredits()) { + const [balance, history] = await Promise.all([creditBalanceCents(userId, c), c.history(userId, CREDIT_UNIT)]); + return { + unit: CREDIT_UNIT, + balance_cents: balance, + balance_usd: centsToUsd(balance), + history: history + .slice(-50) + .reverse() + .map((h) => ({ kind: h.kind, amount_usd: centsToUsd(h.amount), source: h.source, at: new Date(h.at).toISOString() })), + }; +} + +export function inviteUrl(siteUrl: string, code: string): string { + return `${siteUrl.replace(/\/+$/, "")}/i/${encodeURIComponent(code)}`; +} + +export async function invitesSummary(userId: string, siteUrl: string, a: Affiliates = getAffiliates()) { + const [quota, invites, code] = await Promise.all([a.quota(userId), a.invites(userId), a.affiliateCode(userId)]); + return { + quota, + bonus_usd: centsToUsd(INVITE_BONUS_CENTS), + referral_link: `${siteUrl.replace(/\/+$/, "")}/?ref=${encodeURIComponent(code)}`, + invites: invites.map((i) => ({ + id: i.id, + note: i.note, + created_at: new Date(i.createdAt).toISOString(), + expires_at: i.expiresAt ? new Date(i.expiresAt).toISOString() : null, + used: Boolean(i.usedBy), + used_at: i.usedAt ? new Date(i.usedAt).toISOString() : null, + })), + }; +} + +/** Create one invite. The plaintext code exists only in this answer. */ +export async function createInvite(userId: string, siteUrl: string, note: unknown, a: Affiliates = getAffiliates()) { + const quota = await a.quota(userId); + if (quota.left <= 0) return { ok: false as const, error: `You have used all ${quota.total} invites.` }; + try { + const inv = await a.createInvite(userId, String(note ?? "").slice(0, 80)); + return { ok: true as const, code: inv.code, url: inviteUrl(siteUrl, inv.code), expires_at: inv.expiresAt ? new Date(inv.expiresAt).toISOString() : null }; + } catch (e) { + if (e instanceof Error && /no invites left/.test(e.message)) return { ok: false as const, error: "No invites left." }; + throw e; + } +} + +export interface SignupUser { + id: string; + created_at?: string | null; + email_confirmed_at?: string | null; + user_metadata?: Record | null; +} + +/** + * smshub's signup checks, which an account must pass before an invite pays out: + * the email address is confirmed (GoTrue requires it), the account is new, and + * the /register profile (first and last name, validated by @profullstack/profile) + * was given. + */ +export function passesSignupChecks(user: SignupUser, hasProfile: boolean, now = Date.now()): boolean { + if (!user.email_confirmed_at) return false; + const created = user.created_at ? new Date(user.created_at).getTime() : NaN; + if (!Number.isFinite(created) || now - created > INVITE_NEW_ACCOUNT_MS) return false; + return hasProfile; +} + +/** The invite code a sign-up carries: the /i/ cookie, or the one /register put in user metadata. */ +export function inviteCodeFor(cookieValue: string | null | undefined, user: SignupUser): string | null { + const fromMeta = user.user_metadata?.invite_code; + const code = String(cookieValue || (typeof fromMeta === "string" ? fromMeta : "") || "").trim(); + return code && code.length <= 64 ? code : null; +} + +export type InviteOutcome = + | { accepted: true; inviterId: string; bonus_usd: number } + | { accepted: false; reason: string }; + +/** + * Spend an invite for a new account and credit both sides. Only called once the + * account passes the signup checks. The invite itself is single use, so the + * bonus can only be paid once per invite; an account redeems one invite. + */ +export async function acceptInvite( + user: SignupUser, + code: string, + hasProfile: boolean, + deps: { credits?: Credits; affiliates?: Affiliates; now?: number } = {} +): Promise { + if (!passesSignupChecks(user, hasProfile, deps.now)) return { accepted: false, reason: "checks" }; + const a = deps.affiliates ?? getAffiliates(); + const c = deps.credits ?? getCredits(); + if (await a.invitedBy(user.id)) return { accepted: false, reason: "already_invited" }; + const r = await a.redeemInvite(code, user.id); + if (!r.ok) return { accepted: false, reason: r.reason }; + const grant = { unit: CREDIT_UNIT, amount: INVITE_BONUS_CENTS }; + await c.grant(user.id, grant, `invite:${r.inviteId}:joined`); + await c.grant(r.inviterId, grant, `invite:${r.inviteId}:inviter`); + return { accepted: true, inviterId: r.inviterId, bonus_usd: centsToUsd(INVITE_BONUS_CENTS) }; +} + +/** + * First-touch ?ref= attribution for a new account, from ?ref= or the existing + * referral_code cookie. Codes from the old @profullstack/referrals tables come + * back external and are left to that system, so a sign-up is counted once. + */ +export async function attributeReferral( + request: Request, + user: SignupUser, + deps: { affiliates?: Affiliates; now?: number } = {} +): Promise<{ ok: boolean; external?: boolean; affiliateId?: string } | null> { + const created = user.created_at ? new Date(user.created_at).getTime() : NaN; + if (!Number.isFinite(created) || (deps.now ?? Date.now()) - created > INVITE_NEW_ACCOUNT_MS) return null; + const code = refFromRequest(request); + if (!code) return null; + return (deps.affiliates ?? getAffiliates()).attribute(code, user.id); +} + +/** Run both on a confirmed sign-in; never throws (sign-in must not fail on rewards). */ +export async function onConfirmedSignIn( + request: Request, + user: SignupUser, + inviteCookie: string | null | undefined, + hasProfile: () => Promise +): Promise<{ invite: InviteOutcome | null }> { + let invite: InviteOutcome | null = null; + try { + const code = inviteCodeFor(inviteCookie, user); + if (code) invite = await acceptInvite(user, code, await hasProfile()); + } catch (e) { + console.error("[rewards] invite accept failed", e); + } + try { + await attributeReferral(request, user); + } catch (e) { + console.error("[rewards] referral attribution failed", e); + } + return { invite }; +} diff --git a/supabase/migrations/017_redeem_credits.sql b/supabase/migrations/017_redeem_credits.sql new file mode 100644 index 00000000..4704373b --- /dev/null +++ b/supabase/migrations/017_redeem_credits.sql @@ -0,0 +1,203 @@ +-- Copied from @profullstack/redeem-credits@0.2.0 migrations/supabase.sql (credit codes, unit usd_cents on smshub). +-- @profullstack/redeem-credits: Supabase schema (table prefix "credit_") +-- Apply as a migration (supabase/migrations/_redeem_credits.sql). +-- Tables have RLS enabled and NO policies: only the service role (which +-- bypasses RLS) can read or write them. The functions are SECURITY DEFINER +-- with a pinned search_path, and only service_role may execute them. + +CREATE TABLE IF NOT EXISTS public.credit_codes ( + id TEXT PRIMARY KEY, + hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + unit TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + recur TEXT, + months INTEGER, + max_redemptions INTEGER NOT NULL, + redemptions INTEGER NOT NULL DEFAULT 0, + expires_at BIGINT, + created_at BIGINT NOT NULL, + revoked_at BIGINT, + created_by TEXT +); + +CREATE TABLE IF NOT EXISTS public.credit_redemptions ( + code_id TEXT NOT NULL, + user_id TEXT NOT NULL, + at BIGINT NOT NULL, + PRIMARY KEY (code_id, user_id) +); + +CREATE TABLE IF NOT EXISTS public.credit_ledger ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + unit TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + kind TEXT NOT NULL, + recur TEXT, + months INTEGER, + source TEXT NOT NULL, + at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS credit_ledger_user_unit ON public.credit_ledger (user_id, unit, at); + +CREATE TABLE IF NOT EXISTS public.credit_accounts ( + user_id TEXT NOT NULL, + unit TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, unit) +); + +ALTER TABLE public.credit_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.credit_redemptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.credit_ledger ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.credit_accounts ENABLE ROW LEVEL SECURITY; + +-- Redeem a code (by sha256 hash) for a user, atomically. The row lock on the +-- code serializes concurrent redeemers, so max_redemptions and one redemption +-- per (code, user) hold. Returns {"ok":true,"code":{...}} or {"ok":false,"reason":"..."}. +CREATE OR REPLACE FUNCTION public.credit_redeem(p_hash text, p_user_id text, p_at bigint) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $fn$ +DECLARE + c public.credit_codes%ROWTYPE; +BEGIN + SELECT * INTO c FROM public.credit_codes WHERE hash = p_hash FOR UPDATE; + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'reason', 'invalid'); + END IF; + IF c.revoked_at IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'reason', 'revoked'); + END IF; + IF c.expires_at IS NOT NULL AND p_at >= c.expires_at THEN + RETURN jsonb_build_object('ok', false, 'reason', 'expired'); + END IF; + IF EXISTS (SELECT 1 FROM public.credit_redemptions r WHERE r.code_id = c.id AND r.user_id = p_user_id) THEN + RETURN jsonb_build_object('ok', false, 'reason', 'already_redeemed'); + END IF; + IF c.redemptions >= c.max_redemptions THEN + RETURN jsonb_build_object('ok', false, 'reason', 'used_up'); + END IF; + UPDATE public.credit_codes SET redemptions = redemptions + 1 WHERE id = c.id; + INSERT INTO public.credit_redemptions (code_id, user_id, at) VALUES (c.id, p_user_id, p_at); + INSERT INTO public.credit_ledger (user_id, unit, amount, kind, recur, months, source, at) + VALUES (p_user_id, c.unit, c.amount, 'grant', c.recur, c.months, 'code:' || c.id, p_at); + c.redemptions := c.redemptions + 1; + RETURN jsonb_build_object('ok', true, 'code', to_jsonb(c)); +END; +$fn$; + +-- Compare-and-swap append: bump the (user, unit) version and append the entry +-- only if the version still equals p_version. Spends go through this, so two +-- concurrent spends can never both pass the balance check. +CREATE OR REPLACE FUNCTION public.credit_append_if_version( + p_user_id text, p_unit text, p_amount double precision, p_kind text, p_recur text, + p_months integer, p_source text, p_at bigint, p_version integer +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $fn$ +BEGIN + INSERT INTO public.credit_accounts (user_id, unit, version) VALUES (p_user_id, p_unit, 0) ON CONFLICT DO NOTHING; + UPDATE public.credit_accounts SET version = version + 1 + WHERE user_id = p_user_id AND unit = p_unit AND version = p_version; + IF NOT FOUND THEN + RETURN false; + END IF; + INSERT INTO public.credit_ledger (user_id, unit, amount, kind, recur, months, source, at) + VALUES (p_user_id, p_unit, p_amount, p_kind, p_recur, p_months, p_source, p_at); + RETURN true; +END; +$fn$; + +-- One user's version and ledger for one unit, from a single snapshot, as one +-- JSON value (so PostgREST's max-rows never truncates it). +CREATE OR REPLACE FUNCTION public.credit_account(p_user_id text, p_unit text) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $fn$ + SELECT jsonb_build_object( + 'version', COALESCE((SELECT a.version FROM public.credit_accounts a WHERE a.user_id = p_user_id AND a.unit = p_unit), 0), + 'entries', COALESCE((SELECT jsonb_agg(to_jsonb(l) ORDER BY l.at, l.id) FROM public.credit_ledger l + WHERE l.user_id = p_user_id AND l.unit = p_unit), '[]'::jsonb) + ); +$fn$; + +-- A user's ledger, optionally for one unit (p_unit null = all units). +CREATE OR REPLACE FUNCTION public.credit_history(p_user_id text, p_unit text DEFAULT NULL) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $fn$ + SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.at, l.id), '[]'::jsonb) + FROM public.credit_ledger l + WHERE l.user_id = p_user_id AND (p_unit IS NULL OR l.unit = p_unit); +$fn$; + +-- Revoke a code by id (idempotent: keeps the first revoked_at). False if no such id. +CREATE OR REPLACE FUNCTION public.credit_revoke(p_id text, p_at bigint) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $fn$ + WITH u AS ( + UPDATE public.credit_codes SET revoked_at = COALESCE(revoked_at, p_at) WHERE id = p_id RETURNING 1 + ) + SELECT EXISTS (SELECT 1 FROM u); +$fn$; + +REVOKE ALL ON FUNCTION public.credit_redeem(text, text, bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_append_if_version(text, text, double precision, text, text, integer, text, bigint, integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_account(text, text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_history(text, text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_revoke(text, bigint) FROM PUBLIC; + +-- Supabase roles: anon and authenticated get nothing (Supabase's default +-- privileges would otherwise grant them table and function access); +-- service_role gets what supabaseStore needs. Skipped on plain Postgres. +DO $grants$ +DECLARE + r text; +BEGIN + FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN + EXECUTE format('REVOKE ALL ON TABLE public.credit_codes FROM %I', r); + EXECUTE format('REVOKE ALL ON TABLE public.credit_redemptions FROM %I', r); + EXECUTE format('REVOKE ALL ON TABLE public.credit_ledger FROM %I', r); + EXECUTE format('REVOKE ALL ON TABLE public.credit_accounts FROM %I', r); + EXECUTE format('REVOKE ALL ON SEQUENCE public.credit_ledger_id_seq FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_redeem(text, text, bigint) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_append_if_version(text, text, double precision, text, text, integer, text, bigint, integer) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_account(text, text) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_history(text, text) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_revoke(text, bigint) FROM %I', r); + END IF; + END LOOP; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_codes TO service_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_redemptions TO service_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_ledger TO service_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_accounts TO service_role; + GRANT USAGE, SELECT ON SEQUENCE public.credit_ledger_id_seq TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_redeem(text, text, bigint) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_append_if_version(text, text, double precision, text, text, integer, text, bigint, integer) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_account(text, text) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_history(text, text) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_revoke(text, bigint) TO service_role; + END IF; +END +$grants$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/018_affiliates.sql b/supabase/migrations/018_affiliates.sql new file mode 100644 index 00000000..125f6b34 --- /dev/null +++ b/supabase/migrations/018_affiliates.sql @@ -0,0 +1,322 @@ +-- Copied from @profullstack/affiliates@0.2.0 migrations/supabase.sql (invites + ?ref= attribution). +-- @profullstack/affiliates: Supabase migration (prefix aff_) +-- Generated by supabaseMigrationSql('aff_'). Same tables as postgresStore, so both stores can share them. +-- Use with supabaseStore(serviceRoleClient). Idempotent. + +CREATE TABLE IF NOT EXISTS public.aff_invites ( + id TEXT PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + inviter_id TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT, + used_by TEXT, + used_at BIGINT, + note TEXT NOT NULL DEFAULT '', + revoked_at BIGINT +); +CREATE INDEX IF NOT EXISTS aff_invites_inviter_idx ON public.aff_invites (inviter_id); +CREATE INDEX IF NOT EXISTS aff_invites_used_by_idx ON public.aff_invites (used_by); +CREATE TABLE IF NOT EXISTS public.aff_quotas ( + user_id TEXT PRIMARY KEY, + total INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS public.aff_codes ( + user_id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL +); +CREATE TABLE IF NOT EXISTS public.aff_attributions ( + user_id TEXT PRIMARY KEY, + affiliate_id TEXT NOT NULL, + code TEXT NOT NULL, + at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS aff_attributions_affiliate_idx ON public.aff_attributions (affiliate_id); + +-- RLS on with no policies: anon and authenticated keys see nothing. Access goes only through the +-- SECURITY DEFINER functions below, which only service_role may execute. +ALTER TABLE public.aff_invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.aff_quotas ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.aff_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.aff_attributions ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION public.aff_get_quota(p_user TEXT) +RETURNS INTEGER +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT total FROM public.aff_quotas WHERE user_id = p_user +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_set_quota(p_user TEXT, p_total INTEGER) +RETURNS VOID +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +INSERT INTO public.aff_quotas (user_id, total) VALUES (p_user, p_total) + ON CONFLICT (user_id) DO UPDATE SET total = excluded.total +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_count_invites(p_inviter TEXT) +RETURNS INTEGER +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT count(*)::INTEGER FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL) +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_insert_invite(p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +BEGIN + -- Per-inviter lock; plpgsql takes a fresh snapshot per statement, so the count sees every committed invite. + PERFORM pg_advisory_xact_lock(hashtext('aff_invites'), hashtext(p_inviter)); + IF (SELECT count(*) FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)) >= p_limit THEN + RETURN FALSE; + END IF; + INSERT INTO public.aff_invites (id, code_hash, inviter_id, created_at, expires_at, note) + VALUES (p_id, p_hash, p_inviter, p_created, p_expires, coalesce(p_note, '')); + RETURN TRUE; +END +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_invite_by_hash(p_hash TEXT) +RETURNS SETOF public.aff_invites +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_invites WHERE code_hash = p_hash +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_invite(p_id TEXT) +RETURNS SETOF public.aff_invites +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_invites WHERE id = p_id +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_claim_invite(p_id TEXT, p_user TEXT, p_at BIGINT) +RETURNS BOOLEAN +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +WITH won AS ( + UPDATE public.aff_invites SET used_by = p_user, used_at = p_at + WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL + RETURNING 1 +) +SELECT EXISTS (SELECT 1 FROM won) +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_revoke_invite(p_id TEXT, p_at BIGINT) +RETURNS VOID +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +UPDATE public.aff_invites SET revoked_at = p_at WHERE id = p_id AND revoked_at IS NULL +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_list_invites(p_inviter TEXT) +RETURNS SETOF public.aff_invites +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_invites WHERE inviter_id = p_inviter ORDER BY created_at DESC, id +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_invited_by(p_user TEXT) +RETURNS TEXT +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT inviter_id FROM public.aff_invites WHERE used_by = p_user ORDER BY used_at ASC LIMIT 1 +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_invitees(p_inviter TEXT) +RETURNS TABLE (user_id TEXT, at BIGINT) +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT used_by, used_at FROM public.aff_invites + WHERE inviter_id = p_inviter AND used_by IS NOT NULL ORDER BY used_at ASC +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_affiliate_code(p_user TEXT) +RETURNS TEXT +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT code FROM public.aff_codes WHERE user_id = p_user +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_put_affiliate_code(p_user TEXT, p_code TEXT, p_at BIGINT) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +BEGIN + INSERT INTO public.aff_codes (user_id, code, created_at) VALUES (p_user, p_code, p_at) ON CONFLICT DO NOTHING; + RETURN (SELECT code FROM public.aff_codes WHERE user_id = p_user); +END +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_affiliate_by_code(p_code TEXT) +RETURNS TEXT +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT user_id FROM public.aff_codes WHERE code = p_code +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_attribute(p_user TEXT, p_affiliate TEXT, p_code TEXT, p_at BIGINT) +RETURNS BOOLEAN +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +WITH ins AS ( + INSERT INTO public.aff_attributions (user_id, affiliate_id, code, at) VALUES (p_user, p_affiliate, p_code, p_at) + ON CONFLICT (user_id) DO NOTHING + RETURNING 1 +) +SELECT EXISTS (SELECT 1 FROM ins) +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_attribution(p_user TEXT) +RETURNS SETOF public.aff_attributions +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_attributions WHERE user_id = p_user +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_is_external_code(p_code TEXT) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +DECLARE found BOOLEAN := FALSE; +BEGIN + IF to_regclass('public.referral_codes') IS NULL THEN + RETURN FALSE; + END IF; + EXECUTE 'SELECT EXISTS (SELECT 1 FROM public.referral_codes WHERE code = $1)' + INTO found USING upper(btrim(p_code)); + RETURN found; +END +$fn$; + +REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM PUBLIC; + +DO $grants$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM anon; + REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM authenticated; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + GRANT EXECUTE ON FUNCTION public.aff_get_quota(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_set_quota(TEXT, INTEGER) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_count_invites(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_invite_by_hash(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_invite(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_list_invites(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_invited_by(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_invitees(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_affiliate_code(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_affiliate_by_code(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_attribution(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_is_external_code(TEXT) TO service_role; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public.aff_invites FROM anon; + REVOKE ALL ON TABLE public.aff_quotas FROM anon; + REVOKE ALL ON TABLE public.aff_codes FROM anon; + REVOKE ALL ON TABLE public.aff_attributions FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public.aff_invites FROM authenticated; + REVOKE ALL ON TABLE public.aff_quotas FROM authenticated; + REVOKE ALL ON TABLE public.aff_codes FROM authenticated; + REVOKE ALL ON TABLE public.aff_attributions FROM authenticated; + END IF; +END +$grants$; + +NOTIFY pgrst, 'reload schema'; + +NOTIFY pgrst, 'reload schema';