@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.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +53 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/book-meeting-action.d.ts +91 -0
- package/src/lib/components/book-meeting-action.js +296 -0
- package/src/lib/components/book-meeting-action.js.map +1 -0
- package/src/lib/components/booking.d.ts +33 -0
- package/src/lib/components/booking.js +463 -0
- package/src/lib/components/booking.js.map +1 -0
- package/src/lib/components/bookings-console-page.d.ts +12 -0
- package/src/lib/components/bookings-console-page.js +743 -0
- package/src/lib/components/bookings-console-page.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +26 -0
- package/src/lib/constants/bundle-common.js +26 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/booking-purchase-analytics.d.ts +129 -0
- package/src/lib/model/booking-purchase-analytics.js +197 -0
- package/src/lib/model/booking-purchase-analytics.js.map +1 -0
- package/src/lib/model/bookings.d.ts +141 -0
- package/src/lib/model/bookings.js +124 -0
- package/src/lib/model/bookings.js.map +1 -0
- package/src/lib/model/index.d.ts +22 -0
- package/src/lib/model/index.js +22 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/plugin-config.d.ts +24 -0
- package/src/lib/plugin-config.js +51 -0
- package/src/lib/plugin-config.js.map +1 -0
- package/src/lib/plugin.d.ts +22 -0
- package/src/lib/plugin.js +70 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/billing-webhook.d.ts +23 -0
- package/src/lib/server/billing-webhook.js +345 -0
- package/src/lib/server/billing-webhook.js.map +1 -0
- package/src/lib/server/booking-analytics.d.ts +68 -0
- package/src/lib/server/booking-analytics.js +115 -0
- package/src/lib/server/booking-analytics.js.map +1 -0
- package/src/lib/server/booking-crm.d.ts +39 -0
- package/src/lib/server/booking-crm.js +67 -0
- package/src/lib/server/booking-crm.js.map +1 -0
- package/src/lib/server/booking-figures.d.ts +32 -0
- package/src/lib/server/booking-figures.js +155 -0
- package/src/lib/server/booking-figures.js.map +1 -0
- package/src/lib/server/refund.d.ts +65 -0
- package/src/lib/server/refund.js +292 -0
- package/src/lib/server/refund.js.map +1 -0
- package/src/lib/server.d.ts +63 -0
- package/src/lib/server.js +968 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/site.d.ts +27 -0
- package/src/lib/site.js +50 -0
- package/src/lib/site.js.map +1 -0
- package/src/lib/testing/stand-in-tax-profile.d.ts +29 -0
- package/src/lib/testing/stand-in-tax-profile.js +64 -0
- package/src/lib/testing/stand-in-tax-profile.js.map +1 -0
- package/src/lib/utils/generate-preset-id.d.ts +19 -0
- package/src/lib/utils/generate-preset-id.js +25 -0
- package/src/lib/utils/generate-preset-id.js.map +1 -0
- package/src/lib/utils/use-booking-purchase-event.d.ts +65 -0
- package/src/lib/utils/use-booking-purchase-event.js +147 -0
- 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;
|