@aglyn/plugins-bookings 1.0.0-beta.143

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.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +53 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/book-meeting-action.d.ts +91 -0
  8. package/src/lib/components/book-meeting-action.js +296 -0
  9. package/src/lib/components/book-meeting-action.js.map +1 -0
  10. package/src/lib/components/booking.d.ts +33 -0
  11. package/src/lib/components/booking.js +463 -0
  12. package/src/lib/components/booking.js.map +1 -0
  13. package/src/lib/components/bookings-console-page.d.ts +12 -0
  14. package/src/lib/components/bookings-console-page.js +743 -0
  15. package/src/lib/components/bookings-console-page.js.map +1 -0
  16. package/src/lib/constants/bundle-common.d.ts +26 -0
  17. package/src/lib/constants/bundle-common.js +26 -0
  18. package/src/lib/constants/bundle-common.js.map +1 -0
  19. package/src/lib/model/booking-purchase-analytics.d.ts +129 -0
  20. package/src/lib/model/booking-purchase-analytics.js +197 -0
  21. package/src/lib/model/booking-purchase-analytics.js.map +1 -0
  22. package/src/lib/model/bookings.d.ts +141 -0
  23. package/src/lib/model/bookings.js +124 -0
  24. package/src/lib/model/bookings.js.map +1 -0
  25. package/src/lib/model/index.d.ts +22 -0
  26. package/src/lib/model/index.js +22 -0
  27. package/src/lib/model/index.js.map +1 -0
  28. package/src/lib/plugin-config.d.ts +24 -0
  29. package/src/lib/plugin-config.js +51 -0
  30. package/src/lib/plugin-config.js.map +1 -0
  31. package/src/lib/plugin.d.ts +22 -0
  32. package/src/lib/plugin.js +70 -0
  33. package/src/lib/plugin.js.map +1 -0
  34. package/src/lib/server/billing-webhook.d.ts +23 -0
  35. package/src/lib/server/billing-webhook.js +345 -0
  36. package/src/lib/server/billing-webhook.js.map +1 -0
  37. package/src/lib/server/booking-analytics.d.ts +68 -0
  38. package/src/lib/server/booking-analytics.js +115 -0
  39. package/src/lib/server/booking-analytics.js.map +1 -0
  40. package/src/lib/server/booking-crm.d.ts +39 -0
  41. package/src/lib/server/booking-crm.js +67 -0
  42. package/src/lib/server/booking-crm.js.map +1 -0
  43. package/src/lib/server/booking-figures.d.ts +32 -0
  44. package/src/lib/server/booking-figures.js +155 -0
  45. package/src/lib/server/booking-figures.js.map +1 -0
  46. package/src/lib/server/refund.d.ts +65 -0
  47. package/src/lib/server/refund.js +292 -0
  48. package/src/lib/server/refund.js.map +1 -0
  49. package/src/lib/server.d.ts +63 -0
  50. package/src/lib/server.js +968 -0
  51. package/src/lib/server.js.map +1 -0
  52. package/src/lib/site.d.ts +27 -0
  53. package/src/lib/site.js +50 -0
  54. package/src/lib/site.js.map +1 -0
  55. package/src/lib/testing/stand-in-tax-profile.d.ts +29 -0
  56. package/src/lib/testing/stand-in-tax-profile.js +64 -0
  57. package/src/lib/testing/stand-in-tax-profile.js.map +1 -0
  58. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  59. package/src/lib/utils/generate-preset-id.js +25 -0
  60. package/src/lib/utils/generate-preset-id.js.map +1 -0
  61. package/src/lib/utils/use-booking-purchase-event.d.ts +65 -0
  62. package/src/lib/utils/use-booking-purchase-event.js +147 -0
  63. package/src/lib/utils/use-booking-purchase-event.js.map +1 -0
