@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.
- package/package.json +12 -12
- package/src/lib/components/book-meeting-action.d.ts +7 -4
- package/src/lib/components/book-meeting-action.js +5 -5
- package/src/lib/components/book-meeting-action.js.map +1 -1
- package/src/lib/components/booking.js +10 -10
- package/src/lib/components/booking.js.map +1 -1
- package/src/lib/components/bookings-console-page.js +17 -12
- package/src/lib/components/bookings-console-page.js.map +1 -1
- package/src/lib/model/booking-record.d.ts +74 -0
- package/src/lib/model/booking-record.js +131 -0
- package/src/lib/model/booking-record.js.map +1 -0
- package/src/lib/model/booking-time.d.ts +60 -0
- package/src/lib/model/booking-time.js +65 -0
- package/src/lib/model/booking-time.js.map +1 -0
- package/src/lib/model/bookings-record-routes.d.ts +46 -0
- package/src/lib/model/bookings-record-routes.js +63 -0
- package/src/lib/model/bookings-record-routes.js.map +1 -0
- package/src/lib/model/bookings-search-sources.d.ts +19 -0
- package/src/lib/model/bookings-search-sources.js +36 -0
- package/src/lib/model/bookings-search-sources.js.map +1 -0
- package/src/lib/model/bookings.d.ts +4 -4
- package/src/lib/model/bookings.js +3 -3
- package/src/lib/model/bookings.js.map +1 -1
- package/src/lib/model/index.d.ts +2 -0
- package/src/lib/model/index.js +2 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.js +4 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/billing-webhook.js +18 -9
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/booking-crm.d.ts +40 -24
- package/src/lib/server/booking-crm.js +84 -26
- package/src/lib/server/booking-crm.js.map +1 -1
- package/src/lib/server/booking-time-zone.d.ts +32 -0
- package/src/lib/server/booking-time-zone.js +59 -0
- package/src/lib/server/booking-time-zone.js.map +1 -0
- package/src/lib/server.js +56 -45
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.d.ts +29 -0
- package/src/lib/tenant-emails.js +134 -0
- package/src/lib/tenant-emails.js.map +1 -0
- package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
- package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
- package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
- package/src/lib/testing/stand-in-tax-profile.d.ts +6 -1
- package/src/lib/testing/stand-in-tax-profile.js +5 -1
- 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
|
|
35
|
-
// booking link carried, and the meeting a free booking files on
|
|
36
|
-
//
|
|
37
|
-
|
|
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 {
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
const 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
|
|
341
|
-
var _storeSnapshot_data;
|
|
349
|
+
var _ref5;
|
|
342
350
|
const ownerUid = ownerOrg == null ? void 0 : ownerOrg.ownerUid;
|
|
343
|
-
const [ownerProfile,
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
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:
|
|
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,
|
|
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((
|
|
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:
|
|
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: (
|
|
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
|
|
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((
|
|
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: (
|
|
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: (
|
|
755
|
+
serviceName: (_service_name3 = service.name) != null ? _service_name3 : '',
|
|
749
756
|
email,
|
|
750
757
|
startsAtMs,
|
|
751
758
|
endsAtMs
|
|
752
759
|
}, crmRef ? {
|
|
753
|
-
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
|
} : {}, {
|