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/CHANGELOG.md +69 -0
- package/README.md +8 -1
- package/package.json +5 -5
- package/src/app.js +8 -1
- package/src/db/003-stripe-invoices.sql +6 -0
- package/src/db/004-payment-booking.sql +5 -0
- package/src/db/index.js +84 -82
- package/src/routes/admin.js +728 -692
- package/src/routes/me.js +4 -10
- package/src/routes/public.js +21 -25
- package/src/services/booking.js +61 -8
- package/src/services/notify.js +19 -0
- package/src/services/stripe.js +38 -1
- package/test/booking-link.test.js +47 -0
- package/test/booking.test.js +80 -1
- package/test/error-paths.test.js +49 -0
- package/test/stripe.test.js +84 -1
- package/test/web.test.js +113 -1
- package/views/admin/roster.ejs +19 -0
- package/views/public/book_result.ejs +6 -6
- package/views/public/buy_thanks.ejs +1 -1
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
|
|
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.');
|
package/src/routes/public.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
|
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) {
|
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
|
}
|
|
@@ -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
|
@@ -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
|
+
});
|
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
|
+
});
|
|
@@ -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
|
+
});
|
package/test/stripe.test.js
CHANGED
|
@@ -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: '
|
|
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/);
|