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
1 change: 1 addition & 0 deletions apps/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ <h2 style="margin: 0; font-size: clamp(32px, 4vw, 52px); letter-spacing: -0.035e
<div style="display: flex; flex-direction: column; gap: 14px; max-width: 680px">
<h2 style="margin: 0; font-size: clamp(32px, 4vw, 52px); letter-spacing: -0.035em; line-height: 1.02; font-weight: 600">Pay for your team. Never for patients.</h2>
<p style="margin: 0; color: #A3ACA6; font-size: 17px; line-height: 1.5">You always pay the lower of the two. At 20 seats the flat plan wins. Pay through CoinPay in any major coin or PayPal.</p>
<p style="margin: 0; color: #A3ACA6; font-size: 15px; line-height: 1.5">Deposit $99 in your CoinPay wallet and get a free month: <a href="https://coinpayportal.com/pass" style="color: inherit">CoinPay Pass</a> (practices you own, signed in with CoinPay).</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px">
<div style="border: 1px solid #222A27; border-radius: 20px; padding: 36px; display: flex; flex-direction: column; gap: 18px">
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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. */
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/credits.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions test/credits.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
Loading