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 @@
You always pay the lower of the two. At 20 seats the flat plan wins. Pay through CoinPay in any major coin or PayPal.
+
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');
});