@aglyn/plugins-bookings 1.0.0-beta.218 → 1.0.0-beta.220

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 (47) hide show
  1. package/package.json +12 -12
  2. package/src/lib/components/book-meeting-action.d.ts +7 -4
  3. package/src/lib/components/book-meeting-action.js +5 -5
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.js +10 -10
  6. package/src/lib/components/booking.js.map +1 -1
  7. package/src/lib/components/bookings-console-page.js +17 -12
  8. package/src/lib/components/bookings-console-page.js.map +1 -1
  9. package/src/lib/model/booking-record.d.ts +74 -0
  10. package/src/lib/model/booking-record.js +131 -0
  11. package/src/lib/model/booking-record.js.map +1 -0
  12. package/src/lib/model/booking-time.d.ts +60 -0
  13. package/src/lib/model/booking-time.js +65 -0
  14. package/src/lib/model/booking-time.js.map +1 -0
  15. package/src/lib/model/bookings-record-routes.d.ts +46 -0
  16. package/src/lib/model/bookings-record-routes.js +63 -0
  17. package/src/lib/model/bookings-record-routes.js.map +1 -0
  18. package/src/lib/model/bookings-search-sources.d.ts +19 -0
  19. package/src/lib/model/bookings-search-sources.js +36 -0
  20. package/src/lib/model/bookings-search-sources.js.map +1 -0
  21. package/src/lib/model/bookings.d.ts +4 -4
  22. package/src/lib/model/bookings.js +3 -3
  23. package/src/lib/model/bookings.js.map +1 -1
  24. package/src/lib/model/index.d.ts +2 -0
  25. package/src/lib/model/index.js +2 -0
  26. package/src/lib/model/index.js.map +1 -1
  27. package/src/lib/plugin.js +4 -0
  28. package/src/lib/plugin.js.map +1 -1
  29. package/src/lib/server/billing-webhook.js +18 -9
  30. package/src/lib/server/billing-webhook.js.map +1 -1
  31. package/src/lib/server/booking-crm.d.ts +40 -24
  32. package/src/lib/server/booking-crm.js +84 -26
  33. package/src/lib/server/booking-crm.js.map +1 -1
  34. package/src/lib/server/booking-time-zone.d.ts +32 -0
  35. package/src/lib/server/booking-time-zone.js +59 -0
  36. package/src/lib/server/booking-time-zone.js.map +1 -0
  37. package/src/lib/server.js +56 -45
  38. package/src/lib/server.js.map +1 -1
  39. package/src/lib/tenant-emails.d.ts +29 -0
  40. package/src/lib/tenant-emails.js +134 -0
  41. package/src/lib/tenant-emails.js.map +1 -0
  42. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  43. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  44. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
  45. package/src/lib/testing/stand-in-tax-profile.d.ts +6 -1
  46. package/src/lib/testing/stand-in-tax-profile.js +5 -1
  47. package/src/lib/testing/stand-in-tax-profile.js.map +1 -1
