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
9 changes: 8 additions & 1 deletion app/auth/coinpay/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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/<code> set the cookie): accept it on
// the account this CoinPay identity is bound to. Never blocks the sign-in.
Expand Down
1 change: 1 addition & 0 deletions app/invites/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export default function InvitesPage() {
{plan && <span className="pill">{plan.plan === "paid" ? `paid plan${plan.paid_until ? ` · until ${day(plan.paid_until)}` : ""}` : "free plan"}</span>}
</div>
<p className="sub">A credit code adds days of the paid affiliate plan ($1/mo): set your own commission rate instead of the 80% floor.</p>
<p className="sub">Deposit $99 in your CoinPay wallet and get a free month: <a href="https://coinpayportal.com/pass">CoinPay Pass</a></p>
<form className="row" onSubmit={redeem}>
<input className="inp" placeholder="MOSH-XXXX-XXXX-XXXX-XXXX" value={code} onChange={(e) => setCode(e.target.value)} autoComplete="off" />
<button className="btn2" type="submit" disabled={busy || !code.trim()}>Redeem</button>
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

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

2 changes: 2 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ async function initSchema(): Promise<void> {
// 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
Expand Down
44 changes: 43 additions & 1 deletion lib/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<number | null> {
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<boolean> {
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;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions tests/coinpay-pass.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading