studio-os 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-27
4
+
5
+ **Upgrading with Stripe:** add `checkout.session.async_payment_succeeded` and
6
+ `invoice.paid` to your webhook endpoint's events. Without them, delayed
7
+ payments never turn into passes and renewals still go unrecorded. The database
8
+ migrates itself on start (schema v3, one new column on `payments`).
9
+
10
+ - **Security: booking in someone else's name handed over their magic link.**
11
+ Booking takes only a name and an email, and the confirmation page always
12
+ showed the booked client's `/me` link, a 7-day token to their bookings,
13
+ passes and cancel buttons. With SMTP configured the link now only goes out
14
+ by email. Without SMTP it's still shown on screen, as before, and the
15
+ README says what that means.
16
+ - **Security: a 100kb POST to any public form leaked a stack trace.** The
17
+ body-size 413 reached the error handler before the layout locals were set,
18
+ `error.ejs` threw, and Express's fallback answered 500 with the
19
+ ReferenceError and server paths (hidden only when `NODE_ENV=production`,
20
+ which the Docker image sets). The error page now renders on its own and 4xx
21
+ statuses stay 4xx.
22
+ - **Stripe renewals were never recorded.** Only a membership's first month
23
+ showed up as a payment, so the revenue report undercounted every
24
+ subscription after month one. `invoice.paid` now records each renewal,
25
+ keyed on the invoice id so webhook retries can't count a month twice.
26
+ - **Delayed Stripe payments got a pass before the money cleared.** Bank
27
+ debits and other delayed methods complete Checkout with `payment_status:
28
+ unpaid`, and fulfilment ignored that. Unpaid sessions now wait for
29
+ `checkout.session.async_payment_succeeded`.
30
+ - **Mindbody imports past a few hundred clients got a 413.** Admin → Import
31
+ now accepts up to 20mb.
32
+ - **The waitlist page said "You're # in line"** with no number. It now shows
33
+ the actual position.
34
+ - `GET /me` without a token answered 500 instead of the "link expired" page.
35
+ - **Dependencies**: dotenv 17 → 18, nodemailer → 10.0.10.
36
+ - **Tests**: 127 → 135.
37
+
3
38
  ## 0.3.0 — 2026-09-17
4
39
 
5
40
  - **Security: magic links were emailed as `http://` behind a reverse proxy.**
package/README.md CHANGED
@@ -135,7 +135,10 @@ activate purchases manually from the client profile — fully usable cash-only.
135
135
  - `STRIPE_PUBLISHABLE_KEY`
136
136
  - `STRIPE_WEBHOOK_SECRET` — see step 3
137
137
  3. Add a webhook endpoint in Stripe: `https://your-domain/webhooks/stripe`,
138
- events `checkout.session.completed` and `customer.subscription.deleted`.
138
+ events `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
139
+ `invoice.paid` and `customer.subscription.deleted`. The async event is what
140
+ fulfils payments that clear later, like bank debits; card-only studios never
141
+ see it. `invoice.paid` records each membership renewal in the revenue report.
139
142
  Copy the signing secret into `STRIPE_WEBHOOK_SECRET`.
140
143
  (Local testing: `stripe listen --forward-to localhost:3000/webhooks/stripe`.)
141
144
  The webhook secret is not optional: online payment stays off until it is
@@ -184,6 +187,10 @@ What's protected out of the box:
184
187
  What's *not* there yet — plan accordingly:
185
188
 
186
189
  - **No 2FA** on staff logins.
190
+ - **Without SMTP, client links are shown on screen**, so anyone who knows a
191
+ client's email can open their bookings page. That's fine for trying the app
192
+ out; configure SMTP before real clients use it. With SMTP on, links only
193
+ ever go to the inbox.
187
194
  - The rate limiter is **single-instance and in-memory**: counters are
188
195
  per-process and reset on restart. Fine for the one-container target; a
189
196
  multi-instance deployment needs a shared store (or limit at the proxy).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "studio-os",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "bcryptjs": "^3.0.3",
20
20
  "better-sqlite3": "^13.0.1",
21
21
  "cookie-session": "^2.1.0",
22
- "dotenv": "^17.4.2",
22
+ "dotenv": "^18.0.2",
23
23
  "ejs": "^6.0.1",
24
24
  "express": "^5.2.1",
25
25
  "marked": "^18.0.9",
package/src/app.js CHANGED
@@ -53,6 +53,9 @@ export function createApp({ db, mailer, stripeService, env = process.env, now =
53
53
  // before any body parser.
54
54
  app.use('/webhooks/stripe', express.raw({ type: '*/*' }), webhookRoutes(services));
55
55
 
56
+ // A pasted Mindbody client export is 100+ bytes a row once URL-encoded, so
57
+ // the 100kb default refused any studio with more than a few hundred clients.
58
+ app.use('/admin/import', express.urlencoded({ extended: true, limit: '20mb' }));
56
59
  app.use(express.urlencoded({ extended: true }));
57
60
  app.use(express.static(path.join(__dirname, '..', 'public')));
58
61
  app.use(cookieSession({
@@ -149,7 +152,11 @@ export function createApp({ db, mailer, stripeService, env = process.env, now =
149
152
  // eslint-disable-next-line no-unused-vars
150
153
  app.use((err, req, res, next) => {
151
154
  console.error('[error]', err);
152
- res.status(500).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
155
+ // Body-parser errors (oversized or malformed bodies) arrive before the
156
+ // locals middleware has run, and the layout can't render without these.
157
+ res.locals = { settings: {}, user: null, flash: null, ...res.locals };
158
+ const status = err.status >= 400 && err.status < 500 ? err.status : 500;
159
+ res.status(status).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
153
160
  });
154
161
 
155
162
  return app;
@@ -0,0 +1,6 @@
1
+ -- v3: membership renewals are recorded from Stripe invoices, keyed on the
2
+ -- invoice id so webhook retries can't record the same month twice.
3
+
4
+ ALTER TABLE payments ADD COLUMN stripe_invoice_id TEXT;
5
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_payments_invoice
6
+ ON payments(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL;
package/src/db/index.js CHANGED
@@ -1,82 +1,83 @@
1
- import Database from 'better-sqlite3';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
- import { fileURLToPath } from 'node:url';
6
-
7
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
-
9
- const MIGRATIONS = [
10
- { version: 1, file: 'schema.sql' },
11
- { version: 2, file: '002-instructor-role.sql' },
12
- ];
13
-
14
- const DEFAULT_SETTINGS = {
15
- studio_name: '',
16
- timezone: 'Asia/Hong_Kong',
17
- currency: 'HKD',
18
- cancellation_window_hours: '12',
19
- late_cancel_policy: 'forfeit', // forfeit | refund
20
- waiver_markdown: '## Liability Waiver\n\nI understand that participation in classes involves physical activity and I take part at my own risk. I confirm I am in good health and will inform the instructor of any injuries or conditions.\n\nBy checking the box I agree to this waiver.',
21
- setup_complete: '0',
22
- horizon_weeks: '8',
23
- };
24
-
25
- /**
26
- * Open (or create) the database, enable WAL, run migrations, seed defaults.
27
- * @param {string} [dbPath] file path or ':memory:' (tests)
28
- */
29
- export function openDb(dbPath) {
30
- const file = dbPath || process.env.DB_PATH || path.join(process.cwd(), 'data', 'studio.db');
31
- if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
32
- const db = new Database(file);
33
- db.pragma('journal_mode = WAL');
34
- db.pragma('foreign_keys = ON');
35
- db.pragma('busy_timeout = 5000');
36
- migrate(db);
37
- return db;
38
- }
39
-
40
- function migrate(db) {
41
- db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
42
- const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
43
- for (const m of MIGRATIONS) {
44
- if (applied.has(m.version)) continue;
45
- const sql = fs.readFileSync(path.join(__dirname, m.file), 'utf8');
46
- db.transaction(() => {
47
- db.exec(sql);
48
- db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
49
- .run(m.version, new Date().toISOString());
50
- })();
51
- }
52
- // seed default settings (INSERT OR IGNORE keeps existing values)
53
- const ins = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
54
- for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) ins.run(k, v);
55
- // app secret: generated once, persisted, overridable via env
56
- if (!process.env.APP_SECRET) {
57
- ins.run('app_secret', crypto.randomBytes(32).toString('hex'));
58
- }
59
- }
60
-
61
- export function getSetting(db, key, fallback = '') {
62
- if (key === 'app_secret' && process.env.APP_SECRET) return process.env.APP_SECRET;
63
- const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
64
- return row ? row.value : fallback;
65
- }
66
-
67
- export function setSetting(db, key, value) {
68
- db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
69
- .run(key, String(value));
70
- }
71
-
72
- export function allSettings(db) {
73
- const out = {};
74
- for (const r of db.prepare('SELECT key, value FROM settings').all()) out[r.key] = r.value;
75
- if (process.env.APP_SECRET) out.app_secret = process.env.APP_SECRET;
76
- return out;
77
- }
78
-
79
- export function audit(db, who, action, entity = '') {
80
- db.prepare('INSERT INTO audit_log (who, action, entity, at) VALUES (?, ?, ?, ?)')
81
- .run(who || '', action, entity, new Date().toISOString());
82
- }
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ const MIGRATIONS = [
10
+ { version: 1, file: 'schema.sql' },
11
+ { version: 2, file: '002-instructor-role.sql' },
12
+ { version: 3, file: '003-stripe-invoices.sql' },
13
+ ];
14
+
15
+ const DEFAULT_SETTINGS = {
16
+ studio_name: '',
17
+ timezone: 'Asia/Hong_Kong',
18
+ currency: 'HKD',
19
+ cancellation_window_hours: '12',
20
+ late_cancel_policy: 'forfeit', // forfeit | refund
21
+ waiver_markdown: '## Liability Waiver\n\nI understand that participation in classes involves physical activity and I take part at my own risk. I confirm I am in good health and will inform the instructor of any injuries or conditions.\n\nBy checking the box I agree to this waiver.',
22
+ setup_complete: '0',
23
+ horizon_weeks: '8',
24
+ };
25
+
26
+ /**
27
+ * Open (or create) the database, enable WAL, run migrations, seed defaults.
28
+ * @param {string} [dbPath] file path or ':memory:' (tests)
29
+ */
30
+ export function openDb(dbPath) {
31
+ const file = dbPath || process.env.DB_PATH || path.join(process.cwd(), 'data', 'studio.db');
32
+ if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
33
+ const db = new Database(file);
34
+ db.pragma('journal_mode = WAL');
35
+ db.pragma('foreign_keys = ON');
36
+ db.pragma('busy_timeout = 5000');
37
+ migrate(db);
38
+ return db;
39
+ }
40
+
41
+ function migrate(db) {
42
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
43
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
44
+ for (const m of MIGRATIONS) {
45
+ if (applied.has(m.version)) continue;
46
+ const sql = fs.readFileSync(path.join(__dirname, m.file), 'utf8');
47
+ db.transaction(() => {
48
+ db.exec(sql);
49
+ db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
50
+ .run(m.version, new Date().toISOString());
51
+ })();
52
+ }
53
+ // seed default settings (INSERT OR IGNORE keeps existing values)
54
+ const ins = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
55
+ for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) ins.run(k, v);
56
+ // app secret: generated once, persisted, overridable via env
57
+ if (!process.env.APP_SECRET) {
58
+ ins.run('app_secret', crypto.randomBytes(32).toString('hex'));
59
+ }
60
+ }
61
+
62
+ export function getSetting(db, key, fallback = '') {
63
+ if (key === 'app_secret' && process.env.APP_SECRET) return process.env.APP_SECRET;
64
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
65
+ return row ? row.value : fallback;
66
+ }
67
+
68
+ export function setSetting(db, key, value) {
69
+ db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
70
+ .run(key, String(value));
71
+ }
72
+
73
+ export function allSettings(db) {
74
+ const out = {};
75
+ for (const r of db.prepare('SELECT key, value FROM settings').all()) out[r.key] = r.value;
76
+ if (process.env.APP_SECRET) out.app_secret = process.env.APP_SECRET;
77
+ return out;
78
+ }
79
+
80
+ export function audit(db, who, action, entity = '') {
81
+ db.prepare('INSERT INTO audit_log (who, action, entity, at) VALUES (?, ?, ?, ?)')
82
+ .run(who || '', action, entity, new Date().toISOString());
83
+ }
package/src/routes/me.js CHANGED
@@ -10,7 +10,7 @@ export default function meRoutes(services) {
10
10
  const r = Router();
11
11
 
12
12
  function requireClient(req, res) {
13
- const token = req.query.token || req.body.token;
13
+ const token = req.query.token || (req.body && req.body.token);
14
14
  const clientId = verifyMagicToken(db, token);
15
15
  if (!clientId) {
16
16
  res.status(401).render('public/magic_request', {
@@ -117,9 +117,19 @@ export default function publicRoutes(services) {
117
117
  });
118
118
  mailer.send({ to: result.client.email, ...tpl });
119
119
 
120
+ // Same order promoteNext() uses to pick who gets the next free spot.
121
+ const b = result.booking;
122
+ const waitlistPosition = result.waitlisted ? db.prepare(
123
+ `SELECT COUNT(*) c FROM bookings WHERE class_instance_id = ? AND status = 'waitlist'
124
+ AND (created_at < ? OR (created_at = ? AND id <= ?))`
125
+ ).get(inst.id, b.created_at, b.created_at, b.id).c : null;
126
+
127
+ // Booking needs nothing but an email, so the link can't go on screen when
128
+ // it can go to the inbox: anyone who knows a client's address could book
129
+ // a class in their name and walk off with their self-service link.
120
130
  res.render('public/book_result', {
121
131
  title: result.waitlisted ? 'Waitlisted' : 'Booked',
122
- inst, result, magicUrl,
132
+ inst, result, magicUrl: mailer.smtpConfigured ? null : magicUrl, waitlistPosition,
123
133
  });
124
134
  } catch (err) {
125
135
  if (err instanceof BookingError) {
@@ -86,6 +86,9 @@ export function fulfillCheckoutSession(db, session) {
86
86
  const email = (meta.client_email || session.customer_email
87
87
  || (session.customer_details && session.customer_details.email) || '').toLowerCase();
88
88
  if (!email) throw new Error('Checkout session has no client email');
89
+ // Bank debits and other delayed methods complete the session before the money
90
+ // arrives. checkout.session.async_payment_succeeded brings it back as 'paid'.
91
+ if (session.payment_status === 'unpaid') return { fulfilled: false, reason: 'awaiting_payment' };
89
92
 
90
93
  return db.transaction(() => {
91
94
  const existing = db.prepare('SELECT id FROM payments WHERE stripe_session_id = ?').get(session.id);
@@ -158,11 +161,45 @@ export function fulfillCheckoutSession(db, session) {
158
161
  })();
159
162
  }
160
163
 
164
+ /**
165
+ * Record a membership renewal payment. The subscription's first invoice was
166
+ * already recorded from its Checkout session, so only later cycles count.
167
+ * Idempotent via payments.stripe_invoice_id.
168
+ */
169
+ export function recordMembershipRenewal(db, invoice) {
170
+ if (invoice.billing_reason === 'subscription_create') return { fulfilled: false, reason: 'first_invoice' };
171
+ // Newer API versions moved this under parent.subscription_details, and a
172
+ // webhook payload follows the endpoint's API version, so read both.
173
+ const subscriptionId = (invoice.parent && invoice.parent.subscription_details
174
+ && invoice.parent.subscription_details.subscription) || invoice.subscription;
175
+ if (!subscriptionId) return { fulfilled: false, reason: 'not_a_subscription' };
176
+ if (!invoice.amount_paid) return { fulfilled: false, reason: 'nothing_paid' };
177
+
178
+ return db.transaction(() => {
179
+ const membership = db.prepare('SELECT * FROM memberships WHERE stripe_subscription_id = ?').get(subscriptionId);
180
+ if (!membership) return { fulfilled: false, reason: 'unknown_subscription' };
181
+ const existing = db.prepare('SELECT id FROM payments WHERE stripe_invoice_id = ?').get(invoice.id);
182
+ if (existing) return { fulfilled: false, reason: 'already_processed' };
183
+
184
+ const currency = (invoice.currency || getSetting(db, 'currency', 'HKD')).toUpperCase();
185
+ db.prepare(
186
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status, stripe_invoice_id)
187
+ VALUES (?, ?, ?, 'stripe', ?, 'membership', 'paid', ?)`
188
+ ).run(membership.client_id, invoice.amount_paid, currency, membership.plan_name, invoice.id);
189
+ audit(db, 'stripe-webhook', 'membership_renewed', `membership:${membership.id} invoice:${invoice.id}`);
190
+ return { fulfilled: true, kind: 'membership_renewal', clientId: membership.client_id };
191
+ })();
192
+ }
193
+
161
194
  /** Handle a parsed Stripe event. Returns a result object for logging. */
