studio-os 0.4.0 → 0.6.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,9 +1,10 @@
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;
@@ -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.');
@@ -5,21 +5,18 @@ import { upcomingInstances } from '../services/schedule.js';
5
5
  import { book, resolvePayment, BookingError } from '../services/booking.js';
6
6
  import { makeMagicToken } from '../services/auth.js';
7
7
  import { emails } from '../services/mailer.js';
8
+ import { isEmail } from '../lib/email.js';
8
9
 
9
10
  function findOrCreateClient(db, name, email) {
10
11
  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;
12
+ const client = db.prepare('SELECT * FROM clients WHERE email = ?').get(cleanEmail);
13
+ // An existing client keeps their name. The form proves nothing about who's
14
+ // typing, so it can't be allowed to rename whoever owns the address.
15
+ if (client) return client;
16
+ const info = db.prepare(
17
+ "INSERT INTO clients (name, email, source) VALUES (?, ?, 'self')"
18
+ ).run(String(name || cleanEmail.split('@')[0]).trim(), cleanEmail);
19
+ return db.prepare('SELECT * FROM clients WHERE id = ?').get(info.lastInsertRowid);
23
20
  }
24
21
 
25
22
  function loadInstanceFull(db, id) {
@@ -77,7 +74,7 @@ export default function publicRoutes(services) {
77
74
  error, form: { name, email },
78
75
  });
79
76
 
80
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return renderErr('A valid email is required.');
77
+ if (!isEmail(email)) return renderErr('A valid email is required.');
81
78
 
82
79
  try {
83
80
  const result = db.transaction(() => {
@@ -90,20 +87,10 @@ export default function publicRoutes(services) {
90
87
  db.prepare('UPDATE clients SET waiver_signed_at = ? WHERE id = ?')
91
88
  .run(new Date().toISOString(), client.id);
92
89
  }
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
- }
90
+ const booked = book(db, client.id, inst.id, { recordDue: true });
104
91
  audit(db, client.email, booked.waitlisted ? 'waitlist_join' : 'booking_create',
105
92
  `instance:${inst.id} booking:${booked.booking.id}`);
106
- return { client, ...booked, paymentId: payment };
93
+ return { client, ...booked };
107
94
  })();
108
95
 
