@tummycrypt/acuity-middleware 0.1.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.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Middleware HTTP Server
3
+ *
4
+ * Standalone Node.js HTTP server wrapping the Effect TS wizard programs.
5
+ * Designed to run inside a Docker container with Playwright + Chromium
6
+ * on Modal Labs, Fly.io, or any host.
7
+ *
8
+ * Endpoints:
9
+ * GET /health - Health check
10
+ * GET /services - List services (scraper)
11
+ * GET /services/:id - Get service by ID
12
+ * POST /availability/dates - Available dates for a service
13
+ * POST /availability/slots - Time slots for a date
14
+ * POST /availability/check - Check if a slot is available
15
+ * POST /booking/create - Create booking (standard)
16
+ * POST /booking/create-with-payment - Create booking with payment ref (coupon bypass)
17
+ *
18
+ * Environment variables:
19
+ * PORT - Server port (default: 3001)
20
+ * ACUITY_BASE_URL - Acuity scheduling URL
21
+ * ACUITY_BYPASS_COUPON - 100% coupon code
22
+ * AUTH_TOKEN - Required Bearer token for all endpoints
23
+ * PLAYWRIGHT_HEADLESS - Browser headless mode (default: true)
24
+ * PLAYWRIGHT_TIMEOUT - Page timeout in ms (default: 30000)
25
+ *
26
+ * Usage:
27
+ * node --import tsx/esm src/middleware/server.ts
28
+ * # or after build:
29
+ * node dist/middleware/server.js
30
+ */
31
+
32
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
33
+ import { Effect, Scope } from 'effect';
34
+ import * as E from 'fp-ts/Either';
35
+ import { createScraperAdapter, type ScraperConfig } from '../adapters/acuity-scraper.js';
36
+ import { BrowserService, BrowserServiceLive, type BrowserConfig, defaultBrowserConfig } from './browser-service.js';
37
+ import { toSchedulingError, type MiddlewareError } from './errors.js';
38
+ import {
39
+ navigateToBooking,
40
+ fillFormFields,
41
+ bypassPayment,
42
+ generateCouponCode,
43
+ submitBooking,
44
+ extractConfirmation,
45
+ toBooking,
46
+ } from './steps/index.js';
47
+ import type {
48
+ Booking,
49
+ BookingRequest,
50
+ Service,
51
+ SchedulingError,
52
+ } from '../core/types.js';
53
+
54
+ // =============================================================================
55
+ // CONFIGURATION
56
+ // =============================================================================
57
+
58
+ const PORT = Number(process.env.PORT ?? 3001);
59
+ const AUTH_TOKEN = process.env.AUTH_TOKEN;
60
+ const ACUITY_BASE_URL = process.env.ACUITY_BASE_URL ?? 'https://MassageIthaca.as.me';
61
+ const COUPON_CODE = process.env.ACUITY_BYPASS_COUPON;
62
+
63
+ const browserConfig: BrowserConfig = {
64
+ ...defaultBrowserConfig,
65
+ baseUrl: ACUITY_BASE_URL,
66
+ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false',
67
+ timeout: Number(process.env.PLAYWRIGHT_TIMEOUT ?? 30000),
68
+ executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
69
+ launchArgs: process.env.CHROMIUM_LAUNCH_ARGS?.split(','),
70
+ };
71
+
72
+ const scraperConfig: ScraperConfig = {
73
+ baseUrl: ACUITY_BASE_URL,
74
+ headless: browserConfig.headless,
75
+ timeout: browserConfig.timeout,
76
+ userAgent: browserConfig.userAgent,
77
+ executablePath: browserConfig.executablePath,
78
+ launchArgs: browserConfig.launchArgs ? [...browserConfig.launchArgs] : undefined,
79
+ };
80
+
81
+ // =============================================================================
82
+ // RESPONSE HELPERS
83
+ // =============================================================================
84
+
85
+ interface SuccessResponse<T> {
86
+ success: true;
87
+ data: T;
88
+ }
89
+
90
+ interface ErrorResponse {
91
+ success: false;
92
+ error: {
93
+ tag: string;
94
+ code: string;
95
+ message: string;
96
+ };
97
+ }
98
+
99
+ const sendJson = (res: ServerResponse, status: number, body: SuccessResponse<unknown> | ErrorResponse) => {
100
+ res.writeHead(status, { 'Content-Type': 'application/json' });
101
+ res.end(JSON.stringify(body));
102
+ };
103
+
104
+ const sendSuccess = <T>(res: ServerResponse, data: T) =>
105
+ sendJson(res, 200, { success: true, data });
106
+
107
+ const sendError = (res: ServerResponse, status: number, err: SchedulingError) =>
108
+ sendJson(res, status, {
109
+ success: false,
110
+ error: {
111
+ tag: err._tag,
112
+ code: 'code' in err ? (err as { code: string }).code : err._tag,
113
+ message: 'message' in err ? (err as { message: string }).message : 'Unknown error',
114
+ },
115
+ });
116
+
117
+ const parseBody = async (req: IncomingMessage): Promise<unknown> => {
118
+ const chunks: Buffer[] = [];
119
+ for await (const chunk of req) {
120
+ chunks.push(chunk as Buffer);
121
+ }
122
+ const raw = Buffer.concat(chunks).toString('utf8');
123
+ return raw ? JSON.parse(raw) : {};
124
+ };
125
+
126
+ // =============================================================================
127
+ // EFFECT RUNNER
128
+ // =============================================================================
129
+
130
+ const layer = BrowserServiceLive(browserConfig);
131
+
132
+ const runEffect = async <A>(
133
+ effect: Effect.Effect<A, MiddlewareError, BrowserService | Scope.Scope>,
134
+ ): Promise<E.Either<SchedulingError, A>> => {
135
+ try {
136
+ const result = await Effect.runPromise(
137
+ Effect.scoped(effect.pipe(Effect.provide(layer))),
138
+ );
139
+ return E.right(result);
140
+ } catch (e) {
141
+ return E.left(toSchedulingError(e as MiddlewareError));
142
+ }
143
+ };
144
+
145
+ // =============================================================================
146
+ // SCRAPER (cached)
147
+ // =============================================================================
148
+
149
+ let scraper: ReturnType<typeof createScraperAdapter> | null = null;
150
+
151
+ const getScraper = () => {
152
+ if (!scraper) {
153
+ scraper = createScraperAdapter(scraperConfig);
154
+ }
155
+ return scraper;
156
+ };
157
+
158
+ let cachedServices: Service[] | null = null;
159
+
160
+ // =============================================================================
161
+ // ROUTE HANDLERS
162
+ // =============================================================================
163
+
164
+ const handleHealth = (_req: IncomingMessage, res: ServerResponse) => {
165
+ sendSuccess(res, {
166
+ status: 'ok',
167
+ baseUrl: ACUITY_BASE_URL,
168
+ hasCoupon: !!COUPON_CODE,
169
+ headless: browserConfig.headless,
170
+ timestamp: new Date().toISOString(),
171
+ });
172
+ };
173
+
174
+
175
+ const handleGetServices = async (_req: IncomingMessage, res: ServerResponse) => {
176
+ const result = await getScraper().getServices()();
177
+ if (E.isLeft(result)) return sendError(res, 500, result.left);
178
+ cachedServices = result.right;
179
+ sendSuccess(res, result.right);
180
+ };
181
+
182
+ const handleGetService = async (serviceId: string, res: ServerResponse) => {
183
+ if (!cachedServices) {
184
+ const all = await getScraper().getServices()();
185
+ if (E.isLeft(all)) return sendError(res, 500, all.left);
186
+ cachedServices = all.right;
187
+ }
188
+ const found = cachedServices.find((s) => s.id === serviceId);
189
+ if (!found) {
190
+ return sendJson(res, 404, {
191
+ success: false,
192
+ error: { tag: 'AcuityError', code: 'NOT_FOUND', message: `Service ${serviceId} not found` },
193
+ });
194
+ }
195
+ sendSuccess(res, found);
196
+ };
197
+
198
+ const handleAvailableDates = async (req: IncomingMessage, res: ServerResponse) => {
199
+ const body = (await parseBody(req)) as { serviceId: string; startDate?: string };
200
+ const result = await getScraper().getAvailableDates(
201
+ body.serviceId,
202
+ body.startDate?.slice(0, 7),
203
+ )();
204
+ if (E.isLeft(result)) return sendError(res, 500, result.left);
205
+ sendSuccess(res, result.right.map((d) => ({ date: d, slots: 1 })));
206
+ };
207
+
208
+ const handleAvailableSlots = async (req: IncomingMessage, res: ServerResponse) => {
209
+ const body = (await parseBody(req)) as { serviceId: string; date: string };
210
+ const result = await getScraper().getTimeSlots(body.serviceId, body.date)();
211
+ if (E.isLeft(result)) return sendError(res, 500, result.left);
212
+ sendSuccess(res, result.right);
213
+ };
214
+
215
+ const handleCheckSlot = async (req: IncomingMessage, res: ServerResponse) => {
216
+ const body = (await parseBody(req)) as { serviceId: string; datetime: string };
217
+ const date = body.datetime.split('T')[0];
218
+ const result = await getScraper().getTimeSlots(body.serviceId, date)();
219
+ if (E.isLeft(result)) return sendError(res, 500, result.left);
220
+ const available = result.right.some((s) => s.datetime === body.datetime && s.available);
221
+ sendSuccess(res, available);
222
+ };
223
+
224
+ const handleCreateBooking = async (req: IncomingMessage, res: ServerResponse) => {
225
+ const body = (await parseBody(req)) as { request: BookingRequest; couponCode?: string };
226
+ const { request } = body;
227
+
228
+ const serviceName = cachedServices?.find((s) => s.id === request.serviceId)?.name;
229
+
230
+ const result = await runEffect(
231
+ Effect.gen(function* () {
232
+ yield* navigateToBooking({
233
+ serviceName: serviceName ?? request.serviceId,
234
+ datetime: request.datetime,
235
+ client: request.client,
236
+ appointmentTypeId: request.serviceId,
237
+ });
238
+ yield* fillFormFields({ client: request.client, customFields: request.client.customFields });
239
+ yield* submitBooking();
240
+ const confirmation = yield* extractConfirmation();
241
+ return toBooking(confirmation, request, '', 'acuity');
242
+ }),
243
+ );
244
+
245
+ if (E.isLeft(result)) return sendError(res, 500, result.left);
246
+ sendSuccess(res, result.right);
247
+ };
248
+
249
+ const handleCreateBookingWithPayment = async (req: IncomingMessage, res: ServerResponse) => {
250
+ const body = (await parseBody(req)) as {
251
+ request: BookingRequest;
252
+ paymentRef: string;
253
+ paymentProcessor: string;
254
+ couponCode?: string;
255
+ };
256
+ const { request, paymentRef, paymentProcessor } = body;
257
+ const coupon = body.couponCode ?? COUPON_CODE;
258
+
259
+ if (!coupon) {
260
+ return sendJson(res, 400, {
261
+ success: false,
262
+ error: { tag: 'ValidationError', code: 'couponCode', message: 'Coupon code is required for payment bypass' },
263
+ });
264
+ }
265
+
266
+ // Try to get service details for richer booking data
267
+ const service = cachedServices?.find((s) => s.id === request.serviceId);
268
+ const serviceName = service?.name ?? request.serviceId;
269
+
270
+ const result = await runEffect(
271
+ Effect.gen(function* () {
272
+ yield* navigateToBooking({
273
+ serviceName,
274
+ datetime: request.datetime,
275
+ client: request.client,
276
+ appointmentTypeId: request.serviceId,
277
+ });
278
+ yield* fillFormFields({ client: request.client, customFields: request.client.customFields });
279
+ yield* bypassPayment(coupon);
280
+ yield* submitBooking();
281
+ const confirmation = yield* extractConfirmation();
282
+ return toBooking(
283
+ confirmation,
284
+ request,
285
+ paymentRef,
286
+ paymentProcessor,
287
+ service ? { name: service.name, duration: service.duration, price: service.price, currency: service.currency } : undefined,
288
+ );
289
+ }),
290
+ );
291
+
292
+ if (E.isLeft(result)) return sendError(res, 500, result.left);
293
+ sendSuccess(res, result.right);
294
+ };
295
+
296
+ // =============================================================================
297
+ // SERVER
298
+ // =============================================================================
299
+
300
+ const server = createServer(async (req, res) => {
301
+ const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
302
+ const path = url.pathname;
303
+ const method = req.method?.toUpperCase() ?? 'GET';
304
+
305
+ // Auth check (skip health endpoint)
306
+ if (AUTH_TOKEN && path !== '/health') {
307
+ const auth = req.headers.authorization;
308
+ if (auth !== `Bearer ${AUTH_TOKEN}`) {
309
+ return sendJson(res, 401, {
310
+ success: false,
311
+ error: { tag: 'InfrastructureError', code: 'UNAUTHORIZED', message: 'Invalid auth token' },
312
+ });
313
+ }
314
+ }
315
+
316
+ try {
317
+ // Route matching
318
+ if (path === '/health' && method === 'GET') {
319
+ return handleHealth(req, res);
320
+ }
321
+ if (path === '/services' && method === 'GET') {
322
+ return await handleGetServices(req, res);
323
+ }
324
+ if (path.startsWith('/services/') && method === 'GET') {
325
+ const serviceId = decodeURIComponent(path.slice('/services/'.length));
326
+ return await handleGetService(serviceId, res);
327
+ }
328
+ if (path === '/availability/dates' && method === 'POST') {
329
+ return await handleAvailableDates(req, res);
330
+ }
331
+ if (path === '/availability/slots' && method === 'POST') {
332
+ return await handleAvailableSlots(req, res);
333
+ }
334
+ if (path === '/availability/check' && method === 'POST') {
335
+ return await handleCheckSlot(req, res);
336
+ }
337
+ if (path === '/booking/create' && method === 'POST') {
338
+ return await handleCreateBooking(req, res);
339
+ }
340
+ if (path === '/booking/create-with-payment' && method === 'POST') {
341
+ return await handleCreateBookingWithPayment(req, res);
342
+ }
343
+
344
+ sendJson(res, 404, {
345
+ success: false,
346
+ error: { tag: 'InfrastructureError', code: 'NOT_FOUND', message: `Unknown route: ${method} ${path}` },
347
+ });
348
+ } catch (e) {
349
+ console.error(`[middleware-server] Unhandled error on ${method} ${path}:`, e);
350
+ sendJson(res, 500, {
351
+ success: false,
352
+ error: {
353
+ tag: 'InfrastructureError',
354
+ code: 'UNKNOWN',
355
+ message: e instanceof Error ? e.message : 'Internal server error',
356
+ },
357
+ });
358
+ }
359
+ });
360
+
361
+ // Only start listening when this file is executed directly (not imported)
362
+ if (process.argv[1]?.match(/server\.(ts|js|mjs)$/)) {
363
+ server.listen(PORT, '0.0.0.0', () => {
364
+ console.log(`[middleware-server] Listening on port ${PORT}`);
365
+ console.log(`[middleware-server] Acuity URL: ${ACUITY_BASE_URL}`);
366
+ console.log(`[middleware-server] Coupon: ${COUPON_CODE ? 'configured' : 'NOT SET'}`);
367
+ console.log(`[middleware-server] Auth: ${AUTH_TOKEN ? 'enabled' : 'disabled'}`);
368
+ console.log(`[middleware-server] Headless: ${browserConfig.headless}`);
369
+ });
370
+ }
371
+
372
+ export { server };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Wizard Step: Bypass Payment
3
+ *
4
+ * Applies a 100% gift certificate code on Acuity's payment page to bypass
5
+ * the credit card requirement. This allows the booking to complete at $0,
6
+ * since actual payment is handled by our Venmo/Cash adapters.
7
+ *
8
+ * Strategy: A pre-configured gift certificate in Acuity admin covers the full amount.
9
+ * The certificate code is passed as ACUITY_BYPASS_COUPON env var.
10
+ *
11
+ * Acuity's payment page coupon flow (verified 2026-02-26):
12
+ * 1. Page is at URL .../datetime/<ISO>/payment
13
+ * 2. Click "Package, gift, or coupon code" toggle to expand the coupon section
14
+ * 3. Enter the gift certificate code in the "Enter code" input
15
+ * 4. Click "Apply" to validate the code
16
+ * 5. Acuity calls POST /api/scheduling/v1/appointments/order-summary
17
+ * with certificateCode in the body; response includes discount and total
18
+ * 6. If successful: order summary shows "Gift certificate [CODE] -$X.XX"
19
+ * and total drops to $0.00
20
+ * 7. "PAY & CONFIRM" button can now be clicked without entering card details
21
+ *
22
+ * Note: There IS a separate payment page (URL ends in /payment).
23
+ * The "Check Code Balance" modal on the client form is INFORMATIONAL ONLY.
24
+ */
25
+
26
+ import { Effect } from 'effect';
27
+ import type { Page } from 'playwright-core';
28
+ import { BrowserService } from '../browser-service.js';
29
+ import { CouponError } from '../errors.js';
30
+ import { resolveSelector, Selectors } from '../selectors.js';
31
+
32
+ // =============================================================================
33
+ // TYPES
34
+ // =============================================================================
35
+
36
+ export interface BypassPaymentResult {
37
+ readonly couponApplied: boolean;
38
+ readonly code: string;
39
+ readonly totalAfterCoupon: string | null;
40
+ }
41
+
42
+ // =============================================================================
43
+ // IMPLEMENTATION
44
+ // =============================================================================
45
+
46
+ /**
47
+ * Apply a gift certificate code on the payment page to bypass card entry.
48
+ *
49
+ * Prerequisite: The wizard must already be on the payment page
50
+ * (URL contains /payment). Call after fillFormFields + advancePastForm.
51
+ *
52
+ * Flow: Expand "Package, gift, or coupon code" → enter code → click "Apply"
53
+ */
54
+ export const bypassPayment = (couponCode: string) =>
55
+ Effect.gen(function* () {
56
+ const { acquirePage } = yield* BrowserService;
57
+ const page: Page = yield* acquirePage;
58
+
59
+ // Verify we're on the payment page
60
+ const url = page.url();
61
+ if (!url.includes('/payment')) {
62
+ return yield* Effect.fail(
63
+ new CouponError({
64
+ code: couponCode,
65
+ message:
66
+ `Not on payment page (URL: ${url}). ` +
67
+ 'The wizard must advance past the client form first.',
68
+ }),
69
+ );
70
+ }
71
+
72
+ // Step 1: Click "Package, gift, or coupon code" to expand the coupon section
73
+ const couponToggle = yield* resolveSelector(page, Selectors.paymentCouponToggle, 10000).pipe(
74
+ Effect.catchTag('SelectorError', () =>
75
+ Effect.fail(
76
+ new CouponError({
77
+ code: couponCode,
78
+ message:
79
+ '"Package, gift, or coupon code" toggle not found on payment page.',
80
+ }),
81
+ ),
82
+ ),
83
+ );
84
+
85
+ yield* Effect.tryPromise({
86
+ try: async () => {
87
+ await couponToggle.element.click();
88
+ // Wait for the coupon input to appear after expansion
89
+ await page.waitForSelector('input[placeholder="Enter code"]', { timeout: 5000 });
90
+ },
91
+ catch: (e) =>
92
+ new CouponError({
93
+ code: couponCode,
94
+ message: `Failed to expand coupon section: ${e instanceof Error ? e.message : String(e)}`,
95
+ }),
96
+ });
97
+
98
+ // Step 2: Enter the gift certificate code
99
+ const couponInput = yield* resolveSelector(page, Selectors.paymentCouponInput, 5000).pipe(
100
+ Effect.catchTag('SelectorError', () =>
101
+ Effect.fail(
102
+ new CouponError({
103
+ code: couponCode,
104
+ message: 'Coupon code input not found after expanding section',
105
+ }),
106
+ ),
107
+ ),
108
+ );
109
+
110
+ yield* Effect.tryPromise({
111
+ try: async () => {
112
+ await couponInput.element.click();
113
+ await couponInput.element.fill(couponCode);
114
+ },
115
+ catch: (e) =>
116
+ new CouponError({
117
+ code: couponCode,
118
+ message: `Failed to enter coupon code: ${e instanceof Error ? e.message : String(e)}`,
119
+ }),
120
+ });
121
+
122
+ // Step 3: Click "Apply" to validate the code
123
+ const applyBtn = yield* resolveSelector(page, Selectors.paymentCouponApply, 5000).pipe(
124
+ Effect.catchTag('SelectorError', () =>
125
+ Effect.fail(
126
+ new CouponError({
127
+ code: couponCode,
128
+ message: '"Apply" button not found in coupon section',
129
+ }),
130
+ ),
131
+ ),
132
+ );
133
+
134
+ yield* Effect.tryPromise({
135
+ try: () => applyBtn.element.click(),
136
+ catch: (e) =>
137
+ new CouponError({
138
+ code: couponCode,
139
+ message: `Failed to click "Apply": ${e instanceof Error ? e.message : String(e)}`,
140
+ }),
141
+ });
142
+
143
+ // Step 4: Wait for the order-summary API response
144
+ // Acuity calls POST /api/scheduling/v1/appointments/order-summary
145
+ // with certificateCode in the body.
146
+ yield* Effect.tryPromise({
147
+ try: () => page.waitForTimeout(3000),
148
+ catch: () =>
149
+ new CouponError({ code: couponCode, message: 'Timeout waiting for coupon validation' }),
150
+ });
151
+
152
+ // Step 5: Check if the coupon was applied
153
+ // On success: "Gift certificate [CODE]" and "-$X.XX" appear in order summary
154
+ // On error: Acuity may show an error message or the total remains unchanged
155
+ const result = yield* Effect.tryPromise({
156
+ try: async () => {
157
+ const bodyText = await page.evaluate(() => document.body.textContent ?? '');
158
+ const hasGiftCert = bodyText.includes('Gift certificate') && bodyText.includes(couponCode);
159
+ const hasDiscount = bodyText.includes('-$');
160
+ const totalMatch = bodyText.match(/Total\s*\$?([\d.]+)/);
161
+ const total = totalMatch ? totalMatch[1] : null;
162
+ return { hasGiftCert, hasDiscount, total };
163
+ },
164
+ catch: () => ({ hasGiftCert: false, hasDiscount: false, total: null }),
165
+ }).pipe(Effect.orElseSucceed(() => ({ hasGiftCert: false, hasDiscount: false, total: null })));
166
+
167
+ if (!result.hasGiftCert) {
168
+ // Check for error indicators
169
+ const errorText = yield* Effect.tryPromise({
170
+ try: async () => {
171
+ const errs: string[] = [];
172
+ const errEls = await page.$$('[class*="error"], [role="alert"]');
173
+ for (const el of errEls) {
174
+ const text = await el.textContent().catch(() => null);
175
+ if (text && text.trim().length > 0) errs.push(text.trim());
176
+ }
177
+ return errs.join('; ') || null;
178
+ },
179
+ catch: () => null,
180
+ }).pipe(Effect.orElseSucceed(() => null));
181
+
182
+ if (errorText) {
183
+ return yield* Effect.fail(
184
+ new CouponError({
185
+ code: couponCode,
186
+ message: `Coupon rejected: ${errorText}`,
187
+ }),
188
+ );
189
+ }
190
+ }
191
+
192
+ const totalAfterCoupon = result.total ? `$${result.total}` : null;
193
+
194
+ return {
195
+ couponApplied: result.hasGiftCert && result.hasDiscount,
196
+ code: couponCode,
197
+ totalAfterCoupon,
198
+ } satisfies BypassPaymentResult;
199
+ });
200
+
201
+ // =============================================================================
202
+ // HELPERS
203
+ // =============================================================================
204
+
205
+ /**
206
+ * Generate a unique coupon code for a payment reference.
207
+ * Format: ALT-{PROCESSOR}-{SHORT_REF}
208
+ *
209
+ * Note: For MVP, we use a single reusable coupon code from env.
210
+ * This function is here for future per-transaction coupon support.
211
+ */
212
+ export const generateCouponCode = (
213
+ _paymentRef: string,
214
+ _processor: string,
215
+ envCouponCode?: string,
216
+ ): string => {
217
+ // MVP: Use pre-configured reusable coupon
218
+ if (envCouponCode) return envCouponCode;
219
+
220
+ // Future: Generate per-transaction code
221
+ // return `ALT-${processor.toUpperCase()}-${paymentRef.slice(0, 8)}`;
222
+ throw new Error(
223
+ 'ACUITY_BYPASS_COUPON environment variable is required. ' +
224
+ 'Create a 100% gift certificate in Acuity admin and set this env var.',
225
+ );
226
+ };