studio-os 0.2.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/.dockerignore +7 -0
- package/.env.example +34 -0
- package/CHANGELOG.md +86 -0
- package/Dockerfile +25 -0
- package/LICENSE +21 -0
- package/PROGRESS.md +60 -0
- package/README.md +253 -0
- package/SPEC.md +130 -0
- package/docker-compose.yml +26 -0
- package/docs/screenshots/admin-client.png +0 -0
- package/docs/screenshots/admin-dashboard.png +0 -0
- package/docs/screenshots/admin-reports.png +0 -0
- package/docs/screenshots/admin-roster.png +0 -0
- package/docs/screenshots/admin-schedule.png +0 -0
- package/docs/screenshots/public-booking.png +0 -0
- package/docs/screenshots/public-buy.png +0 -0
- package/docs/screenshots/public-schedule.png +0 -0
- package/package.json +54 -0
- package/public/css/custom.css +40 -0
- package/public/js/icon.svg +4 -0
- package/public/js/sw.js +23 -0
- package/public/manifest.json +17 -0
- package/public/vendor/htmx.min.js +1 -0
- package/public/vendor/pico.min.css +4 -0
- package/scripts/import-mindbody.mjs +109 -0
- package/scripts/seed.mjs +147 -0
- package/src/app.js +141 -0
- package/src/db/002-instructor-role.sql +26 -0
- package/src/db/index.js +82 -0
- package/src/db/schema.sql +164 -0
- package/src/lib/ratelimit.js +50 -0
- package/src/lib/time.js +87 -0
- package/src/routes/admin.js +692 -0
- package/src/routes/auth.js +34 -0
- package/src/routes/instructor.js +82 -0
- package/src/routes/me.js +101 -0
- package/src/routes/public.js +215 -0
- package/src/routes/setup.js +55 -0
- package/src/routes/webhooks.js +28 -0
- package/src/server.js +29 -0
- package/src/services/auth.js +81 -0
- package/src/services/booking.js +261 -0
- package/src/services/importer.js +201 -0
- package/src/services/mailer.js +92 -0
- package/src/services/schedule.js +71 -0
- package/src/services/stripe.js +169 -0
- package/test/booking.test.js +346 -0
- package/test/fixtures/mindbody-clients.csv +9 -0
- package/test/fixtures/mindbody-passes.csv +8 -0
- package/test/helpers.js +59 -0
- package/test/importer.test.js +173 -0
- package/test/instructor.test.js +217 -0
- package/test/ratelimit.test.js +123 -0
- package/test/schedule.test.js +102 -0
- package/test/stripe.test.js +249 -0
- package/test/views.test.js +38 -0
- package/test/web.test.js +392 -0
- package/views/admin/class_types.ejs +45 -0
- package/views/admin/client.ejs +149 -0
- package/views/admin/clients.ejs +33 -0
- package/views/admin/dashboard.ejs +55 -0
- package/views/admin/import.ejs +39 -0
- package/views/admin/instructor_classes.ejs +21 -0
- package/views/admin/instructor_new.ejs +15 -0
- package/views/admin/instructors.ejs +49 -0
- package/views/admin/products.ejs +83 -0
- package/views/admin/reports.ejs +34 -0
- package/views/admin/roster.ejs +58 -0
- package/views/admin/rules.ejs +71 -0
- package/views/admin/schedule.ejs +50 -0
- package/views/admin/settings.ejs +53 -0
- package/views/error.ejs +7 -0
- package/views/instructor/roster.ejs +27 -0
- package/views/instructor/schedule.ejs +21 -0
- package/views/login.ejs +11 -0
- package/views/partials/foot.ejs +11 -0
- package/views/partials/head.ejs +54 -0
- package/views/public/book_result.ejs +26 -0
- package/views/public/buy.ejs +37 -0
- package/views/public/buy_manual.ejs +17 -0
- package/views/public/buy_thanks.ejs +7 -0
- package/views/public/class.ejs +29 -0
- package/views/public/magic_request.ejs +21 -0
- package/views/public/me.ejs +47 -0
- package/views/public/schedule.ejs +49 -0
- package/views/setup.ejs +53 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// All Stripe interaction lives here. The service is constructed with an
|
|
2
|
+
// injectable client so tests never touch the network. When env keys are absent
|
|
3
|
+
// the service reports unconfigured and every purchase flow falls back to manual.
|
|
4
|
+
import { audit } from '../db/index.js';
|
|
5
|
+
import { addDays, localDateStr } from '../lib/time.js';
|
|
6
|
+
import { getSetting } from '../db/index.js';
|
|
7
|
+
|
|
8
|
+
export function createStripeService({ env = process.env, client } = {}) {
|
|
9
|
+
const secretKey = env.STRIPE_SECRET_KEY || '';
|
|
10
|
+
const webhookSecret = env.STRIPE_WEBHOOK_SECRET || '';
|
|
11
|
+
const publishableKey = env.STRIPE_PUBLISHABLE_KEY || '';
|
|
12
|
+
let stripe = client || null;
|
|
13
|
+
|
|
14
|
+
async function getClient() {
|
|
15
|
+
if (!secretKey) throw new Error('Stripe not configured');
|
|
16
|
+
if (!stripe) {
|
|
17
|
+
const Stripe = (await import('stripe')).default;
|
|
18
|
+
stripe = new Stripe(secretKey);
|
|
19
|
+
}
|
|
20
|
+
return stripe;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
configured: Boolean(secretKey),
|
|
25
|
+
webhookConfigured: Boolean(webhookSecret),
|
|
26
|
+
publishableKey,
|
|
27
|
+
|
|
28
|
+
/** One-time Checkout for a class pack. Returns the session (url etc). */
|
|
29
|
+
async createPackCheckout({ product, clientEmail, baseUrl, currency }) {
|
|
30
|
+
const s = await getClient();
|
|
31
|
+
return s.checkout.sessions.create({
|
|
32
|
+
mode: 'payment',
|
|
33
|
+
customer_email: clientEmail,
|
|
34
|
+
line_items: product.stripe_price_id
|
|
35
|
+
? [{ price: product.stripe_price_id, quantity: 1 }]
|
|
36
|
+
: [{
|
|
37
|
+
price_data: {
|
|
38
|
+
currency: currency.toLowerCase(),
|
|
39
|
+
unit_amount: product.price_cents,
|
|
40
|
+
product_data: { name: product.name },
|
|
41
|
+
},
|
|
42
|
+
quantity: 1,
|
|
43
|
+
}],
|
|
44
|
+
metadata: { kind: 'pack', pack_product_id: String(product.id), client_email: clientEmail },
|
|
45
|
+
success_url: `${baseUrl}/buy/thanks?session_id={CHECKOUT_SESSION_ID}`,
|
|
46
|
+
cancel_url: `${baseUrl}/buy`,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/** Subscription Checkout for a membership plan (needs stripe_price_id). */
|
|
51
|
+
async createMembershipCheckout({ plan, clientEmail, baseUrl }) {
|
|
52
|
+
const s = await getClient();
|
|
53
|
+
if (!plan.stripe_price_id) throw new Error('Plan has no Stripe Price ID');
|
|
54
|
+
return s.checkout.sessions.create({
|
|
55
|
+
mode: 'subscription',
|
|
56
|
+
customer_email: clientEmail,
|
|
57
|
+
line_items: [{ price: plan.stripe_price_id, quantity: 1 }],
|
|
58
|
+
metadata: { kind: 'membership', plan_id: String(plan.id), client_email: clientEmail },
|
|
59
|
+
success_url: `${baseUrl}/buy/thanks?session_id={CHECKOUT_SESSION_ID}`,
|
|
60
|
+
cancel_url: `${baseUrl}/buy`,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** Verify webhook signature and return the event. Throws on bad signature. */
|
|
65
|
+
async parseWebhook(rawBody, signature) {
|
|
66
|
+
const s = await getClient();
|
|
67
|
+
if (!webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET not set');
|
|
68
|
+
return s.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fulfill a completed Checkout session: create the pass or activate the
|
|
75
|
+
* membership, and record the payment. Idempotent via payments.stripe_session_id
|
|
76
|
+
* UNIQUE — a replayed webhook is a no-op. Pure DB logic (testable sans Stripe).
|
|
77
|
+
*/
|
|
78
|
+
export function fulfillCheckoutSession(db, session) {
|
|
79
|
+
const meta = session.metadata || {};
|
|
80
|
+
const email = (meta.client_email || session.customer_email
|
|
81
|
+
|| (session.customer_details && session.customer_details.email) || '').toLowerCase();
|
|
82
|
+
if (!email) throw new Error('Checkout session has no client email');
|
|
83
|
+
|
|
84
|
+
return db.transaction(() => {
|
|
85
|
+
const existing = db.prepare('SELECT id FROM payments WHERE stripe_session_id = ?').get(session.id);
|
|
86
|
+
if (existing) return { fulfilled: false, reason: 'already_processed' };
|
|
87
|
+
|
|
88
|
+
let client = db.prepare('SELECT * FROM clients WHERE email = ?').get(email);
|
|
89
|
+
if (!client) {
|
|
90
|
+
const info = db.prepare(
|
|
91
|
+
"INSERT INTO clients (name, email, source) VALUES (?, ?, 'self')"
|
|
92
|
+
).run(email.split('@')[0], email);
|
|
93
|
+
client = db.prepare('SELECT * FROM clients WHERE id = ?').get(info.lastInsertRowid);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const currency = (session.currency || getSetting(db, 'currency', 'HKD')).toUpperCase();
|
|
97
|
+
const amount = session.amount_total ?? 0;
|
|
98
|
+
|
|
99
|
+
if (meta.kind === 'pack') {
|
|
100
|
+
const product = db.prepare('SELECT * FROM pack_products WHERE id = ?').get(Number(meta.pack_product_id));
|
|
101
|
+
if (!product) throw new Error(`Unknown pack product ${meta.pack_product_id}`);
|
|
102
|
+
const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
|
|
103
|
+
const expiresOn = product.validity_days
|
|
104
|
+
? addDays(localDateStr(tz), product.validity_days) : null;
|
|
105
|
+
db.prepare(
|
|
106
|
+
`INSERT INTO passes (client_id, name, credits_total, credits_remaining, expires_on, price_paid_cents, source)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, ?, 'purchase')`
|
|
108
|
+
).run(client.id, product.name, product.credits, product.credits, expiresOn, amount);
|
|
109
|
+
db.prepare(
|
|
110
|
+
`INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status, stripe_session_id)
|
|
111
|
+
VALUES (?, ?, ?, 'stripe', ?, 'pack', 'paid', ?)`
|
|
112
|
+
).run(client.id, amount, currency, product.name, session.id);
|
|
113
|
+
audit(db, 'stripe-webhook', 'pack_fulfilled', `client:${client.id} product:${product.id}`);
|
|
114
|
+
return { fulfilled: true, kind: 'pack', clientId: client.id };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (meta.kind === 'membership') {
|
|
118
|
+
const plan = db.prepare('SELECT * FROM membership_plans WHERE id = ?').get(Number(meta.plan_id));
|
|
119
|
+
if (!plan) throw new Error(`Unknown membership plan ${meta.plan_id}`);
|
|
120
|
+
const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
|
|
121
|
+
const today = localDateStr(tz);
|
|
122
|
+
db.prepare(
|
|
123
|
+
`INSERT INTO memberships (client_id, plan_id, plan_name, status, started_on, renews_on,
|
|
124
|
+
stripe_subscription_id, unlimited, credits_per_month, cycle_started_on)
|
|
125
|
+
VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?, ?)`
|
|
126
|
+
).run(client.id, plan.id, plan.name, today, session.subscription || null,
|
|
127
|
+
plan.unlimited, plan.credits_per_month, today);
|
|
128
|
+
db.prepare(
|
|
129
|
+
`INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status, stripe_session_id)
|
|
130
|
+
VALUES (?, ?, ?, 'stripe', ?, 'membership', 'paid', ?)`
|
|
131
|
+
).run(client.id, amount, currency, plan.name, session.id);
|
|
132
|
+
audit(db, 'stripe-webhook', 'membership_fulfilled', `client:${client.id} plan:${plan.id}`);
|
|
133
|
+
return { fulfilled: true, kind: 'membership', clientId: client.id };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (meta.kind === 'drop_in') {
|
|
137
|
+
// Pay-online for a drop-in booking: mark the pending payment paid.
|
|
138
|
+
const payment = db.prepare(
|
|
139
|
+
"SELECT * FROM payments WHERE id = ? AND status = 'pending'"
|
|
140
|
+
).get(Number(meta.payment_id));
|
|
141
|
+
if (payment) {
|
|
142
|
+
db.prepare(
|
|
143
|
+
"UPDATE payments SET status = 'paid', method = 'stripe', stripe_session_id = ? WHERE id = ?"
|
|
144
|
+
).run(session.id, payment.id);
|
|
145
|
+
audit(db, 'stripe-webhook', 'drop_in_paid', `payment:${payment.id}`);
|
|
146
|
+
return { fulfilled: true, kind: 'drop_in', clientId: payment.client_id };
|
|
147
|
+
}
|
|
148
|
+
return { fulfilled: false, reason: 'payment_not_found' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { fulfilled: false, reason: `unknown kind ${meta.kind}` };
|
|
152
|
+
})();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Handle a parsed Stripe event. Returns a result object for logging. */
|
|
156
|
+
export function handleStripeEvent(db, event) {
|
|
157
|
+
if (event.type === 'checkout.session.completed') {
|
|
158
|
+
return fulfillCheckoutSession(db, event.data.object);
|
|
159
|
+
}
|
|
160
|
+
if (event.type === 'customer.subscription.deleted') {
|
|
161
|
+
const sub = event.data.object;
|
|
162
|
+
const r = db.prepare(
|
|
163
|
+
"UPDATE memberships SET status = 'cancelled' WHERE stripe_subscription_id = ?"
|
|
164
|
+
).run(sub.id);
|
|
165
|
+
audit(db, 'stripe-webhook', 'subscription_cancelled', sub.id);
|
|
166
|
+
return { fulfilled: r.changes > 0, kind: 'subscription_cancelled' };
|
|
167
|
+
}
|
|
168
|
+
return { fulfilled: false, reason: `ignored event ${event.type}` };
|
|
169
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
testDb, makeClient, makeClassType, makeInstance, makePass, makeMembership,
|
|
5
|
+
getPass, getBooking, getMembership,
|
|
6
|
+
} from './helpers.js';
|
|
7
|
+
import {
|
|
8
|
+
book, cancelBooking, cancelClass, markAttendance, resolvePayment, BookingError,
|
|
9
|
+
} from '../src/services/booking.js';
|
|
10
|
+
|
|
11
|
+
test('booking deducts a pack credit at booking time, in the same transaction', () => {
|
|
12
|
+
const db = testDb();
|
|
13
|
+
const type = makeClassType(db);
|
|
14
|
+
const inst = makeInstance(db, type);
|
|
15
|
+
const client = makeClient(db);
|
|
16
|
+
const pass = makePass(db, client, { total: 10 });
|
|
17
|
+
|
|
18
|
+
const { booking, waitlisted } = book(db, client, inst);
|
|
19
|
+
assert.equal(waitlisted, false);
|
|
20
|
+
assert.equal(booking.status, 'booked');
|
|
21
|
+
assert.equal(booking.paid_with, 'pack');
|
|
22
|
+
assert.equal(booking.pass_id, pass);
|
|
23
|
+
assert.equal(getPass(db, pass).credits_remaining, 9);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('pack selection prefers the soonest-expiring pass with credits', () => {
|
|
27
|
+
const db = testDb();
|
|
28
|
+
const type = makeClassType(db);
|
|
29
|
+
const inst = makeInstance(db, type, { hoursFromNow: 24 });
|
|
30
|
+
const client = makeClient(db);
|
|
31
|
+
const later = makePass(db, client, { name: 'Later', expiresOn: '2030-12-31' });
|
|
32
|
+
const sooner = makePass(db, client, { name: 'Sooner', expiresOn: '2027-06-01' });
|
|
33
|
+
const noExpiry = makePass(db, client, { name: 'NoExpiry', expiresOn: null });
|
|
34
|
+
|
|
35
|
+
const { booking } = book(db, client, inst);
|
|
36
|
+
assert.equal(booking.pass_id, sooner);
|
|
37
|
+
assert.equal(getPass(db, sooner).credits_remaining, 9);
|
|
38
|
+
assert.equal(getPass(db, later).credits_remaining, 10);
|
|
39
|
+
assert.equal(getPass(db, noExpiry).credits_remaining, 10);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('expired and empty packs are skipped', () => {
|
|
43
|
+
const db = testDb();
|
|
44
|
+
const type = makeClassType(db);
|
|
45
|
+
const inst = makeInstance(db, type, { hoursFromNow: 24 });
|
|
46
|
+
const client = makeClient(db);
|
|
47
|
+
makePass(db, client, { name: 'Expired', expiresOn: '2020-01-01' });
|
|
48
|
+
makePass(db, client, { name: 'Empty', remaining: 0 });
|
|
49
|
+
const good = makePass(db, client, { name: 'Good', expiresOn: null });
|
|
50
|
+
|
|
51
|
+
const { booking } = book(db, client, inst);
|
|
52
|
+
assert.equal(booking.pass_id, good);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a pass expiring before the class date is not used for that class', () => {
|
|
56
|
+
const db = testDb();
|
|
57
|
+
const type = makeClassType(db);
|
|
58
|
+
const client = makeClient(db);
|
|
59
|
+
// class ~10 days out; pass expires in ~5 days
|
|
60
|
+
const inst = makeInstance(db, type, { hoursFromNow: 240 });
|
|
61
|
+
const soonExpiry = new Date(Date.now() + 5 * 86400000).toISOString().slice(0, 10);
|
|
62
|
+
makePass(db, client, { name: 'TooShort', expiresOn: soonExpiry });
|
|
63
|
+
|
|
64
|
+
const { booking } = book(db, client, inst);
|
|
65
|
+
assert.equal(booking.paid_with, 'drop_in_manual');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('unlimited membership outranks packs', () => {
|
|
69
|
+
const db = testDb();
|
|
70
|
+
const type = makeClassType(db);
|
|
71
|
+
const inst = makeInstance(db, type);
|
|
72
|
+
const client = makeClient(db);
|
|
73
|
+
const pass = makePass(db, client);
|
|
74
|
+
const mem = makeMembership(db, client, { unlimited: true });
|
|
75
|
+
|
|
76
|
+
const { booking } = book(db, client, inst);
|
|
77
|
+
assert.equal(booking.paid_with, 'membership');
|
|
78
|
+
assert.equal(booking.membership_id, mem);
|
|
79
|
+
assert.equal(getPass(db, pass).credits_remaining, 10);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('credit membership deducts monthly credits and falls back to pack when exhausted', () => {
|
|
83
|
+
const db = testDb();
|
|
84
|
+
const type = makeClassType(db, { capacity: 10 });
|
|
85
|
+
const client = makeClient(db);
|
|
86
|
+
const mem = makeMembership(db, client, { unlimited: false, creditsPerMonth: 2, startedOn: '2026-07-01' });
|
|
87
|
+
const pass = makePass(db, client);
|
|
88
|
+
|
|
89
|
+
const b1 = book(db, client, makeInstance(db, type, { hoursFromNow: 24 }));
|
|
90
|
+
const b2 = book(db, client, makeInstance(db, type, { hoursFromNow: 26 }));
|
|
91
|
+
assert.equal(b1.booking.paid_with, 'membership');
|
|
92
|
+
assert.equal(b2.booking.paid_with, 'membership');
|
|
93
|
+
assert.equal(getMembership(db, mem).credits_used_this_cycle, 2);
|
|
94
|
+
|
|
95
|
+
const b3 = book(db, client, makeInstance(db, type, { hoursFromNow: 28 }));
|
|
96
|
+
assert.equal(b3.booking.paid_with, 'pack');
|
|
97
|
+
assert.equal(getPass(db, pass).credits_remaining, 9);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('paused/cancelled memberships are ignored', () => {
|
|
101
|
+
const db = testDb();
|
|
102
|
+
const type = makeClassType(db);
|
|
103
|
+
const inst = makeInstance(db, type);
|
|
104
|
+
const client = makeClient(db);
|
|
105
|
+
makeMembership(db, client, { status: 'paused' });
|
|
106
|
+
const res = resolvePayment(db, client, db.prepare('SELECT ci.*, ct.credits_required FROM class_instances ci JOIN class_types ct ON ct.id=ci.class_type_id WHERE ci.id=?').get(inst));
|
|
107
|
+
assert.equal(res.paidWith, 'drop_in');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('no membership or pack falls through to drop-in', () => {
|
|
111
|
+
const db = testDb();
|
|
112
|
+
const type = makeClassType(db);
|
|
113
|
+
const inst = makeInstance(db, type);
|
|
114
|
+
const client = makeClient(db);
|
|
115
|
+
const { booking } = book(db, client, inst);
|
|
116
|
+
assert.equal(booking.paid_with, 'drop_in_manual');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('capacity is enforced and overflow goes to the waitlist FIFO', () => {
|
|
120
|
+
const db = testDb();
|
|
121
|
+
const type = makeClassType(db, { capacity: 2 });
|
|
122
|
+
const inst = makeInstance(db, type);
|
|
123
|
+
const c1 = makeClient(db); const c2 = makeClient(db);
|
|
124
|
+
const c3 = makeClient(db); const c4 = makeClient(db);
|
|
125
|
+
|
|
126
|
+
assert.equal(book(db, c1, inst).waitlisted, false);
|
|
127
|
+
assert.equal(book(db, c2, inst).waitlisted, false);
|
|
128
|
+
const w1 = book(db, c3, inst, { now: '2026-07-27T01:00:00Z' });
|
|
129
|
+
const w2 = book(db, c4, inst, { now: '2026-07-27T02:00:00Z' });
|
|
130
|
+
assert.equal(w1.waitlisted, true);
|
|
131
|
+
assert.equal(w1.booking.status, 'waitlist');
|
|
132
|
+
assert.equal(w2.waitlisted, true);
|
|
133
|
+
|
|
134
|
+
const rows = db.prepare("SELECT status, COUNT(*) c FROM bookings WHERE class_instance_id=? GROUP BY status").all(inst);
|
|
135
|
+
const byStatus = Object.fromEntries(rows.map((r) => [r.status, r.c]));
|
|
136
|
+
assert.equal(byStatus.booked, 2);
|
|
137
|
+
assert.equal(byStatus.waitlist, 2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('waitlisted bookings do not deduct credits until promoted', () => {
|
|
141
|
+
const db = testDb();
|
|
142
|
+
const type = makeClassType(db, { capacity: 1 });
|
|
143
|
+
const inst = makeInstance(db, type);
|
|
144
|
+
const c1 = makeClient(db); const c2 = makeClient(db);
|
|
145
|
+
const pass2 = makePass(db, c2);
|
|
146
|
+
|
|
147
|
+
book(db, c1, inst);
|
|
148
|
+
const { booking: wl } = book(db, c2, inst);
|
|
149
|
+
assert.equal(wl.status, 'waitlist');
|
|
150
|
+
assert.equal(getPass(db, pass2).credits_remaining, 10);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('duplicate booking for the same class is rejected; rebooking after cancel works', () => {
|
|
154
|
+
const db = testDb();
|
|
155
|
+
const type = makeClassType(db);
|
|
156
|
+
const inst = makeInstance(db, type);
|
|
157
|
+
const client = makeClient(db);
|
|
158
|
+
const { booking } = book(db, client, inst);
|
|
159
|
+
assert.throws(() => book(db, client, inst), (e) => e instanceof BookingError && e.code === 'already_booked');
|
|
160
|
+
cancelBooking(db, booking.id);
|
|
161
|
+
const again = book(db, client, inst);
|
|
162
|
+
assert.equal(again.booking.status, 'booked');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('booking a cancelled or past class is rejected', () => {
|
|
166
|
+
const db = testDb();
|
|
167
|
+
const type = makeClassType(db);
|
|
168
|
+
const client = makeClient(db);
|
|
169
|
+
const past = makeInstance(db, type, { hoursFromNow: -2 });
|
|
170
|
+
assert.throws(() => book(db, client, past), (e) => e.code === 'in_past');
|
|
171
|
+
|
|
172
|
+
const inst = makeInstance(db, type);
|
|
173
|
+
db.prepare("UPDATE class_instances SET status='cancelled' WHERE id=?").run(inst);
|
|
174
|
+
assert.throws(() => book(db, client, inst), (e) => e.code === 'class_cancelled');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('early cancel refunds the pack credit', () => {
|
|
178
|
+
const db = testDb({ cancellation_window_hours: '12' });
|
|
179
|
+
const type = makeClassType(db);
|
|
180
|
+
const inst = makeInstance(db, type, { hoursFromNow: 48 });
|
|
181
|
+
const client = makeClient(db);
|
|
182
|
+
const pass = makePass(db, client);
|
|
183
|
+
const { booking } = book(db, client, inst);
|
|
184
|
+
assert.equal(getPass(db, pass).credits_remaining, 9);
|
|
185
|
+
|
|
186
|
+
const res = cancelBooking(db, booking.id); // 48h out > 12h window
|
|
187
|
+
assert.equal(res.late, false);
|
|
188
|
+
assert.equal(res.refunded, true);
|
|
189
|
+
assert.equal(getPass(db, pass).credits_remaining, 10);
|
|
190
|
+
assert.equal(getBooking(db, booking.id).status, 'cancelled');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('late cancel forfeits the credit under the default policy', () => {
|
|
194
|
+
const db = testDb({ cancellation_window_hours: '12' });
|
|
195
|
+
const type = makeClassType(db);
|
|
196
|
+
const inst = makeInstance(db, type, { hoursFromNow: 3 }); // inside 12h window
|
|
197
|
+
const client = makeClient(db);
|
|
198
|
+
const pass = makePass(db, client);
|
|
199
|
+
const { booking } = book(db, client, inst);
|
|
200
|
+
|
|
201
|
+
const res = cancelBooking(db, booking.id);
|
|
202
|
+
assert.equal(res.late, true);
|
|
203
|
+
assert.equal(res.refunded, false);
|
|
204
|
+
assert.equal(getPass(db, pass).credits_remaining, 9); // forfeited
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('late cancel refunds when policy is set to refund', () => {
|
|
208
|
+
const db = testDb({ cancellation_window_hours: '12', late_cancel_policy: 'refund' });
|
|
209
|
+
const type = makeClassType(db);
|
|
210
|
+
const inst = makeInstance(db, type, { hoursFromNow: 3 });
|
|
211
|
+
const client = makeClient(db);
|
|
212
|
+
const pass = makePass(db, client);
|
|
213
|
+
const { booking } = book(db, client, inst);
|
|
214
|
+
|
|
215
|
+
const res = cancelBooking(db, booking.id);
|
|
216
|
+
assert.equal(res.late, true);
|
|
217
|
+
assert.equal(res.refunded, true);
|
|
218
|
+
assert.equal(getPass(db, pass).credits_remaining, 10);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('membership credit is returned on eligible cancellation', () => {
|
|
222
|
+
const db = testDb();
|
|
223
|
+
const type = makeClassType(db);
|
|
224
|
+
const inst = makeInstance(db, type, { hoursFromNow: 48 });
|
|
225
|
+
const client = makeClient(db);
|
|
226
|
+
const mem = makeMembership(db, client, { unlimited: false, creditsPerMonth: 4, startedOn: '2026-07-01' });
|
|
227
|
+
const { booking } = book(db, client, inst);
|
|
228
|
+
assert.equal(getMembership(db, mem).credits_used_this_cycle, 1);
|
|
229
|
+
cancelBooking(db, booking.id);
|
|
230
|
+
assert.equal(getMembership(db, mem).credits_used_this_cycle, 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('cancellation promotes the first waitlisted booking (FIFO) and deducts their credit', () => {
|
|
234
|
+
const db = testDb();
|
|
235
|
+
const type = makeClassType(db, { capacity: 1 });
|
|
236
|
+
const inst = makeInstance(db, type, { hoursFromNow: 48 });
|
|
237
|
+
const c1 = makeClient(db); const c2 = makeClient(db); const c3 = makeClient(db);
|
|
238
|
+
const pass2 = makePass(db, c2);
|
|
239
|
+
|
|
240
|
+
const b1 = book(db, c1, inst, { now: '2026-07-26T01:00:00Z' });
|
|
241
|
+
const w2 = book(db, c2, inst, { now: '2026-07-26T02:00:00Z' });
|
|
242
|
+
const w3 = book(db, c3, inst, { now: '2026-07-26T03:00:00Z' });
|
|
243
|
+
|
|
244
|
+
const res = cancelBooking(db, b1.booking.id);
|
|
245
|
+
assert.ok(res.promoted, 'someone was promoted');
|
|
246
|
+
assert.equal(res.promoted.id, w2.booking.id, 'FIFO: earliest waitlist first');
|
|
247
|
+
assert.equal(res.promoted.status, 'booked');
|
|
248
|
+
assert.equal(res.promoted.paid_with, 'pack');
|
|
249
|
+
assert.equal(getPass(db, pass2).credits_remaining, 9);
|
|
250
|
+
assert.equal(getBooking(db, w3.booking.id).status, 'waitlist');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('cancelling a waitlist booking does not promote or refund', () => {
|
|
254
|
+
const db = testDb();
|
|
255
|
+
const type = makeClassType(db, { capacity: 1 });
|
|
256
|
+
const inst = makeInstance(db, type);
|
|
257
|
+
const c1 = makeClient(db); const c2 = makeClient(db);
|
|
258
|
+
book(db, c1, inst);
|
|
259
|
+
const { booking: wl } = book(db, c2, inst);
|
|
260
|
+
const res = cancelBooking(db, wl.id);
|
|
261
|
+
assert.equal(res.refunded, false);
|
|
262
|
+
assert.equal(res.promoted, null);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('promotion re-resolves payment if the waitlisted client acquired a membership meanwhile', () => {
|
|
266
|
+
const db = testDb();
|
|
267
|
+
const type = makeClassType(db, { capacity: 1 });
|
|
268
|
+
const inst = makeInstance(db, type, { hoursFromNow: 48 });
|
|
269
|
+
const c1 = makeClient(db); const c2 = makeClient(db);
|
|
270
|
+
const b1 = book(db, c1, inst);
|
|
271
|
+
book(db, c2, inst); // waitlisted as drop_in
|
|
272
|
+
makeMembership(db, c2, { unlimited: true });
|
|
273
|
+
|
|
274
|
+
const res = cancelBooking(db, b1.booking.id);
|
|
275
|
+
assert.equal(res.promoted.paid_with, 'membership');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('cancelClass refunds all confirmed bookings regardless of window and cancels the instance', () => {
|
|
279
|
+
const db = testDb({ cancellation_window_hours: '12' });
|
|
280
|
+
const type = makeClassType(db, { capacity: 2 });
|
|
281
|
+
const inst = makeInstance(db, type, { hoursFromNow: 2 }); // inside window — still refunds
|
|
282
|
+
const c1 = makeClient(db); const c2 = makeClient(db); const c3 = makeClient(db);
|
|
283
|
+
const p1 = makePass(db, c1); const p2 = makePass(db, c2);
|
|
284
|
+
book(db, c1, inst); book(db, c2, inst);
|
|
285
|
+
const wl = book(db, c3, inst);
|
|
286
|
+
|
|
287
|
+
const { affected } = cancelClass(db, inst);
|
|
288
|
+
assert.equal(affected.length, 3);
|
|
289
|
+
assert.equal(getPass(db, p1).credits_remaining, 10);
|
|
290
|
+
assert.equal(getPass(db, p2).credits_remaining, 10);
|
|
291
|
+
assert.equal(getBooking(db, wl.booking.id).status, 'cancelled');
|
|
292
|
+
assert.equal(db.prepare('SELECT status FROM class_instances WHERE id=?').get(inst).status, 'cancelled');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('attendance marking: attended, no_show, and cancelled bookings free capacity accounting correctly', () => {
|
|
296
|
+
const db = testDb();
|
|
297
|
+
const type = makeClassType(db, { capacity: 2 });
|
|
298
|
+
const inst = makeInstance(db, type);
|
|
299
|
+
const c1 = makeClient(db); const c2 = makeClient(db); const c3 = makeClient(db);
|
|
300
|
+
const b1 = book(db, c1, inst);
|
|
301
|
+
const b2 = book(db, c2, inst);
|
|
302
|
+
markAttendance(db, b1.booking.id, 'attended');
|
|
303
|
+
markAttendance(db, b2.booking.id, 'no_show');
|
|
304
|
+
// attended/no_show still occupy the class — next booking waitlists
|
|
305
|
+
const b3 = book(db, c3, inst);
|
|
306
|
+
assert.equal(b3.waitlisted, true);
|
|
307
|
+
assert.throws(() => markAttendance(db, b3.booking.id, 'attended'), (e) => e.code === 'not_found');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('multi-credit classes deduct and refund credits_required', () => {
|
|
311
|
+
const db = testDb();
|
|
312
|
+
const type = makeClassType(db, { credits: 2 });
|
|
313
|
+
const inst = makeInstance(db, type, { hoursFromNow: 48 });
|
|
314
|
+
const client = makeClient(db);
|
|
315
|
+
const pass = makePass(db, client, { total: 5 });
|
|
316
|
+
const { booking } = book(db, client, inst);
|
|
317
|
+
assert.equal(getPass(db, pass).credits_remaining, 3);
|
|
318
|
+
cancelBooking(db, booking.id);
|
|
319
|
+
assert.equal(getPass(db, pass).credits_remaining, 5);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('a pass with fewer credits than required is skipped', () => {
|
|
323
|
+
const db = testDb();
|
|
324
|
+
const type = makeClassType(db, { credits: 2 });
|
|
325
|
+
const inst = makeInstance(db, type);
|
|
326
|
+
const client = makeClient(db);
|
|
327
|
+
makePass(db, client, { total: 5, remaining: 1 });
|
|
328
|
+
const { booking } = book(db, client, inst);
|
|
329
|
+
assert.equal(booking.paid_with, 'drop_in_manual');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('capacity race: concurrent-style bookings never exceed capacity (transactional)', () => {
|
|
333
|
+
const db = testDb();
|
|
334
|
+
const type = makeClassType(db, { capacity: 5 });
|
|
335
|
+
const inst = makeInstance(db, type);
|
|
336
|
+
const clients = Array.from({ length: 20 }, () => makeClient(db));
|
|
337
|
+
for (const c of clients) book(db, c, inst);
|
|
338
|
+
const booked = db.prepare(
|
|
339
|
+
"SELECT COUNT(*) c FROM bookings WHERE class_instance_id=? AND status='booked'"
|
|
340
|
+
).get(inst).c;
|
|
341
|
+
const wait = db.prepare(
|
|
342
|
+
"SELECT COUNT(*) c FROM bookings WHERE class_instance_id=? AND status='waitlist'"
|
|
343
|
+
).get(inst).c;
|
|
344
|
+
assert.equal(booked, 5);
|
|
345
|
+
assert.equal(wait, 15);
|
|
346
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
First Name,Last Name,Email,Mobile Phone,Home Phone,Address,City,Notes,Liability Waiver,Client Since
|
|
2
|
+
Wing Yan,Chan,wingyan.chan@example.com,+852 9123 4567,,"Flat 12B, Harbour Towers",Hong Kong,"Prefers morning classes, shoulder injury (2024)",Yes,1/15/2023
|
|
3
|
+
Ka Ming,Wong,kaming.wong@example.com,+852 9876 5432,+852 2345 6789,,Kowloon,,Yes,3/2/2024
|
|
4
|
+
Siu Fung,Lee,siufung.lee@example.com,,,,,"Referred by Wing Yan",No,11/20/2025
|
|
5
|
+
Emily,Tsang,emily.tsang@example.com,+852 6011 2233,,,"Sha Tin","VIP — annual member since 2022",Yes,6/6/2022
|
|
6
|
+
David,Ng,,+852 5544 3322,,,,No email on file — front desk only,No,2/14/2025
|
|
7
|
+
Hoi Ching,Lam,hoiching.lam@example.com,+852 6677 8899,,,Tsuen Wan,,Yes,9/9/2024
|
|
8
|
+
Jason,"O'Brien, Jr.",jason.obrien@example.com,+852 9000 1111,,,Central,"Name contains comma, tests quoting",Yes,5/1/2025
|
|
9
|
+
Mei,Cheung,MEI.CHEUNG@Example.com,+852 6123 9876,,,Mong Kok,Uppercase email in export,Yes,7/30/2025
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Client,Email,Pricing Option,Remaining,Total,Expiration,Price Paid,Purchase Date
|
|
2
|
+
Wing Yan Chan,wingyan.chan@example.com,10 Class Pack,7,10,10/31/2026,"$1,500.00",4/12/2026
|
|
3
|
+
Ka Ming Wong,kaming.wong@example.com,10 Class Pack,2,10,8/15/2026,"$1,500.00",1/20/2026
|
|
4
|
+
Ka Ming Wong,kaming.wong@example.com,5 Class Intro Pack,0,5,3/1/2026,$550.00,12/2/2025
|
|
5
|
+
Emily Tsang,emily.tsang@example.com,20 Class Pack,18,20,2026-12-31,"$2,800.00",6/1/2026
|
|
6
|
+
Hoi Ching Lam,hoiching.lam@example.com,Single Class,1,1,9/30/2026,$180.00,7/1/2026
|
|
7
|
+
Walk In,,10 Class Pack,10,10,12/31/2026,"$1,500.00",7/15/2026
|
|
8
|
+
Priya Sharma,priya.sharma@example.com,10 Class Pack,5,11/11/2026,"$1,500.00",5/5/2026
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Shared test fixtures: in-memory DB with a small studio.
|
|
2
|
+
import { openDb, setSetting } from '../src/db/index.js';
|
|
3
|
+
|
|
4
|
+
export function testDb(settings = {}) {
|
|
5
|
+
const db = openDb(':memory:');
|
|
6
|
+
setSetting(db, 'studio_name', 'Test Studio');
|
|
7
|
+
setSetting(db, 'timezone', 'Asia/Hong_Kong');
|
|
8
|
+
setSetting(db, 'setup_complete', '1');
|
|
9
|
+
for (const [k, v] of Object.entries(settings)) setSetting(db, k, v);
|
|
10
|
+
return db;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function makeClient(db, { name = 'Alice Wong', email = `c${Math.random().toString(36).slice(2)}@t.test`, waiver = true } = {}) {
|
|
14
|
+
const info = db.prepare(
|
|
15
|
+
'INSERT INTO clients (name, email, waiver_signed_at, source) VALUES (?, ?, ?, ?)'
|
|
16
|
+
).run(name, email, waiver ? new Date().toISOString() : null, 'manual');
|
|
17
|
+
return info.lastInsertRowid;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function makeClassType(db, { name = 'Yoga', capacity = 3, credits = 1, price = 15000, duration = 60 } = {}) {
|
|
21
|
+
return db.prepare(
|
|
22
|
+
'INSERT INTO class_types (name, duration_min, capacity, drop_in_price_cents, credits_required) VALUES (?, ?, ?, ?, ?)'
|
|
23
|
+
).run(name, duration, capacity, price, credits).lastInsertRowid;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A class instance N hours from `now` (default: from real now). */
|
|
27
|
+
export function makeInstance(db, typeId, { hoursFromNow = 48, capacity, now } = {}) {
|
|
28
|
+
const base = now ? new Date(now).getTime() : Date.now();
|
|
29
|
+
const startsAt = new Date(base + hoursFromNow * 3600000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
30
|
+
const cap = capacity ?? db.prepare('SELECT capacity FROM class_types WHERE id = ?').get(typeId).capacity;
|
|
31
|
+
return db.prepare(
|
|
32
|
+
"INSERT INTO class_instances (class_type_id, starts_at, duration_min, capacity, status) VALUES (?, ?, 60, ?, 'scheduled')"
|
|
33
|
+
).run(typeId, startsAt, cap).lastInsertRowid;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function makePass(db, clientId, { name = '10 Pack', total = 10, remaining = total, expiresOn = null, source = 'manual' } = {}) {
|
|
37
|
+
return db.prepare(
|
|
38
|
+
'INSERT INTO passes (client_id, name, credits_total, credits_remaining, expires_on, source) VALUES (?, ?, ?, ?, ?, ?)'
|
|
39
|
+
).run(clientId, name, total, remaining, expiresOn, source).lastInsertRowid;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function makeMembership(db, clientId, { unlimited = true, creditsPerMonth = null, status = 'active', startedOn = '2026-01-01' } = {}) {
|
|
43
|
+
return db.prepare(
|
|
44
|
+
`INSERT INTO memberships (client_id, plan_name, status, started_on, unlimited, credits_per_month, cycle_started_on)
|
|
45
|
+
VALUES (?, 'Test Plan', ?, ?, ?, ?, ?)`
|
|
46
|
+
).run(clientId, status, startedOn, unlimited ? 1 : 0, creditsPerMonth, startedOn).lastInsertRowid;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** GET a page with a cookie-persisting supertest agent and pull the CSRF token out of its form HTML. */
|
|
50
|
+
export async function csrfToken(agent, url) {
|
|
51
|
+
const res = await agent.get(url);
|
|
52
|
+
const m = res.text.match(/name="_csrf" value="([^"]+)"/);
|
|
53
|
+
if (!m) throw new Error(`no CSRF token found on ${url} (status ${res.status})`);
|
|
54
|
+
return m[1];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const getPass = (db, id) => db.prepare('SELECT * FROM passes WHERE id = ?').get(id);
|
|
58
|
+
export const getBooking = (db, id) => db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
|
59
|
+
export const getMembership = (db, id) => db.prepare('SELECT * FROM memberships WHERE id = ?').get(id);
|