diff --git a/apps/web/package.json b/apps/web/package.json index 394e59f..13c29dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,12 +7,15 @@ "start": "bun src/main.js" }, "dependencies": { + "@profullstack/affiliates": "0.3.0", "@profullstack/profile": "0.1.6", + "@profullstack/redeem-credits": "0.2.0", "@profullstack/x402-gateway": "^0.13.1", "@simplewebauthn/browser": "^14.0.0", "@simplewebauthn/server": "^14.0.3", "@tleehealth/db": "workspace:*", "@tleehealth/payments": "workspace:*", + "disposable-email-domains": "1.0.62", "hono": "^4.13.14" } } diff --git a/apps/web/public/app/app.js b/apps/web/public/app/app.js index cda7556..26b29bc 100644 --- a/apps/web/public/app/app.js +++ b/apps/web/public/app/app.js @@ -190,6 +190,11 @@ async function render() { await loadMe(); if (path === '/portal/records') return renderRecords(q); if (path.startsWith('/portal')) return renderPortal(); + if (path === '/app/invites' && !state.orgId) { + const v = await viewInvites(q); + root.innerHTML = `
`; + return v.bind(); + } if (!state.orgId) return state.me.patient_of.length && path !== '/app/new' ? go('/portal') : renderNewPractice(); if (path === '/app/new') return renderNewPractice(); @@ -204,6 +209,7 @@ async function render() { else if (path === '/app/locations') view = viewLocations(); else if (path === '/app/billing') view = await viewBilling(q); else if (path === '/app/settings') view = await viewSettings(); + else if (path === '/app/invites') view = await viewInvites(q); else view = await viewToday(q); root.innerHTML = shell(path, view.html); bindShell(); @@ -238,6 +244,7 @@ function shell(path, main) { ${navItem('/app/team', 'Team')} ${navItem('/app/locations', 'Locations')} ${is(ADMIN) ? navItem('/app/billing', 'Billing', o.billing.active ? '' : 'unpaid') : ''} + ${navItem('/app/invites', 'Invites')} ${navItem('/app/settings', 'Settings')} ${state.me.patient_of.length ? navItem('/portal', 'My health') : ''} ${navItem('/portal/records', 'My records')} @@ -309,7 +316,8 @@ function renderSignin(q, signup = false) { e.preventDefault(); const data = formData(e.target); try { - await post('/auth/link', signup ? { ...data, mode: 'signup' } : { email: data.email }); + const invite = q.get('invite') ?? undefined; + await post('/auth/link', signup ? { ...data, mode: 'signup', invite } : { email: data.email, invite }); e.target.innerHTML = `We sent a sign-in link to ${esc(data.email)}. It works once and expires in 20 minutes.
Paid through CoinPay in any major coin or PayPal. Each payment adds one month.
` : 'Online payment is not switched on yet. Your practice keeps working; we will email the owner before it is needed.
'} +Credit codes and invites give days of the paid plan. Redeeming here adds them to this practice; days from invites wait in your balance until you add them. Invite someone.
+When someone new signs up with your invite, you both get ${bonus_days} days of the paid plan. Each invite works once and expires after 30 days.
+ + +New accounts can accept one invite.
+ +Send this link. It is shown only once.
${esc(invite.url)}
`; + e.target.reset(); + } catch (err) { + toast(err.message, true); + } + }; + const acc = document.getElementById('acc-form'); + if (acc) acc.onsubmit = async (e) => { + e.preventDefault(); + try { + const r = await post('/invites/accept', formData(e.target)); + toast(`Invite accepted: ${r.days} days added to your balance`); + go('/app/invites'); + } catch (err) { + toast(err.message, true); + } + }; }, }; } diff --git a/apps/web/scripts/mint-credit-code.js b/apps/web/scripts/mint-credit-code.js new file mode 100644 index 0000000..02e1844 --- /dev/null +++ b/apps/web/scripts/mint-credit-code.js @@ -0,0 +1,40 @@ +#!/usr/bin/env bun +// Mint a tleehealth credit code (staff only; there is no minting API). +// +// docker exec, or typed) rides on the emailed link, so opening
+ // it on another device still counts. Only a link that creates the account uses it.
+ const invite = credits.cleanInvite(b.invite) ?? credits.cleanInvite(getCookie(c, credits.INVITE_COOKIE));
+ await sendLoginLink({ email: e, url: await auth.createLoginLink(e, { profile, invite }) });
} catch (err) {
console.error(`[auth] could not send link: ${err?.message ?? err}`);
}
@@ -1421,6 +1425,71 @@ api.post('/orgs/:org/appointments/:id/call', async (c) => {
return c.json({ call: after });
});
+/* ------------------------------------------------- credit codes + invites -- */
+// Codes and invites grant pro_days into the user's balance; an owner or org
+// manager applies them to a practice (org_billing.paid_through). Minting is
+// staff-only and done with scripts/mint-credit-code.js, never over the API.
+
+api.get('/credits', async (c) => {
+ const user = await requireUser(c);
+ const g = credits.getCredits();
+ const [balance, history] = await Promise.all([g.balance(user.id, credits.UNIT), g.history(user.id, credits.UNIT)]);
+ return c.json({ unit: credits.UNIT, balance, history });
+});
+
+api.post('/credits/redeem', async (c) => {
+ const user = await requireUser(c);
+ const b = await body(c);
+ const code = str(b.code, 64);
+ if (!code) return c.json({ error: 'enter a code' }, 400);
+ // Check the practice before the code is spent, so a bad org never burns it.
+ let me = null;
+ if (b.org_id) ({ me } = await member(c, b.org_id, ADMIN));
+ const r = await credits.getCredits().redeem(code, user.id);
+ if (!r.ok) return c.json({ error: credits.REDEEM_MESSAGES[r.reason] ?? 'that code did not work', reason: r.reason }, 400);
+ let applied = null;
+ if (me && r.grant.unit === credits.UNIT) {
+ applied = await credits.applyDays(user.id, me.org_id, r.grant.amount);
+ if (applied.ok) await audit(me.org_id, user.id, 'billing.credit_apply');
+ }
+ return c.json({ ok: true, grant: r.grant, applied, balance: await credits.getCredits().balance(user.id, credits.UNIT) });
+});
+
+api.post('/orgs/:org/billing/apply-credits', async (c) => {
+ const { me, user } = await member(c, c.req.param('org'), ADMIN);
+ const days = Number((await body(c)).days);
+ const r = await credits.applyDays(user.id, me.org_id, days);
+ if (!r.ok) return c.json({ error: r.error, balance: r.balance }, 400);
+ await audit(me.org_id, user.id, 'billing.credit_apply');
+ return c.json(r);
+});
+
+api.get('/invites', async (c) => {
+ const user = await requireUser(c);
+ const aff = credits.getAffiliates();
+ const [quota, invites, invited_by] = await Promise.all([aff.quota(user.id), aff.invites(user.id), aff.invitedBy(user.id)]);
+ const new_account = Date.now() - new Date(user.created_at).getTime() <= credits.INVITEE_MAX_AGE_DAYS * 86400_000;
+ return c.json({ quota, invites, can_accept: !invited_by && new_account, bonus_days: credits.INVITE_BONUS_DAYS });
+});
+
+api.post('/invites', async (c) => {
+ const user = await requireUser(c);
+ const note = str((await body(c)).note, 120) ?? '';
+ const aff = credits.getAffiliates();
+ if ((await aff.quota(user.id)).left < 1) return c.json({ error: 'no invites left' }, 400);
+ const invite = await aff.createInvite(user.id, note);
+ return c.json({ invite: { ...invite, url: `${config.siteUrl}/i/${invite.code}` }, quota: await aff.quota(user.id) }, 201);
+});
+
+api.post('/invites/accept', async (c) => {
+ const user = await requireUser(c);
+ const code = credits.cleanInvite((await body(c)).code);
+ if (!code) return c.json({ error: 'enter an invite code' }, 400);
+ const r = await credits.acceptInvite(user.id, code);
+ if (!r.ok) return c.json({ error: credits.ACCEPT_MESSAGES[r.reason] ?? r.reason, reason: r.reason }, 400);
+ return c.json({ ok: true, days: r.inviteeDays });
+});
+
/* --------------------------------------------------------------- billing -- */
async function billing(orgId) {
diff --git a/apps/web/src/app.js b/apps/web/src/app.js
index ce1b046..79ad160 100644
--- a/apps/web/src/app.js
+++ b/apps/web/src/app.js
@@ -9,6 +9,8 @@ import { createCrawlGate } from './crawl-gate.js';
import { api, coinpayWebhook, saveSignup } from './api.js';
import { withPitFooter } from './pit-footer.js';
import * as auth from './auth.js';
+import { config } from './config.js';
+import * as credits from './credits.js';
import * as calls from './calls.js';
import * as records from './records.js';
import * as telnyx from './telnyx.js';
@@ -88,9 +90,43 @@ app.get('/auth/magic', async (c) => {
const s = token ? await auth.consumeLoginLink(token, { userAgent: c.req.header('user-agent') }) : null;
if (!s) return c.redirect('/signin?error=expired', 302);
await saveSignup(s.user, s.profile);
+ // The link proved the mailbox; a link that just created the account takes its invite.
+ let to = '/app';
+ if (s.created && s.invite) {
+ try {
+ const r = await credits.acceptInvite(s.user.id, s.invite);
+ console.log(`[invite] ${s.user.id} ${r.ok ? 'accepted' : r.reason}`);
+ if (r.ok) to = '/app/invites?joined=1';
+ } catch (err) {
+ console.error(`[invite] accept failed: ${err?.message ?? err}`);
+ }
+ }
c.header('set-cookie', auth.sessionCookie(s.sessionId));
- return c.redirect('/app', 302);
+ if (s.invite) c.header('set-cookie', `${credits.INVITE_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`, { append: true });
+ return c.redirect(to, 302);
+});
+
+// An invite link: keep the code (cookie + the signup link) and send them to sign up.
+app.get('/i/:code', async (c) => {
+ const code = credits.cleanInvite(c.req.param('code'));
+ if (!code) return c.redirect('/signup', 302);
+ c.header(
+ 'set-cookie',
+ `${credits.INVITE_COOKIE}=${code}; Path=/; Max-Age=${30 * 86400}; HttpOnly; SameSite=Lax${config.isProd ? '; Secure' : ''}`,
+ );
+ const esc = (s) => String(s).replace(/[&<>"']/g, (ch) => `${ch.charCodeAt(0)};`);
+ return c.html(
+ await withPitFooter(`
+You're invited to tleehealth
+You're invited to tleehealth
+Scheduling, prescriptions, labs and AI follow-up calls for your practice. Join with this invite and you both get ${credits.INVITE_BONUS_DAYS} days of the paid plan, on top of the 14-day trial.
+${esc(code)}
+
+Already have an account? Sign in; invites count for accounts in their first ${credits.INVITEE_MAX_AGE_DAYS} days.
+`),
+ );
});
+app.get('/invites', (c) => c.redirect('/app/invites', 302));
// The provider (MyChart or any SMART on FHIR portal) sends the patient back here
// after they sign in there and allow access. The import starts right away.
@@ -224,6 +260,8 @@ Authenticate with a session cookie or \`Authorization: Bearer th_live_...\` (cre
- GET /orgs/:org/patients/:id/records what that patient shared with the practice (audit-logged)
- GET|PUT /orgs/:org/call-settings AI calls: on/off, hours before/after, calling window, attempts
- POST /orgs/:org/appointments/:id/call call the patient now ({"kind":"reminder"|"followup"})
+- GET /credits, POST /credits/redeem {code, org_id?}, POST /orgs/:org/billing/apply-credits {days} credit codes: days of a practice's paid plan
+- GET|POST /invites, POST /invites/accept {code} invites: 3 per account, both sides get 7 days; landing page /i/
- GET /orgs/:org/calls/:id outcome, summary and transcript; POST .../resolve when handled
- GET|PUT /orgs/:org/patients/:id/care-plan, POST .../care-plan/items, PATCH /orgs/:org/care-plan-items/:id
goals and tasks with owners and due dates; shared plans show in the portal
diff --git a/apps/web/src/auth.js b/apps/web/src/auth.js
index 623ffa2..b44547e 100644
--- a/apps/web/src/auth.js
+++ b/apps/web/src/auth.js
@@ -36,25 +36,28 @@ export const expectedOrigins = () => {
* the token because the account row does not exist until the link is spent.
* Bound as text and cast, so the driver never JSON-encodes the string a second time.
*/
-export async function createLoginLink(email, { profile = null } = {}) {
+export async function createLoginLink(email, { profile = null, invite = null } = {}) {
const token = randomBytes(32).toString('base64url');
await db()`
- insert into login_tokens (token_hash, email, expires_at, profile)
+ insert into login_tokens (token_hash, email, expires_at, profile, invite)
values (${sha(token)}, ${email.trim().toLowerCase()}, ${new Date(Date.now() + TOKEN_TTL_MINUTES * 60_000)},
- ${profile ? JSON.stringify(profile) : null}::text::jsonb)`;
+ ${profile ? JSON.stringify(profile) : null}::text::jsonb, ${invite})`;
return `${config.siteUrl}/auth/magic?t=${token}`;
}
-/** Spend a link. An address nobody has used before gets an account. */
+/**
+ * Spend a link. An address nobody has used before gets an account; `created`
+ * says so, and `invite` is the invite the signup carried (accepted only then).
+ */
export async function consumeLoginLink(token, { userAgent } = {}) {
const [row] = await db()`
update login_tokens set used_at = now()
where token_hash = ${sha(token)} and used_at is null and expires_at > now()
- returning email`;
+ returning email, invite`;
if (!row) return null;
const profile = await takeSignupProfile(sha(token));
- const user = await findOrCreateUser(row.email);
- return { user, sessionId: await startSession(user.id, userAgent), profile };
+ const { created, ...user } = await findOrCreateUser(row.email);
+ return { user, created: Boolean(created), invite: row.invite ?? null, sessionId: await startSession(user.id, userAgent), profile };
}
/**
@@ -81,7 +84,7 @@ export async function findOrCreateUser(email) {
const [user] = await sql`
insert into users (email) values (${e})
on conflict (lower(email)) do update set email = users.email
- returning *`;
+ returning *, (xmax = 0) as created`;
await linkPeople(user);
return user;
}
diff --git a/apps/web/src/crawl-gate.js b/apps/web/src/crawl-gate.js
index c20ca67..7c317ae 100644
--- a/apps/web/src/crawl-gate.js
+++ b/apps/web/src/crawl-gate.js
@@ -19,7 +19,7 @@ export function createCrawlGate(env = process.env) {
coinpay: { apiKey },
payTo,
contact: 'mailto:anthony@profullstack.com',
- openPaths: ['/healthz', '/install.sh', '/llms.txt', '/api/', '/webhooks/'],
+ openPaths: ['/healthz', '/install.sh', '/llms.txt', '/api/', '/webhooks/', '/i/'],
exempt: (request) => /(?:^|;\s*)th_session=/.test(request.headers.get('cookie') ?? ''),
});
}
diff --git a/apps/web/src/credits.js b/apps/web/src/credits.js
new file mode 100644
index 0000000..b9b87f7
--- /dev/null
+++ b/apps/web/src/credits.js
@@ -0,0 +1,157 @@
+import { db } from '@tleehealth/db';
+import { Credits, postgresStore as creditStore } from '@profullstack/redeem-credits';
+import { Affiliates, postgresStore as affStore } from '@profullstack/affiliates';
+
+/**
+ * Credit codes (@profullstack/redeem-credits) and invites (@profullstack/affiliates).
+ *
+ * What both grant is `pro_days`: days of a practice's paid plan. They land in the
+ * user's balance (the redeem-credits ledger) and an owner or org manager applies
+ * them to a practice, which moves org_billing.paid_through forward, the same
+ * column a CoinPay payment extends. No health data is read or written here.
+ */
+
+export const UNIT = 'pro_days';
+export const INVITE_QUOTA = 3;
+export const INVITE_BONUS_DAYS = 7;
+export const INVITER_MONTHLY_CAP = 3;
+export const INVITEE_MAX_AGE_DAYS = 7;
+const TRIAL_DAYS = 14;
+const DAY_MS = 86400_000;
+
+/** node-postgres shaped adapter over the app's porsager client. */
+const pool = () => ({
+ query: async (text, params) => ({ rows: params?.length ? await db().unsafe(text, params) : await db().unsafe(text) }),
+});
+
+let credits = null;
+let affiliates = null;
+export const getCredits = () => (credits ??= new Credits(creditStore(pool())));
+export const getAffiliates = () => (affiliates ??= new Affiliates(affStore(pool()), { defaultQuota: INVITE_QUOTA }));
+
+/** Create the module tables at boot, not on a first request. */
+export async function warm() {
+ await getCredits().list();
+ await getAffiliates().quota('00000000-0000-0000-0000-000000000000');
+}
+
+export const REDEEM_MESSAGES = {
+ invalid: 'that code is not valid',
+ expired: 'that code has expired',
+ used_up: 'that code has already been used',
+ already_redeemed: 'you already redeemed that code',
+ revoked: 'that code was revoked',
+};
+
+/**
+ * Move `days` from the user's balance onto a practice's paid period. The caller
+ * has checked the user is its owner or org manager. The spend is compare-and-swap
+ * safe; if the period write fails the days go back.
+ */
+export async function applyDays(userId, orgId, days) {
+ if (!Number.isInteger(days) || days < 1 || days > 36500) return { ok: false, error: 'pick a whole number of days' };
+ const c = getCredits();
+ const spent = await c.spend(userId, UNIT, days, `org:${orgId}`);
+ if (!spent.ok) return { ok: false, error: `you have ${spent.balance} day${spent.balance === 1 ? '' : 's'} to apply`, balance: spent.balance };
+ try {
+ // A practice still in its free trial keeps the trial: paid time starts when it ends.
+ const [b] = await db()`
+ insert into org_billing (org_id, paid_through)
+ select o.id, greatest(now(), o.created_at + make_interval(days => ${TRIAL_DAYS})) + make_interval(days => ${days})
+ from organizations o where o.id = ${orgId}
+ on conflict (org_id) do update set paid_through =
+ greatest(coalesce(org_billing.paid_through, now()), now(),
+ (select created_at from organizations where id = ${orgId}) + make_interval(days => ${TRIAL_DAYS}))
+ + make_interval(days => ${days})
+ returning paid_through`;
+ if (!b) throw new Error('no such practice');
+ return { ok: true, days, paid_through: b.paid_through, balance: spent.balance };
+ } catch (err) {
+ await c.grant(userId, { unit: UNIT, amount: days }, `refund:org:${orgId}`);
+ throw err;
+ }
+}
+
+/* ----------------------------------------------------------------- invites -- */
+
+let disposable = null;
+async function throwaway(email) {
+ disposable ??= new Set((await import('disposable-email-domains')).default);
+ const domain = String(email).toLowerCase().split('@')[1] ?? '';
+ if (!domain) return true;
+ const parts = domain.split('.');
+ for (let i = 0; i < parts.length - 1; i++) if (disposable.has(parts.slice(i).join('.'))) return true;
+ return false;
+}
+
+/** Same mailbox: no +tags, no dots on Gmail, googlemail = gmail. */
+export function mailboxKey(email) {
+ let [local = '', domain = ''] = String(email).trim().toLowerCase().split('@');
+ local = local.split('+')[0];
+ if (domain === 'googlemail.com') domain = 'gmail.com';
+ if (domain === 'gmail.com') local = local.replace(/\./g, '');
+ return `${local}@${domain}`;
+}
+
+export const ACCEPT_MESSAGES = {
+ not_found: 'sign in first',
+ account_too_old: `invites are for new accounts (first ${INVITEE_MAX_AGE_DAYS} days)`,
+ throwaway_email: 'invites are not open to throwaway email addresses',
+ duplicate_account: 'another tleehealth account already uses that mailbox',
+ invalid: 'that invite code is not valid',
+ expired: 'that invite has expired',
+ used: 'that invite has already been used',
+ self: 'you cannot accept your own invite',
+ revoked: 'that invite was revoked',
+ already_invited: 'your account has already accepted an invite',
+};
+
+/** Gates on the invitee. Every account here is email-verified: the emailed link made it. */
+async function gate(userId, now) {
+ const [u] = await db()`select id, email, created_at from users where id = ${userId}`;
+ if (!u) return 'not_found';
+ if (now - new Date(u.created_at).getTime() > INVITEE_MAX_AGE_DAYS * DAY_MS) return 'account_too_old';
+ if (await throwaway(u.email)) return 'throwaway_email';
+ const key = mailboxKey(u.email);
+ const [local, domain] = key.split('@');
+ // Candidates on the same domain (googlemail folded into gmail), compared in JS.
+ const others = await db()`
+ select email from users where id <> ${userId}
+ and lower(split_part(email, '@', 2)) in (${domain}, ${domain === 'gmail.com' ? 'googlemail.com' : domain})`;
+ if (others.some((o) => mailboxKey(o.email) === `${local}@${domain}`)) return 'duplicate_account';
+ return null;
+}
+
+/**
+ * Accept an invite for a new account and pay both sides INVITE_BONUS_DAYS.
+ * redeemInvite() is the one atomic gate (one invite per account; a second is
+ * refused `already_invited` and left unused); invitedBy() is never pre-checked.
+ */
+export async function acceptInvite(userId, code, now = Date.now()) {
+ const g = await gate(userId, now);
+ if (g) return { ok: false, reason: g };
+ const r = await getAffiliates().redeemInvite(code, userId);
+ if (!r.ok) return { ok: false, reason: r.reason };
+
+ const monthStart = new Date(Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1));
+ const [{ n }] = await db()`
+ select count(*)::int as n from invite_bonuses
+ where inviter_id = ${r.inviterId} and inviter_paid and created_at >= ${monthStart}`;
+ const inviterPaid = n < INVITER_MONTHLY_CAP;
+ const [row] = await db()`
+ insert into invite_bonuses (invitee_id, inviter_id, invite_id, days, inviter_paid)
+ values (${userId}, ${r.inviterId}, ${r.inviteId}, ${INVITE_BONUS_DAYS}, ${inviterPaid})
+ on conflict (invitee_id) do nothing returning invitee_id`;
+ if (!row) return { ok: true, inviterId: r.inviterId, inviteeDays: 0, inviterDays: 0 };
+ const grant = { unit: UNIT, amount: INVITE_BONUS_DAYS };
+ await getCredits().grant(userId, grant, `invite:${r.inviteId}:invitee`);
+ if (inviterPaid) await getCredits().grant(r.inviterId, grant, `invite:${r.inviteId}:inviter`);
+ return { ok: true, inviterId: r.inviterId, inviteeDays: INVITE_BONUS_DAYS, inviterDays: inviterPaid ? INVITE_BONUS_DAYS : 0 };
+}
+
+/** Invite codes are INV-XXXX-XXXX-XXXX; keep only what could be one. */
+export const cleanInvite = (v) => {
+ const s = String(v ?? '').replace(/[^A-Za-z0-9-]/g, '').slice(0, 40);
+ return s.length >= 12 ? s : null;
+};
+export const INVITE_COOKIE = 'th_invite';
diff --git a/apps/web/src/main.js b/apps/web/src/main.js
index 65db702..9f104e2 100644
--- a/apps/web/src/main.js
+++ b/apps/web/src/main.js
@@ -5,6 +5,7 @@ import { app } from './app.js';
import { startScheduler } from './calls.js';
import { enabled as callingEnabled } from './telnyx.js';
import { config } from './config.js';
+import { warm as warmCredits } from './credits.js';
// Production never runs without its database; the deploy's health check then
// fails loudly instead of serving a site that cannot store anything.
@@ -16,6 +17,8 @@ if (configured()) {
await migrate();
// The coinpay object goes in whole: its getters read the environment on each access.
configurePayments({ sql: db(), coinpay: config.coinpay, siteUrl: config.siteUrl });
+ // The credit-code and invite tables, created before the first request needs them.
+ await warmCredits();
// The AI-call dialler: every minute, inside each practice's calling hours.
if (process.env.CALLS_SCHEDULER !== 'off') startScheduler();
}
diff --git a/bun.lock b/bun.lock
index a66d690..7791d59 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,18 +9,21 @@
"name": "@tleehealth/web",
"version": "0.1.2",
"dependencies": {
+ "@profullstack/affiliates": "0.3.0",
"@profullstack/profile": "0.1.6",
+ "@profullstack/redeem-credits": "0.2.0",
"@profullstack/x402-gateway": "^0.13.1",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"@tleehealth/db": "workspace:*",
"@tleehealth/payments": "workspace:*",
+ "disposable-email-domains": "1.0.62",
"hono": "^4.13.14",
},
},
"packages/cli": {
"name": "@profullstack/tleehealth",
- "version": "0.4.2",
+ "version": "0.5.0",
"bin": {
"tleehealth": "./bin/tleehealth.mjs",
},
@@ -38,12 +41,12 @@
},
"packages/mcp": {
"name": "@profullstack/tleehealth-mcp",
- "version": "0.4.2",
+ "version": "0.5.0",
"bin": {
"tleehealth-mcp": "./bin/tleehealth-mcp.mjs",
},
"dependencies": {
- "@profullstack/tleehealth": "^0.4.2",
+ "@profullstack/tleehealth": "^0.5.0",
},
},
"packages/payments": {
@@ -86,6 +89,8 @@
"@peculiar/x509": ["@peculiar/x509@2.2.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-csr": "^2.11.0", "@peculiar/asn1-ecc": "^2.11.0", "@peculiar/asn1-pkcs9": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-post-quantum": "^2.11.0", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q=="],
+ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="],
+
"@profullstack/hqtui": ["@profullstack/hqtui@0.11.1", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-c/UzTmiRDjdLZQEO/gdVinUKtx6Q0WBPdixIm90MBTvC3DqFPqpersRs0BIDPS7XVokCSr1DtELI91kj5J1kCg=="],
"@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="],
@@ -94,6 +99,10 @@
"@profullstack/profile": ["@profullstack/profile@0.1.6", "", { "peerDependencies": { "@libsql/client": ">=0.6.0", "postgres": "^3.4.0", "react": ">=18" }, "optionalPeers": ["@libsql/client", "postgres", "react"], "bin": { "profile": "src/cli.js" } }, "sha512-xkn0P3ANz151QJdlBwcBXkHk813qW9qAmuTZKcuvpEZYkyHDC9J9KfvpVSRepfjECzz4ONGHWO5Zwc4BHpfK8g=="],
+ "@profullstack/redeem-credits": ["@profullstack/redeem-credits@0.2.0", "", { "bin": { "redeem-credits": "dist/cli.js" } }, "sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog=="],
+
+ "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="],
+
"@profullstack/tleehealth": ["@profullstack/tleehealth@workspace:packages/cli"],
"@profullstack/tleehealth-mcp": ["@profullstack/tleehealth-mcp@workspace:packages/mcp"],
@@ -112,6 +121,8 @@
"asn1js": ["asn1js@3.1.0", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.2.0", "tslib": "^2.8.1" } }, "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA=="],
+ "disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="],
+
"hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
diff --git a/packages/cli/package.json b/packages/cli/package.json
index f6441aa..1aa9306 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/tleehealth",
- "version": "0.4.2",
+ "version": "0.5.0",
"description": "tleehealth from the terminal: the CLI, the front-desk TUI, and the API client the MCP server uses.",
"type": "module",
"license": "MIT",
diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js
index 3b41579..2918de6 100644
--- a/packages/cli/src/cli.js
+++ b/packages/cli/src/cli.js
@@ -27,6 +27,7 @@ import { createInterface } from 'node:readline/promises';
import {
caseload, connect, connection, disconnect, exportRecords, health, logTime, providers, recordAccess, recordFile, recordItems, records,
resolveAuth, saveConfig, schedule, shareConnection, superbill, syncConnection,
+ applyCredits, createInvite, creditBalance, invites, redeemCode,
} from './client.js';
const HELP = `tleehealth: telehealth and practice management.
@@ -47,6 +48,12 @@ Your health records from other providers (MyChart and any SMART on FHIR portal):
tleehealth access who at which practice opened your shared records
tleehealth disconnect CONNECTION_ID remove it and everything it imported
+Credit codes and invites (days of a practice's paid plan):
+ tleehealth redeem CODE [--org ORG] [--keep] redeem a code onto a practice (--keep: into your balance)
+ tleehealth credits [--apply DAYS] [--org ORG] your balance; --apply moves days onto a practice
+ tleehealth invite [--note TEXT] make an invite link (3 per account)
+ tleehealth invites [--json] your invites and what is left
+
tleehealth health is the API up
tleehealth login [KEY] save an API key
tleehealth dashboard the dashboard, as a screen (alias: tui)
@@ -299,6 +306,42 @@ export async function main(argv = process.argv.slice(2)) {
await save(r.bytes, typeof flags.out === 'string' ? flags.out : (r.filename ?? id));
return 0;
}
+ case 'redeem': {
+ if (!rest[0]) throw new Error('usage: tleehealth redeem CODE [--org ORG] [--keep]');
+ const r = await redeemCode(auth, { code: rest[0], org: flags.org, keep: Boolean(flags.keep) });
+ console.log(
+ r.applied?.ok
+ ? `Added ${r.applied.days} days. Paid through ${String(r.applied.paid_through).slice(0, 10)}.`
+ : `Added ${r.grant.amount} days to your balance (${r.balance} now).${r.applied?.error ? ` ${r.applied.error}` : ''}`,
+ );
+ return 0;
+ }
+ case 'credits': {
+ if (flags.apply) {
+ const r = await applyCredits(auth, { org: flags.org, days: flags.apply });
+ console.log(`Added ${r.days} days. Paid through ${String(r.paid_through).slice(0, 10)}. Balance ${r.balance}.`);
+ return 0;
+ }
+ const c = await creditBalance(auth);
+ console.log(flags.json ? JSON.stringify(c, null, 2) : `${c.balance} ${c.unit.replace('_', ' ')}`);
+ return 0;
+ }
+ case 'invite': {
+ const r = await createInvite(auth, { note: flags.note });
+ console.log(`${r.invite.url}
+(shown once; ${r.quota.left} of ${r.quota.total} left)`);
+ return 0;
+ }
+ case 'invites': {
+ const r = await invites(auth);
+ if (flags.json) console.log(JSON.stringify(r, null, 2));
+ else {
+ console.log(`${r.quota.left} of ${r.quota.total} invites left. Each gives you both ${r.bonus_days} days.`);
+ for (const i of r.invites)
+ console.log(` ${new Date(i.createdAt).toISOString().slice(0, 10)} ${(i.note || 'invite').padEnd(24)} ${i.revokedAt ? 'revoked' : i.usedAt ? 'accepted' : 'open'}`);
+ }
+ return 0;
+ }
case 'dashboard':
case 'tui': {
const { runTui } = await import('./tui.js');
diff --git a/packages/cli/src/client.js b/packages/cli/src/client.js
index bbd90dd..7220fe7 100644
--- a/packages/cli/src/client.js
+++ b/packages/cli/src/client.js
@@ -182,3 +182,26 @@ export const recordFile = (auth, id) => download(auth, `/api/v1/records/files/${
/** Who at which practice viewed, downloaded or exported the records you shared. */
export const recordAccess = (auth) => call(auth, '/api/v1/records/access');
+
+/* ------------------------------------------------- credit codes + invites -- */
+
+const postJson = (auth, path, body) =>
+ call(auth, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
+
+/** Your balance of pro_days (days of a practice's paid plan) and its history. */
+export const creditBalance = (auth) => call(auth, '/api/v1/credits');
+
+/** Redeem a credit code; with an org (yours to administer) the days go straight onto its plan. */
+export async function redeemCode(auth, { code, org, keep = false } = {}) {
+ const orgId = keep ? undefined : await resolveOrg(auth, org).catch(() => undefined);
+ return postJson(auth, '/api/v1/credits/redeem', { code, ...(orgId ? { org_id: orgId } : {}) });
+}
+
+/** Move days from your balance onto a practice's paid plan. */
+export async function applyCredits(auth, { org, days } = {}) {
+ const id = await resolveOrg(auth, org);
+ return postJson(auth, `/api/v1/orgs/${id}/billing/apply-credits`, { days: Number(days) });
+}
+
+export const invites = (auth) => call(auth, '/api/v1/invites');
+export const createInvite = (auth, { note } = {}) => postJson(auth, '/api/v1/invites', { note });
diff --git a/packages/db/migrations/0008_credits_invites.sql b/packages/db/migrations/0008_credits_invites.sql
new file mode 100644
index 0000000..3c59063
--- /dev/null
+++ b/packages/db/migrations/0008_credits_invites.sql
@@ -0,0 +1,20 @@
+-- Credit codes + invites. The credit_* and aff_* tables belong to
+-- @profullstack/redeem-credits and @profullstack/affiliates (their postgres
+-- stores create them; apps/web/src/credits.js warms them at boot). What a code
+-- or invite grants is pro_days: days added to an org's org_billing.paid_through,
+-- the same column a CoinPay payment extends.
+
+-- The invite a signup carried, kept on the emailed link so opening it on
+-- another device still counts. Accepted only when that link creates the account.
+alter table login_tokens add column if not exists invite text;
+
+-- One bonus per invited account, however often anything retries.
+create table if not exists invite_bonuses (
+ invitee_id uuid primary key references users(id) on delete cascade,
+ inviter_id uuid not null references users(id) on delete cascade,
+ invite_id text not null,
+ days int not null,
+ inviter_paid boolean not null default false,
+ created_at timestamptz not null default now()
+);
+create index if not exists invite_bonuses_inviter_idx on invite_bonuses (inviter_id, created_at);
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 66ec48d..2fe6204 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/tleehealth-mcp",
- "version": "0.4.2",
+ "version": "0.5.0",
"description": "MCP server for tleehealth.com: read the practice schedule, the navigation caseload and superbill, and log navigation time from any MCP client.",
"type": "module",
"license": "MIT",
@@ -26,7 +26,7 @@
"README.md"
],
"dependencies": {
- "@profullstack/tleehealth": "^0.4.2"
+ "@profullstack/tleehealth": "^0.5.0"
},
"keywords": [
"mcp",
diff --git a/packages/mcp/src/server.js b/packages/mcp/src/server.js
index cb86a44..7d42987 100644
--- a/packages/mcp/src/server.js
+++ b/packages/mcp/src/server.js
@@ -4,6 +4,7 @@
*/
import { createInterface } from 'node:readline';
import {
+ call, resolveOrg,
caseload, connect, dashboardDay, health, logTime, providers, recordItem, recordItems, records, resolveAuth, schedule, superbill,
syncConnection,
} from '@profullstack/tleehealth/client';
@@ -128,6 +129,27 @@ const TOOLS = [
description: 'Import again from one connected provider (or all when connection is omitted) and wait for it to finish.',
inputSchema: { type: 'object', properties: { connection: { type: 'string' } }, additionalProperties: false },
},
+ {
+ name: 'redeem_credit_code',
+ description:
+ "Redeem a tleehealth credit code (CR-XXXX-XXXX-XXXX-XXXX) for days of a practice's paid plan. With org (or by default your first practice you administer) the days go onto that practice; keep=true puts them in your balance.",
+ inputSchema: {
+ type: 'object',
+ properties: { code: { type: 'string' }, org: { type: 'string', description: 'practice id' }, keep: { type: 'boolean' } },
+ required: ['code'],
+ additionalProperties: false,
+ },
+ },
+ {
+ name: 'get_credits',
+ description: "Your balance of pro_days (days of a practice's paid plan from codes and invites) and its history.",
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ },
+ {
+ name: 'create_invite',
+ description: 'Make an invite link (3 per account). When someone new signs up with it you both get days of the paid plan. The link is shown once.',
+ inputSchema: { type: 'object', properties: { note: { type: 'string' } }, additionalProperties: false },
+ },
{
name: 'api_health',
description: 'Whether the tleehealth API is up, and its version.',
@@ -171,6 +193,18 @@ async function callTool(name, args) {
for (const id of ids) out.push({ connection: id, ...(await syncConnection(auth, id, { wait: true })) });
return textResult(out);
}
+ case 'redeem_credit_code': {
+ const org = args.keep ? undefined : await resolveOrg(auth, args.org).catch(() => undefined);
+ return textResult(await call(auth, '/api/v1/credits/redeem', {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code: args.code, ...(org ? { org_id: org } : {}) }),
+ }));
+ }
+ case 'get_credits':
+ return textResult(await call(auth, '/api/v1/credits'));
+ case 'create_invite':
+ return textResult(await call(auth, '/api/v1/invites', {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ note: args.note }),
+ }));
case 'api_health':
return textResult(await health(auth));
default:
diff --git a/test/client.test.js b/test/client.test.js
index 4dd4208..8be2b33 100644
--- a/test/client.test.js
+++ b/test/client.test.js
@@ -37,7 +37,7 @@ describe('cli', () => {
describe('mcp', () => {
test('lists tools', async () => {
const { tools } = await handle({ method: 'tools/list' });
- expect(tools.map((t) => t.name)).toEqual(['get_schedule', 'get_caseload', 'get_superbill', 'log_navigation_time', 'get_dashboard', 'get_health_records', 'list_health_records', 'get_health_record', 'find_health_providers', 'connect_health_provider', 'sync_health_records', 'api_health']);
+ expect(tools.map((t) => t.name)).toEqual(['get_schedule', 'get_caseload', 'get_superbill', 'log_navigation_time', 'get_dashboard', 'get_health_records', 'list_health_records', 'get_health_record', 'find_health_providers', 'connect_health_provider', 'sync_health_records', 'redeem_credit_code', 'get_credits', 'create_invite', 'api_health']);
});
test('initialize', async () => {
expect((await handle({ method: 'initialize' })).serverInfo.name).toBe('tleehealth');
diff --git a/test/credits.test.js b/test/credits.test.js
new file mode 100644
index 0000000..4c0f93d
--- /dev/null
+++ b/test/credits.test.js
@@ -0,0 +1,134 @@
+// Credit codes + invites end to end through the app against a real Postgres.
+import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
+import { configured, close, db } from '../packages/db/src/index.js';
+import { migrate } from '../packages/db/src/migrate.js';
+
+const { app } = configured() ? await import('../apps/web/src/app.js') : { app: null };
+const auth = configured() ? await import('../apps/web/src/auth.js') : null;
+const credits = configured() ? await import('../apps/web/src/credits.js') : null;
+
+async function magic(email, opts) {
+ const url = new URL(await auth.createLoginLink(email, opts));
+ return app.request(`/auth/magic?t=${url.searchParams.get('t')}`);
+}
+const cookieOf = (res) => res.headers.get('set-cookie').split(';')[0];
+
+function client(cookie) {
+ const call = async (method, path, body) => {
+ const res = await app.request(`/api/v1${path}`, {
+ method,
+ headers: { cookie, 'content-type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ return { status: res.status, body: await res.json() };
+ };
+ return { get: (p) => call('GET', p), post: (p, b = {}) => call('POST', p, b) };
+}
+
+describe.skipIf(!configured())('credit codes + invites', () => {
+ const tag = Date.now();
+ let owner, orgId;
+
+ beforeAll(async () => {
+ await migrate({ log: () => {} });
+ await credits.warm();
+ owner = client(cookieOf(await magic(`credowner${tag}@example.com`)));
+ orgId = (await owner.post('/orgs', { name: `Credit Practice ${tag}` })).body.org.id;
+ });
+ afterAll(() => close());
+
+ test('a code redeemed for a practice extends paid_through past the trial', async () => {
+ const { code } = await credits.getCredits().mint({ grant: { unit: credits.UNIT, amount: 30 } });
+ const r = await owner.post('/credits/redeem', { code, org_id: orgId });
+ expect(r.status).toBe(200);
+ expect(r.body.applied.ok).toBe(true);
+ const days = (new Date(r.body.applied.paid_through) - Date.now()) / 86400_000;
+ expect(days).toBeGreaterThan(43.9); // 14 trial + 30
+ expect(days).toBeLessThan(44.1);
+ expect((await owner.get(`/orgs/${orgId}/billing`)).body.billing.paid_through).toBeTruthy();
+
+ const again = await owner.post('/credits/redeem', { code });
+ expect(again.status).toBe(400);
+ expect(again.body.reason).toBe('already_redeemed');
+ });
+
+ test('a bad practice never burns the code', async () => {
+ const { code } = await credits.getCredits().mint({ grant: { unit: credits.UNIT, amount: 5 } });
+ const other = client(cookieOf(await magic(`credother${tag}@example.com`)));
+ const r = await other.post('/credits/redeem', { code, org_id: orgId });
+ expect(r.status).toBe(404);
+ expect((await owner.post('/credits/redeem', { code })).body.balance).toBe(5);
+ const apply = await owner.post(`/orgs/${orgId}/billing/apply-credits`, { days: 6 });
+ expect(apply.status).toBe(400);
+ expect((await owner.post(`/orgs/${orgId}/billing/apply-credits`, { days: 5 })).body.ok).toBe(true);
+ expect((await owner.get('/credits')).body.balance).toBe(0);
+ });
+
+ test('an invite on the signup link is accepted when that link creates the account', async () => {
+ const inv = await owner.post('/invites', { note: 'friend' });
+ expect(inv.status).toBe(201);
+ expect(inv.body.invite.url).toContain(`/i/${inv.body.invite.code}`);
+ const res = await magic(`credfriend${tag}@example.com`, { invite: inv.body.invite.code });
+ expect(res.headers.get('location')).toBe('/app/invites?joined=1');
+ const friend = client(cookieOf(res));
+ expect((await friend.get('/credits')).body.balance).toBe(credits.INVITE_BONUS_DAYS);
+ expect((await owner.get('/credits')).body.balance).toBe(credits.INVITE_BONUS_DAYS);
+ // signing in again later with another invite does nothing
+ const inv2 = await owner.post('/invites', {});
+ const again = await magic(`credfriend${tag}@example.com`, { invite: inv2.body.invite.code });
+ expect(again.headers.get('location')).toBe('/app');
+ });
+
+ test('concurrent double-accept: one wins, the other is already_invited and stays unused', async () => {
+ const a = client(cookieOf(await magic(`credinvA${tag}@example.com`)));
+ const b = client(cookieOf(await magic(`credinvB${tag}@example.com`)));
+ const newbie = client(cookieOf(await magic(`crednew${tag}@example.com`)));
+ const ca = (await a.post('/invites', {})).body.invite.code;
+ const cb = (await b.post('/invites', {})).body.invite.code;
+ const rs = await Promise.all([newbie.post('/invites/accept', { code: ca }), newbie.post('/invites/accept', { code: cb })]);
+ expect(rs.filter((r) => r.status === 200)).toHaveLength(1);
+ const lost = rs.find((r) => r.status !== 200);
+ expect(lost.body.reason).toBe('already_invited');
+ expect((await newbie.get('/credits')).body.balance).toBe(credits.INVITE_BONUS_DAYS);
+ const loser = rs[0].status === 200 ? b : a;
+ expect((await loser.get('/invites')).body.invites[0].usedBy).toBeNull();
+ expect((await loser.get('/credits')).body.balance).toBe(0);
+ });
+
+ test('gates: throwaway domains, mailbox aliases, self, old accounts', async () => {
+ const inviter = client(cookieOf(await magic(`credgate${tag}@example.com`)));
+ const code = (await inviter.post('/invites', {})).body.invite.code;
+ const throw_ = client(cookieOf(await magic(`x${tag}@mailinator.com`)));
+ expect((await throw_.post('/invites/accept', { code })).body.reason).toBe('throwaway_email');
+ expect((await inviter.post('/invites/accept', { code })).body.reason).toBe('self');
+ const alias = client(cookieOf(await magic(`credgate${tag}+2@example.com`)));
+ expect((await alias.post('/invites/accept', { code })).body.reason).toBe('duplicate_account');
+ const old = client(cookieOf(await magic(`credold${tag}@example.com`)));
+ await db()`update users set created_at = now() - interval '8 days' where email = ${`credold${tag}@example.com`}`;
+ expect((await old.post('/invites/accept', { code })).body.reason).toBe('account_too_old');
+ expect((await inviter.get('/invites')).body.invites[0].usedBy).toBeNull();
+ });
+
+ test('/i/ keeps the invite and links to signup', async () => {
+ const res = await app.request('/i/INV-ABCD-EFGH-JKMN');
+ expect(res.status).toBe(200);
+ expect(res.headers.get('set-cookie')).toContain('th_invite=INV-ABCD-EFGH-JKMN');
+ expect(await res.text()).toContain('/signup?invite=INV-ABCD-EFGH-JKMN');
+ expect((await app.request('/invites')).headers.get('location')).toBe('/app/invites');
+ });
+
+ test('/auth/link stores the cookie invite on the emailed link', async () => {
+ const email = `credlink${tag}@example.com`;
+ await app.request('/api/v1/auth/link', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', cookie: 'th_invite=INV-ZZZZ-ZZZZ-ZZZZ' },
+ body: JSON.stringify({ email }),
+ });
+ const [row] = await db()`select invite from login_tokens where email = ${email}`;
+ expect(row.invite).toBe('INV-ZZZZ-ZZZZ-ZZZZ');
+ });
+
+ test('mailboxKey folds +tags and Gmail dots', () => {
+ expect(credits.mailboxKey('J.Doe+x@GoogleMail.com')).toBe('jdoe@gmail.com');
+ });
+});