package/src/lib/server.js CHANGED
@@ -22,7 +22,8 @@ import { checkEntitlement, registerPluginConfigSchema, registerPluginJob, resolv
22
22
  // reach the real verdict.
23
23
  import { siteLockdownJobGate } from "@aglyn/tenant-data-admin/server/tenant-write-lockdown";
24
24
  import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
25
- import { BOOKING_MAX_DAYS_AHEAD, computeOpenSlots, isBookingReminderDue, isSlotOpen, REMINDER_WINDOW_END_HOURS, REMINDER_WINDOW_START_HOURS } from "./model/index.js";
25
+ import { BOOKING_MAX_DAYS_AHEAD, bookingTimeZone, computeOpenSlots, formatBookingWhen, isBookingReminderDue, isSlotOpen, REMINDER_WINDOW_END_HOURS, REMINDER_WINDOW_START_HOURS } from "./model/index.js";
26
+ import { bookingTimeZoneFor } from "./server/booking-time-zone.js";
26
27
  import { registerBillingWebhookHandler, registerPluginApiRoute } from "@aglyn/aglyn/server";
27
28
  import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile";
28
29
  import { BUNDLE_ID } from "./constants/bundle-common.js";
@@ -31,12 +32,10 @@ import { bookingsBillingWebhookHandler } from "./server/billing-webhook.js";
31
32
  import { bookingAnalyticsHandler } from "./server/booking-analytics.js";
32
33
  import { registerBookingFigureReader } from "./server/booking-figures.js";
33
34
  import { bookingRefundHandler } from "./server/refund.js";
34
- // The booking's way back to the CRM record (AGL-2660): the reference a
35
- // booking link carried, and the meeting a free booking files on landing.
36
- // The leaf, not the barrel: this library's specs substitute the barrel
37
- // wholesale, and a parser that vanished under them would fail every
38
- // booking they take.
39
- import { formatCrmBookingRef, parseCrmBookingRef } from "@aglyn/aglyn/app-utils/crm-booking";
35
+ // The booking's way back to the record it was booked from (AGL-2660): the
36
+ // reference a booking link carried, and the meeting a free booking files on
37
+ // landing.
38
+ import { formatBookingRecordRef, parseBookingRecordRef } from "./model/booking-record.js";
40
39
  import { fileBookingOnCrm } from "./server/booking-crm.js";
41
40
  // Settings schema (AGL-428): registered here too so server-only loads
42
41
  // (API dispatchers) get defaults-merged getPluginConfig reads.
@@ -87,7 +86,7 @@ registerPluginJob({
87
86
  }
88
87
  });
89
88
  import { firebaseAdmin, getOrgForHost, meterHostEmail, notifyHostManagers, attributeCampaignConversion, resolveCampaignTouch, getPluginConfig, resolveOrgIdForHost, renderHostEmailWithTokens, hostSendingIdentity } from "@aglyn/tenant-data-admin";
90
- import { connectLinkageIsReady } from "@aglyn/tenant-data-admin/server/stripe-account-mode";
89
+ import { merchantAccountIsReady } from "@aglyn/tenant-data-admin/server/payment-provider";
91
90
  import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admin/server/stripe-card-authentication";
92
91
  import { emitHostEvent } from "@aglyn/tenant-runtime";
93
92
  import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
