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/CHANGELOG.md +64 -0
- package/README.md +9 -4
- package/package.json +6 -5
- package/src/app.js +6 -0
- package/src/db/004-payment-booking.sql +5 -0
- package/src/db/index.js +1 -0
- package/src/lib/email.js +5 -0
- package/src/lib/ratelimit.js +65 -24
- package/src/routes/admin.js +791 -692
- package/src/routes/me.js +3 -9
- package/src/routes/public.js +14 -27
- package/src/routes/setup.js +2 -1
- package/src/services/booking.js +61 -8
- package/src/services/importer.js +3 -2
- package/src/services/notify.js +19 -0
- package/src/services/stripe.js +51 -0
- package/test/booking.test.js +80 -1
- package/test/ratelimit.test.js +28 -0
- package/test/stripe.test.js +90 -1
- package/test/web.test.js +184 -1
- package/views/admin/class_types.ejs +1 -1
- package/views/admin/roster.ejs +19 -0
- package/views/admin/rules.ejs +1 -1
- package/views/public/book_result.ejs +1 -1
- package/views/public/buy_thanks.ejs +1 -1
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
|
|
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.');
|
package/src/routes/public.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 (!
|
|
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
|
|
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 (
|
|
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 (
|
|
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) {
|
package/src/routes/setup.js
CHANGED
|
@@ -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 (!
|
|
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 {
|
package/src/services/booking.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
}
|
package/src/services/importer.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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
|
+
}
|
package/src/services/stripe.js
CHANGED
|
@@ -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
|
}
|
package/test/booking.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/ratelimit.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/stripe.test.js
CHANGED
|
@@ -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
|
-
|
|
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(
|