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
2 changes: 1 addition & 1 deletion apps/web/public/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,7 @@ async function viewBilling(q) {
<dt>Paid through</dt><dd>${b.paid_through ? fmtDate(b.paid_through) : '—'}</dd>
<dt>Free trial</dt><dd>${new Date(b.trial_ends) > new Date() ? `ends ${fmtDate(b.trial_ends)}` : 'ended'}</dd>
</dl>
${b.payments_enabled ? `<div><button class="primary" id="pay">Pay ${money(b.monthly_cents)} for one month</button></div><p class="dim small">Card or crypto through CoinPay. Each payment adds one month.</p>` : '<p class="muted">Online payment is not switched on yet. Your practice keeps working; we will email the owner before it is needed.</p>'}
${b.payments_enabled ? `<div><button class="primary" id="pay">Pay ${money(b.monthly_cents)} for one month</button></div><p class="dim small">Paid in crypto (USDC and other coins) through CoinPay. Each payment adds one month.</p>` : '<p class="muted">Online payment is not switched on yet. Your practice keeps working; we will email the owner before it is needed.</p>'}
</div></section>
<section class="card narrow"><div class="card-h"><h2>Payments</h2></div>
${payments.length ? `<ul class="list">${payments.map((p) => `<li><span>${money(p.amount_cents)}<div class="sub">${fmtDate(p.created_at)}</div></span><span class="badge">${esc(p.status)}</span></li>`).join('')}</ul>` : '<div class="empty">No payments yet.</div>'}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ <h2 style="margin: 0; font-size: clamp(32px, 4vw, 52px); letter-spacing: -0.035e
<section id="pricing" style="max-width: 1200px; margin: 0 auto; padding: 96px 24px; display: flex; flex-direction: column; gap: 40px">
<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 by card or crypto through CoinPay.</p>
<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 in crypto through 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
14 changes: 11 additions & 3 deletions apps/web/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -929,16 +929,24 @@ api.post('/orgs/:org/billing/checkout', async (c) => {
if (!paymentsEnabled()) return c.json({ error: 'payments are not switched on yet' }, 503);
const b = await billing(me.org_id);
if (b.monthly_cents === null) return c.json({ error: 'over 1,000 seats: contact us for pricing' }, 400);
const { checkoutUrl } = await createCheckout({
// Crypto only: Stripe banned the owner, so CoinPay's card path (which goes
// through Stripe) must never be offered. See memory no-stripe-banned.
let checkoutUrl;
try {
({ checkoutUrl } = await createCheckout({
user,
amountCents: b.monthly_cents,
description: `tleehealth: ${me.org_name}, 1 month, ${b.seats} seat${b.seats === 1 ? '' : 's'}`,
metadata: { org_id: me.org_id, kind: 'month', seats: String(b.seats) },
blockchain: config.coinpay.defaultChain,
paymentMethod: 'both',
paymentMethod: 'crypto',
successUrl: `${config.siteUrl}/app/billing?paid=1`,
cancelUrl: `${config.siteUrl}/app/billing`,
});
}));
} catch (err) {
console.error('[billing] checkout', err.message);
return c.json({ error: 'CoinPay could not start the payment. Try again in a minute.' }, 502);
}
await audit(me.org_id, user.id, 'billing.checkout');
return c.json({ checkout_url: checkoutUrl });
});
Expand Down
2 changes: 1 addition & 1 deletion design/Main.dc.html
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ <h2 style="margin: 0; font-size: clamp(32px, 4vw, 52px); letter-spacing: -0.035e
<section id="pricing" style="max-width: 1200px; margin: 0 auto; padding: 96px 24px; display: flex; flex-direction: column; gap: 40px">
<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 by card or crypto through CoinPay.</p>
<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 in crypto through 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
57 changes: 57 additions & 0 deletions test/billing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Billing checkout: crypto only (Stripe is banned for the owner, so CoinPay's
// card path must never be requested), and a CoinPay refusal is a readable 502.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { close, configured } from '../packages/db/src/index.js';
import { migrate } from '../packages/db/src/migrate.js';

describe.skipIf(!configured())('billing checkout', () => {
const realFetch = globalThis.fetch;
const sent = [];
let reply = { status: 201, body: { success: true, payment: { id: 'pay_test_1' } } };
let web, auth, cookie, orgId;

beforeAll(async () => {
await migrate({ log: () => {} });
Object.assign(process.env, { COINPAY_API_KEY: 'cp_test_x', COINPAY_BUSINESS_ID: 'biz_test' });
globalThis.fetch = async (url, init = {}) => {
if (String(url).startsWith('https://coinpayportal.com/')) {
sent.push(JSON.parse(init.body));
return new Response(JSON.stringify(reply.body), { status: reply.status });
}
return realFetch(url, init);
};
const { configurePayments } = await import('../packages/payments/src/index.js');
const { config } = await import('../apps/web/src/config.js');
const { db } = await import('../packages/db/src/index.js');
configurePayments({ sql: db(), coinpay: config.coinpay, siteUrl: config.siteUrl });
web = (await import('../apps/web/src/app.js')).app;
auth = await import('../apps/web/src/auth.js');
const url = new URL(await auth.createLoginLink(`bill${Date.now()}@example.com`));
cookie = (await web.request(`/auth/magic?t=${url.searchParams.get('t')}`)).headers.get('set-cookie').split(';')[0];
const res = await web.request('/api/v1/orgs', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Billing Test' }) });
orgId = (await res.json()).org.id;
});
afterAll(async () => {
globalThis.fetch = realFetch;
delete process.env.COINPAY_API_KEY;
delete process.env.COINPAY_BUSINESS_ID;
await close();
});

const checkout = () => web.request(`/api/v1/orgs/${orgId}/billing/checkout`, { method: 'POST', headers: { cookie } });

test('asks CoinPay for crypto only, never card', async () => {
const res = await checkout();
expect(res.status).toBe(200);
expect((await res.json()).checkout_url).toContain('/pay/pay_test_1');
expect(sent.at(-1).payment_method).toBe('crypto');
expect(sent.at(-1).amount).toBe(10);
});

test('a CoinPay refusal is a readable 502, not an internal error', async () => {
reply = { status: 400, body: { success: false, error: 'nope' } };
const res = await checkout();
expect(res.status).toBe(502);
expect((await res.json()).error).toContain('CoinPay could not start the payment');
});
});
Loading