diff --git a/app/auth/coinpay/callback/route.ts b/app/auth/coinpay/callback/route.ts index 085da7e..5856d4d 100644 --- a/app/auth/coinpay/callback/route.ts +++ b/app/auth/coinpay/callback/route.ts @@ -4,6 +4,8 @@ import { finishCoinpaySignIn, stateMatches, PENDING_COOKIES } from "@/lib/oauth" import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin"; import { upsertUser, accountIdForCoinpay, linkCoinpay } from "@/lib/db"; import { acceptInviteAndNotify, INVITE_COOKIE } from "@/lib/credits"; +import { recordCoinpayPass } from "@/lib/plan"; +import type { CoinpayPass } from "@profullstack/coinpay-onboard/pass"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -39,7 +41,7 @@ export async function GET(req: NextRequest) { // byte -- the callback arrives on whatever host that step named. const origin = resolveOrigin(requestHosts(req.headers), requestIsHttps(req)); - let info: { sub: string; email: string | null; name: string | null }; + let info: { sub: string; email: string | null; name: string | null; pass?: CoinpayPass }; try { info = await finishCoinpaySignIn({ code, verifier, nonce, redirectUri: redirectUriFor(origin) }); } catch (err: any) { @@ -53,6 +55,7 @@ export async function GET(req: NextRequest) { const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); if (!s?.sub?.startsWith("acct:")) return fail("sign in to your moshcoding account first"); const r = await linkCoinpay(s.sub.slice("acct:".length), info.sub); + if (r.ok) await recordCoinpayPass(s.sub.slice("acct:".length), info.pass).catch((err) => console.error("[auth] pass:", err?.message)); const res = NextResponse.redirect(`${origin}/dashboard?coinpay=${r.ok ? "linked" : r.reason}`); clearPending(res); return res; @@ -61,6 +64,10 @@ export async function GET(req: NextRequest) { await upsertUser({ sub: info.sub, email: info.email, name: info.name }); // Bound by sub; a first sign-in gets its own account (never one found by email). const accountId = await accountIdForCoinpay(info.sub); + // Profullstack Pass: the paid plan until it ends. Never blocks the sign-in. + if (accountId) { + await recordCoinpayPass(accountId, info.pass).catch((err) => console.error("[auth] pass:", err?.message)); + } // Arrived through an invite link (/i/ set the cookie): accept it on // the account this CoinPay identity is bound to. Never blocks the sign-in. diff --git a/app/invites/page.tsx b/app/invites/page.tsx index 53fcbe1..0023d53 100644 --- a/app/invites/page.tsx +++ b/app/invites/page.tsx @@ -134,6 +134,7 @@ export default function InvitesPage() { {plan && {plan.plan === "paid" ? `paid plan${plan.paid_until ? ` ยท until ${day(plan.paid_until)}` : ""}` : "free plan"}}

A credit code adds days of the paid affiliate plan ($1/mo): set your own commission rate instead of the 80% floor.

+

Deposit $99 in your CoinPay wallet and get a free month: CoinPay Pass

