@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,39 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CrmBookingRecordOutcome } from '@aglyn/tenant-data-admin/server/crm-booking-activity';
18
+ import type { HostBookingService } from '../model';
19
+ /**
20
+ * THE BOOKING'S WAY BACK TO THE CRM (AGL-2660) — the plugin's half.
21
+ *
22
+ * Two doors write a confirmed booking: the create route, for a free
23
+ * service, and the payment webhook, for a paid one once the charge clears.
24
+ * Both call this with what they hold — the row as written and, when they
25
+ * already read it, the service — and it gathers the rest (the owning org,
26
+ * the service's two switches) and hands the filing to the shared writer.
27
+ *
28
+ * **Never throws**: the guest has their slot whatever the CRM did, so a
29
+ * failure here is an outcome the caller may log, never an error into the
30
+ * request or the webhook.
31
+ */
32
+ export declare function fileBookingOnCrm(firestore: FirebaseFirestore.Firestore, input: {
33
+ hostId: string;
34
+ bookingId: string;
35
+ /** The booking row as written — or as read back by the webhook. */
36
+ booking: Record<string, unknown>;
37
+ /** The service, when the caller already read it. Read here otherwise. */
38
+ service?: HostBookingService | null;
39
+ }): Promise<CrmBookingRecordOutcome>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { getOrgForHost } from "@aglyn/tenant-data-admin";
17
+ // The leaf, not the barrel: this library's specs substitute the barrel
18
+ // wholesale, and the writer must reach the real filing logic under them.
19
+ import { recordCrmBooking } from "@aglyn/tenant-data-admin/server/crm-booking-activity";
20
+ /**
21
+ * THE BOOKING'S WAY BACK TO THE CRM (AGL-2660) — the plugin's half.
22
+ *
23
+ * Two doors write a confirmed booking: the create route, for a free
24
+ * service, and the payment webhook, for a paid one once the charge clears.
25
+ * Both call this with what they hold — the row as written and, when they
26
+ * already read it, the service — and it gathers the rest (the owning org,
27
+ * the service's two switches) and hands the filing to the shared writer.
28
+ *
29
+ * **Never throws**: the guest has their slot whatever the CRM did, so a
30
+ * failure here is an outcome the caller may log, never an error into the
31
+ * request or the webhook.
32
+ */ export async function fileBookingOnCrm(firestore, input) {
33
+ const { hostId, bookingId, booking } = input;
34
+ try {
35
+ var _booking_serviceId, _data, _booking_serviceName, _booking_email, _booking_startsAtMs, _booking_endsAtMs;
36
+ const owner = await getOrgForHost(hostId);
37
+ if (!(owner == null ? void 0 : owner.orgId)) return {
38
+ filed: false,
39
+ reason: 'failed'
40
+ };
41
+ const serviceId = String((_booking_serviceId = booking['serviceId']) != null ? _booking_serviceId : '');
42
+ const service = input.service !== undefined ? input.service : serviceId ? (_data = (await firestore.collection('hosts').doc(hostId).collection('services').doc(serviceId).get()).data()) != null ? _data : null : null;
43
+ return await recordCrmBooking(firestore, {
44
+ hostId,
45
+ org: owner.org,
46
+ orgId: owner.orgId,
47
+ booking: {
48
+ id: bookingId,
49
+ serviceId,
50
+ serviceName: String((_booking_serviceName = booking['serviceName']) != null ? _booking_serviceName : ''),
51
+ email: String((_booking_email = booking['email']) != null ? _booking_email : ''),
52
+ startsAtMs: Number((_booking_startsAtMs = booking['startsAtMs']) != null ? _booking_startsAtMs : 0),
53
+ endsAtMs: Number((_booking_endsAtMs = booking['endsAtMs']) != null ? _booking_endsAtMs : 0),
54
+ crmRef: typeof booking['crmRef'] === 'string' ? booking['crmRef'] : null
55
+ },
56
+ service
57
+ });
58
+ } catch (error) {
59
+ console.error('[bookings] the CRM filing failed', hostId, bookingId, error);
60
+ return {
61
+ filed: false,
62
+ reason: 'failed'
63
+ };
64
+ }
65
+ }
66
+
67
+ //# sourceMappingURL=booking-crm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/booking-crm.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getOrgForHost } from '@aglyn/tenant-data-admin'\n// The leaf, not the barrel: this library's specs substitute the barrel\n// wholesale, and the writer must reach the real filing logic under them.\nimport {\n type CrmBookingRecordOutcome,\n recordCrmBooking,\n} from '@aglyn/tenant-data-admin/server/crm-booking-activity'\nimport type { HostBookingService } from '../model'\n\n/**\n * THE BOOKING'S WAY BACK TO THE CRM (AGL-2660) — the plugin's half.\n *\n * Two doors write a confirmed booking: the create route, for a free\n * service, and the payment webhook, for a paid one once the charge clears.\n * Both call this with what they hold — the row as written and, when they\n * already read it, the service — and it gathers the rest (the owning org,\n * the service's two switches) and hands the filing to the shared writer.\n *\n * **Never throws**: the guest has their slot whatever the CRM did, so a\n * failure here is an outcome the caller may log, never an error into the\n * request or the webhook.\n */\nexport async function fileBookingOnCrm(\n firestore: FirebaseFirestore.Firestore,\n input: {\n hostId: string\n bookingId: string\n /** The booking row as written — or as read back by the webhook. */\n booking: Record<string, unknown>\n /** The service, when the caller already read it. Read here otherwise. */\n service?: HostBookingService | null\n },\n): Promise<CrmBookingRecordOutcome> {\n const { hostId, bookingId, booking } = input\n try {\n const owner = await getOrgForHost(hostId)\n if (!owner?.orgId) return { filed: false, reason: 'failed' }\n const serviceId = String(booking['serviceId'] ?? '')\n const service =\n input.service !== undefined\n ? input.service\n : serviceId\n ? ((\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('services')\n .doc(serviceId)\n .get()\n ).data() as HostBookingService | undefined) ?? null\n : null\n return await recordCrmBooking(firestore, {\n hostId,\n org: owner.org as Record<string, unknown>,\n orgId: owner.orgId,\n booking: {\n id: bookingId,\n serviceId,\n serviceName: String(booking['serviceName'] ?? ''),\n email: String(booking['email'] ?? ''),\n startsAtMs: Number(booking['startsAtMs'] ?? 0),\n endsAtMs: Number(booking['endsAtMs'] ?? 0),\n crmRef: typeof booking['crmRef'] === 'string' ? booking['crmRef'] : null,\n },\n service,\n })\n } catch (error) {\n console.error('[bookings] the CRM filing failed', hostId, bookingId, error)\n return { filed: false, reason: 'failed' }\n }\n}\n"],"names":["getOrgForHost","recordCrmBooking","fileBookingOnCrm","firestore","input","hostId","bookingId","booking","owner","orgId","filed","reason","serviceId","String","service","undefined","collection","doc","get","data","org","id","serviceName","email","startsAtMs","Number","endsAtMs","crmRef","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,2BAA0B;AACxD,uEAAuE;AACvE,yEAAyE;AACzE,SAEEC,gBAAgB,QACX,uDAAsD;AAG7D;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,iBACpBC,SAAsC,EACtCC,KAOC;IAED,MAAM,EAAEC,MAAM,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGH;IACvC,IAAI;YAGuBG,oBAKhB,OAgBeA,sBACNA,gBACKA,qBACFA;QA1BrB,MAAMC,QAAQ,MAAMR,cAAcK;QAClC,IAAI,EAACG,yBAAAA,MAAOC,KAAK,GAAE,OAAO;YAAEC,OAAO;YAAOC,QAAQ;QAAS;QAC3D,MAAMC,YAAYC,QAAON,qBAAAA,OAAO,CAAC,YAAY,YAApBA,qBAAwB;QACjD,MAAMO,UACJV,MAAMU,OAAO,KAAKC,YACdX,MAAMU,OAAO,GACbF,aACG,QAAA,AACC,CAAA,MAAMT,UACHa,UAAU,CAAC,SACXC,GAAG,CAACZ,QACJW,UAAU,CAAC,YACXC,GAAG,CAACL,WACJM,GAAG,EAAC,EACPC,IAAI,cAPL,QAO8C,OAC/C;QACR,OAAO,MAAMlB,iBAAiBE,WAAW;YACvCE;YACAe,KAAKZ,MAAMY,GAAG;YACdX,OAAOD,MAAMC,KAAK;YAClBF,SAAS;gBACPc,IAAIf;gBACJM;gBACAU,aAAaT,QAAON,uBAAAA,OAAO,CAAC,cAAc,YAAtBA,uBAA0B;gBAC9CgB,OAAOV,QAAON,iBAAAA,OAAO,CAAC,QAAQ,YAAhBA,iBAAoB;gBAClCiB,YAAYC,QAAOlB,sBAAAA,OAAO,CAAC,aAAa,YAArBA,sBAAyB;gBAC5CmB,UAAUD,QAAOlB,oBAAAA,OAAO,CAAC,WAAW,YAAnBA,oBAAuB;gBACxCoB,QAAQ,OAAOpB,OAAO,CAAC,SAAS,KAAK,WAAWA,OAAO,CAAC,SAAS,GAAG;YACtE;YACAO;QACF;IACF,EAAE,OAAOc,OAAO;QACdC,QAAQD,KAAK,CAAC,oCAAoCvB,QAAQC,WAAWsB;QACrE,OAAO;YAAElB,OAAO;YAAOC,QAAQ;QAAS;IAC1C;AACF"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginFigureReader } from '@aglyn/aglyn/plugin-manager/plugin-figures';
18
+ /**
19
+ * A site's bookings as a figure table another plugin reads by id (AGL-2915).
20
+ *
21
+ * Counted by service: how many were booked in the window, how many of those
22
+ * were canceled, and the change in bookings from the window before. A booking
23
+ * names a person and says where they will be and when, so nothing of one
24
+ * reaches a table but its service and its status.
25
+ */
26
+ type Firestore = FirebaseFirestore.Firestore;
27
+ /** Bookings one window reads; a busier site is told its figures read low. */
28
+ export declare const BOOKING_FIGURES_WINDOW_CEILING = 1000;
29
+ export declare function bookingFigureReader(firestore: () => Firestore): PluginFigureReader;
30
+ /** Registers the bookings reader from the console surface, where insight jobs run. */
31
+ export declare function registerBookingFigureReader(firestore: () => Firestore): void;
32
+ export {};
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { PLUGIN_FIGURE_MAX_ROWS, pluginFigureChange, pluginFigureWindows, registerPluginFigureReader } from "@aglyn/aglyn/plugin-manager/plugin-figures";
17
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
18
+ /** Bookings one window reads; a busier site is told its figures read low. */ export const BOOKING_FIGURES_WINDOW_CEILING = 1000;
19
+ const WINDOWS = [
20
+ 7,
21
+ 14,
22
+ 30,
23
+ 90
24
+ ];
25
+ /** A payment hold that lapsed never became a booking, and is not counted as one. */ const NOT_BOOKED = new Set([
26
+ 'canceled',
27
+ 'pendingPayment'
28
+ ]);
29
+ async function bookingsBetween(firestore, hostId, startMs, endMs) {
30
+ // `createdAt` is stamped by the server on every booking the book route writes.
31
+ const snapshot = await firestore.collection('hosts').doc(hostId).collection('bookings').where('createdAt', '>=', new Date(startMs)).where('createdAt', '<', new Date(endMs)).orderBy('createdAt', 'desc').limit(BOOKING_FIGURES_WINDOW_CEILING + 1).get();
32
+ const counts = new Map();
33
+ for (const doc of snapshot.docs.slice(0, BOOKING_FIGURES_WINDOW_CEILING)){
34
+ var _doc_data, _data_serviceName, _data_status, _counts_get;
35
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
36
+ const service = String((_data_serviceName = data['serviceName']) != null ? _data_serviceName : '').trim() || 'A service with no name';
37
+ const status = String((_data_status = data['status']) != null ? _data_status : '');
38
+ const entry = (_counts_get = counts.get(service)) != null ? _counts_get : {
39
+ booked: 0,
40
+ canceled: 0
41
+ };
42
+ if (status === 'canceled') entry.canceled += 1;
43
+ if (!NOT_BOOKED.has(status)) entry.booked += 1;
44
+ counts.set(service, entry);
45
+ }
46
+ return {
47
+ counts,
48
+ truncated: snapshot.docs.length > BOOKING_FIGURES_WINDOW_CEILING
49
+ };
50
+ }
51
+ export function bookingFigureReader(firestore) {
52
+ return {
53
+ id: 'bookings.services',
54
+ label: 'Bookings',
55
+ description: 'Bookings made on the site over the window, by service: how many were booked, how many were canceled, and the change in bookings from the window before.',
56
+ scope: 'site',
57
+ windows: WINDOWS,
58
+ feature: 'bookings',
59
+ read: async (request)=>{
60
+ if (!request.hostId) return {
61
+ ok: false,
62
+ status: 400,
63
+ error: 'Open a site to read its bookings'
64
+ };
65
+ if (!WINDOWS.includes(request.days)) {
66
+ return {
67
+ ok: false,
68
+ status: 400,
69
+ error: 'That window is not one bookings are read over'
70
+ };
71
+ }
72
+ const { current, previous } = pluginFigureWindows(request.now, request.days);
73
+ const [now, before] = await Promise.all([
74
+ bookingsBetween(firestore(), request.hostId, current.startMs, current.endMs),
75
+ bookingsBetween(firestore(), request.hostId, previous.startMs, previous.endMs)
76
+ ]);
77
+ const sum = (counts, key)=>[
78
+ ...counts.values()
79
+ ].reduce((total, entry)=>total + entry[key], 0);
80
+ const services = [
81
+ ...now.counts.entries()
82
+ ].sort((a, b)=>b[1].booked - a[1].booked || a[0].localeCompare(b[0]));
83
+ const rows = [
84
+ {
85
+ service: 'All services',
86
+ booked: sum(now.counts, 'booked'),
87
+ canceled: sum(now.counts, 'canceled'),
88
+ change: pluginFigureChange(sum(now.counts, 'booked'), sum(before.counts, 'booked'))
89
+ },
90
+ ...services.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1).map(([service, counts])=>{
91
+ var _ref;
92
+ var _before_counts_get;
93
+ return {
94
+ service,
95
+ booked: counts.booked,
96
+ canceled: counts.canceled,
97
+ change: pluginFigureChange(counts.booked, (_ref = (_before_counts_get = before.counts.get(service)) == null ? void 0 : _before_counts_get.booked) != null ? _ref : null)
98
+ };
99
+ })
100
+ ];
101
+ return {
102
+ ok: true,
103
+ table: {
104
+ title: 'Bookings',
105
+ source: {
106
+ label: 'Bookings',
107
+ path: 'bookings'
108
+ },
109
+ period: {
110
+ from: current.from,
111
+ to: current.to,
112
+ days: request.days
113
+ },
114
+ columns: [
115
+ {
116
+ key: 'service',
117
+ label: 'Service',
118
+ kind: 'text'
119
+ },
120
+ {
121
+ key: 'booked',
122
+ label: 'Booked',
123
+ kind: 'count'
124
+ },
125
+ {
126
+ key: 'canceled',
127
+ label: 'Canceled',
128
+ kind: 'count'
129
+ },
130
+ {
131
+ key: 'change',
132
+ label: 'Change in bookings',
133
+ kind: 'change'
134
+ }
135
+ ],
136
+ rows,
137
+ omitted: Math.max(0, services.length - (PLUGIN_FIGURE_MAX_ROWS - 1)),
138
+ notes: [
139
+ 'A booking is counted in the window it was made in, not the day it is for.',
140
+ ...now.truncated || before.truncated ? [
141
+ `More than ${BOOKING_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} bookings were made in a window, so the figures count the most recent ${BOOKING_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} and read low.`
142
+ ] : []
143
+ ]
144
+ }
145
+ };
146
+ }
147
+ };
148
+ }
149
+ /** Registers the bookings reader from the console surface, where insight jobs run. */ export function registerBookingFigureReader(firestore) {
150
+ registerPluginFigureReader(bookingFigureReader(firestore), {
151
+ pluginId: BUNDLE_ID
152
+ });
153
+ }
154
+
155
+ //# sourceMappingURL=booking-figures.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/booking-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureChange,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * A site's bookings as a figure table another plugin reads by id (AGL-2915).\n *\n * Counted by service: how many were booked in the window, how many of those\n * were canceled, and the change in bookings from the window before. A booking\n * names a person and says where they will be and when, so nothing of one\n * reaches a table but its service and its status.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Bookings one window reads; a busier site is told its figures read low. */\nexport const BOOKING_FIGURES_WINDOW_CEILING = 1_000\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\n/** A payment hold that lapsed never became a booking, and is not counted as one. */\nconst NOT_BOOKED = new Set(['canceled', 'pendingPayment'])\n\ninterface ServiceCounts {\n booked: number\n canceled: number\n}\n\nasync function bookingsBetween(\n firestore: Firestore,\n hostId: string,\n startMs: number,\n endMs: number,\n): Promise<{ counts: Map<string, ServiceCounts>; truncated: boolean }> {\n // `createdAt` is stamped by the server on every booking the book route writes.\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('bookings')\n .where('createdAt', '>=', new Date(startMs))\n .where('createdAt', '<', new Date(endMs))\n .orderBy('createdAt', 'desc')\n .limit(BOOKING_FIGURES_WINDOW_CEILING + 1)\n .get()\n const counts = new Map<string, ServiceCounts>()\n for (const doc of snapshot.docs.slice(0, BOOKING_FIGURES_WINDOW_CEILING)) {\n const data = doc.data() ?? {}\n const service = String(data['serviceName'] ?? '').trim() || 'A service with no name'\n const status = String(data['status'] ?? '')\n const entry = counts.get(service) ?? { booked: 0, canceled: 0 }\n if (status === 'canceled') entry.canceled += 1\n if (!NOT_BOOKED.has(status)) entry.booked += 1\n counts.set(service, entry)\n }\n return { counts, truncated: snapshot.docs.length > BOOKING_FIGURES_WINDOW_CEILING }\n}\n\nexport function bookingFigureReader(firestore: () => Firestore): PluginFigureReader {\n return {\n id: 'bookings.services',\n label: 'Bookings',\n description:\n 'Bookings made on the site over the window, by service: how many were booked, how many were canceled, and the change in bookings from the window before.',\n scope: 'site',\n windows: WINDOWS,\n feature: 'bookings',\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its bookings' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one bookings are read over' }\n }\n const { current, previous } = pluginFigureWindows(request.now, request.days)\n const [now, before] = await Promise.all([\n bookingsBetween(firestore(), request.hostId, current.startMs, current.endMs),\n bookingsBetween(firestore(), request.hostId, previous.startMs, previous.endMs),\n ])\n const sum = (counts: Map<string, ServiceCounts>, key: keyof ServiceCounts) =>\n [...counts.values()].reduce((total, entry) => total + entry[key], 0)\n const services = [...now.counts.entries()].sort((a, b) => b[1].booked - a[1].booked || a[0].localeCompare(b[0]))\n const rows = [\n {\n service: 'All services',\n booked: sum(now.counts, 'booked'),\n canceled: sum(now.counts, 'canceled'),\n change: pluginFigureChange(sum(now.counts, 'booked'), sum(before.counts, 'booked')),\n },\n ...services.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1).map(([service, counts]) => ({\n service,\n booked: counts.booked,\n canceled: counts.canceled,\n change: pluginFigureChange(counts.booked, before.counts.get(service)?.booked ?? null),\n })),\n ]\n return {\n ok: true,\n table: {\n title: 'Bookings',\n source: { label: 'Bookings', path: 'bookings' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'service', label: 'Service', kind: 'text' },\n { key: 'booked', label: 'Booked', kind: 'count' },\n { key: 'canceled', label: 'Canceled', kind: 'count' },\n { key: 'change', label: 'Change in bookings', kind: 'change' },\n ],\n rows,\n omitted: Math.max(0, services.length - (PLUGIN_FIGURE_MAX_ROWS - 1)),\n notes: [\n 'A booking is counted in the window it was made in, not the day it is for.',\n ...(now.truncated || before.truncated\n ? [\n `More than ${BOOKING_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} bookings were made in a window, so the figures count the most recent ${BOOKING_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} and read low.`,\n ]\n : []),\n ],\n },\n }\n },\n }\n}\n\n/** Registers the bookings reader from the console surface, where insight jobs run. */\nexport function registerBookingFigureReader(firestore: () => Firestore): void {\n registerPluginFigureReader(bookingFigureReader(firestore), { pluginId: BUNDLE_ID })\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","pluginFigureChange","pluginFigureWindows","registerPluginFigureReader","BUNDLE_ID","BOOKING_FIGURES_WINDOW_CEILING","WINDOWS","NOT_BOOKED","Set","bookingsBetween","firestore","hostId","startMs","endMs","snapshot","collection","doc","where","Date","orderBy","limit","get","counts","Map","docs","slice","data","service","String","trim","status","entry","booked","canceled","has","set","truncated","length","bookingFigureReader","id","label","description","scope","windows","feature","read","request","ok","error","includes","days","current","previous","now","before","Promise","all","sum","key","values","reduce","total","services","entries","sort","a","b","localeCompare","rows","change","map","table","title","source","path","period","from","to","columns","kind","omitted","Math","max","notes","toLocaleString","registerBookingFigureReader","pluginId"],"mappings":"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"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * Refund a paid booking (AGL-2315), full or partial, site-admin only.
20
+ *
21
+ * This route exists BECAUSE of the destination charge, not alongside it. While
22
+ * a paid booking settled in Aglyn's own balance there was nothing here to get
23
+ * wrong: cancelling a booking refunded the guest nothing at all, and any
24
+ * refund issued by hand simply gave back money Aglyn was already holding. Once
25
+ * the charge goes to the merchant's connected account that stops being true in
26
+ * two directions at once, and both are money:
27
+ *
28
+ * - **The guest.** Cancelling a paid booking wrote `status: 'canceled'` from
29
+ * the console and moved no money. The slot reopened, the appointment was
30
+ * gone, and the guest had paid for it. That was survivable only because the
31
+ * platform still held the funds and could be made to give them back; with
32
+ * the money at the merchant it is a silent, unrecoverable overcharge.
33
+ * - **Aglyn.** A refund on a destination charge that does NOT set
34
+ * `reverse_transfer` is paid entirely out of the PLATFORM's balance while
35
+ * the merchant keeps the transfer in full. The merchant cancels, the guest
36
+ * is made whole, and Aglyn funds the whole concession out of a cut that was
37
+ * at most 5% of it. This repo has shipped that exact bug once already, on a
38
+ * marketplace partial refund.
39
+ *
40
+ * So both switches are sent, and they are two different decisions:
41
+ *
42
+ * - `reverse_transfer` pulls the refunded share back OUT of the merchant's
43
+ * account. Stripe prorates it against a partial refund, so a $30 refund on
44
+ * a $75 booking claws back $30 of the merchant's $71.25, not all of it.
45
+ * - `refund_application_fee` returns Aglyn's commission on the refunded
46
+ * share. A refunded appointment earned the platform nothing, and this
47
+ * matches `commerce/refund.ts`, which has sent both since AGL-287. The
48
+ * DISPUTE path deliberately sends only the reversal — that asymmetry is
49
+ * AGL-1809's and is not copied here.
50
+ *
51
+ * Two SEPARATE controls guard the money, the AGL-1696 lesson from the order
52
+ * refund, and conflating them is how that one originally went wrong:
53
+ *
54
+ * - The idempotency key stops a DUPLICATE refund — one attempt sent twice
55
+ * because the response was lost or the admin double-clicked. It is minted
56
+ * per attempt by the console and deliberately not derived from the booking
57
+ * or the amount: two $10 refunds on a $75 booking are two real refunds.
58
+ * - The cap stops an OVER-refund — several partials summing past what was
59
+ * captured, including two admins refunding at once, where the attempts are
60
+ * genuinely distinct and no key can help. That needs the counter read and
61
+ * written inside one transaction, BEFORE Stripe is called, so a lost
62
+ * response leaves the amount counted and the retry refunds less, never
63
+ * more.
64
+ */
65
+ export declare const bookingRefundHandler: PluginApiHandler;