From 714b1949cf0a939377f5109118217d71a5290f4a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 16:20:32 +0000 Subject: [PATCH] feat(pass): Profullstack Pass pays an owner's practices until it expires CoinPay userinfo carries `pass: { active, expires_at }`. The bespoke callback now returns it with the subject (active only with a future expiry), and on a CoinPay sign-in or link an active pass sets org_billing.paid_through = max(stored, pass expiry) on every practice the user owns, with an audit_log row. Idempotent (greatest), never shortens paid time, and a payment during the pass extends from the later end. Bound to the CoinPay subject only. Pricing section gets one line pointing at https://coinpayportal.com/pass. Co-Authored-By: Claude Opus 5.5 --- apps/web/public/index.html | 1 + apps/web/src/app.js | 15 ++++++++++++++- apps/web/src/auth.js | 20 +++++++++++++++++++- apps/web/src/credits.js | 27 +++++++++++++++++++++++++++ test/credits.test.js | 27 +++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/apps/web/public/index.html b/apps/web/public/index.html index f2c58ba..dd69787 100644 --- a/apps/web/public/index.html +++ b/apps/web/public/index.html @@ -323,6 +323,7 @@

Pay for your team. Never for patients.

You always pay the lower of the two. At 20 seats the flat plan wins. Pay through CoinPay in any major coin or PayPal.

+

Deposit $99 in your CoinPay wallet and get a free month: CoinPay Pass (practices you own, signed in with CoinPay).

diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 84f4e11..71071ea 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -152,6 +152,16 @@ app.get('/auth/coinpay', async (c) => { return c.redirect(authorize.toString(), 302); }); +/** Profullstack Pass: practices this user owns are paid until it expires. Never blocks sign-in. */ +async function grantPass(userId, pass) { + if (!pass?.active) return; + try { + await credits.applyPass(userId, pass); + } catch (err) { + console.error(`[coinpay] pass apply failed: ${err?.message ?? err}`); + } +} + app.get('/auth/coinpay/callback', async (c) => { const query = c.req.query(); const pending = await auth.takeCoinPayOAuthState(query.state); @@ -167,20 +177,23 @@ app.get('/auth/coinpay/callback', async (c) => { } let subject; + let pass; try { - subject = await auth.coinPaySubjectFromCode(query.code, pending.codeVerifier); + ({ subject, pass } = await auth.coinPayIdentityFromCode(query.code, pending.codeVerifier)); } catch { return c.redirect(pending.userId ? '/app/account?coinpay=failed' : signInError('coinpay-failed'), 302); } if (pending.userId) { const linked = await auth.linkCoinPayIdentity(pending.userId, subject); + if (linked === 'linked') await grantPass(pending.userId, pass); const result = linked === 'linked' ? 'linked' : linked === 'already_used' ? 'already-used' : 'already-connected'; return c.redirect(`/app/account?coinpay=${result}`, 302); } const session = await auth.signInWithCoinPay(subject, c.req.header('user-agent')); if (!session) return c.redirect(signInError('coinpay-not-linked'), 302); + await grantPass(session.user.id, pass); c.header('set-cookie', auth.sessionCookie(session.sessionId)); return c.redirect(pending.next ?? '/app', 302); }); diff --git a/apps/web/src/auth.js b/apps/web/src/auth.js index 66bc0a3..03bb853 100644 --- a/apps/web/src/auth.js +++ b/apps/web/src/auth.js @@ -142,8 +142,26 @@ export async function takeCoinPayOAuthState(state) { return row ? { codeVerifier: row.code_verifier, userId: row.user_id ?? null, next: state.endsWith('.health') ? '/health/' : null } : null; } +/** + * Profullstack Pass (https://coinpayportal.com/pass) from a CoinPay userinfo + * body: `pass: { active, expires_at }`. Active only when `active === true` AND + * `expires_at` parses to a date still in the future; anything else is no pass. + */ +export function passFromUserinfo(identity, now = new Date()) { + const pass = identity && typeof identity === 'object' ? identity.pass : null; + if (!pass || typeof pass !== 'object') return { active: false, expiresAt: null }; + const d = typeof pass.expires_at === 'string' && pass.expires_at.trim() ? new Date(pass.expires_at) : null; + const expiresAt = d && Number.isFinite(d.getTime()) ? d : null; + return { active: pass.active === true && expiresAt !== null && expiresAt.getTime() > now.getTime(), expiresAt }; +} + /** Exchange the authorization code, then ask CoinPay's userinfo endpoint for its stable subject. */ export async function coinPaySubjectFromCode(code, codeVerifier) { + return (await coinPayIdentityFromCode(code, codeVerifier)).subject; +} + +/** The same exchange, returning the subject and the Profullstack Pass claim. */ +export async function coinPayIdentityFromCode(code, codeVerifier) { if (!config.coinpayOAuth.enabled || !code || code.length > 4096) throw new Error('CoinPay sign-in unavailable'); const tokenResponse = await fetch(config.coinpayOAuth.tokenUrl, { method: 'POST', @@ -174,7 +192,7 @@ export async function coinPaySubjectFromCode(code, codeVerifier) { if (!userResponse.ok || typeof identity?.sub !== 'string' || !identity.sub || identity.sub.length > 500) throw new Error('CoinPay identity could not be verified'); // CoinPay explicitly marks email unverified; identity matching uses sub only. - return identity.sub; + return { subject: identity.sub, pass: passFromUserinfo(identity) }; } /** Link by provider subject only. The caller must already have an active session. */ diff --git a/apps/web/src/credits.js b/apps/web/src/credits.js index b9b87f7..41c5456 100644 --- a/apps/web/src/credits.js +++ b/apps/web/src/credits.js @@ -72,6 +72,33 @@ export async function applyDays(userId, orgId, days) { } } +/* --------------------------------------------------------- Profullstack Pass -- */ + +/** + * A CoinPay user who deposited $99 into their own CoinPay wallet holds a + * Profullstack Pass: a free month on every Profullstack property. CoinPay sends + * it at sign-in only. Here what is paid for is a practice, so an active pass + * makes every practice the user OWNS paid until the pass expires: + * org_billing.paid_through = max(stored, pass expiry). Re-applying the same pass + * changes nothing (greatest), so it is idempotent; a payment during the pass + * extends from the later end, so paid time is never eaten. Bound to the CoinPay + * subject by the caller, never to an email. Returns the practices it touched. + */ +export async function applyPass(userId, pass) { + const exp = pass?.expiresAt instanceof Date ? pass.expiresAt : null; + if (!userId || pass?.active !== true || !exp || exp.getTime() <= Date.now()) return []; + const rows = await db()` + insert into org_billing (org_id, paid_through) + select p.org_id, ${exp}::timestamptz from org_people p + where p.user_id = ${userId} and p.user_type = 'owner' and p.status = 'active' + on conflict (org_id) do update set paid_through = + greatest(coalesce(org_billing.paid_through, excluded.paid_through), excluded.paid_through) + returning org_id, paid_through`; + for (const r of rows) + await db()`insert into audit_log (org_id, user_id, action, subject) values (${r.org_id}, ${userId}, 'billing.coinpay_pass', ${exp.toISOString()})`; + return rows; +} + /* ----------------------------------------------------------------- invites -- */ let disposable = null; diff --git a/test/credits.test.js b/test/credits.test.js index 4c0f93d..1115e05 100644 --- a/test/credits.test.js +++ b/test/credits.test.js @@ -128,6 +128,33 @@ describe.skipIf(!configured())('credit codes + invites', () => { expect(row.invite).toBe('INV-ZZZZ-ZZZZ-ZZZZ'); }); + test('Profullstack Pass: an owned practice is paid until the pass expires, idempotently', async () => { + const [u] = await db()`select id from users where email = ${`credowner${tag}@example.com`}`; + const [before] = await db()`select paid_through from org_billing where org_id = ${orgId}`; + const exp = new Date(Math.max(Date.now(), new Date(before?.paid_through ?? 0).getTime()) + 40 * 86400_000); + const pass = auth.passFromUserinfo({ sub: 's', pass: { active: true, expires_at: exp.toISOString() } }); + expect(pass.active).toBe(true); + expect((await credits.applyPass(u.id, pass)).length).toBe(1); + await credits.applyPass(u.id, pass); + const [after] = await db()`select paid_through from org_billing where org_id = ${orgId}`; + expect(new Date(after.paid_through).toISOString()).toBe(exp.toISOString()); + // An earlier pass never shortens paid time. + const shorter = { active: true, expiresAt: new Date(Date.now() + 86400_000) }; + await credits.applyPass(u.id, shorter); + const [still] = await db()`select paid_through from org_billing where org_id = ${orgId}`; + expect(new Date(still.paid_through).toISOString()).toBe(exp.toISOString()); + expect(await credits.applyPass(u.id, { active: false, expiresAt: null })).toEqual([]); + }); + + test('passFromUserinfo: active only with active === true and a future expiry', () => { + const f = new Date(Date.now() + 86400_000).toISOString(); + expect(auth.passFromUserinfo({ pass: { active: true, expires_at: f } }).active).toBe(true); + expect(auth.passFromUserinfo({ pass: { active: true } }).active).toBe(false); + expect(auth.passFromUserinfo({ pass: { active: 'true', expires_at: f } }).active).toBe(false); + expect(auth.passFromUserinfo({ pass: { active: true, expires_at: new Date(Date.now() - 5).toISOString() } }).active).toBe(false); + expect(auth.passFromUserinfo({}).active).toBe(false); + }); + test('mailboxKey folds +tags and Gmail dots', () => { expect(credits.mailboxKey('J.Doe+x@GoogleMail.com')).toBe('jdoe@gmail.com'); });