From 83de701318e06c89c862192b2e3fcc4fb1583324 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 16:19:53 +0000 Subject: [PATCH] feat(pass): Profullstack Pass gives Pro until it ends CoinPay sign-in reads the userinfo `pass` claim (coinpay-onboard ^0.5.0) and calls the new service-role plan_apply_coinpay_pass() for the account bound to the CoinPay sub. It writes profiles.plan / plan_expires_at with max semantics (bigger plan kept, expiry = GREATEST(running, pass end)), so effectivePlan, LiveKit limits and the desktop app all see Pro, and stores coinpay_pass_until so a repeat sign-in is a no-op. Pricing links the Pass. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- .../api/v1/coinpay/oauth/callback/route.ts | 9 +++ apps/web/src/app/pricing/page.tsx | 10 ++++ apps/web/src/lib/coinpay-pass.test.ts | 41 +++++++++++++ apps/web/src/lib/coinpay-pass.ts | 43 ++++++++++++++ pnpm-lock.yaml | 16 ++---- .../20261107120000_coinpay_pass.sql | 57 +++++++++++++++++++ 7 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/lib/coinpay-pass.test.ts create mode 100644 apps/web/src/lib/coinpay-pass.ts create mode 100644 supabase/migrations/20261107120000_coinpay_pass.sql diff --git a/apps/web/package.json b/apps/web/package.json index fe0458d2..6c10b8d7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "@pairux/meeting-translation": "workspace:*", "@profullstack/affiliates": "^0.2.0", "@profullstack/autoblog": "0.5.0", - "@profullstack/coinpay-onboard": "0.3.1", + "@profullstack/coinpay-onboard": "^0.5.0", "@profullstack/emailer": "^1.0.3", "@profullstack/footer": "latest", "@profullstack/live-translate": "^0.3.0", diff --git a/apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts b/apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts index 65c8dcbf..208e621d 100644 --- a/apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts +++ b/apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts @@ -17,6 +17,7 @@ import { } from '@/lib/coinpay-signin'; import { mintMagicLinkHash, supabaseCoinpayStore } from '@/lib/coinpay-store'; import { INVITE_COOKIE, cookieFrom, onSignedIn, refFromRequest } from '@/lib/credits'; +import { applyCoinpayPass } from '@/lib/coinpay-pass'; export const dynamic = 'force-dynamic'; @@ -46,6 +47,7 @@ export async function GET(request: NextRequest) { if (params.get('error') || !code) return fail('denied'); let claims; + let pass: Awaited>['pass'] | null = null; try { const r = await finishSignIn({ code, @@ -57,6 +59,7 @@ export async function GET(request: NextRequest) { base: cfg.base, }); claims = claimsFrom(r); + pass = r.pass; } catch (err) { console.error('[coinpay] sign-in failed:', err instanceof Error ? err.message : err); return fail('failed'); @@ -82,6 +85,12 @@ export async function GET(request: NextRequest) { return fail('failed'); } if (!result.ok) return fail(result.code); + // Profullstack Pass: Pro until it ends, on the account bound to this sub. + try { + await applyCoinpayPass(admin, result.userId, pass); + } catch (err) { + console.error('[coinpay] pass:', err instanceof Error ? err.message : err); + } if (pending.mode === 'connect' && claims.openprofile_url) { try { await saveOpenProfileContact(profileStore(), result.userId, claims.openprofile_url); diff --git a/apps/web/src/app/pricing/page.tsx b/apps/web/src/app/pricing/page.tsx index f8418634..750adbee 100644 --- a/apps/web/src/app/pricing/page.tsx +++ b/apps/web/src/app/pricing/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { getFormatter, getTranslations } from 'next-intl/server'; import Link from 'next/link'; +import { PASS_URL } from '@profullstack/coinpay-onboard/pass'; import { Check, X, Zap, Shield, Users, DollarSign } from 'lucide-react'; import { Header } from '@/components/header'; import { Footer } from '@/components/footer'; @@ -344,6 +345,15 @@ export default async function PricingPage() {

{t('payMethods')}

+

+ Deposit $99 in your CoinPay wallet and get a free month:{' '} + + CoinPay Pass + +

diff --git a/apps/web/src/lib/coinpay-pass.test.ts b/apps/web/src/lib/coinpay-pass.test.ts new file mode 100644 index 00000000..ed9c17c7 --- /dev/null +++ b/apps/web/src/lib/coinpay-pass.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { passFromUserinfo } from '@profullstack/coinpay-onboard/pass'; +import { applyCoinpayPass, passUntilToApply } from './coinpay-pass'; + +const NOW = Date.parse('2026-11-07T00:00:00Z'); +const END = '2026-12-07T00:00:00.000Z'; +const claim = (pass: unknown) => passFromUserinfo({ pass }, new Date(NOW)); + +describe('coinpay pass', () => { + it('applies only an active pass that ends in the future', () => { + expect(passUntilToApply(claim({ active: true, expires_at: END }), NOW)).toBe(END); + expect(passUntilToApply(claim({ active: false, expires_at: END }), NOW)).toBeNull(); + expect(passUntilToApply(claim({ active: true, expires_at: 'x' }), NOW)).toBeNull(); + expect( + passUntilToApply(claim({ active: true, expires_at: '2026-01-01T00:00:00Z' }), NOW) + ).toBeNull(); + expect(passUntilToApply(undefined, NOW)).toBeNull(); + }); + + it('calls the service-role RPC with the pass end for the bound user', async () => { + const rpc = vi.fn().mockResolvedValue({ data: END, error: null }); + await expect( + applyCoinpayPass({ rpc } as never, 'user-1', claim({ active: true, expires_at: END }), NOW) + ).resolves.toBe(END); + expect(rpc).toHaveBeenCalledWith('plan_apply_coinpay_pass', { + p_user_id: 'user-1', + p_until: END, + }); + }); + + it('does nothing without an active pass, and surfaces RPC errors', async () => { + const rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } }); + await expect( + applyCoinpayPass({ rpc } as never, 'user-1', claim({ active: false }), NOW) + ).resolves.toBeNull(); + expect(rpc).not.toHaveBeenCalled(); + await expect( + applyCoinpayPass({ rpc } as never, 'user-1', claim({ active: true, expires_at: END }), NOW) + ).rejects.toThrow('boom'); + }); +}); diff --git a/apps/web/src/lib/coinpay-pass.ts b/apps/web/src/lib/coinpay-pass.ts new file mode 100644 index 00000000..1f67cfce --- /dev/null +++ b/apps/web/src/lib/coinpay-pass.ts @@ -0,0 +1,43 @@ +/** + * Profullstack Pass on PairUX: a CoinPay user who deposited $99 into their own + * CoinPay wallet gets a free month of Pro. + * + * CoinPay reports the pass only in the userinfo `pass` claim at sign-in + * (finishSignIn() reads it with passFromUserinfo()). The callback hands it to + * plan_apply_coinpay_pass (supabase/migrations/20261107120000_coinpay_pass.sql) + * for the account bound to the CoinPay `sub`, never one matched by email. That + * writes profiles.plan / plan_expires_at with max semantics, so every reader + * (effectivePlan, LiveKit limits, the desktop app) sees Pro until the pass + * ends, and a paid term that runs longer is kept. + */ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { PASS_URL, type CoinpayPass } from '@profullstack/coinpay-onboard/pass'; + +export { PASS_URL }; + +/** The pass end to apply, or null when the claim grants nothing. */ +export function passUntilToApply( + pass: CoinpayPass | null | undefined, + now: number = Date.now() +): string | null { + if (!pass?.active || !pass.expiresAt) return null; + const t = pass.expiresAt.getTime(); + return Number.isFinite(t) && t > now ? pass.expiresAt.toISOString() : null; +} + +/** Applies an active pass to the account. Returns the new plan expiry, or null when nothing changed. */ +export async function applyCoinpayPass( + db: Pick, + userId: string, + pass: CoinpayPass | null | undefined, + now: number = Date.now() +): Promise { + const until = passUntilToApply(pass, now); + if (!userId || !until) return null; + const res = (await db.rpc('plan_apply_coinpay_pass', { + p_user_id: userId, + p_until: until, + })) as { data: string | null; error: { message: string } | null }; + if (res.error) throw new Error(`plan_apply_coinpay_pass: ${res.error.message}`); + return res.data ?? null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e63c901..79179cab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,8 +392,8 @@ importers: specifier: 0.5.0 version: 0.5.0 '@profullstack/coinpay-onboard': - specifier: 0.3.1 - version: 0.3.1 + specifier: ^0.5.0 + version: 0.5.0 '@profullstack/emailer': specifier: ^1.0.3 version: 1.0.3 @@ -2146,7 +2146,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -2835,19 +2835,16 @@ packages: '@nut-tree-fork/libnut-darwin@2.7.5': resolution: {integrity: sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q==} engines: {node: '>=10.15.3'} - cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-linux@2.7.5': resolution: {integrity: sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg==} engines: {node: '>=10.15.3'} - cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-win32@2.7.5': resolution: {integrity: sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw==} engines: {node: '>=10.15.3'} - cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut@4.2.6': @@ -2861,7 +2858,6 @@ packages: '@nut-tree-fork/nut-js@4.2.6': resolution: {integrity: sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg==} engines: {node: '>=16'} - cpu: [x64, arm64] os: [linux, darwin, win32] '@nut-tree-fork/provider-interfaces@4.2.6': @@ -2996,8 +2992,8 @@ packages: resolution: {integrity: sha512-KewpX0rM55o+7TMYeU35gqZceQFNF6zmo8zDoZRP5zoPU+XqTRcBlv6bZvl9oQHUXKXAsePJVNZKtDNhTrNEfw==} engines: {node: '>=18'} - '@profullstack/coinpay-onboard@0.3.1': - resolution: {integrity: sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ==} + '@profullstack/coinpay-onboard@0.5.0': + resolution: {integrity: sha512-yqSx3pgJLP521Km+XNNn9fyjbbHppgozuGTtzTln9yy+2I0h0bjqXSpsFPwoSgVb5QcyBx+Wzv5170Qb7va1iQ==} engines: {node: '>=20'} hasBin: true @@ -12551,7 +12547,7 @@ snapshots: dependencies: '@profullstack/ai-providers': 0.1.0 - '@profullstack/coinpay-onboard@0.3.1': + '@profullstack/coinpay-onboard@0.5.0': dependencies: '@modelcontextprotocol/sdk': 1.32.2(zod@4.6.7) zod: 4.6.7 diff --git a/supabase/migrations/20261107120000_coinpay_pass.sql b/supabase/migrations/20261107120000_coinpay_pass.sql new file mode 100644 index 00000000..9446befd --- /dev/null +++ b/supabase/migrations/20261107120000_coinpay_pass.sql @@ -0,0 +1,57 @@ +-- Profullstack Pass (https://coinpayportal.com/pass): a CoinPay user who +-- deposited $99 into their own CoinPay wallet gets a free month of Pro on +-- every Profullstack property. CoinPay reports it only in the userinfo `pass` +-- claim at sign-in, so the CoinPay callback calls plan_apply_coinpay_pass() +-- for the account bound to the CoinPay sub (never by email). +-- +-- The pass is written into profiles.plan / plan_expires_at, the columns every +-- reader already checks (web effectivePlan, LiveKit limits, the desktop app), +-- with max semantics, unlike plan_grant_days which adds days: +-- plan = the bigger of the running plan and 'pro' +-- plan_expires_at = GREATEST(running expiry, pass end) +-- coinpay_pass_until keeps the latest pass end seen, so a repeat sign-in with +-- the same pass is a no-op. Service role only; the existing trigger keeps +-- users from writing either column. + +ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS coinpay_pass_until TIMESTAMPTZ; + +CREATE OR REPLACE FUNCTION public.plan_apply_coinpay_pass( + p_user_id UUID, + p_until TIMESTAMPTZ +) RETURNS TIMESTAMPTZ +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + new_expiry TIMESTAMPTZ; +BEGIN + IF p_until IS NULL OR p_until <= NOW() THEN + RETURN NULL; + END IF; + + UPDATE public.profiles + SET plan = CASE + WHEN plan_expires_at > NOW() + AND array_position(ARRAY['free','plus','pro','team'], plan) + > array_position(ARRAY['free','plus','pro','team'], 'pro') + THEN plan + ELSE 'pro' + END, + plan_expires_at = CASE + WHEN plan_expires_at > NOW() AND plan <> 'free' + THEN GREATEST(plan_expires_at, p_until) + ELSE p_until + END, + coinpay_pass_until = p_until, + updated_at = NOW() + WHERE id = p_user_id + AND (coinpay_pass_until IS NULL OR coinpay_pass_until < p_until) + RETURNING plan_expires_at INTO new_expiry; + + RETURN new_expiry; +END; +$$; + +REVOKE ALL ON FUNCTION public.plan_apply_coinpay_pass(UUID, TIMESTAMPTZ) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.plan_apply_coinpay_pass(UUID, TIMESTAMPTZ) TO service_role;