109
96
  const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, result.client.id)}`;
@@ -157,7 +144,7 @@ export default function publicRoutes(services) {
157
144
  const product = db.prepare('SELECT * FROM pack_products WHERE id = ? AND active = 1').get(req.params.id);
158
145
  if (!product) return res.status(404).render('error', { title: 'Not found', message: 'Pack not found.' });
159
146
  const email = String(req.body.email || '').trim().toLowerCase();
160
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
147
+ if (!isEmail(email)) {
161
148
  return res.status(400).render('public/buy_manual', { title: 'Buy', product, plan: null, email: null, error: 'A valid email is required.' });
162
149
  }
163
150
  if (services.stripe.checkoutReady) {
@@ -178,7 +165,7 @@ export default function publicRoutes(services) {
178
165
  const plan = db.prepare('SELECT * FROM membership_plans WHERE id = ? AND active = 1').get(req.params.id);
179
166
  if (!plan) return res.status(404).render('error', { title: 'Not found', message: 'Plan not found.' });
180
167
  const email = String(req.body.email || '').trim().toLowerCase();
181
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
168
+ if (!isEmail(email)) {
182
169
  return res.status(400).render('public/buy_manual', { title: 'Buy', product: null, plan, email: null, error: 'A valid email is required.' });
183
170
  }
184
171
  if (services.stripe.checkoutReady && plan.stripe_price_id) {
@@ -1,6 +1,7 @@
1
1
  import { Router } from 'express';
2
2
  import { setSetting, getSetting, audit } from '../db/index.js';
3
3
  import { createUser } from '../services/auth.js';
4
+ import { isEmail } from '../lib/email.js';
4
5
 
5
6
  const TIMEZONES = [
6
7
  'Asia/Hong_Kong', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Bangkok',
@@ -21,7 +22,7 @@ export default function setupRoutes({ db }) {
21
22
  const fail = (error) => res.status(400).render('setup', { title: 'Setup', timezones: TIMEZONES, error, form });
22
23
 
23
24
  if (!studio_name || !studio_name.trim()) return fail('Studio name is required.');
24
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return fail('A valid owner email is required.');
25
+ if (!isEmail(email)) return fail('A valid owner email is required.');
25
26
  if (!password || password.length < 8) return fail('Password must be at least 8 characters.');
26
27
  if (password !== password2) return fail('Passwords do not match.');
27
28
  try {
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  // Mindbody CSV importer core. Used by scripts/import-mindbody.mjs and the
2
2
  // admin import page. Idempotent by client email: re-running updates, never
3
3
  // duplicates. No external CSV dependency — small RFC-4180-ish parser below.
4
+ import { isEmail } from '../lib/email.js';
4
5
 
5
6
  /** Parse CSV text into an array of row objects keyed by header. Handles quoted
6
7
  * fields, embedded commas/newlines, and doubled quotes. */
@@ -96,7 +97,7 @@ export function importClients(db, csvText, { mapping = DEFAULT_MAPPING.clients,
96
97
  const work = db.transaction(() => {
97
98
  for (const row of rows) {
98
99
  const email = pick(row, mapping.email).toLowerCase();
99
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
100
+ if (!isEmail(email)) {
100
101
  result.skipped++;
101
102
  result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
102
103
  continue;
@@ -148,7 +149,7 @@ export function importPasses(db, csvText, { mapping = DEFAULT_MAPPING.passes, dr
148
149
  const work = db.transaction(() => {
149
150
  for (const row of rows) {
150
151
  const email = pick(row, mapping.email).toLowerCase();
151
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
152
+ if (!isEmail(email)) {
152
153
  result.skipped++;
153
154
  result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
154
155
  continue;
@@ -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
+ }
@@ -67,6 +67,29 @@ export function createStripeService({ env = process.env, client } = {}) {
67
67
  });
68
68
  },
69
69
 
70
+ /**
71
+ * Carry a staff status change over to the membership's subscription, so a
72
+ * cancelled or paused member stops being billed. Pausing voids invoices
73
+ * rather than deferring them.
74
+ */
75
+ async setSubscriptionStatus(subscriptionId, status) {
76
+ const s = await getClient();
77
+ if (status !== 'cancelled') {
78
+ return s.subscriptions.update(subscriptionId, {
79
+ pause_collection: status === 'paused' ? { behavior: 'void' } : '',
80
+ });
81
+ }
82
+ try {
83
+ return await s.subscriptions.cancel(subscriptionId);
84
+ } catch (err) {
85
+ // Already cancelled at Stripe's end is what we wanted anyway.
86
+ if (err.code === 'resource_missing') return null;
87
+ const sub = await s.subscriptions.retrieve(subscriptionId);
88
+ if (sub.status !== 'canceled') throw err;
89
+ return sub;
90
+ }
91
+ },
92
+
70
93
  /** Verify webhook signature and return the event. Throws on bad signature. */
71
94
  async parseWebhook(rawBody, signature) {
72
95
  const s = await getClient();
@@ -191,6 +214,31 @@ export function recordMembershipRenewal(db, invoice) {
191
214
  })();
192
215
  }
193
216
 
217
+ // Subscription status → membership status. A failed renewal pauses the
218
+ // membership, so it stops covering classes until the card goes through.
219
+ const MEMBERSHIP_STATUS = {
220
+ active: 'active', trialing: 'active',
221
+ past_due: 'paused', unpaid: 'paused', paused: 'paused',
222
+ canceled: 'cancelled', incomplete_expired: 'cancelled',
223
+ };
224
+
225
+ /**
226
+ * Follow a subscription's status onto its membership. Only an actual status
227
+ * change counts: every renewal fires customer.subscription.updated too, and
228
+ * those mustn't undo a pause staff set in the app. A cancelled membership
229
+ * stays cancelled.
230
+ */
231
+ function followSubscriptionStatus(db, sub, previous) {
232
+ const status = MEMBERSHIP_STATUS[sub.status];
233
+ if (!('status' in previous) || !status) return { fulfilled: false, reason: 'no_status_change' };
234
+ const r = db.prepare(
235
+ "UPDATE memberships SET status = ? WHERE stripe_subscription_id = ? AND status != 'cancelled'"
236
+ ).run(status, sub.id);
237
+ if (!r.changes) return { fulfilled: false, reason: 'unknown_subscription' };
238
+ audit(db, 'stripe-webhook', 'subscription_status', `${sub.id} ${previous.status} → ${sub.status}`);
239
+ return { fulfilled: true, kind: 'subscription_status', status };
240
+ }
241
+
194
242
  /** Handle a parsed Stripe event. Returns a result object for logging. */
195
243
  export function handleStripeEvent(db, event) {
196
244
  if (event.type === 'checkout.session.completed'
@@ -208,5 +256,8 @@ export function handleStripeEvent(db, event) {
208
256
  audit(db, 'stripe-webhook', 'subscription_cancelled', sub.id);
209
257
  return { fulfilled: r.changes > 0, kind: 'subscription_cancelled' };
210
258
  }
259
+ if (event.type === 'customer.subscription.updated') {
260
+ return followSubscriptionStatus(db, event.data.object, event.data.previous_attributes || {});
261
+ }
211
262
  return { fulfilled: false, reason: `ignored event ${event.type}` };
212
263
  }
@@ -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
+ });
@@ -121,3 +121,31 @@ test('X-Forwarded-For is ignored unless TRUST_PROXY is set', async () => {
121
121
  .set('X-Forwarded-For', '2.2.2.2').send({ email: 'a@t.test', _csrf: csrf2 });
122
122
  assert.equal(otherIp.status, 200, 'different forwarded IP has its own bucket');
123
123
  });
124
+
125
+ test('one IPv6 host cannot dodge the limit by rotating addresses', async () => {
126
+ const { app } = makeApp({ env: { TRUST_PROXY: '1' }, now: () => 10 * WINDOW });
127
+ const agent = request.agent(app);
128
+ const _csrf = await csrfToken(agent, '/magic-link');
129
+ const statuses = [];
130
+ for (let i = 1; i <= 6; i++) {
131
+ const res = await agent.post('/magic-link').type('form')
132
+ .set('X-Forwarded-For', `2001:db8:0:1::${i}`).send({ email: 'a@t.test', _csrf });
133
+ statuses.push(res.status);
134
+ }
135
+ assert.deepEqual(statuses, [200, 200, 200, 200, 200, 429]);
136
+ });
137
+
138
+ test('every route sits under a 1000 per 15 min ceiling; static files do not count', async () => {
139
+ let t = 10 * WINDOW;
140
+ const { app } = makeApp({ now: () => t });
141
+ const agent = request.agent(app);
142
+ for (let i = 0; i < 50; i++) assert.equal((await agent.get('/css/custom.css')).status, 200);
143
+ for (let i = 1; i <= 1000; i++) {
144
+ const res = await agent.get('/magic-link');
145
+ if (res.status !== 200) assert.fail(`request ${i} got ${res.status}`);
146
+ }
147
+ const blocked = await agent.get('/admin');
148
+ assert.equal(blocked.status, 429);
149
+ t += WINDOW;
150
+ assert.equal((await agent.get('/magic-link')).status, 200);
151
+ });
@@ -8,14 +8,23 @@ import { createStripeService, fulfillCheckoutSession } from '../src/services/str
8
8
  import { createMailer } from '../src/services/mailer.js';
9
9
  import { createUser } from '../src/services/auth.js';
10
10
  import { openDb, setSetting } from '../src/db/index.js';
11
+ import { csrfToken } from './helpers.js';
11
12
 
12
13
  const VALID_SIG = 't=123,v1=goodsignature';
13
14
 
14
15
  /** Mock of the stripe npm client: signature check + checkout session capture. */
15
16
  function mockStripeClient() {
16
17
  const created = [];
17
- return {
18
+ const subscriptionCalls = [];
19
+ const client = {
18
20
  created,
21
+ subscriptionCalls,
22
+ failNext: null,
23
+ subscriptions: {
24
+ async update(id, params) { return call(['update', id, params]); },
25
+ async cancel(id) { return call(['cancel', id]); },
26
+ async retrieve(id) { return { id, status: 'active' }; },
27
+ },
19
28
  checkout: {
20
29
  sessions: {
21
30
  create: async (params) => {
@@ -32,6 +41,16 @@ function mockStripeClient() {
32
41
  },
33
42
  },
34
43
  };
44
+ function call(entry) {
45
+ if (client.failNext) {
46
+ const err = client.failNext;
47
+ client.failNext = null;
48
+ throw err;
49
+ }
50
+ subscriptionCalls.push(entry);
51
+ return { id: entry[1] };
52
+ }
53
+ return client;
35
54
  }
36
55
 
37
56
  function makeApp() {
@@ -247,6 +266,76 @@ test('customer.subscription.deleted cancels the matching membership', async () =
247
266
  assert.equal(db.prepare('SELECT status FROM memberships WHERE client_id = ?').get(clientId).status, 'cancelled');
248
267
  });
249
268
 
269
+ function membershipWithSub(db, subId, status = 'active') {
270
+ const clientId = db.prepare(
271
+ "INSERT INTO clients (name, email, source) VALUES ('M', ?, 'self')"
272
+ ).run(`${subId}@test.hk`).lastInsertRowid;
273
+ const id = db.prepare(
274
+ `INSERT INTO memberships (client_id, plan_name, status, started_on, stripe_subscription_id, unlimited)
275
+ VALUES (?, 'Unlimited', ?, '2026-07-01', ?, 1)`
276
+ ).run(clientId, status, subId).lastInsertRowid;
277
+ return { clientId, id, status: () => db.prepare('SELECT status FROM memberships WHERE id = ?').get(id).status };
278
+ }
279
+
280
+ test('a failed renewal pauses the membership until the card goes through', async () => {
281
+ const { db, app } = makeApp();
282
+ const m = membershipWithSub(db, 'sub_dunning');
283
+ const updated = (status, previous) => postEvent(app, {
284
+ type: 'customer.subscription.updated',
285
+ data: { object: { id: 'sub_dunning', status }, previous_attributes: previous },
286
+ });
287
+
288
+ await updated('past_due', { status: 'active' });
289
+ assert.equal(m.status(), 'paused');
290
+ await updated('active', { status: 'past_due' });
291
+ assert.equal(m.status(), 'active');
292
+
293
+ // A renewal also fires .updated, without a status change. It must not
294
+ // undo a pause staff set in the app.
295
+ db.prepare("UPDATE memberships SET status = 'paused' WHERE id = ?").run(m.id);
296
+ const renewal = await updated('active', { current_period_end: 1790000000 });
297
+ assert.equal(renewal.body.reason, 'no_status_change');
298
+ assert.equal(m.status(), 'paused');
299
+
300
+ db.prepare("UPDATE memberships SET status = 'cancelled' WHERE id = ?").run(m.id);
301
+ await updated('active', { status: 'past_due' });
302
+ assert.equal(m.status(), 'cancelled', 'a cancelled membership stays cancelled');
303
+ });
304
+
305
+ test('staff pausing, resuming or cancelling a Stripe membership changes the subscription too', async () => {
306
+ const { db, app, client } = makeApp();
307
+ const m = membershipWithSub(db, 'sub_staff');
308
+ const agent = request.agent(app);
309
+ let _csrf = await csrfToken(agent, '/admin/login');
310
+ await agent.post('/admin/login').type('form').send({ email: 'owner@test.test', password: 'password123', _csrf });
311
+ _csrf = await csrfToken(agent, `/admin/clients/${m.clientId}`);
312
+ const setStatus = (status) => agent.post(`/admin/clients/${m.clientId}/memberships`).type('form')
313
+ .send({ action: 'status', membership_id: String(m.id), status, _csrf });
314
+
315
+ await setStatus('paused');
316
+ assert.equal(m.status(), 'paused');
317
+ await setStatus('active');
318
+ assert.equal(m.status(), 'active');
319
+ assert.deepEqual(client.subscriptionCalls, [
320
+ ['update', 'sub_staff', { pause_collection: { behavior: 'void' } }],
321
+ ['update', 'sub_staff', { pause_collection: '' }],
322
+ ]);
323
+
324
+ // Stripe saying no leaves the membership as it was.
325
+ client.failNext = Object.assign(new Error('card_declined'), { code: 'card_declined' });
326
+ const refused = await setStatus('paused');
327
+ assert.equal(refused.status, 302);
328
+ assert.equal(m.status(), 'active');
329
+
330
+ const bogus = await setStatus('frozen');
331
+ assert.equal(bogus.status, 302, 'an unknown status is a flash, not a 500');
332
+ assert.equal(m.status(), 'active');
333
+
334
+ await setStatus('cancelled');
335
+ assert.equal(m.status(), 'cancelled');
336
+ assert.deepEqual(client.subscriptionCalls.at(-1), ['cancel', 'sub_staff']);
337
+ });
338
+
250
339
  test('drop-in checkout marks the pending payment paid', async () => {
251
340
  const { db, app } = makeApp();
252
341
  const clientId = db.prepare(