@@ -0,0 +1,968 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { checkEntitlement, pluginJobHostGate, registerPluginConfigSchema, registerPluginJob, resolveBrandingProfile, resolveTransactionFeeCents, sanitizeAuthorHtml } from "@aglyn/aglyn/server";
18
+ import { BOOKING_MAX_DAYS_AHEAD, computeOpenSlots, isBookingReminderDue, isSlotOpen, REMINDER_WINDOW_END_HOURS, REMINDER_WINDOW_START_HOURS } from "./model/index.js";
19
+ import { registerBillingWebhookHandler, registerPluginApiRoute } from "@aglyn/aglyn/server";
20
+ import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile";
21
+ import { BUNDLE_ID } from "./constants/bundle-common.js";
22
+ import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
23
+ import { bookingsBillingWebhookHandler } from "./server/billing-webhook.js";
24
+ import { bookingAnalyticsHandler } from "./server/booking-analytics.js";
25
+ import { registerBookingFigureReader } from "./server/booking-figures.js";
26
+ import { bookingRefundHandler } from "./server/refund.js";
27
+ // The booking's way back to the CRM record (AGL-2660): the reference a
28
+ // booking link carried, and the meeting a free booking files on landing.
29
+ // The leaf, not the barrel: this library's specs substitute the barrel
30
+ // wholesale, and a parser that vanished under them would fail every
31
+ // booking they take.
32
+ import { formatCrmBookingRef, parseCrmBookingRef } from "@aglyn/aglyn/app-utils/crm-booking";
33
+ import { fileBookingOnCrm } from "./server/booking-crm.js";
34
+ // Settings schema (AGL-428): registered here too so server-only loads
35
+ // (API dispatchers) get defaults-merged getPluginConfig reads.
36
+ registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA);
37
+ // Scheduled cleanup (AGL-435): lapse day-old expired payment holds to
38
+ // 'canceled' (the read paths already treat them as released — this keeps
39
+ // the collection tidy and the read-time filter cheap). Bounded + failure
40
+ // tolerant: a missing collection-group index just logs and retries on
41
+ // the next beat.
42
+ registerPluginJob({
43
+ pluginId: BUNDLE_ID,
44
+ name: 'expire-stale-holds',
45
+ intervalMinutes: 6 * 60,
46
+ description: 'Cancel pendingPayment bookings whose hold lapsed >24h ago.',
47
+ lockdown: {
48
+ scope: 'per-host'
49
+ },
50
+ handler: async (gate)=>{
51
+ const cutoff = Date.now() - 24 * 60 * 60000;
52
+ const snapshot = await firebaseAdmin.app().firestore().collectionGroup('bookings').where('status', '==', 'pendingPayment').where('expiresAtMs', '<', cutoff).limit(100).get();
53
+ let lapsed = 0;
54
+ for (const doc of snapshot.docs){
55
+ var _ref;
56
+ var _doc_ref_parent_parent;
57
+ // LOCKDOWN (AGL-2495). `bookings` live at `hosts/{hostId}/bookings/{id}`,
58
+ // so the grandparent is the host. This flips a booking to `canceled` for
59
+ // a site that may be suspended mid-dispute, and a status a customer can
60
+ // see is not a change to make while the operator is deciding whether the
61
+ // site should exist.
62
+ //
63
+ // SKIPPED, NOT DROPPED: the query selects on `status == 'pendingPayment'`
64
+ // and an age cutoff that only gets truer with time, so an untouched hold
65
+ // is picked up by the next six-hour beat after the lift. The read paths
66
+ // already treat a lapsed hold as released, so waiting costs tidiness and
67
+ // nothing else — which is why this one can afford to wait at all.
68
+ const hostId = (_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : '';
69
+ if (await gate.isLocked(hostId)) continue;
70
+ await doc.ref.set({
71
+ status: 'canceled'
72
+ }, {
73
+ merge: true
74
+ });
75
+ lapsed += 1;
76
+ }
77
+ if (lapsed) {
78
+ console.info(`bookings: lapsed ${lapsed} stale payment holds`);
79
+ }
80
+ }
81
+ });
82
+ import { addHostLead, firebaseAdmin, getOrgForHost, meterHostEmail, notifyHostManagers, attributeCampaignConversion, resolveCampaignTouch, getPluginConfig, resolveOrgIdForHost, renderHostEmailWithTokens, hostSendingIdentity } from "@aglyn/tenant-data-admin";
83
+ import { connectLinkageIsReady } from "@aglyn/tenant-data-admin/server/stripe-account-mode";
84
+ import { captureHostContact, emitHostEvent } from "@aglyn/tenant-runtime";
85
+ import { isEmailConfigured, loadHostEmail, renderLoadedHostEmail, sendEmail } from "@aglyn/shared-util-email";
86
+ import { FieldValue } from "firebase-admin/firestore";
87
+ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
88
+ const BOOKING_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
89
+ // Best-effort per-instance rate limit (mirrors forms/submit).
90
+ const recentBookingIpHits = new Map();
91
+ const BOOKING_RATE_WINDOW_MS = 60000;
92
+ const BOOKING_RATE_MAX = 5;
93
+ function bookingRateLimited(ip) {
94
+ var _recentBookingIpHits_get;
95
+ const now = Date.now();
96
+ const hits = ((_recentBookingIpHits_get = recentBookingIpHits.get(ip)) != null ? _recentBookingIpHits_get : []).filter((t)=>now - t < BOOKING_RATE_WINDOW_MS);
97
+ hits.push(now);
98
+ recentBookingIpHits.set(ip, hits);
99
+ return hits.length > BOOKING_RATE_MAX;
100
+ }
101
+ /**
102
+ * Public open-slot listing (AGL-159): visitors browse a service's next
103
+ * open times. Server-side because bookings (names/emails) must never be
104
+ * client-readable — only the derived busy intervals are used here.
105
+ */ // Exported for `bookings-slot-window.spec.ts`: the query's bounds are a
106
+ // read-cost and correctness property of THIS handler, and asserting them
107
+ // through the route registry would test the registry instead. Its siblings
108
+ // (`bookHandler`, the refund and analytics handlers) are exported already.
109
+ export const slotsHandler = async (req, res)=>{
110
+ var _req_query_hostId, _req_query_serviceId;
111
+ if (req.method !== 'GET') {
112
+ return res.status(405).json({
113
+ error: 'Method not allowed'
114
+ });
115
+ }
116
+ const hostId = String((_req_query_hostId = req.query['hostId']) != null ? _req_query_hostId : '');
117
+ const serviceId = String((_req_query_serviceId = req.query['serviceId']) != null ? _req_query_serviceId : '');
118
+ if (!hostId) {
119
+ return res.status(400).json({
120
+ error: 'Missing hostId'
121
+ });
122
+ }
123
+ try {
124
+ var _config_maxDaysAhead, _service_priceUsd, _service_timezone;
125
+ const firestore = firebaseAdmin.app().firestore();
126
+ const hostRef = firestore.collection('hosts').doc(hostId);
127
+ // No serviceId → public service directory for the booking widget
128
+ // (AGL-160): names/durations/prices only, never availability internals.
129
+ if (!serviceId) {
130
+ const services = await hostRef.collection('services').limit(50).get();
131
+ return res.status(200).json({
132
+ services: services.docs.filter((doc)=>!doc.get('deletedAt')).map((doc)=>{
133
+ var _doc_get, _doc_get1, _doc_get2, _doc_get3;
134
+ return {
135
+ $id: doc.id,
136
+ name: (_doc_get = doc.get('name')) != null ? _doc_get : '',
137
+ durationMinutes: Number((_doc_get1 = doc.get('durationMinutes')) != null ? _doc_get1 : 30),
138
+ priceUsd: Number((_doc_get2 = doc.get('priceUsd')) != null ? _doc_get2 : 0),
139
+ description: (_doc_get3 = doc.get('description')) != null ? _doc_get3 : ''
140
+ };
141
+ })
142
+ });
143
+ }
144
+ const serviceSnapshot = await hostRef.collection('services').doc(serviceId).get();
145
+ const service = serviceSnapshot.data();
146
+ if (!service || serviceSnapshot.get('deletedAt')) {
147
+ return res.status(404).json({
148
+ error: 'Unknown service'
149
+ });
150
+ }
151
+ const fromMs = Date.now();
152
+ // Booking horizon (AGL-428): configurable via the plugin settings
153
+ // framework; defaults to BOOKING_MAX_DAYS_AHEAD through the schema.
154
+ //
155
+ // Resolved for THIS SITE (AGL-428, AGL-1014), not just the workspace.
156
+ // The horizon is the setting per-site overrides were built for — one
157
+ // chain, one answer, and the flagship branch taking bookings further out
158
+ // — so reading only the org's would leave the console showing a number
159
+ // this endpoint never honors.
160
+ const config = await getPluginConfig(await resolveOrgIdForHost(hostId), 'bookings', {
161
+ hostId
162
+ });
163
+ const maxDaysAhead = Number((_config_maxDaysAhead = config.maxDaysAhead) != null ? _config_maxDaysAhead : BOOKING_MAX_DAYS_AHEAD);
164
+ const toMs = fromMs + maxDaysAhead * 24 * 60 * 60000;
165
+ // Bounded at BOTH ends, to the window the slots are actually computed
166
+ // over. `computeOpenSlots` below is handed `fromMs`/`toMs` and ignores
167
+ // anything outside them, so a booking past the horizon was read, billed
168
+ // and discarded — and worse, it competed for the 500 with the bookings
169
+ // that do matter, so a service booked far ahead could push the near-term
170
+ // stays out of its own availability check and offer a taken slot.
171
+ //
172
+ // A second range on `startsAtMs` needs no new index: the field already
173
+ // carries one, and the range it narrows is the one already here.
174
+ const bookedSnapshot = await hostRef.collection('bookings').where('serviceId', '==', serviceId).where('startsAtMs', '>=', fromMs - 24 * 60 * 60000).where('startsAtMs', '<=', toMs).limit(500).get();
175
+ const booked = bookedSnapshot.docs.filter((doc)=>{
176
+ var _doc_get;
177
+ return doc.get('status') !== 'canceled' && // Expired payment holds release the slot (AGL-170).
178
+ !(doc.get('status') === 'pendingPayment' && Number((_doc_get = doc.get('expiresAtMs')) != null ? _doc_get : 0) < Date.now());
179
+ }).map((doc)=>{
180
+ var _doc_get, _doc_get1;
181
+ return {
182
+ startsAtMs: Number((_doc_get = doc.get('startsAtMs')) != null ? _doc_get : 0),
183
+ endsAtMs: Number((_doc_get1 = doc.get('endsAtMs')) != null ? _doc_get1 : 0)
184
+ };
185
+ });
186
+ const slots = computeOpenSlots(service, fromMs, toMs, booked, 120);
187
+ return res.status(200).json({
188
+ service: {
189
+ name: service.name,
190
+ durationMinutes: service.durationMinutes,
191
+ priceUsd: (_service_priceUsd = service.priceUsd) != null ? _service_priceUsd : 0,
192
+ timezone: (_service_timezone = service.timezone) != null ? _service_timezone : 'UTC'
193
+ },
194
+ slots
195
+ });
196
+ } catch (error) {
197
+ console.error(error);
198
+ return res.status(500).json({
199
+ error: 'Slot lookup failed'
200
+ });
201
+ }
202
+ };
203
+ /**
204
+ * Booking creation (AGL-159): validates the slot against the service's
205
+ * windows AND stored bookings inside a transaction (double-booking safe),
206
+ * stores the booking, records a lead, emits the `booking` host event, and
207
+ * sends an env-gated Resend confirmation. Plan gate: the owning org
208
+ * needs the `bookings` flag (dark-launch workspaces pass).
209
+ */ /**
210
+ * Exported for spec (AGL-2000). The tax decision this handler takes lives in
211
+ * the ABSENCE of a parameter, and an absence is only a decision if something
212
+ * asserts it — see `server-booking-tax.spec.ts`.
213
+ */ export const bookHandler = async (req, res)=>{
214
+ var _ref, _ref1, _ref2, _ref3, _ref4, _readClientIp;
215
+ var _req_body, _req_body1, _req_body2, _req_body3, _req_body4, _req_body5, _req_body6, _req_socket;
216
+ if (req.method !== 'POST') {
217
+ return res.status(405).json({
218
+ error: 'Method not allowed'
219
+ });
220
+ }
221
+ const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
222
+ const serviceId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.serviceId) != null ? _ref1 : '');
223
+ const startsAtMs = Number((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.startsAtMs) != null ? _ref2 : 0);
224
+ const name = String((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.name) != null ? _ref3 : '').trim().slice(0, 80);
225
+ const email = String((_ref4 = (_req_body4 = req.body) == null ? void 0 : _req_body4.email) != null ? _ref4 : '').trim().toLowerCase();
226
+ // Explicit opt-in checkbox (AGL-2499) — a booking is not by itself
227
+ // consent to be emailed marketing, so this is only set when the visitor
228
+ // checked it.
229
+ const marketingConsent = ((_req_body5 = req.body) == null ? void 0 : _req_body5.marketingConsent) === true;
230
+ // The CRM record the booking link named (AGL-2660), kept on the row so
231
+ // the booking can be attributed to it even when the visitor books with
232
+ // a different address. Parsed, never stored raw: this is a public door.
233
+ const crmRef = parseCrmBookingRef((_req_body6 = req.body) == null ? void 0 : _req_body6.crmRef);
234
+ if (!hostId || !serviceId || !Number.isFinite(startsAtMs) || !startsAtMs) {
235
+ return res.status(400).json({
236
+ error: 'Invalid booking request'
237
+ });
238
+ }
239
+ if (!name) return res.status(400).json({
240
+ error: 'Enter your name'
241
+ });
242
+ if (!BOOKING_EMAIL_PATTERN.test(email)) {
243
+ return res.status(400).json({
244
+ error: 'Enter a valid email'
245
+ });
246
+ }
247
+ if (startsAtMs < Date.now()) {
248
+ return res.status(409).json({
249
+ error: 'That time has already passed'
250
+ });
251
+ }
252
+ // Keeps counting under the no-address bucket rather than being skipped: an
253
+ // unauthenticated booking endpoint that stops counting writes reservations
254
+ // for anyone who can reach it.
255
+ const ip = (_readClientIp = readClientIp(req.headers, {
256
+ remoteAddress: (_req_socket = req.socket) == null ? void 0 : _req_socket.remoteAddress
257
+ })) != null ? _readClientIp : NO_CLIENT_ADDRESS_BUCKET;
258
+ if (bookingRateLimited(ip)) {
259
+ return res.status(429).json({
260
+ error: 'Too many booking attempts'
261
+ });
262
+ }
263
+ try {
264
+ var _service_priceUsd, _service_name, _service_name1, _service_name2;
265
+ var _this, _req_body7;
266
+ const firestore = firebaseAdmin.app().firestore();
267
+ const hostRef = firestore.collection('hosts').doc(hostId);
268
+ const [hostSnapshot, serviceSnapshot] = await Promise.all([
269
+ hostRef.get(),
270
+ hostRef.collection('services').doc(serviceId).get()
271
+ ]);
272
+ if (!hostSnapshot.exists) {
273
+ return res.status(404).json({
274
+ error: 'Unknown site'
275
+ });
276
+ }
277
+ const service = serviceSnapshot.data();
278
+ if (!service || serviceSnapshot.get('deletedAt')) {
279
+ return res.status(404).json({
280
+ error: 'Unknown service'
281
+ });
282
+ }
283
+ // Plan gate (dark-launch rule preserved). Plan/quota gates ride the
284
+ // owning org's doc (AGL-238). Hoisted out of its block for AGL-2315:
285
+ // the same org doc now also prices the platform's cut below, and reading
286
+ // it twice would let a gate and a fee disagree within one request.
287
+ const ownerOrg = (_this = await getOrgForHost(hostId)) == null ? void 0 : _this.org;
288
+ if (!checkEntitlement(ownerOrg, 'bookings')) {
289
+ return res.status(403).json({
290
+ error: 'Bookings are not enabled on this site'
291
+ });
292
+ }
293
+ const durationMs = Math.max(5, Math.round(service.durationMinutes || 30)) * 60000;
294
+ const endsAtMs = startsAtMs + durationMs;
295
+ const bookingsRef = hostRef.collection('bookings');
296
+ // Paid services (AGL-170): the slot is HELD pending payment — the
297
+ // booking lands as `pendingPayment` with a 15-minute expiry (expired
298
+ // holds release the slot in the collision filters), and the visitor
299
+ // goes to Stripe Checkout; the webhook confirms + emails on payment.
300
+ const priceUsd = Number((_service_priceUsd = service.priceUsd) != null ? _service_priceUsd : 0);
301
+ const paid = priceUsd > 0;
302
+ if (paid && !process.env.STRIPE_SECRET_KEY) {
303
+ return res.status(501).json({
304
+ error: 'Paid bookings are not configured (STRIPE_SECRET_KEY).'
305
+ });
306
+ }
307
+ // WHO GETS THE MONEY (AGL-2315). Until this landed, a paid booking opened
308
+ // a Checkout Session with no Connect wiring of any kind — no
309
+ // `transfer_data`, no `application_fee_amount`, no connected account read
310
+ // anywhere in the plugin — so 100% of every paid booking settled in
311
+ // AGLYN'S OWN platform balance and the merchant was never paid at all.
312
+ // Not under-paid: never paid. Every sibling tenant money path (storefront
313
+ // checkout, cart, draft orders, POS, reservations) was already a
314
+ // destination charge to the merchant's connected account; this was the one
315
+ // door that took a card and kept the proceeds.
316
+ //
317
+ // Resolved BEFORE the hold transaction on purpose, the `reserve.ts`
318
+ // ordering: an unconnected merchant is a deterministic refusal the site
319
+ // owner fixes out of band, and discovering it after the slot is written
320
+ // would strand a real appointment slot as `pendingPayment` for 15 minutes
321
+ // on a booking that could never be paid.
322
+ let chargeAccountId = '';
323
+ let feeCents = 0;
324
+ // No tax until a paid booking asks the tenant's tax rule what is owed.
325
+ let serviceTax = {
326
+ taxCents: 0,
327
+ label: '',
328
+ pct: 0
329
+ };
330
+ if (paid) {
331
+ var _ref5, _ref6;
332
+ var _storeSnapshot_data;
333
+ const ownerUid = ownerOrg == null ? void 0 : ownerOrg.ownerUid;
334
+ const [ownerProfile, storeSnapshot] = await Promise.all([
335
+ ownerUid ? firestore.collection('profiles').doc(String(ownerUid)).get() : Promise.resolve(null),
336
+ // The merchant's own service rate (AGL-2028), in the SAME round trip
337
+ // as the connected-account read this path already makes.
338
+ hostRef.collection('settings').doc('store').get()
339
+ ]);
340
+ chargeAccountId = String((_ref5 = ownerProfile == null ? void 0 : ownerProfile.get('stripeAccountId')) != null ? _ref5 : '');
341
+ if (!connectLinkageIsReady({
342
+ accountId: chargeAccountId,
343
+ chargesEnabled: ownerProfile == null ? void 0 : ownerProfile.get('stripeChargesEnabled'),
344
+ accountLivemode: ownerProfile == null ? void 0 : ownerProfile.get('stripeAccountLivemode')
345
+ }, {
346
+ subject: `booking host ${hostId}`
347
+ })) {
348
+ return res.status(409).json({
349
+ error: 'Payments are not set up yet'
350
+ });
351
+ }
352
+ // THE TAKE RATE IS THE STOREFRONT LADDER'S, NOT A NEW ONE (AGL-2315).
353
+ //
354
+ // the decision, 2026-08-19: bookings mirror the storefront ladder —
355
+ // the same rate a storefront sale already takes, tapering to 0% on the
356
+ // upper tiers. It is consistent with what merchants already agreed to
357
+ // and needs no new price communication, so pricing stays locked for
358
+ // Sept 1.
359
+ //
360
+ // Implemented by ROUTING THROUGH the existing resolver rather than
361
+ // adding a bookings rate: `resolveTransactionFeePct` already carries a
362
+ // `'service'` axis (it resolves to the digital rate), and a booking is a
363
+ // service sale — `reserve.ts` prices a stay through the identical call.
364
+ // So there is no new constant, no new plan-table row, and no second
365
+ // place for a rate to drift out of step with `/pricing`. Every guard
366
+ // that already holds for the storefront rate holds here for free: the
367
+ // malformed-override floor (AGL-2114), the lapsed-plan re-resolution,
368
+ // and the staff override.
369
+ //
370
+ // FEE BASIS: post-discount ITEMS ONLY, matching the one-time storefront
371
+ // paths and deliberately NOT the subscription path's
372
+ // `application_fee_percent`, which Stripe applies to the whole invoice
373
+ // and so takes a cut of sales tax and shipping (AGL-2317). Here the two
374
+ // bases happen to coincide, because this session carries exactly one
375
+ // line item and, by AGL-2000's stated decision, no tax line and no
376
+ // shipping option. That coincidence is not the reason for the choice —
377
+ // sending a computed CENTS amount rather than a percent is what makes
378
+ // the basis survive the day this path does compute service tax: a fee
379
+ // derived from `priceUsd` stays off the tax the moment tax is added,
380
+ // whereas a percent would silently start taxing the state's money.
381
+ //
382
+ // PLUS STRIPE'S CARD COST, AT COST (AGL-2152). A 0%-fee tier used to
383
+ // take a destination charge with NO application fee at all, which is not
384
+ // "no take rate" — Stripe debits 2.9% + 30¢ from the PLATFORM's balance
385
+ // when that charge settles, so every paid booking on Pro and above cost
386
+ // Aglyn money. `resolveTransactionFeeCents` is the same resolver's cents
387
+ // form: the advertised take (still 0% where the ladder says 0%) plus the
388
+ // card cost passed through, so the net per booking is the take and never
389
+ // less. The `Math.max(1, …)` floor on a chargeable take is folded into
390
+ // it, unchanged.
391
+ //
392
+ // The fee base and the charge base are the same number here: by
393
+ // AGL-2000's stated decision this session carries one line item, no tax
394
+ // line and no shipping option, so the card runs for exactly `priceUsd`.
395
+ // SERVICE TAX: the MERCHANT'S OWN RATE, off unless they set one
396
+ // (AGL-2028, answering the decision AGL-2000 recorded here).
397
+ //
398
+ // AGL-2000's reasoning is unchanged and is why this is a field of its
399
+ // own rather than a reuse of the goods table. A service is not goods:
400
+ // whether one is taxable is jurisdiction-specific and frequently the
401
+ // opposite answer, so the AGL-285 `rates[]` are still never read here
402
+ // — a test in `server-booking-tax.spec.ts` pins that with a fully
403
+ // configured sales-tax store. Stripe Tax is still never asked either:
404
+ // it has no service tax code from this handler and would compute a
405
+ // goods rate against AGLYN's registrations (AGL-1904).
406
+ //
407
+ // What changed is that the merchant now has somewhere to answer. A
408
+ // service business is one of the three named ICPs and in many states
409
+ // their services ARE taxable, so "Aglyn does not compute it" could only
410
+ // ever be a holding position. They type the rate, we compute it, charge
411
+ // it, record it and stamp the regime; the settings card states plainly
412
+ // that determining what is owed and to whom is theirs. Aglyn takes no
413
+ // tax position, and the existence of a separate, blank-by-default field
414
+ // is also AGL-2000's second reason answered — it is the merchant
415
+ // saying, explicitly, that they mean this to cover bookings.
416
+ //
417
+ // DEFAULT OFF, and load-bearing: an absent, zero, negative or
418
+ // out-of-range rate resolves to zero, so no existing merchant's charge
419
+ // moves because this shipped.
420
+ const taxSettings = (_ref6 = storeSnapshot == null ? void 0 : (_storeSnapshot_data = storeSnapshot.data()) == null ? void 0 : _storeSnapshot_data.tax) != null ? _ref6 : {};
421
+ const chargeCents = Math.round(priceUsd * 100);
422
+ // Asked of the plugin that owns the merchant's tax rule, which throws
423
+ // rather than answer zero if none is loaded: an untaxed charge recorded
424
+ // as untaxed is the failure nobody sees until the merchant owes it.
425
+ serviceTax = pluginTaxProfile().flatTax(taxSettings.service, chargeCents, 'Service tax');
426
+ // THE TWO FEE BASES ARE NOW DIFFERENT NUMBERS (AGL-2028). The take is
427
+ // computed on the SERVICE, never on the state's money — AGL-2317's rule
428
+ // — while Stripe's card cost is debited from the platform balance on
429
+ // the WHOLE charge and so is passed through on the whole charge
430
+ // (AGL-2152). The comment above already promised this: "a fee derived
431
+ // from `priceUsd` stays off the tax the moment tax is added". This is
432
+ // the moment.
433
+ feeCents = resolveTransactionFeeCents(ownerOrg, 'service', chargeCents, chargeCents + serviceTax.taxCents);
434
+ }
435
+ // Transaction: re-read overlapping bookings and validate the slot so
436
+ // two simultaneous requests cannot double-book.
437
+ const bookingId = await firestore.runTransaction(async (transaction)=>{
438
+ var _service_name;
439
+ const overlapping = await transaction.get(bookingsRef.where('serviceId', '==', serviceId).where('startsAtMs', '>=', startsAtMs - 24 * 60 * 60000).limit(500));
440
+ const booked = overlapping.docs.filter((doc)=>{
441
+ var _doc_get;
442
+ return doc.get('status') !== 'canceled' && // Expired payment holds release the slot (AGL-170).
443
+ !(doc.get('status') === 'pendingPayment' && Number((_doc_get = doc.get('expiresAtMs')) != null ? _doc_get : 0) < Date.now());
444
+ }).map((doc)=>{
445
+ var _doc_get, _doc_get1;
446
+ return {
447
+ startsAtMs: Number((_doc_get = doc.get('startsAtMs')) != null ? _doc_get : 0),
448
+ endsAtMs: Number((_doc_get1 = doc.get('endsAtMs')) != null ? _doc_get1 : 0)
449
+ };
450
+ });
451
+ if (!isSlotOpen(service, startsAtMs, booked)) {
452
+ throw Object.assign(new Error('Slot unavailable'), {
453
+ code: 409
454
+ });
455
+ }
456
+ const bookingRef = bookingsRef.doc();
457
+ transaction.set(bookingRef, _extends({
458
+ serviceId,
459
+ serviceName: (_service_name = service.name) != null ? _service_name : '',
460
+ name,
461
+ email,
462
+ startsAtMs,
463
+ endsAtMs,
464
+ status: paid ? 'pendingPayment' : 'confirmed'
465
+ }, paid && {
466
+ expiresAtMs: Date.now() + 15 * 60000
467
+ }, crmRef ? {
468
+ crmRef: formatCrmBookingRef(crmRef)
469
+ } : {}, {
470
+ createdAt: FieldValue.serverTimestamp()
471
+ }));
472
+ return bookingRef.id;
473
+ });
474
+ /*
475
+ * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE BOOKING.
476
+ *
477
+ * A booking is an identify moment: the visitor was anonymous while they
478
+ * browsed and picked a slot, and this request is the first thing that
479
+ * names them. Both channels are asked here — the labels their device
480
+ * carried, and the campaign mail they clicked, joined on the address they
481
+ * just typed — and the later of the two is credited, exactly as the
482
+ * revenue join credits the later click.
483
+ *
484
+ * Resolved once and handed to the booking record, the contact and the
485
+ * lead below, so the email-channel lookup is one keyed read for the whole
486
+ * request and the three records cannot name different campaigns.
487
+ */ const bookedAtMs = Date.now();
488
+ const campaignTouch = await resolveCampaignTouch({
489
+ hostId,
490
+ wire: (_req_body7 = req.body) == null ? void 0 : _req_body7.campaignTouch,
491
+ email,
492
+ atMs: bookedAtMs
493
+ });
494
+ void attributeCampaignConversion({
495
+ hostId,
496
+ kind: 'booking',
497
+ refId: bookingId,
498
+ touch: campaignTouch,
499
+ convertedAtMs: bookedAtMs
500
+ });
501
+ // Contacts ingestion (AGL-197) — booking requests identify a person.
502
+ void captureHostContact(_extends({
503
+ hostId,
504
+ email,
505
+ name: name || undefined,
506
+ source: 'booking',
507
+ // A request is interest, not a sale (AGL-2612): no money has moved at
508
+ // this point, so the stage is `lead`. The payment webhook is the door
509
+ // that makes a customer, once the charge has actually cleared.
510
+ initialLifecycleStage: 'lead',
511
+ interaction: {
512
+ refId: bookingId,
513
+ summary: `Booked "${String((_service_name = service.name) != null ? _service_name : 'a service').slice(0, 60)}"`
514
+ }
515
+ }, marketingConsent ? {
516
+ marketingConsent: true
517
+ } : {}, campaignTouch ? {
518
+ campaignTouch
519
+ } : {}));
520
+ if (paid) {
521
+ var _req_headers_origin, _service_name3;
522
+ const origin = (_req_headers_origin = req.headers.origin) != null ? _req_headers_origin : `https://${req.headers.host}`;
523
+ // TAX ON A PAID BOOKING: the merchant's own service rate, resolved
524
+ // above and default OFF (AGL-2028, answering AGL-2000).
525
+ //
526
+ // The rate rides as an ORDINARY product line Stripe is never told is
527
+ // tax — the AGL-1711 construction every other manual rate in this
528
+ // repo uses. That is what makes the derived `taxMode` read `manual`:
529
+ // the figure is the merchant's own, from their own rate, and is never
530
+ // computed against Aglyn's registrations the way `automatic_tax` would
531
+ // be (AGL-1904). Absent entirely when no rate is set, so a default
532
+ // store's session carries no tax parameter of any kind — the property
533
+ // `server-booking-tax.spec.ts` asserts over the whole emitted body.
534
+ //
535
+ // The sale is recorded either way: `booking-payment` has been in
536
+ // `storefront-tax-record.ts`'s SESSION_TYPES since AGL-2000, so an
537
+ // untaxed booking still files a row reading `taxMode: 'none'` and a
538
+ // taxed one files `manual` with the figure, both with no further
539
+ // wiring.
540
+ const params = new URLSearchParams(_extends({
541
+ mode: 'payment',
542
+ 'line_items[0][quantity]': '1',
543
+ 'line_items[0][price_data][currency]': 'usd',
544
+ 'line_items[0][price_data][unit_amount]': String(Math.round(priceUsd * 100)),
545
+ 'line_items[0][price_data][product_data][name]': String((_service_name3 = service.name) != null ? _service_name3 : 'Booking').slice(0, 120)
546
+ }, serviceTax.taxCents > 0 ? {
547
+ 'line_items[1][quantity]': '1',
548
+ 'line_items[1][price_data][currency]': 'usd',
549
+ 'line_items[1][price_data][unit_amount]': String(serviceTax.taxCents),
550
+ 'line_items[1][price_data][product_data][name]': serviceTax.label
551
+ } : {}, {
552
+ // Destination charge to the MERCHANT's connected account (AGL-2315).
553
+ // Unconditional: `transfer_data[destination]` is what makes this the
554
+ // merchant's money, and it is required on every paid booking whatever
555
+ // the take rate. Only the fee below is conditional.
556
+ 'payment_intent_data[transfer_data][destination]': chargeAccountId
557
+ }, feeCents > 0 ? {
558
+ 'payment_intent_data[application_fee_amount]': String(feeCents)
559
+ } : {}, {
560
+ // `{CHECKOUT_SESSION_ID}` is Stripe's own template token, substituted
561
+ // on the redirect (AGL-2481). Without it the guest came back holding
562
+ // the word `paid` and nothing else — no id, no amount — so the
563
+ // merchant-side `purchase` had nothing to look itself up by and could
564
+ // not have stated what was actually charged. The id is safe in the
565
+ // URL: Stripe hands it only to the payer, and
566
+ // `booking-analytics.ts` scopes it to the host and answers with a
567
+ // projection rather than the booking document.
568
+ success_url: `${origin}/?booking=paid&session_id={CHECKOUT_SESSION_ID}`,
569
+ cancel_url: `${origin}/?booking=canceled`,
570
+ customer_email: email,
571
+ 'metadata[type]': 'booking-payment',
572
+ 'metadata[hostId]': hostId,
573
+ 'metadata[bookingId]': bookingId,
574
+ // The fee actually charged, recorded on the session exactly as every
575
+ // sibling path records it — the figure a refund reverses and the
576
+ // figure a reconciliation reads. `'0'` on a 0%-fee tier is a real
577
+ // answer, not a missing one.
578
+ 'metadata[feeCents]': String(feeCents)
579
+ }, serviceTax.taxCents > 0 ? {
580
+ 'metadata[taxCents]': String(serviceTax.taxCents),
581
+ 'metadata[taxPct]': String(serviceTax.pct)
582
+ } : {}, {
583
+ expires_at: String(Math.floor(Date.now() / 1000) + 30 * 60)
584
+ }));
585
+ const stripeResponse = await fetch('https://api.stripe.com/v1/checkout/sessions', {
586
+ method: 'POST',
587
+ headers: {
588
+ Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
589
+ 'Content-Type': 'application/x-www-form-urlencoded',
590
+ // AGL-2147. This was the ONE session-creating path in the repo
591
+ // with no idempotency key: console checkout, enterprise billing,
592
+ // marketplace checkout, and commerce's checkout / cart-checkout /
593
+ // draft-order / reservation / POS paths all carry one, and this
594
+ // takes a card payment exactly as they do.
595
+ //
596
+ // Keyed on the BOOKING, not on a client-minted header: the
597
+ // transaction above mints a fresh `bookingId` per attempt, so it
598
+ // already names this attempt uniquely and needs no widget change
599
+ // to supply. That also sidesteps Stripe's 24h key expiry — a key
600
+ // reused across attempts eventually replays a session that has
601
+ // since expired (these are opened with a 30-minute `expires_at`),
602
+ // handing the guest a dead checkout page instead of a new one.
603
+ //
604
+ // What it closes: a retried POST after Stripe has already created
605
+ // the session but before its response arrived. Without the key
606
+ // that opens a SECOND payable session against one held slot, and
607
+ // a guest who pays both is charged twice for one appointment.
608
+ 'Idempotency-Key': `booking-${bookingId}`
609
+ },
610
+ body: params.toString()
611
+ });
612
+ const session = await stripeResponse.json();
613
+ if (!stripeResponse.ok || !session.url) {
614
+ console.error('Stripe booking checkout error', session.error);
615
+ // Release the hold so the slot isn't stuck for 15 minutes.
616
+ await bookingsRef.doc(bookingId).set({
617
+ status: 'canceled'
618
+ }, {
619
+ merge: true
620
+ }).catch(()=>undefined);
621
+ return res.status(502).json({
622
+ error: 'Payment setup failed'
623
+ });
624
+ }
625
+ // Lead lands now; the confirmation email + workflow event fire from
626
+ // the payment webhook. Through the one writer that enforces
627
+ // `LEADS_MAX_PER_HOST` (AGL-1529) — a refused lead never fails the
628
+ // booking, and the trip is recorded for the site's owner either way.
629
+ await addHostLead(_extends({
630
+ hostRef,
631
+ hostId,
632
+ lead: _extends({
633
+ email
634
+ }, name ? {
635
+ name
636
+ } : {}, {
637
+ source: 'booking'
638
+ }, marketingConsent ? {
639
+ marketingConsent: true
640
+ } : {})
641
+ }, campaignTouch ? {
642
+ touch: campaignTouch
643
+ } : {}));
644
+ return res.status(200).json({
645
+ bookingId,
646
+ startsAtMs,
647
+ endsAtMs,
648
+ checkoutUrl: session.url
649
+ });
650
+ }
651
+ // Bookings double as leads for the site owner (mirrors sign-ups), through
652
+ // the one bounded writer (AGL-1529), same as the checkout branch above.
653
+ await addHostLead(_extends({
654
+ hostRef,
655
+ hostId,
656
+ lead: _extends({
657
+ email
658
+ }, name ? {
659
+ name
660
+ } : {}, {
661
+ source: 'booking'
662
+ }, marketingConsent ? {
663
+ marketingConsent: true
664
+ } : {})
665
+ }, campaignTouch ? {
666
+ touch: campaignTouch
667
+ } : {}));
668
+ // Event trigger (AGL-128/148/159).
669
+ // In-app notification to the site's managers (AGL-259).
670
+ void notifyHostManagers(hostId, {
671
+ type: 'content.booking',
672
+ title: 'New booking',
673
+ body: new Date(startsAtMs).toLocaleString(),
674
+ link: `/${hostId}/bookings`
675
+ });
676
+ const { alerts } = await emitHostEvent(hostId, 'booking', {
677
+ serviceName: (_service_name1 = service.name) != null ? _service_name1 : '',
678
+ email,
679
+ startsAtMs
680
+ });
681
+ // Env-gated confirmation email (same provider as AGL-98).
682
+ if (isEmailConfigured()) {
683
+ var _service_name4, _ref7;
684
+ var _this1;
685
+ const timezone = service.timezone || 'UTC';
686
+ const when = new Intl.DateTimeFormat('en-US', {
687
+ timeZone: timezone,
688
+ dateStyle: 'full',
689
+ timeStyle: 'short'
690
+ }).format(new Date(startsAtMs));
691
+ const fallbackText = `Hi ${name},\n\nYour booking for "${service.name}" is ` + `confirmed for ${when} (${timezone}).\n\n` + `Reference: ${bookingId}`;
692
+ // Site-owner-designed template when published (AGL-770); null keeps the
693
+ // built-in copy above.
694
+ const designed = await renderHostEmailWithTokens(firestore, hostId, 'booking-confirmed', {
695
+ name: String(name != null ? name : ''),
696
+ 'service.name': String((_service_name4 = service.name) != null ? _service_name4 : ''),
697
+ when,
698
+ timezone,
699
+ 'booking.ref': String(bookingId)
700
+ });
701
+ // White-label sender identity (White-Label Phase 3): the store's brand
702
+ // via the one shared resolver, from the owning org doc.
703
+ const branding = resolveBrandingProfile((_this1 = await getOrgForHost(hostId).catch(()=>null)) == null ? void 0 : _this1.org);
704
+ await sendEmail(_extends({
705
+ to: email,
706
+ subject: (_ref7 = designed == null ? void 0 : designed.subject) != null ? _ref7 : `Booking confirmed: ${service.name}`,
707
+ text: (designed == null ? void 0 : designed.text) || fallbackText
708
+ }, (designed == null ? void 0 : designed.html) ? {
709
+ html: designed.html
710
+ } : {}, {
711
+ fromName: branding.fromName,
712
+ sendingIdentity: await hostSendingIdentity(hostId),
713
+ audience: 'tenant',
714
+ context: 'booking confirmation'
715
+ }));
716
+ // Cost meter (AGL-1438). Transactional: counted, never capped — a
717
+ // confirmation the customer never receives reads to them as a booking
718
+ // that did not happen.
719
+ await meterHostEmail(hostId);
720
+ }
721
+ // THE RECORD'S MEETING (AGL-2660). A free booking is confirmed right
722
+ // here, so this is where it reaches the CRM: the meeting on the
723
+ // record's timeline and, when the service asks, the follow-up task. A
724
+ // paid booking waits for the webhook, which files it once the charge
725
+ // has cleared. Awaited, because a route handler is frozen the moment
726
+ // it answers (AGL-2327) — and never thrown: the slot is the guest's
727
+ // whatever the CRM did.
728
+ await fileBookingOnCrm(firestore, {
729
+ hostId,
730
+ bookingId,
731
+ booking: _extends({
732
+ serviceId,
733
+ serviceName: (_service_name2 = service.name) != null ? _service_name2 : '',
734
+ email,
735
+ startsAtMs,
736
+ endsAtMs
737
+ }, crmRef ? {
738
+ crmRef: formatCrmBookingRef(crmRef)
739
+ } : {}),
740
+ service
741
+ });
742
+ return res.status(200).json({
743
+ bookingId,
744
+ startsAtMs,
745
+ endsAtMs,
746
+ alerts
747
+ });
748
+ } catch (error) {
749
+ if ((error == null ? void 0 : error.code) === 409) {
750
+ return res.status(409).json({
751
+ error: 'That time was just taken — pick another slot'
752
+ });
753
+ }
754
+ console.error(error);
755
+ return res.status(500).json({
756
+ error: 'Booking failed'
757
+ });
758
+ }
759
+ };
760
+ /**
761
+ * One reminder pass (AGL-160), extracted from its HTTP door in AGL-2431.
762
+ *
763
+ * Finds confirmed bookings starting 23–25 hours out (collection-group query,
764
+ * so one call covers every host), emails each visitor through the env-gated
765
+ * Resend path, and stamps `reminderSentAt` so re-runs are idempotent.
766
+ *
767
+ * WHY THIS IS A FUNCTION NOW. It only ever existed as `POST bookings/reminders`
768
+ * behind `x-cron-secret`, and nothing anywhere POSTed to it — not
769
+ * `scheduled-crons.yml`, not `vercel.json` (which has no `crons` key), not
770
+ * `registerPluginJob`. The route's own docblock said "invoke hourly from the
771
+ * scheduler" for as long as it has existed. It never ran, for anyone. Same
772
+ * shape as the two commerce beats in AGL-2227, one plugin over.
773
+ */ export async function scanBookingReminders(/** The lockdown gate, injected by the caller (AGL-2495). Not optional. */ gate) {
774
+ const firestore = firebaseAdmin.app().firestore();
775
+ // ONE instant for the query bounds and for the per-booking predicate. Two
776
+ // `Date.now()` calls would put a booking sitting on an edge inside the
777
+ // query and outside the test, so the pass would fetch it, skip it, and
778
+ // report a `skipped` the console could not explain.
779
+ const nowMs = Date.now();
780
+ const windowStart = nowMs + REMINDER_WINDOW_START_HOURS * 60 * 60 * 1000;
781
+ const windowEnd = nowMs + REMINDER_WINDOW_END_HOURS * 60 * 60 * 1000;
782
+ const upcoming = await firestore.collectionGroup('bookings').where('startsAtMs', '>=', windowStart).where('startsAtMs', '<=', windowEnd).limit(500).get();
783
+ let sent = 0;
784
+ let skipped = 0;
785
+ let skippedLocked = 0;
786
+ // Resolve each host's designed reminder template once per run (AGL-770),
787
+ // not once per booking — a busy site has many bookings in the window.
788
+ const templateCache = new Map();
789
+ // White-label brand per host (White-Label Phase 3): resolved once per host
790
+ // from the owning org doc through the one shared resolver, so a reminder
791
+ // reads as the store's brand.
792
+ const brandingByHost = new Map();
793
+ /*
794
+ * One identity resolution per SITE, not per booking.
795
+ *
796
+ * The same reason the branding and template maps above exist: this sweep
797
+ * runs across every site, and an uncached resolution would be two Firestore
798
+ * reads per reminder rather than per host. Scoped to the run, so a domain
799
+ * un-verified between runs is picked up on the next one.
800
+ */ const identityByHost = new Map();
801
+ for (const doc of upcoming.docs){
802
+ var _ref, _data_serviceName, _data_name, _ref1, _data_name1;
803
+ var _doc_ref_parent_parent, _brandingByHost_get;
804
+ const data = doc.data();
805
+ // The shared predicate, not a local copy of it (AGL-2431): the console
806
+ // card counts what is waiting with this same function, so the number a
807
+ // merchant reads is the number this loop will act on.
808
+ if (!isBookingReminderDue(data, nowMs)) {
809
+ skipped += 1;
810
+ continue;
811
+ }
812
+ const when = new Date(Number(data['startsAtMs'])).toLocaleString('en-US', {
813
+ dateStyle: 'full',
814
+ timeStyle: 'short'
815
+ });
816
+ // bookings live at hosts/{hostId}/bookings/{id}, so the grandparent is
817
+ // the host.
818
+ const hostId = (_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : '';
819
+ // LOCKDOWN (AGL-2495), ahead of the send, the meter and the
820
+ // `reminderSentAt` stamp — the three things this loop does, all of them
821
+ // for the host. Counted separately from `skipped` on purpose: `skipped`
822
+ // means "this booking was not due", and folding a locked site into it
823
+ // would make a suspended merchant's silence read as an ordinary quiet
824
+ // hour on the console card that shows these numbers.
825
+ //
826
+ // SKIPPED, NOT DROPPED: `reminderSentAt` is what retires a booking from
827
+ // this pass, and it is stamped only on a successful send. An unstamped
828
+ // booking is re-scanned hourly — though unlike the other five, this one
829
+ // has a real deadline: the 23–25h window closes. A reminder whose window
830
+ // passed under a lock is genuinely lost, and that is the correct
831
+ // outcome, not a defect to engineer around. A site under takedown should
832
+ // not be mailing its customers, and a reminder that arrives after the
833
+ // appointment is worse than none.
834
+ if (await gate.isLocked(hostId)) {
835
+ skippedLocked += 1;
836
+ continue;
837
+ }
838
+ let loaded = templateCache.get(hostId);
839
+ if (loaded === undefined) {
840
+ var _this;
841
+ loaded = hostId ? await loadHostEmail(firestore, hostId, 'booking-reminder') : null;
842
+ templateCache.set(hostId, loaded);
843
+ brandingByHost.set(hostId, resolveBrandingProfile((_this = hostId ? await getOrgForHost(hostId).catch(()=>null) : null) == null ? void 0 : _this.org));
844
+ }
845
+ const serviceName = String((_data_serviceName = data['serviceName']) != null ? _data_serviceName : 'your booking');
846
+ const designed = loaded ? renderLoadedHostEmail(loaded, {
847
+ name: String((_data_name = data['name']) != null ? _data_name : ''),
848
+ 'service.name': serviceName,
849
+ when
850
+ }, sanitizeAuthorHtml) : null;
851
+ const result = await sendEmail(_extends({
852
+ to: String(data['email']),
853
+ subject: (_ref1 = designed == null ? void 0 : designed.subject) != null ? _ref1 : `Reminder: ${serviceName} tomorrow`,
854
+ text: (designed == null ? void 0 : designed.text) || `Hi ${(_data_name1 = data['name']) != null ? _data_name1 : ''},\n\nA reminder that "${serviceName}" is ` + `scheduled for ${when}.\n\nReference: ${doc.id}`
855
+ }, (designed == null ? void 0 : designed.html) ? {
856
+ html: designed.html
857
+ } : {}, {
858
+ fromName: (_brandingByHost_get = brandingByHost.get(hostId)) == null ? void 0 : _brandingByHost_get.fromName,
859
+ sendingIdentity: await hostSendingIdentity(hostId, identityByHost),
860
+ audience: 'tenant',
861
+ context: 'booking reminder'
862
+ }));
863
+ if (result.sent) {
864
+ // Cost meter (AGL-1438). Transactional: a reminder a quota refused is
865
+ // a missed appointment for the site's own customer.
866
+ await meterHostEmail(hostId);
867
+ sent += 1;
868
+ await doc.ref.set({
869
+ reminderSentAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
870
+ }, {
871
+ merge: true
872
+ }).catch(()=>undefined);
873
+ }
874
+ }
875
+ return {
876
+ scanned: upcoming.size,
877
+ sent,
878
+ skipped,
879
+ skippedLocked
880
+ };
881
+ }
882
+ /**
883
+ * The reminder beat (AGL-2431).
884
+ *
885
+ * MODULE SCOPE, like `expire-stale-holds` above and the three commerce beats:
886
+ * the runner route reaches jobs through `ensureAll(['tenantApi'])`, so a
887
+ * registration inside a `register*` function would depend on which entry
888
+ * point happened to be loaded — the AGL-2227 lesson.
889
+ *
890
+ * HOURLY, matching the window's own resolution. The scan mails a two-hour
891
+ * band 23–25h out, so a beat faster than the band buys nothing; a beat slower
892
+ * than 2h could step over the band entirely and skip a day's reminders. An
893
+ * hour sits inside that with room, and the pass is bounded (500 docs) and
894
+ * idempotent — `reminderSentAt` is stamped on send — so an overlapping or
895
+ * repeated beat cannot double-mail anybody.
896
+ */ registerPluginJob({
897
+ pluginId: BUNDLE_ID,
898
+ name: 'booking-reminders',
899
+ intervalMinutes: 60,
900
+ description: 'Email a 24-hour reminder for each upcoming confirmed booking.',
901
+ lockdown: {
902
+ scope: 'per-host'
903
+ },
904
+ handler: async (gate)=>{
905
+ // Quietly, not as an error: email is optional per deployment, and a beat
906
+ // that logs hourly on a self-host without Resend buries everything else.
907
+ if (!isEmailConfigured()) return;
908
+ const { sent } = await scanBookingReminders(gate);
909
+ if (sent) console.info(`bookings: sent ${sent} booking reminders`);
910
+ }
911
+ });
912
+ /**
913
+ * The manual door, kept for ops (AGL-2431): the scheduling now lives in the
914
+ * `booking-reminders` job above, and this stays so a pass can be forced
915
+ * without waiting for the beat.
916
+ */ const remindersHandler = async (req, res)=>{
917
+ if (req.method !== 'POST') {
918
+ return res.status(405).json({
919
+ error: 'Method not allowed'
920
+ });
921
+ }
922
+ const cronSecret = process.env.CRON_SECRET;
923
+ if (!cronSecret) {
924
+ return res.status(501).json({
925
+ error: 'Reminders are not configured (CRON_SECRET).'
926
+ });
927
+ }
928
+ if (req.headers['x-cron-secret'] !== cronSecret) {
929
+ return res.status(401).json({
930
+ error: 'Unauthenticated'
931
+ });
932
+ }
933
+ if (!isEmailConfigured()) {
934
+ return res.status(501).json({
935
+ error: 'Reminders are not configured (RESEND_API_KEY, USAGE_EMAIL_FROM).'
936
+ });
937
+ }
938
+ try {
939
+ // The manual door asks the same question the beat does (AGL-2495).
940
+ return res.status(200).json(await scanBookingReminders(pluginJobHostGate()));
941
+ } catch (error) {
942
+ console.error(error);
943
+ return res.status(500).json({
944
+ error: 'Reminders failed'
945
+ });
946
+ }
947
+ };
948
+ /** Registers the bookings plugin's public (site-facing) API routes (AGL-396). */ export function registerBookingsApi() {
949
+ registerPluginApiRoute('bookings/slots', slotsHandler);
950
+ registerPluginApiRoute('bookings/book', bookHandler);
951
+ }
952
+ /** Registers the bookings plugin's console-side API routes (AGL-396). */ export function registerBookingsConsoleApi() {
953
+ registerPluginApiRoute('bookings/reminders', remindersHandler);
954
+ // Refunding a paid booking (AGL-2315). Console-side, because it is
955
+ // site-admin-gated and moves money — never a site-facing route.
956
+ registerPluginApiRoute('bookings/refund', bookingRefundHandler);
957
+ // The merchant-side `purchase` lookup (AGL-2481). A public, unauthenticated
958
+ // read like `bookings/slots`, authorised by the unguessable Stripe session
959
+ // id and answering with a projection that carries no guest identity.
960
+ registerPluginApiRoute('bookings/booking-analytics', bookingAnalyticsHandler);
961
+ // Paid-booking confirmation rides the platform Stripe webhook (AGL-418).
962
+ registerBillingWebhookHandler(bookingsBillingWebhookHandler);
963
+ // Bookings by service as a figure table (AGL-2915), for the AI plugin's
964
+ // insights to read by id; the console runs insight jobs.
965
+ registerBookingFigureReader(()=>firebaseAdmin.app().firestore());
966
+ }
967
+
968
+ //# sourceMappingURL=server.js.map