Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -46,6 +47,7 @@ export async function GET(request: NextRequest) {
if (params.get('error') || !code) return fail('denied');

let claims;
let pass: Awaited<ReturnType<typeof finishSignIn>>['pass'] | null = null;
try {
const r = await finishSignIn({
code,
Expand All @@ -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');
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -344,6 +345,15 @@ export default async function PricingPage() {
<p className="mt-8 text-center text-sm text-gray-500" data-testid="pricing-pay-methods">
{t('payMethods')}
</p>
<p
className="mt-2 text-center text-sm text-gray-500"
data-testid="pricing-coinpay-pass"
>
Deposit $99 in your CoinPay wallet and get a free month:{' '}
<a href={PASS_URL} className="underline">
CoinPay Pass
</a>
</p>
</div>
</section>

Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/lib/coinpay-pass.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
43 changes: 43 additions & 0 deletions apps/web/src/lib/coinpay-pass.ts
Original file line number Diff line number Diff line change
@@ -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<SupabaseClient, 'rpc'>,
userId: string,
pass: CoinpayPass | null | undefined,
now: number = Date.now()
): Promise<string | null> {
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;
}
16 changes: 6 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions supabase/migrations/20261107120000_coinpay_pass.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading