studio-os 0.3.0 → 0.5.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/src/routes/me.js CHANGED
@@ -1,16 +1,17 @@
1
1
  // Client self-service via signed magic links. No passwords.
2
2
  import { Router } from 'express';
3
- import { verifyMagicToken, makeMagicToken } from '../services/auth.js';
3
+ import { verifyMagicToken } from '../services/auth.js';
4
4
  import { cancelBooking, BookingError } from '../services/booking.js';
5
5
  import { getSetting, audit } from '../db/index.js';
6
6
  import { emails } from '../services/mailer.js';
7
+ import { notifyPromoted } from '../services/notify.js';
7
8
 
8
9
  export default function meRoutes(services) {
9
10
  const { db, mailer } = services;
10
11
  const r = Router();
11
12
 
12
13
  function requireClient(req, res) {
13
- const token = req.query.token || req.body.token;
14
+ const token = req.query.token || (req.body && req.body.token);
14
15
  const clientId = verifyMagicToken(db, token);
15
16
  if (!clientId) {
16
17
  res.status(401).render('public/magic_request', {
@@ -76,14 +77,7 @@ export default function meRoutes(services) {
76
77
  to: client.email,
77
78
  ...emails.cancellation({ studio, clientName: client.name, className: inst.class_name, when, refunded: result.refunded }),
78
79
  });
79
- if (result.promoted) {
80
- const promotedClient = db.prepare('SELECT * FROM clients WHERE id = ?').get(result.promoted.client_id);
81
- const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, promotedClient.id)}`;
82
- mailer.send({
83
- to: promotedClient.email,
84
- ...emails.waitlistPromotion({ studio, clientName: promotedClient.name, className: inst.class_name, when, magicUrl }),
85
- });
86
- }
80
+ if (result.promoted) notifyPromoted(services, req, res, result.promoted, inst);
87
81
  req.session.flash = result.refunded
88
82
  ? 'Booking cancelled — your credit has been returned.'
89
83
  : (result.late ? 'Booking cancelled. This was a late cancellation, so the credit was not returned.' : 'Booking cancelled.');
@@ -8,18 +8,14 @@ import { emails } from '../services/mailer.js';
8
8
 
9
9
  function findOrCreateClient(db, name, email) {
10
10
  const cleanEmail = String(email || '').trim().toLowerCase();
11
- let client = db.prepare('SELECT * FROM clients WHERE email = ?').get(cleanEmail);
12
- if (!client) {
13
- const info = db.prepare(
14
- "INSERT INTO clients (name, email, source) VALUES (?, ?, 'self')"
15
- ).run(String(name || cleanEmail.split('@')[0]).trim(), cleanEmail);
16
- client = db.prepare('SELECT * FROM clients WHERE id = ?').get(info.lastInsertRowid);
17
- } else if (name && name.trim() && client.name !== name.trim()) {
18
- // keep the freshest name the client typed
19
- db.prepare('UPDATE clients SET name = ? WHERE id = ?').run(name.trim(), client.id);
20
- client.name = name.trim();
21
- }
22
- return client;
11
+ const client = db.prepare('SELECT * FROM clients WHERE email = ?').get(cleanEmail);
12
+ // An existing client keeps their name. The form proves nothing about who's
13
+ // typing, so it can't be allowed to rename whoever owns the address.
14
+ if (client) return client;
15
+ const info = db.prepare(
16
+ "INSERT INTO clients (name, email, source) VALUES (?, ?, 'self')"
17
+ ).run(String(name || cleanEmail.split('@')[0]).trim(), cleanEmail);
18
+ return db.prepare('SELECT * FROM clients WHERE id = ?').get(info.lastInsertRowid);
23
19
  }
24
20
 
25
21
  function loadInstanceFull(db, id) {
@@ -90,20 +86,10 @@ export default function publicRoutes(services) {
90
86
  db.prepare('UPDATE clients SET waiver_signed_at = ? WHERE id = ?')
91
87
  .run(new Date().toISOString(), client.id);
92
88
  }
93
- const booked = book(db, client.id, inst.id);
94
- // Drop-in without membership/pack: record the pending manual payment.
95
- let payment = null;
96
- if (booked.booking.paid_with === 'drop_in_manual' && inst.drop_in_price_cents > 0) {
97
- const info = db.prepare(
98
- `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
99
- VALUES (?, ?, ?, 'other', ?, 'drop_in', 'pending')`
100
- ).run(client.id, inst.drop_in_price_cents, getSetting(db, 'currency', 'HKD'),
101
- `Drop-in: ${inst.class_name} ${inst.starts_at}`);
102
- payment = info.lastInsertRowid;
103
- }
89
+ const booked = book(db, client.id, inst.id, { recordDue: true });
104
90
  audit(db, client.email, booked.waitlisted ? 'waitlist_join' : 'booking_create',
105
91
  `instance:${inst.id} booking:${booked.booking.id}`);
106
- return { client, ...booked, paymentId: payment };
92
+ return { client, ...booked };
107
93
  })();
108
94
 
109
95
  const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, result.client.id)}`;
@@ -117,9 +103,19 @@ export default function publicRoutes(services) {
117
103
  });
118
104
  mailer.send({ to: result.client.email, ...tpl });
119
105
 
106
+ // Same order promoteNext() uses to pick who gets the next free spot.
107
+ const b = result.booking;
108
+ const waitlistPosition = result.waitlisted ? db.prepare(
109
+ `SELECT COUNT(*) c FROM bookings WHERE class_instance_id = ? AND status = 'waitlist'
110
+ AND (created_at < ? OR (created_at = ? AND id <= ?))`
111
+ ).get(inst.id, b.created_at, b.created_at, b.id).c : null;
112
+
113
+ // Booking needs nothing but an email, so the link can't go on screen when
114
+ // it can go to the inbox: anyone who knows a client's address could book
115
+ // a class in their name and walk off with their self-service link.
120
116
  res.render('public/book_result', {
121
117
  title: result.waitlisted ? 'Waitlisted' : 'Booked',
122
- inst, result, magicUrl,
118
+ inst, result, magicUrl: mailer.smtpConfigured ? null : magicUrl, waitlistPosition,
123
119
  });
124
120
  } catch (err) {
125
121
  if (err instanceof BookingError) {
@@ -118,6 +118,20 @@ function refund(db, booking, creditsNeeded) {
118
118
  }
119
119
  }
120
120
 
121
+ /** A pay-at-studio drop-in is owed once the client holds a spot. */
122
+ function recordDropInDue(db, booking, inst) {
123
+ if (booking.paid_with !== 'drop_in_manual' || !(inst.drop_in_price_cents > 0)) return null;
124
+ return db.prepare(
125
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status, booking_id)
126
+ VALUES (?, ?, ?, 'other', ?, 'drop_in', 'pending', ?)`
127
+ ).run(booking.client_id, inst.drop_in_price_cents, getSetting(db, 'currency', 'HKD'),
128
+ `Drop-in: ${inst.class_name} ${inst.starts_at}`, booking.id).lastInsertRowid;
129
+ }
130
+
131
+ function dropDropInDue(db, bookingId) {
132
+ db.prepare("DELETE FROM payments WHERE booking_id = ? AND status = 'pending'").run(bookingId);
133
+ }
134
+
121
135
  function loadInstance(db, instanceId) {
122
136
  const inst = db.prepare(
123
137
  `SELECT ci.*, ct.credits_required, ct.name AS class_name, ct.drop_in_price_cents
@@ -131,8 +145,9 @@ function loadInstance(db, instanceId) {
131
145
  /**
132
146
  * Book a client into a class instance.
133
147
  * options: { paidWith } to force ('drop_in_online'|'drop_in_manual'|'comp'),
134
- * { now } ISO override for tests, { allowPast } for admin walk-ins.
135
- * Returns the booking row plus { waitlisted: bool }.
148
+ * { now } ISO override for tests, { allowPast } for admin walk-ins,
149
+ * { recordDue } to record a pending payment for a pay-at-studio drop-in.
150
+ * Returns the booking row plus { waitlisted: bool, paymentId }.
136
151
  */
137
152
  export function book(db, clientId, instanceId, options = {}) {
138
153
  const nowIso = options.now || new Date().toISOString();
@@ -172,7 +187,8 @@ export function book(db, clientId, instanceId, options = {}) {
172
187
  status === 'booked' ? resolution.membershipId ?? null : null,
173
188
  nowIso);
174
189
  const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(info.lastInsertRowid);
175
- return { booking, waitlisted: full, instance: inst };
190
+ const paymentId = options.recordDue && !full ? recordDropInDue(db, booking, inst) : null;
191
+ return { booking, waitlisted: full, instance: inst, paymentId };
176
192
  })();
177
193
  }
178
194
 
@@ -190,12 +206,16 @@ function promoteNext(db, inst, nowIso) {
190
206
  db.prepare(
191
207
  'UPDATE bookings SET status = ?, paid_with = ?, pass_id = ?, membership_id = ? WHERE id = ?'
192
208
  ).run('booked', resolution.paidWith, resolution.passId ?? null, resolution.membershipId ?? null, next.id);
193
- return db.prepare('SELECT * FROM bookings WHERE id = ?').get(next.id);
209
+ const promoted = db.prepare('SELECT * FROM bookings WHERE id = ?').get(next.id);
210
+ recordDropInDue(db, promoted, inst);
211
+ return promoted;
194
212
  }
195
213
 
196
214
  /**
197
215
  * Cancel a booking. Within the cancellation window → credit refunded; late →
198
- * per policy (default forfeit). Auto-promotes the first waitlisted booking.
216
+ * per policy (default forfeit). Auto-promotes the first waitlisted booking
217
+ * while the class has yet to start. { allowPast } lets staff tidy a roster
218
+ * after the fact.
199
219
  * Returns { booking, refunded, late, promoted } (promoted = booking row or null).
200
220
  */
201
221
  export function cancelBooking(db, bookingId, options = {}) {
@@ -207,6 +227,8 @@ export function cancelBooking(db, bookingId, options = {}) {
207
227
  throw new BookingError('not_cancellable', `Cannot cancel a ${booking.status} booking`);
208
228
  }
209
229
  const inst = loadInstance(db, booking.class_instance_id);
230
+ const started = inst.starts_at <= nowIso;
231
+ if (started && !options.allowPast) throw new BookingError('in_past', 'Class has already started');
210
232
  const windowHours = Number(getSetting(db, 'cancellation_window_hours', '12'));
211
233
  const policy = getSetting(db, 'late_cancel_policy', 'forfeit');
212
234
  const hoursToStart = hoursBetween(nowIso, inst.starts_at);
@@ -214,23 +236,53 @@ export function cancelBooking(db, bookingId, options = {}) {
214
236
  const wasBooked = booking.status === 'booked';
215
237
  const creditsNeeded = inst.credits_required ?? 1;
216
238
 
239
+ const excused = !late || policy === 'refund' || options.forceRefund;
217
240
  let refunded = false;
218
- if (wasBooked && (!late || policy === 'refund' || options.forceRefund)) {
241
+ if (wasBooked && excused) {
219
242
  refund(db, booking, creditsNeeded);
220
243
  refunded = booking.paid_with === 'pack' || booking.paid_with === 'membership';
221
244
  }
245
+ // A forfeited late cancel still owes its drop-in, as it would lose a credit.
246
+ if (excused) dropDropInDue(db, bookingId);
222
247
  db.prepare("UPDATE bookings SET status = 'cancelled', cancelled_at = ? WHERE id = ?")
223
248
  .run(nowIso, bookingId);
224
249
 
225
- // A freed confirmed spot promotes the first waitlisted booking.
250
+ // A freed confirmed spot promotes the first waitlisted booking, but not
251
+ // into a class that's already running: they'd be charged for missing it.
226
252
  let promoted = null;
227
- if (wasBooked && bookedCount(db, inst.id) < inst.capacity) {
253
+ if (wasBooked && !started && bookedCount(db, inst.id) < inst.capacity) {
228
254
  promoted = promoteNext(db, inst, nowIso);
229
255
  }
230
256
  return { booking: { ...booking, status: 'cancelled', cancelled_at: nowIso }, refunded, late, promoted, instance: inst };
231
257
  })();
232
258
  }
233
259
 
260
+ /**
261
+ * Staff edit of a single class. Extra capacity before the class starts is
262
+ * filled from the waitlist, first come first served. Lowering it below the
263
+ * booked count cancels nobody. Returns { instance, promoted } (promoted = rows).
264
+ */
265
+ export function updateInstance(db, instanceId, changes, options = {}) {
266
+ const nowIso = options.now || new Date().toISOString();
267
+ return db.transaction(() => {
268
+ const inst = { ...loadInstance(db, instanceId), ...changes };
269
+ if (!Number.isInteger(inst.capacity) || inst.capacity < 1) {
270
+ throw new BookingError('bad_capacity', 'Capacity must be a whole number, at least 1');
271
+ }
272
+ db.prepare('UPDATE class_instances SET capacity = ?, instructor_id = ?, notes = ? WHERE id = ?')
273
+ .run(inst.capacity, inst.instructor_id, inst.notes, instanceId);
274
+ const promoted = [];
275
+ if (inst.status === 'scheduled' && inst.starts_at > nowIso) {
276
+ while (bookedCount(db, instanceId) < inst.capacity) {
277
+ const next = promoteNext(db, inst, nowIso);
278
+ if (!next) break;
279
+ promoted.push(next);
280
+ }
281
+ }
282
+ return { instance: inst, promoted };
283
+ })();
284
+ }
285
+
234
286
  /** Staff roster actions. */
235
287
  export function markAttendance(db, bookingId, status) {
236
288
  if (!['attended', 'no_show', 'booked'].includes(status)) {
@@ -258,6 +310,7 @@ export function cancelClass(db, instanceId, options = {}) {
258
310
  ).all(instanceId);
259
311
  for (const b of affected) {
260
312
  if (b.status === 'booked') refund(db, b, creditsNeeded);
313
+ dropDropInDue(db, b.id);
261
314
  db.prepare("UPDATE bookings SET status = 'cancelled', cancelled_at = ? WHERE id = ?")
262
315
  .run(nowIso, b.id);
263
316
  }
@@ -0,0 +1,19 @@
1
+ // Emails that more than one route sends.
2
+ import { getSetting } from '../db/index.js';
3
+ import { makeMagicToken } from './auth.js';
4
+ import { emails } from './mailer.js';
5
+
6
+ /** Tell a client they've come off the waitlist, with a link to manage the booking. */
7
+ export function notifyPromoted(services, req, res, booking, inst) {
8
+ const { db, mailer } = services;
9
+ const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(booking.client_id);
10
+ if (!client) return;
11
+ const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, client.id)}`;
12
+ mailer.send({
13
+ to: client.email,
14
+ ...emails.waitlistPromotion({
15
+ studio: getSetting(db, 'studio_name', 'the studio'), clientName: client.name,
16
+ className: inst.class_name, when: res.locals.fmtDt(inst.starts_at), magicUrl,
17
+ }),
18
+ });
19
+ }
@@ -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
+ });
@@ -5,7 +5,7 @@ import {
5
5
  getPass, getBooking, getMembership,
6
6
  } from './helpers.js';
7
7
  import {
8
- book, cancelBooking, cancelClass, markAttendance, resolvePayment, BookingError,
8
+ book, cancelBooking, cancelClass, markAttendance, resolvePayment, updateInstance, BookingError,
9
9
  } from '../src/services/booking.js';
10
10
 
11
11
  test('booking deducts a pack credit at booking time, in the same transaction', () => {
@@ -262,6 +262,58 @@ test('cancelling a waitlist booking does not promote or refund', () => {
262
262
  assert.equal(res.promoted, null);
263
263
  });
264
264
 
265
+ test('once a class has started, clients cannot cancel and nobody is promoted into it', () => {
266
+ const db = testDb();
267
+ const now = '2026-10-01T10:00:00Z';
268
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1 }), { now, hoursFromNow: 2 });
269
+ const c1 = makeClient(db); const c2 = makeClient(db);
270
+ const waiting = makePass(db, c2, { total: 5 });
271
+ const { booking } = book(db, c1, inst, { now });
272
+ const { booking: wl } = book(db, c2, inst, { now });
273
+ const afterClass = '2026-10-01T13:30:00Z';
274
+
275
+ assert.throws(() => cancelBooking(db, booking.id, { now: afterClass }),
276
+ (e) => e instanceof BookingError && e.code === 'in_past');
277
+
278
+ // Staff tidying the roster afterwards still can, without dragging the
279
+ // waitlist into a class that's over.
280
+ const res = cancelBooking(db, booking.id, { now: afterClass, allowPast: true });
281
+ assert.equal(res.promoted, null);
282
+ assert.equal(getBooking(db, wl.id).status, 'waitlist');
283
+ assert.equal(getPass(db, waiting).credits_remaining, 5);
284
+ });
285
+
286
+ test('a pay-at-studio drop-in is owed only while the client holds a spot', () => {
287
+ const db = testDb();
288
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1, price: 15000 }), { hoursFromNow: 48 });
289
+ const c1 = makeClient(db); const c2 = makeClient(db);
290
+ const dues = () => db.prepare(
291
+ "SELECT client_id, amount_cents FROM payments WHERE status = 'pending' ORDER BY id"
292
+ ).all();
293
+
294
+ const first = book(db, c1, inst, { recordDue: true });
295
+ assert.ok(first.paymentId);
296
+ const waiting = book(db, c2, inst, { recordDue: true });
297
+ assert.equal(waiting.paymentId, null, 'the waitlist owes nothing yet');
298
+ assert.deepEqual(dues(), [{ client_id: c1, amount_cents: 15000 }]);
299
+
300
+ // c1 cancels in time: their due goes, and c2 owes from the moment they're promoted.
301
+ cancelBooking(db, first.booking.id);
302
+ assert.deepEqual(dues(), [{ client_id: c2, amount_cents: 15000 }]);
303
+
304
+ // The studio cancelling the class clears it too.
305
+ cancelClass(db, inst);
306
+ assert.deepEqual(dues(), []);
307
+ });
308
+
309
+ test('a late cancel under the forfeit policy still owes the drop-in', () => {
310
+ const db = testDb();
311
+ const inst = makeInstance(db, makeClassType(db, { price: 15000 }), { hoursFromNow: 2 });
312
+ const { booking } = book(db, makeClient(db), inst, { recordDue: true });
313
+ cancelBooking(db, booking.id);
314
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM payments WHERE status = 'pending'").get().c, 1);
315
+ });
316
+
265
317
  test('promotion re-resolves payment if the waitlisted client acquired a membership meanwhile', () => {
266
318
  const db = testDb();
267
319
  const type = makeClassType(db, { capacity: 1 });
@@ -344,3 +396,30 @@ test('capacity race: concurrent-style bookings never exceed capacity (transactio
344
396
  assert.equal(booked, 5);
345
397
  assert.equal(wait, 15);
346
398
  });
399
+
400
+ test('raising a class capacity fills the new spots from the waitlist, in order', () => {
401
+ const db = testDb();
402
+ const now = '2026-10-01T10:00:00Z';
403
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1 }), { now, hoursFromNow: 24 });
404
+ const [c1, c2, c3, c4] = [1, 2, 3, 4].map(() => makeClient(db));
405
+ const passes = [c2, c3, c4].map((c) => makePass(db, c, { total: 5 }));
406
+ const [, b2, b3, b4] = [c1, c2, c3, c4].map((c) => book(db, c, inst, { now }).booking);
407
+
408
+ const res = updateInstance(db, inst, { capacity: 3 }, { now });
409
+ assert.deepEqual(res.promoted.map((b) => b.id), [b2.id, b3.id]);
410
+ assert.equal(getPass(db, passes[0]).credits_remaining, 4);
411
+ assert.equal(getPass(db, passes[1]).credits_remaining, 4);
412
+ assert.equal(getBooking(db, b4.id).status, 'waitlist');
413
+ assert.equal(getPass(db, passes[2]).credits_remaining, 5);
414
+
415
+ // Shrinking the room cancels nobody.
416
+ assert.deepEqual(updateInstance(db, inst, { capacity: 1 }, { now }).promoted, []);
417
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM bookings WHERE status = 'booked'").get().c, 3);
418
+
419
+ // Nor does anyone get pulled into a class that has already started.
420
+ assert.deepEqual(updateInstance(db, inst, { capacity: 10 }, { now: '2026-10-02T11:00:00Z' }).promoted, []);
421
+ assert.equal(getBooking(db, b4.id).status, 'waitlist');
422
+
423
+ assert.throws(() => updateInstance(db, inst, { capacity: 0 }, { now }),
424
+ (e) => e instanceof BookingError && e.code === 'bad_capacity');
425
+ });
@@ -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/);