setCode(e.target.value)} autoComplete="off" /> diff --git a/bun.lock b/bun.lock index 820c3af..f2136a8 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "moshcoding", "dependencies": { "@profullstack/affiliates": "0.3.0", - "@profullstack/coinpay-onboard": "0.3.1", + "@profullstack/coinpay-onboard": "^0.5.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/player": "0.17.0", @@ -141,7 +141,7 @@ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], - "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.5.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-yqSx3pgJLP521Km+XNNn9fyjbbHppgozuGTtzTln9yy+2I0h0bjqXSpsFPwoSgVb5QcyBx+Wzv5170Qb7va1iQ=="], "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], diff --git a/lib/db.ts b/lib/db.ts index 8fbf1cf..cb761be 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -232,6 +232,8 @@ async function initSchema(): Promise { // config added via ALTER for accounts tables created before it existed. await addColumnIfMissing("accounts", "config", "TEXT NOT NULL DEFAULT '{}'"); await addColumnIfMissing("accounts", "is_admin", "INTEGER NOT NULL DEFAULT 0"); + // Profullstack Pass end (ISO), stored at CoinPay sign-in; see lib/plan.ts. + await addColumnIfMissing("accounts", "coinpay_pass_until", "TEXT"); // The CoinPay identity (OIDC `sub`) an account is bound to. CoinPay does not // verify email addresses (its userinfo always says email_verified=false), so // a CoinPay sign-in must never be matched to an account by email alone once diff --git a/lib/plan.ts b/lib/plan.ts index 473cf4a..04aa9b5 100644 --- a/lib/plan.ts +++ b/lib/plan.ts @@ -7,9 +7,12 @@ // future $1/mo checkout should call grantPaidDays() with its payment id as the // source, so paid and granted time share one path. +import type { CoinpayPass } from "@profullstack/coinpay-onboard/pass"; import { db, ensureSchema, enrollAffiliate } from "./db"; import { sqlTime } from "./sql-time"; +export { PASS_URL } from "@profullstack/coinpay-onboard/pass"; + const DAY_MS = 86_400_000; /** A stored timestamp (SQLite text "YYYY-MM-DD HH:MM:SS", or Postgres) in ms. */ @@ -36,13 +39,52 @@ export function paidUntilFromGrants(grants: { days: number; granted_at: unknown return end; } +/** The later of the granted run and a Profullstack Pass end. */ +export function laterOf(granted: number | null, pass: number | null): number | null { + const g = granted !== null && Number.isFinite(granted) ? granted : null; + const p = pass !== null && Number.isFinite(pass) ? pass : null; + if (g === null) return p; + if (p === null) return g; + return Math.max(g, p); +} + export async function paidUntil(accountId: string): Promise { await ensureSchema(); const res = await db().execute({ sql: `SELECT days, granted_at FROM plan_grants WHERE account_id = ?`, args: [accountId], }); - return paidUntilFromGrants(res.rows as any); + const acct = await db().execute({ sql: `SELECT coinpay_pass_until FROM accounts WHERE id = ?`, args: [accountId] }); + const pass = toMs(acct.rows[0]?.coinpay_pass_until); + return laterOf(paidUntilFromGrants(res.rows as any), Number.isFinite(pass) ? pass : null); +} + +/** + * Profullstack Pass (https://coinpayportal.com/pass): a CoinPay user who + * deposited $99 into their own CoinPay wallet gets a free month of the paid + * plan on every Profullstack property. CoinPay reports it only in the userinfo + * `pass` claim at sign-in, so the callback stores its end on the account bound + * to the CoinPay `sub` (accountIdForCoinpay, never by email). paidUntil() + * takes the later of it and the granted days, so the pass never uses them up. + */ +export function passUntilToStore(pass: CoinpayPass | null | undefined, now = 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; +} + +/** Stores an active pass on the account; never shortens a later one. True when it wrote. */ +export async function recordCoinpayPass(accountId: string, pass: CoinpayPass | null | undefined, now = Date.now()): Promise { + const until = passUntilToStore(pass, now); + if (!accountId || !until) return false; + await ensureSchema(); + const cur = await db().execute({ sql: `SELECT coinpay_pass_until FROM accounts WHERE id = ?`, args: [accountId] }); + const had = toMs(cur.rows[0]?.coinpay_pass_until); + if (Number.isFinite(had) && had >= Date.parse(until)) return false; + await db().execute({ sql: `UPDATE accounts SET coinpay_pass_until = ? WHERE id = ?`, args: [until, accountId] }); + // The paid plan is the affiliate plan, so enroll like grantPaidDays() does. + await enrollAffiliate(accountId); + return true; } /** diff --git a/package.json b/package.json index 3d997c8..beb1a4f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@profullstack/affiliates": "0.3.0", - "@profullstack/coinpay-onboard": "0.3.1", + "@profullstack/coinpay-onboard": "^0.5.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/player": "0.17.0", diff --git a/tests/coinpay-pass.test.mjs b/tests/coinpay-pass.test.mjs new file mode 100644 index 0000000..bfb447b --- /dev/null +++ b/tests/coinpay-pass.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +process.env.TURSO_DATABASE_URL = "file::memory:"; +delete process.env.DATABASE_URL; +process.env.SESSION_SECRET = "test-secret"; + +const dbm = await import("../lib/db.ts"); +const plan = await import("../lib/plan.ts"); +const { passFromUserinfo } = await import("@profullstack/coinpay-onboard/pass"); +await dbm.ensureSchema(); + +const DAY = 86_400_000; +const inDays = (d) => new Date(Date.now() + d * DAY).toISOString(); +const claim = (pass) => passFromUserinfo({ pass }); + +test("only an active pass with a future end is stored", () => { + const end = inDays(30); + assert.equal(plan.passUntilToStore(claim({ active: true, expires_at: end })), end); + assert.equal(plan.passUntilToStore(claim({ active: false, expires_at: end })), null); + assert.equal(plan.passUntilToStore(claim({ active: true, expires_at: "nope" })), null); + assert.equal(plan.passUntilToStore(claim({ active: true, expires_at: inDays(-1) })), null); + assert.equal(plan.passUntilToStore(undefined), null); +}); + +test("a pass turns on the paid plan for the sub-bound account until it ends", async () => { + const id = await dbm.accountIdForCoinpay("pass-sub-1"); + assert.equal(await plan.hasPaidPlan(id, "free"), false); + const end = inDays(30); + assert.equal(await plan.recordCoinpayPass(id, claim({ active: true, expires_at: end })), true); + assert.equal(await plan.hasPaidPlan(id, "free"), true); + assert.equal(await plan.paidUntil(id), Date.parse(end)); + assert.equal(await plan.hasPaidPlan(id, "free", Date.parse(end) + 1), false); + assert.ok(await dbm.getAffiliate(id), "enrolled as an affiliate"); + // Idempotent, and a shorter claim never shortens the stored pass. + assert.equal(await plan.recordCoinpayPass(id, claim({ active: true, expires_at: end })), false); + assert.equal(await plan.recordCoinpayPass(id, claim({ active: true, expires_at: inDays(5) })), false); + assert.equal(await plan.paidUntil(id), Date.parse(end)); +}); + +test("granted days that run longer than the pass win, and are not used up by it", async () => { + const id = await dbm.accountIdForCoinpay("pass-sub-2"); + await plan.grantPaidDays(id, 60, "code:test"); + const granted = await plan.paidUntil(id); + await plan.recordCoinpayPass(id, claim({ active: true, expires_at: inDays(30) })); + assert.equal(await plan.paidUntil(id), granted); + assert.equal(plan.laterOf(null, null), null); + assert.equal(plan.laterOf(5, null), 5); + assert.equal(plan.laterOf(null, 7), 7); + assert.equal(plan.laterOf(5, 7), 7); +});