@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.
- package/.github/workflows/build-paper.yml +39 -0
- package/.github/workflows/ci.yml +37 -0
- package/Dockerfile +53 -0
- package/README.md +103 -0
- package/docs/blog-post.mdx +240 -0
- package/docs/paper/IEEEtran.bst +2409 -0
- package/docs/paper/IEEEtran.cls +6347 -0
- package/docs/paper/acuity-middleware-paper.tex +375 -0
- package/docs/paper/balance.sty +87 -0
- package/docs/paper/references.bib +231 -0
- package/docs/paper.md +400 -0
- package/flake.nix +32 -0
- package/modal-app.py +82 -0
- package/package.json +48 -0
- package/src/adapters/acuity-scraper.ts +543 -0
- package/src/adapters/types.ts +193 -0
- package/src/core/types.ts +325 -0
- package/src/index.ts +75 -0
- package/src/middleware/acuity-wizard.ts +456 -0
- package/src/middleware/browser-service.ts +183 -0
- package/src/middleware/errors.ts +70 -0
- package/src/middleware/index.ts +80 -0
- package/src/middleware/remote-adapter.ts +246 -0
- package/src/middleware/selectors.ts +308 -0
- package/src/middleware/server.ts +372 -0
- package/src/middleware/steps/bypass-payment.ts +226 -0
- package/src/middleware/steps/extract.ts +174 -0
- package/src/middleware/steps/fill-form.ts +359 -0
- package/src/middleware/steps/index.ts +27 -0
- package/src/middleware/steps/navigate.ts +537 -0
- package/src/middleware/steps/read-availability.ts +399 -0
- package/src/middleware/steps/read-slots.ts +405 -0
- package/src/middleware/steps/submit.ts +168 -0
- package/src/server.ts +5 -0
- package/tsconfig.json +25 -0
|
@@ -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
|
+
};
|