162
195
  export function handleStripeEvent(db, event) {
163
- if (event.type === 'checkout.session.completed') {
196
+ if (event.type === 'checkout.session.completed'
197
+ || event.type === 'checkout.session.async_payment_succeeded') {
164
198
  return fulfillCheckoutSession(db, event.data.object);
165
199
  }
200
+ if (event.type === 'invoice.paid') {
201
+ return recordMembershipRenewal(db, event.data.object);
202
+ }
166
203
  if (event.type === 'customer.subscription.deleted') {
167
204
  const sub = event.data.object;
168
205
  const r = db.prepare(
@@ -0,0 +1,47 @@
1
+ // Booking needs only an email, so the page it returns must not hand over the
2
+ // self-service link for whatever address was typed.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import request from 'supertest';
6
+ import { createApp } from '../src/app.js';
7
+ import { testDb, makeClient, makeClassType, makeInstance, csrfToken } from './helpers.js';
8
+
9
+ function appWith(db, { smtp }) {
10
+ const sent = [];
11
+ const mailer = { smtpConfigured: smtp, async send(msg) { sent.push(msg); return true; } };
12
+ return { app: createApp({ db, mailer, env: {} }), sent };
13
+ }
14
+
15
+ async function bookAs(app, instanceId, email) {
16
+ const agent = request.agent(app);
17
+ const _csrf = await csrfToken(agent, `/class/${instanceId}`);
18
+ return agent.post(`/class/${instanceId}/book`).type('form').send({ _csrf, email, waiver_agree: '1' });
19
+ }
20
+
21
+ test('with SMTP on, booking in someone else\'s name does not reveal their link', async () => {
22
+ const db = testDb();
23
+ makeClient(db, { email: 'member@test.hk', name: 'Member' });
24
+ const inst = makeInstance(db, makeClassType(db));
25
+ const { app, sent } = appWith(db, { smtp: true });
26
+
27
+ const res = await bookAs(app, inst, 'member@test.hk');
28
+
29
+ assert.equal(res.status, 200);
30
+ assert.doesNotMatch(res.text, /\/me\?token=/);
31
+ assert.match(res.text, /on its way/);
32
+ // The owner of the address still gets their link, by email.
33
+ assert.equal(sent.length, 1);
34
+ assert.equal(sent[0].to, 'member@test.hk');
35
+ assert.match(sent[0].text, /\/me\?token=/);
36
+ });
37
+
38
+ test('with SMTP off, the link is still shown so the studio works offline', async () => {
39
+ const db = testDb();
40
+ const inst = makeInstance(db, makeClassType(db));
41
+ const { app } = appWith(db, { smtp: false });
42
+
43
+ const res = await bookAs(app, inst, 'walkin@test.hk');
44
+
45
+ assert.equal(res.status, 200);
46
+ assert.match(res.text, /\/me\?token=/);
47
+ });
@@ -0,0 +1,49 @@
1
+ // Requests that used to end in a 500, or in a crashed error page.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import request from 'supertest';
5
+ import { createApp } from '../src/app.js';
6
+ import { createUser } from '../src/services/auth.js';
7
+ import { testDb, csrfToken } from './helpers.js';
8
+
9
+ function mindbodyClientsCsv(n) {
10
+ const rows = ['First Name,Last Name,Email,Mobile Phone,Notes,Liability Waiver'];
11
+ for (let i = 0; i < n; i++) {
12
+ rows.push(`Client${i},Surname${i},client${i}@example.com,+852 5555 ${String(i).padStart(4, '0')},Prefers a mat by the window,Yes`);
13
+ }
14
+ return rows.join('\n');
15
+ }
16
+
17
+ test('Admin → Import takes a Mindbody export bigger than 100kb', async () => {
18
+ const db = testDb();
19
+ createUser(db, { email: 'owner@test.hk', password: 'password123', role: 'owner' });
20
+ const agent = request.agent(createApp({ db, env: {} }));
21
+ let _csrf = await csrfToken(agent, '/admin/login');
22
+ await agent.post('/admin/login').type('form').send({ email: 'owner@test.hk', password: 'password123', _csrf });
23
+ _csrf = await csrfToken(agent, '/admin/import');
24
+
25
+ const csv = mindbodyClientsCsv(1500);
26
+ assert.ok(csv.length > 100 * 1024);
27
+ const res = await agent.post('/admin/import').type('form').send({ _csrf, kind: 'clients', csv });
28
+
29
+ assert.equal(res.status, 200);
30
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 1500);
31
+ });
32
+
33
+ test('an oversized body elsewhere gets a 413 page, not a crashed error page', async () => {
34
+ const db = testDb();
35
+ const res = await request(createApp({ db, env: {} }))
36
+ .post('/magic-link').type('form').send({ email: 'x'.repeat(200 * 1024) });
37
+
38
+ assert.equal(res.status, 413);
39
+ assert.match(res.text, /<main class="container">/);
40
+ assert.doesNotMatch(res.text, /ReferenceError|at Layer/);
41
+ });
42
+
43
+ test('/me without a token asks for a new link instead of a 500', async () => {
44
+ const db = testDb();
45
+ const res = await request(createApp({ db, env: {} })).get('/me');
46
+
47
+ assert.equal(res.status, 401);
48
+ assert.match(res.text, /invalid or has expired/);
49
+ });
@@ -121,6 +121,34 @@ test('replayed webhook is a no-op (idempotent by stripe_session_id)', async () =
121
121
  assert.equal(db.prepare('SELECT COUNT(*) c FROM payments').get().c, 1, 'no duplicate payment');
122
122
  });
123
123
 
124
+ test('a delayed payment is fulfilled when it clears, not when checkout completes', async () => {
125
+ const { db, app } = makeApp();
126
+ const productId = db.prepare(
127
+ "INSERT INTO pack_products (name, credits, price_cents) VALUES ('5 Pack', 5, 55000)"
128
+ ).run().lastInsertRowid;
129
+ const session = {
130
+ id: 'cs_debit',
131
+ amount_total: 55000,
132
+ currency: 'hkd',
133
+ metadata: { kind: 'pack', pack_product_id: String(productId), client_email: 'debit@test.hk' },
134
+ };
135
+
136
+ const completed = await postEvent(app, checkoutCompleted({ ...session, payment_status: 'unpaid' }));
137
+ assert.equal(completed.status, 200);
138
+ assert.equal(completed.body.fulfilled, false);
139
+ assert.equal(completed.body.reason, 'awaiting_payment');
140
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM passes').get().c, 0, 'no pass before the money arrives');
141
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM payments').get().c, 0);
142
+
143
+ const cleared = await postEvent(app, {
144
+ type: 'checkout.session.async_payment_succeeded',
145
+ data: { object: { ...session, payment_status: 'paid' } },
146
+ });
147
+ assert.equal(cleared.body.fulfilled, true);
148
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM passes').get().c, 1);
149
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM payments WHERE status = 'paid'").get().c, 1);
150
+ });
151
+
124
152
  test('membership checkout fulfillment: activates membership with subscription id', async () => {
125
153
  const { db, app } = makeApp();
126
154
  const planId = db.prepare(
@@ -148,6 +176,61 @@ test('membership checkout fulfillment: activates membership with subscription id
148
176
  assert.equal(payment.status, 'paid');
149
177
  });
150
178
 
179
+ test('invoice.paid records each membership renewal once, but not the first month again', async () => {
180
+ const { db, app } = makeApp();
181
+ const planId = db.prepare(
182
+ "INSERT INTO membership_plans (name, price_cents, unlimited, stripe_price_id) VALUES ('Unlimited Monthly', 128000, 1, 'price_mock')"
183
+ ).run().lastInsertRowid;
184
+ await postEvent(app, checkoutCompleted({
185
+ id: 'cs_renew', amount_total: 128000, currency: 'hkd', subscription: 'sub_renew',
186
+ metadata: { kind: 'membership', plan_id: String(planId), client_email: 'renew@test.hk' },
187
+ }));
188
+ const invoicePaid = (invoice) => postEvent(app, { type: 'invoice.paid', data: { object: invoice } });
189
+ const payments = () => db.prepare("SELECT * FROM payments WHERE what = 'membership' ORDER BY id").all();
190
+
191
+ // Stripe also sends invoice.paid for the first month, which the Checkout
192
+ // session already recorded.
193
+ const first = await invoicePaid({
194
+ id: 'in_1', billing_reason: 'subscription_create', amount_paid: 128000, currency: 'hkd',
195
+ parent: { subscription_details: { subscription: 'sub_renew' } },
196
+ });
197
+ assert.equal(first.body.fulfilled, false);
198
+ assert.equal(payments().length, 1);
199
+
200
+ const renewal = {
201
+ id: 'in_2', billing_reason: 'subscription_cycle', amount_paid: 128000, currency: 'hkd',
202
+ parent: { subscription_details: { subscription: 'sub_renew' } },
203
+ };
204
+ const second = await invoicePaid(renewal);
205
+ assert.equal(second.body.fulfilled, true);
206
+ assert.equal(second.body.kind, 'membership_renewal');
207
+ const replay = await invoicePaid(renewal);
208
+ assert.equal(replay.body.reason, 'already_processed');
209
+
210
+ // Endpoints pinned to an older API version put the id at the top level.
211
+ const legacy = await invoicePaid({
212
+ id: 'in_3', billing_reason: 'subscription_cycle', amount_paid: 128000, currency: 'hkd',
213
+ subscription: 'sub_renew',
214
+ });
215
+ assert.equal(legacy.body.fulfilled, true);
216
+
217
+ const rows = payments();
218
+ assert.equal(rows.length, 3);
219
+ assert.deepEqual(rows.map((p) => p.stripe_invoice_id), [null, 'in_2', 'in_3']);
220
+ assert.ok(rows.every((p) => p.status === 'paid' && p.method === 'stripe' && p.amount_cents === 128000));
221
+ });
222
+
223
+ test('invoice.paid for a subscription Studio OS never sold is acknowledged and ignored', async () => {
224
+ const { db, app } = makeApp();
225
+ const res = await postEvent(app, {
226
+ type: 'invoice.paid',
227
+ data: { object: { id: 'in_other', billing_reason: 'subscription_cycle', amount_paid: 5000, subscription: 'sub_elsewhere' } },
228
+ });
229
+ assert.equal(res.status, 200);
230
+ assert.equal(res.body.reason, 'unknown_subscription');
231
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM payments').get().c, 0);
232
+ });
233
+
151
234
  test('customer.subscription.deleted cancels the matching membership', async () => {
152
235
  const { db, app } = makeApp();
153
236
  const clientId = db.prepare(
@@ -202,7 +285,7 @@ test('fulfillment failure returns 500 so Stripe retries', async () => {
202
285
 
203
286
  test('unhandled event types are acknowledged and ignored', async () => {
204
287
  const { app } = makeApp();
205
- const res = await postEvent(app, { type: 'invoice.paid', data: { object: {} } });
288
+ const res = await postEvent(app, { type: 'charge.refunded', data: { object: {} } });
206
289
  assert.equal(res.status, 200);
207
290
  assert.equal(res.body.fulfilled, false);
208
291
  assert.match(res.body.reason, /ignored event/);
package/test/web.test.js CHANGED
@@ -178,6 +178,11 @@ test('full class books to waitlist via HTTP', async () => {
178
178
  .send({ email: 'b@t.hk', waiver_agree: '1', name: 'B', _csrf });
179
179
  assert.equal(res.status, 200);
180
180
  assert.match(res.text, /waitlist/i);
181
+ assert.match(res.text, /You're #1 in line/);
182
+
183
+ const second = await agent.post(`/class/${inst}/book`).type('form')
184
+ .send({ email: 'c@t.hk', waiver_agree: '1', name: 'C', _csrf });
185
+ assert.match(second.text, /You're #2 in line/);
181
186
  });
182
187
 
183
188
  test('magic link: verify round-trip, view bookings, cancel within policy', async () => {
@@ -3,7 +3,7 @@
3
3
  <% if (result.waitlisted) { %>
4
4
  <h2>You're on the waitlist</h2>
5
5
  <p><strong><%= inst.class_name %></strong> on <%= fmtDt(inst.starts_at) %> is currently full.
6
- You're #<%= result.booking.id ? '' : '' %> in line — if a spot opens we'll book you in automatically<%= smtpConfigured ? ' and email you' : '' %>.</p>
6
+ You're #<%= waitlistPosition %> in line — if a spot opens we'll book you in automatically<%= smtpConfigured ? ' and email you' : '' %>.</p>
7
7
  <% } else { %>
8
8
  <h2>You're booked!</h2>
9
9
  <p><strong><%= inst.class_name %></strong> · <%= fmtDt(inst.starts_at) %></p>
@@ -15,12 +15,12 @@
15
15
  <% } %>
16
16
  </p>
17
17
  <% } %>
18
- <p>Manage or cancel your bookings any time:</p>
19
- <p><a href="<%= magicUrl %>" role="button" class="outline">My bookings</a></p>
20
- <% if (!smtpConfigured) { %>
18
+ <% if (magicUrl) { %>
19
+ <p>Manage or cancel your bookings any time:</p>
20
+ <p><a href="<%= magicUrl %>" role="button" class="outline">My bookings</a></p>
21
21
  <p><small>Save that link — it's your personal booking manager (valid 7 days; you can request a new one from “My bookings” in the menu).</small></p>
22
22
  <% } else { %>
23
- <p><small>A confirmation email with your personal link is on its way.</small></p>
23
+ <p>A confirmation email with a link to manage or cancel your booking is on its way.</p>
24
24
  <% } %>
25
25
  </article>
26
26
  <%- include('../partials/foot') %>