@@ -236,10 +235,10 @@ export const slotsHandler = async (req, res)=>{
236
235
  // consent to be emailed marketing, so this is only set when the visitor
237
236
  // checked it.
238
237
  const marketingConsent = ((_req_body5 = req.body) == null ? void 0 : _req_body5.marketingConsent) === true;
239
- // The CRM record the booking link named (AGL-2660), kept on the row so
240
- // the booking can be attributed to it even when the visitor books with
241
- // a different address. Parsed, never stored raw: this is a public door.
242
- const crmRef = parseCrmBookingRef((_req_body6 = req.body) == null ? void 0 : _req_body6.crmRef);
238
+ // The record the booking link named (AGL-2660), kept on the row so the
239
+ // booking can be attributed to it even when the visitor books with a
240
+ // different address. Parsed, never stored raw: this is a public door.
241
+ const crmRef = parseBookingRecordRef((_req_body6 = req.body) == null ? void 0 : _req_body6.crmRef);
243
242
  if (!hostId || !serviceId || !Number.isFinite(startsAtMs) || !startsAtMs) {
244
243
  return res.status(400).json({
245
244
  error: 'Invalid booking request'
@@ -270,7 +269,7 @@ export const slotsHandler = async (req, res)=>{
270
269
  });
271
270
  }
272
271
  try {
273
- var _service_priceUsd, _service_name, _service_name1, _service_name2;
272
+ var _service_priceUsd, _service_name, _service_name1, _service_name2, _service_name3;
274
273
  var _this, _req_body7;
275
274
  const firestore = firebaseAdmin.app().firestore();
276
275
  const hostRef = firestore.collection('hosts').doc(hostId);
@@ -302,6 +301,16 @@ export const slotsHandler = async (req, res)=>{
302
301
  const durationMs = Math.max(5, Math.round(service.durationMinutes || 30)) * 60000;
303
302
  const endsAtMs = startsAtMs + durationMs;
304
303
  const bookingsRef = hostRef.collection('bookings');
304
+ // The zone this booking's time is told in (AGL-3432), stored on the
305
+ // booking so the paid confirmation and the reminder — which read the
306
+ // booking and not the service — state the same wall-clock time as the
307
+ // free confirmation below, and name the zone it is in.
308
+ const timezone = bookingTimeZone({
309
+ service,
310
+ host: hostSnapshot.data == null ? void 0 : hostSnapshot.data.call(hostSnapshot),
311
+ org: ownerOrg
312
+ });
313
+ const when = formatBookingWhen(startsAtMs, timezone);
305
314
  // Paid services (AGL-170): the slot is HELD pending payment — the
306
315
  // booking lands as `pendingPayment` with a 15-minute expiry (expired
307
316
  // holds release the slot in the collision filters), and the visitor
@@ -337,17 +346,18 @@ export const slotsHandler = async (req, res)=>{
337
346
  pct: 0
338
347
  };
339
348
  if (paid) {
340
- var _ref5, _ref6;
341
- var _storeSnapshot_data;
349
+ var _ref5;
342
350
  const ownerUid = ownerOrg == null ? void 0 : ownerOrg.ownerUid;
343
- const [ownerProfile, storeSnapshot] = await Promise.all([
351
+ const [ownerProfile, serviceRate] = await Promise.all([
344
352
  ownerUid ? firestore.collection('profiles').doc(String(ownerUid)).get() : Promise.resolve(null),
345
353
  // The merchant's own service rate (AGL-2028), in the SAME round trip
346
- // as the connected-account read this path already makes.
347
- hostRef.collection('settings').doc('store').get()
354
+ // as the connected-account read this path already makes — asked of
355
+ // the plugin that keeps the merchant's tax settings, which reads them
356
+ // where it stores them.
357
+ pluginTaxProfile().flatRate(hostId, 'service')
348
358
  ]);
349
359
  chargeAccountId = String((_ref5 = ownerProfile == null ? void 0 : ownerProfile.get('stripeAccountId')) != null ? _ref5 : '');
350
- if (!connectLinkageIsReady({
360
+ if (!merchantAccountIsReady({
351
361
  accountId: chargeAccountId,
352
362
  chargesEnabled: ownerProfile == null ? void 0 : ownerProfile.get('stripeChargesEnabled'),
353
363
  accountLivemode: ownerProfile == null ? void 0 : ownerProfile.get('stripeAccountLivemode')
@@ -426,12 +436,11 @@ export const slotsHandler = async (req, res)=>{
426
436
  // DEFAULT OFF, and load-bearing: an absent, zero, negative or
427
437
  // out-of-range rate resolves to zero, so no existing merchant's charge
428
438
  // moves because this shipped.
429
- const taxSettings = (_ref6 = storeSnapshot == null ? void 0 : (_storeSnapshot_data = storeSnapshot.data()) == null ? void 0 : _storeSnapshot_data.tax) != null ? _ref6 : {};
430
439
  const chargeCents = Math.round(priceUsd * 100);
431
440
  // Asked of the plugin that owns the merchant's tax rule, which throws
432
441
  // rather than answer zero if none is loaded: an untaxed charge recorded
433
442
  // as untaxed is the failure nobody sees until the merchant owes it.
434
- serviceTax = pluginTaxProfile().flatTax(taxSettings.service, chargeCents, 'Service tax');
443
+ serviceTax = pluginTaxProfile().flatTax(serviceRate, chargeCents, 'Service tax');
435
444
  // THE TWO FEE BASES ARE NOW DIFFERENT NUMBERS (AGL-2028). The take is
436
445
  // computed on the SERVICE, never on the state's money — AGL-2317's rule
437
446
  // — while Stripe's card cost is debited from the platform balance on
@@ -470,11 +479,12 @@ export const slotsHandler = async (req, res)=>{
470
479
  email,
471
480
  startsAtMs,
472
481
  endsAtMs,
482
+ timezone,
473
483
  status: paid ? 'pendingPayment' : 'confirmed'
474
484
  }, paid && {
475
485
  expiresAtMs: Date.now() + 15 * 60000
476
486
  }, crmRef ? {
477
- crmRef: formatCrmBookingRef(crmRef)
487
+ crmRef: formatBookingRecordRef(crmRef)
478
488
  } : {}, {
479
489
  createdAt: FieldValue.serverTimestamp()
480
490
  }));
@@ -548,7 +558,7 @@ export const slotsHandler = async (req, res)=>{
548
558
  }
549
559
  } : {}));
550
560
  if (paid) {
551
- var _req_headers_host, _service_name3;
561
+ var _req_headers_host, _service_name4;
552
562
  // The site's own origin, never a header's say-so (AGL-3363): a
553
563
  // caller writes `Origin` and `Host`, and Stripe sends the payer here.
554
564
  const origin = siteReturnOrigin({
@@ -582,7 +592,7 @@ export const slotsHandler = async (req, res)=>{
582
592
  'line_items[0][quantity]': '1',
583
593
  'line_items[0][price_data][currency]': 'usd',
584
594
  'line_items[0][price_data][unit_amount]': String(Math.round(priceUsd * 100)),
585
- 'line_items[0][price_data][product_data][name]': String((_service_name3 = service.name) != null ? _service_name3 : 'Booking').slice(0, 120)
595
+ 'line_items[0][price_data][product_data][name]': String((_service_name4 = service.name) != null ? _service_name4 : 'Booking').slice(0, 120)
586
596
  }, serviceTax.taxCents > 0 ? {
587
597
  'line_items[1][quantity]': '1',
588
598
  'line_items[1][price_data][currency]': 'usd',
@@ -672,15 +682,16 @@ export const slotsHandler = async (req, res)=>{
672
682
  });
673
683
  }
674
684
  // Event trigger (AGL-128/148/159).
675
- // In-app notification to the site's managers (AGL-259).
685
+ // In-app notification to the site's managers (AGL-259): who booked what,
686
+ // and when, in the booking's own zone and naming it (AGL-3432).
676
687
  void notifyHostManagers(hostId, {
677
688
  type: 'content.booking',
678
- title: 'New booking',
679
- body: new Date(startsAtMs).toLocaleString(),
689
+ title: 'New booking on {site}',
690
+ body: `${name} (${email}) booked ${String((_service_name1 = service.name) != null ? _service_name1 : 'a service')} ` + `on {site} for ${when} (${timezone}).`,
680
691
  link: `/${hostId}/bookings`
681
692
  });
682
693
  const { alerts } = await emitHostEvent(hostId, 'booking', {
683
- serviceName: (_service_name1 = service.name) != null ? _service_name1 : '',
694
+ serviceName: (_service_name2 = service.name) != null ? _service_name2 : '',
684
695
  email,
685
696
  startsAtMs
686
697
  }, // The visitor who booked (AGL-3376).
@@ -692,22 +703,18 @@ export const slotsHandler = async (req, res)=>{
692
703
  });
693
704
  // Env-gated confirmation email (same provider as AGL-98).
694
705
  if (isEmailConfigured()) {
695
- var _service_name4, _ref7;
706
+ var _service_name5, _ref6;
696
707
  var _this1;
697
- const timezone = service.timezone || 'UTC';
698
- const when = new Intl.DateTimeFormat('en-US', {
699
- timeZone: timezone,
700
- dateStyle: 'full',
701
- timeStyle: 'short'
702
- }).format(new Date(startsAtMs));
703
708
  const fallbackText = `Hi ${name},\n\nYour booking for "${service.name}" is ` + `confirmed for ${when} (${timezone}).\n\n` + `Reference: ${bookingId}`;
704
709
  // Site-owner-designed template when published (AGL-770); null keeps the
705
710
  // built-in copy above.
706
711
  const designed = await renderHostEmailWithTokens(firestore, hostId, 'booking-confirmed', {
707
712
  name: String(name != null ? name : ''),
708
- 'service.name': String((_service_name4 = service.name) != null ? _service_name4 : ''),
713
+ 'service.name': String((_service_name5 = service.name) != null ? _service_name5 : ''),
709
714
  when,
710
715
  timezone,
716
+ // A free booking charged nothing: empty, so the line is left out.
717
+ 'booking.payment': '',
711
718
  'booking.ref': String(bookingId)
712
719
  });
713
720
  // White-label sender identity (White-Label Phase 3): the store's brand
@@ -715,7 +722,7 @@ export const slotsHandler = async (req, res)=>{
715
722
  const branding = resolveBrandingProfile((_this1 = await getOrgForHost(hostId).catch(()=>null)) == null ? void 0 : _this1.org);
716
723
  await sendEmail(_extends({
717
724
  to: email,
718
- subject: (_ref7 = designed == null ? void 0 : designed.subject) != null ? _ref7 : `Booking confirmed: ${service.name}`,
725
+ subject: (_ref6 = designed == null ? void 0 : designed.subject) != null ? _ref6 : `Booking confirmed: ${service.name}`,
719
726
  text: (designed == null ? void 0 : designed.text) || fallbackText
720
727
  }, (designed == null ? void 0 : designed.html) ? {
721
728
  html: designed.html
@@ -745,12 +752,12 @@ export const slotsHandler = async (req, res)=>{
745
752
  bookingId,
746
753
  booking: _extends({
747
754
  serviceId,
748
- serviceName: (_service_name2 = service.name) != null ? _service_name2 : '',
755
+ serviceName: (_service_name3 = service.name) != null ? _service_name3 : '',
749
756
  email,
750
757
  startsAtMs,
751
758
  endsAtMs
752
759
  }, crmRef ? {
753
- crmRef: formatCrmBookingRef(crmRef)
760
+ crmRef: formatBookingRecordRef(crmRef)
754
761
  } : {}),
755
762
  service
756
763
  });
@@ -813,6 +820,9 @@ export const slotsHandler = async (req, res)=>{
813
820
  * reads per reminder rather than per host. Scoped to the run, so a domain
814
821
  * un-verified between runs is picked up on the next one.
815
822
  */ const identityByHost = new Map();
823
+ // The zone for a booking that predates storing one (AGL-3432), resolved
824
+ // once per service for the whole run.
825
+ const zoneByService = new Map();
816
826
  for (const doc of upcoming.docs){
817
827
  var _ref, _data_serviceName, _data_name, _ref1, _data_name1;
818
828
  var _doc_ref_parent_parent, _brandingByHost_get;
@@ -824,10 +834,6 @@ export const slotsHandler = async (req, res)=>{
824
834
  skipped += 1;
825
835
  continue;
826
836
  }
827
- const when = new Date(Number(data['startsAtMs'])).toLocaleString('en-US', {
828
- dateStyle: 'full',
829
- timeStyle: 'short'
830
- });
831
837
  // bookings live at hosts/{hostId}/bookings/{id}, so the grandparent is
832
838
  // the host.
833
839
  const hostId = (_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : '';
@@ -850,6 +856,10 @@ export const slotsHandler = async (req, res)=>{
850
856
  skippedLocked += 1;
851
857
  continue;
852
858
  }
859
+ // The booking's own zone, named beside the time (AGL-3432). A formatter
860
+ // with no zone reads in the server's — UTC — and states the wrong hour.
861
+ const timezone = await bookingTimeZoneFor(firestore, hostId, data, zoneByService);
862
+ const when = formatBookingWhen(Number(data['startsAtMs']), timezone);
853
863
  let loaded = templateCache.get(hostId);
854
864
  if (loaded === undefined) {
855
865
  var _this;
@@ -862,12 +872,13 @@ export const slotsHandler = async (req, res)=>{
862
872
  const designed = loaded ? renderLoadedHostEmailWithTokens(loaded, {
863
873
  name: String((_data_name = data['name']) != null ? _data_name : ''),
864
874
  'service.name': serviceName,
865
- when
875
+ when,
876
+ timezone
866
877
  }) : null;
867
878
  const result = await sendEmail(_extends({
868
879
  to: String(data['email']),
869
880
  subject: (_ref1 = designed == null ? void 0 : designed.subject) != null ? _ref1 : `Reminder: ${serviceName} tomorrow`,
870
- 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}`
881
+ text: (designed == null ? void 0 : designed.text) || `Hi ${(_data_name1 = data['name']) != null ? _data_name1 : ''},\n\nA reminder that "${serviceName}" is ` + `scheduled for ${when} (${timezone}).\n\nReference: ${doc.id}`
871
882
  }, (designed == null ? void 0 : designed.html) ? {
872
883
  html: designed.html
873
884
  } : {}, {