@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,345 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ // `after()`, never a bare `void promise` (AGL-2327). This handler runs inside
|
|
18
|
+
// the console's `/api/billing/webhook` invocation, which AGL-1133 measured on
|
|
19
|
+
// production is frozen the moment the response is sent — so fire-and-forget
|
|
20
|
+
// work scheduled any other way simply does not run.
|
|
21
|
+
import { after } from "next/server";
|
|
22
|
+
import { resolveBrandingProfile } from "@aglyn/aglyn/server";
|
|
23
|
+
import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail, renderHostEmailWithTokens, sendGa4Purchase } from "@aglyn/tenant-data-admin";
|
|
24
|
+
import { captureHostContact } from "@aglyn/tenant-runtime";
|
|
25
|
+
import { bookingPlatformNetCents, shouldSendBookingPlatformPurchase } from "../model/booking-purchase-analytics.js";
|
|
26
|
+
import { sendEmail } from "@aglyn/shared-util-email";
|
|
27
|
+
import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile";
|
|
28
|
+
import { fileBookingOnCrm } from "./booking-crm.js";
|
|
29
|
+
/**
|
|
30
|
+
* Paid-booking section of the platform Stripe webhook (AGL-170/418):
|
|
31
|
+
* payment confirms the pendingPayment hold — relocated verbatim from the
|
|
32
|
+
* console route; registered via registerBookingsConsoleApi.
|
|
33
|
+
*/ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
|
|
34
|
+
var _object_metadata;
|
|
35
|
+
// Paid bookings (AGL-170): payment confirms the pendingPayment hold.
|
|
36
|
+
if (type === 'checkout.session.completed' && (object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.type) === 'booking-payment' && (object == null ? void 0 : object.payment_status) === 'paid') {
|
|
37
|
+
var _object_metadata1;
|
|
38
|
+
const { hostId, bookingId } = (_object_metadata1 = object.metadata) != null ? _object_metadata1 : {};
|
|
39
|
+
if (hostId && bookingId) {
|
|
40
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
41
|
+
var _object_metadata2, _object_payment_intent, _object_metadata3;
|
|
42
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
43
|
+
const bookingRef = firestore.collection('hosts').doc(String(hostId)).collection('bookings').doc(String(bookingId));
|
|
44
|
+
// WHAT THE CLIENT HANDED OVER, ALL OF IT. Tax included: this is the
|
|
45
|
+
// ceiling `refund.ts` reverses against, and the client paid the tax
|
|
46
|
+
// too — a net figure here would strand part of a refund.
|
|
47
|
+
const paidAmountCents = Math.max(0, Math.round(Number((_ref = object == null ? void 0 : object.amount_total) != null ? _ref : 0)));
|
|
48
|
+
// THE MERCHANT'S SERVICE TAX, SEPARATED OUT (AGL-2028).
|
|
49
|
+
//
|
|
50
|
+
// `server.ts` charges the merchant's own service rate as an ordinary
|
|
51
|
+
// `line_items[1]` Stripe is never told is tax (the AGL-1711
|
|
52
|
+
// construction, which is what keeps the figure the MERCHANT's rather
|
|
53
|
+
// than something computed against Aglyn's registrations). So
|
|
54
|
+
// `amount_total` is service-plus-tax while Stripe's own tax fields
|
|
55
|
+
// read zero, and the session's metadata is the only witness — exactly
|
|
56
|
+
// as it is for a buy-now sale.
|
|
57
|
+
const taxCents = Math.max(0, Math.round(Number((_ref1 = object == null ? void 0 : (_object_metadata2 = object.metadata) == null ? void 0 : _object_metadata2.taxCents) != null ? _ref1 : 0)));
|
|
58
|
+
// The merchant's REVENUE, which is the charge less the tax. Used for
|
|
59
|
+
// the contact's lifetime value below: tax is collected on behalf of
|
|
60
|
+
// an authority and is not money the business earned, so counting it
|
|
61
|
+
// would over-state every service client's worth.
|
|
62
|
+
const serviceCents = Math.max(0, paidAmountCents - taxCents);
|
|
63
|
+
// WHAT A REFUND WILL NEED (AGL-2315). A paid booking is now a
|
|
64
|
+
// destination charge to the merchant's connected account, and
|
|
65
|
+
// reversing one requires the PaymentIntent — which lives only on this
|
|
66
|
+
// event. The booking document recorded `paidAmountCents` and nothing
|
|
67
|
+
// that identifies the charge, so a refund had no handle to pull and
|
|
68
|
+
// the information was gone the moment the webhook returned. Unlike
|
|
69
|
+
// most gaps this one cannot be backfilled from our own data later:
|
|
70
|
+
// every booking taken before this line landed can only be refunded by
|
|
71
|
+
// hand in the Stripe dashboard.
|
|
72
|
+
//
|
|
73
|
+
// `feeCents` is Aglyn's cut as CHARGED, read back off the session's
|
|
74
|
+
// own metadata rather than re-derived from the org at refund time. The
|
|
75
|
+
// rate follows the plan (AGL-2289's rule) and the plan moves, so
|
|
76
|
+
// re-resolving it during a refund would reverse a share that was never
|
|
77
|
+
// taken. `'0'` is a real recorded answer on the 0%-rate tiers.
|
|
78
|
+
const paymentIntentId = typeof (object == null ? void 0 : object.payment_intent) === 'string' ? object.payment_intent : String((_ref2 = object == null ? void 0 : (_object_payment_intent = object.payment_intent) == null ? void 0 : _object_payment_intent.id) != null ? _ref2 : '');
|
|
79
|
+
const chargedFeeCents = Math.max(0, Math.round(Number((_ref3 = object == null ? void 0 : (_object_metadata3 = object.metadata) == null ? void 0 : _object_metadata3.feeCents) != null ? _ref3 : 0)));
|
|
80
|
+
// Redelivery guard (AGL-1755), the AGL-1748 shape. This was an
|
|
81
|
+
// unconditional merge-set with no status check at all — idempotent
|
|
82
|
+
// only by accident, because every value it wrote was fixed. A
|
|
83
|
+
// redelivery re-sent the guest's confirmation and re-metered it, and
|
|
84
|
+
// the moment this handler carries money to a contact the accident stops
|
|
85
|
+
// holding: `purchaseCents` is a `FieldValue.increment`, so every retry
|
|
86
|
+
// would inflate the customer's lifetime value.
|
|
87
|
+
//
|
|
88
|
+
// Keyed on the STATUS, not on the document existing: `server.ts` writes
|
|
89
|
+
// the booking as `pendingPayment` BEFORE it opens the Stripe session,
|
|
90
|
+
// so existence is guaranteed by the time any event arrives and proves
|
|
91
|
+
// nothing — the AGL-1748 reasoning, not AGL-1732's `checkoutSessionId`
|
|
92
|
+
// key, which existed because a sibling event wrote the same path.
|
|
93
|
+
//
|
|
94
|
+
// The key is "not yet confirmed" rather than "is pendingPayment" on
|
|
95
|
+
// purpose. The lapsed-hold sweeper in `server.ts` cancels a
|
|
96
|
+
// `pendingPayment` booking after 24h, and refusing a payment that
|
|
97
|
+
// landed anyway would take the money and record nothing; `confirmed` is
|
|
98
|
+
// reachable for a PAID booking only by this handler having already run,
|
|
99
|
+
// so it is an exact "already processed" test. The existence check is
|
|
100
|
+
// new and deliberate: the old merge-set would CREATE a stub booking
|
|
101
|
+
// from a metadata `bookingId` that pointed at nothing.
|
|
102
|
+
let booking = {};
|
|
103
|
+
const confirmedNow = await firestore.runTransaction(async (transaction)=>{
|
|
104
|
+
var _snapshot_get, _snapshot_data;
|
|
105
|
+
const snapshot = await transaction.get(bookingRef);
|
|
106
|
+
if (!snapshot.exists) return false;
|
|
107
|
+
if (snapshot.get('status') === 'confirmed') return false;
|
|
108
|
+
// "Already processed" is WIDER than `confirmed` once money can
|
|
109
|
+
// flow back out (AGL-2315). A refunded booking is moved off
|
|
110
|
+
// `confirmed`, so a redelivery arriving afterwards would otherwise
|
|
111
|
+
// read as unprocessed and re-confirm an appointment the merchant
|
|
112
|
+
// has already cancelled and refunded — re-sending the guest a
|
|
113
|
+
// confirmation for it and re-metering the email. Any evidence this
|
|
114
|
+
// handler has run before is the real test, and a refund counter or
|
|
115
|
+
// a recorded PaymentIntent is exactly that evidence.
|
|
116
|
+
if (Number((_snapshot_get = snapshot.get('refundedCents')) != null ? _snapshot_get : 0) > 0 || snapshot.get('paymentIntentId')) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
booking = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
120
|
+
transaction.set(bookingRef, _extends({
|
|
121
|
+
status: 'confirmed',
|
|
122
|
+
paidAmountCents,
|
|
123
|
+
// WHICH TAX REGIME THIS BOOKING CARRIED (AGL-2028), on the
|
|
124
|
+
// document the merchant reads — the stamp every other
|
|
125
|
+
// storefront money door already carries (AGL-2451).
|
|
126
|
+
//
|
|
127
|
+
// DERIVED from the one shared derivation, never a constant.
|
|
128
|
+
// A hand-written "manual when the metadata says so" would be
|
|
129
|
+
// a second rule that can drift from the `storefrontTaxCollected`
|
|
130
|
+
// row filed for the same Stripe id, which is the exact
|
|
131
|
+
// disagreement AGL-2451 exists to prevent.
|
|
132
|
+
//
|
|
133
|
+
// TWO-ARGUMENT, because the manual line makes Stripe report
|
|
134
|
+
// `total_details.amount_tax: 0` on a booking that really did
|
|
135
|
+
// charge the client tax; the one-argument form would stamp
|
|
136
|
+
// `none` on precisely those. `absent` remains a fourth state
|
|
137
|
+
// meaning "recorded before this shipped".
|
|
138
|
+
taxMode: pluginTaxProfile().taxModeOf(object, taxCents)
|
|
139
|
+
}, taxCents > 0 ? {
|
|
140
|
+
taxCents
|
|
141
|
+
} : {}, paymentIntentId ? {
|
|
142
|
+
paymentIntentId
|
|
143
|
+
} : {}, {
|
|
144
|
+
feeCents: chargedFeeCents
|
|
145
|
+
}, (object == null ? void 0 : object.id) ? {
|
|
146
|
+
checkoutSessionId: String(object.id)
|
|
147
|
+
} : {}, {
|
|
148
|
+
// NOTE the absent `refundedCents: 0`. Seeding it here would be
|
|
149
|
+
// the AGL-1758 shape: a defaulted field written through
|
|
150
|
+
// `merge` destroying the real one. This handler can re-enter
|
|
151
|
+
// after a refund has already accumulated a counter (see the
|
|
152
|
+
// widened guard above), and a zero written back over it would
|
|
153
|
+
// re-open the full amount for refunding a second time. Absent
|
|
154
|
+
// IS zero — every reader coerces with `?? 0`.
|
|
155
|
+
expiresAtMs: firebaseAdmin.firestore.FieldValue.delete(),
|
|
156
|
+
confirmedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
157
|
+
}), {
|
|
158
|
+
merge: true
|
|
159
|
+
});
|
|
160
|
+
return true;
|
|
161
|
+
});
|
|
162
|
+
if (!confirmedNow) return;
|
|
163
|
+
// THE RECORD'S MEETING (AGL-2660). Payment is what confirms a paid
|
|
164
|
+
// booking, so this is where it reaches the CRM: the meeting on the
|
|
165
|
+
// record's timeline and, when the service asks, the follow-up task.
|
|
166
|
+
// Inside the idempotency gate for the same reason the GA4 purchase
|
|
167
|
+
// below is — a redelivery must not file a second meeting — and
|
|
168
|
+
// through `after()` for the same reason too: this invocation is
|
|
169
|
+
// frozen the moment the response is sent (AGL-2327). Never thrown:
|
|
170
|
+
// the writer swallows its own failures, and the catch is for the
|
|
171
|
+
// scheduling itself.
|
|
172
|
+
after(()=>fileBookingOnCrm(firestore, {
|
|
173
|
+
hostId: String(hostId),
|
|
174
|
+
bookingId: String(bookingId),
|
|
175
|
+
booking
|
|
176
|
+
}).catch(()=>undefined));
|
|
177
|
+
// AGLYN'S REVENUE ON THIS BOOKING (AGL-2481) — into OUR GA4 property.
|
|
178
|
+
//
|
|
179
|
+
// ## Why it is inside the idempotency gate rather than beside it
|
|
180
|
+
//
|
|
181
|
+
// This sits AFTER `if (!confirmedNow) return` and that placement is the
|
|
182
|
+
// whole guard. Stripe redelivers `checkout.session.completed` for up to
|
|
183
|
+
// three days after any 500, and this endpoint 500s on purpose; a
|
|
184
|
+
// redelivery re-enters this handler, the transaction above sees a
|
|
185
|
+
// `confirmed` status (or a refund counter, or a recorded PaymentIntent)
|
|
186
|
+
// and answers false, and everything below — the contact, the email, and
|
|
187
|
+
// now this — is skipped. A `purchase` fired before that gate, or beside
|
|
188
|
+
// it, would inflate our own reported revenue by the redelivery rate.
|
|
189
|
+
//
|
|
190
|
+
// `transaction_id` is the checkout session id, which is the same key
|
|
191
|
+
// the guard turns on, so GA4's own de-duplication is a second
|
|
192
|
+
// independent line of defence: even if the Firestore guard were ever
|
|
193
|
+
// bypassed, GA would collapse the repeats.
|
|
194
|
+
//
|
|
195
|
+
// ## Platform NET, and the fee is the measured one
|
|
196
|
+
//
|
|
197
|
+
// `value` is our fee and not the $95 the guest paid — the AGL-1639
|
|
198
|
+
// settlement. This is our property, the subscription and marketplace
|
|
199
|
+
// `purchase` events in it already mean "what Aglyn was paid", and a
|
|
200
|
+
// gross booking figure sitting beside them would make every combined
|
|
201
|
+
// total, ARPA and revenue audience wrong. The merchant's own gross goes
|
|
202
|
+
// to the MERCHANT's property, client-side, and the two never meet.
|
|
203
|
+
//
|
|
204
|
+
// The figure comes off the session's `metadata.feeCents` — the fee
|
|
205
|
+
// Stripe actually charged as `application_fee_amount` — never from the
|
|
206
|
+
// plan's rate re-applied here. See `bookingPlatformNetCents`.
|
|
207
|
+
//
|
|
208
|
+
// ## Scheduling and failure posture
|
|
209
|
+
//
|
|
210
|
+
// `after()`, never a bare `void` (AGL-2327/AGL-2346): this runs inside
|
|
211
|
+
// the console's `/api/billing/webhook` invocation, which is frozen the
|
|
212
|
+
// moment the response is sent, so fire-and-forget work scheduled any
|
|
213
|
+
// other way simply does not run — the exact way marketplace revenue
|
|
214
|
+
// reported to nothing. And `.catch()`, because a throw here would
|
|
215
|
+
// un-claim the Stripe event and turn a missed analytics hit into a
|
|
216
|
+
// repeated billing side effect.
|
|
217
|
+
if (shouldSendBookingPlatformPurchase(object)) {
|
|
218
|
+
const netCents = bookingPlatformNetCents(object);
|
|
219
|
+
after(()=>{
|
|
220
|
+
var _ref, _ref1, _booking_serviceId, _booking_serviceId1, _ref2, _ref3;
|
|
221
|
+
var _object_metadata, _object_customer_details;
|
|
222
|
+
return sendGa4Purchase({
|
|
223
|
+
transactionId: String((_ref = object == null ? void 0 : object.id) != null ? _ref : ''),
|
|
224
|
+
value: netCents / 100,
|
|
225
|
+
currency: String((_ref1 = object == null ? void 0 : object.currency) != null ? _ref1 : 'usd'),
|
|
226
|
+
items: [
|
|
227
|
+
{
|
|
228
|
+
// Ids only, no merchant free text. A service name is
|
|
229
|
+
// merchant-authored and one edit away from carrying a
|
|
230
|
+
// person's name into a dimension in OUR property; the id
|
|
231
|
+
// already identifies it. The merchant's own property gets
|
|
232
|
+
// the name, because there it is their own content.
|
|
233
|
+
item_id: String((_booking_serviceId = booking['serviceId']) != null ? _booking_serviceId : bookingId),
|
|
234
|
+
item_name: String((_booking_serviceId1 = booking['serviceId']) != null ? _booking_serviceId1 : bookingId),
|
|
235
|
+
item_category: 'booking',
|
|
236
|
+
// GA expects the items to sum to `value`; one item, one
|
|
237
|
+
// price, and that price is our fee — not the service's.
|
|
238
|
+
price: netCents / 100,
|
|
239
|
+
quantity: 1
|
|
240
|
+
}
|
|
241
|
+
],
|
|
242
|
+
// Booking sessions carry no `ga_client_id` today — nothing
|
|
243
|
+
// captures one on a tenant site — so this is read for the day
|
|
244
|
+
// one is stamped and the seed below is what actually resolves.
|
|
245
|
+
clientId: object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.ga_client_id,
|
|
246
|
+
// ONE PAYING GUEST, ONE SYNTHETIC USER. Seeded from Stripe's
|
|
247
|
+
// customer where there is one, and otherwise from the guest's
|
|
248
|
+
// email, which `server.ts` passes as `customer_email` and Stripe
|
|
249
|
+
// echoes on `customer_details`. The seed is hashed inside
|
|
250
|
+
// `synthesizeClientId` and never transmitted.
|
|
251
|
+
//
|
|
252
|
+
// The booking id is the LAST resort deliberately: it is unique
|
|
253
|
+
// per appointment, so seeding from it would turn a regular
|
|
254
|
+
// client into a crowd of one-purchase strangers and make ARPA
|
|
255
|
+
// nonsense — precisely what that helper's determinism exists to
|
|
256
|
+
// prevent. Without any seed at all `sendGa4Purchase` returns
|
|
257
|
+
// `no-client-id` and the event is silently never sent, which is
|
|
258
|
+
// the void this whole change exists to close.
|
|
259
|
+
stripeCustomerId: String((_ref2 = object == null ? void 0 : object.customer) != null ? _ref2 : '') || String((_ref3 = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.email) != null ? _ref3 : '') || String(bookingId)
|
|
260
|
+
}).catch(()=>undefined);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
// Contacts ingestion (AGL-1755). `server.ts` captures the contact at
|
|
264
|
+
// REQUEST time, which is the right place — the booking is written
|
|
265
|
+
// `pendingPayment` and at that moment no money has moved, so passing an
|
|
266
|
+
// amount there would be a lie. But payment completes HERE, and this
|
|
267
|
+
// handler never returned to the contact, so a paid booking's money
|
|
268
|
+
// reached the booking document and nothing else. The same structural
|
|
269
|
+
// shape as AGL-1748's draft branch: the completion handler does
|
|
270
|
+
// everything except the contact.
|
|
271
|
+
//
|
|
272
|
+
// `source` stays `'booking'` — the same source the request-time capture
|
|
273
|
+
// used — so the `sources` map still says where this person came from
|
|
274
|
+
// and no schema change is needed to keep service revenue
|
|
275
|
+
// distinguishable from product revenue. The second interaction is
|
|
276
|
+
// deliberate rather than a duplicate: "Booked X" and "Paid for X" are
|
|
277
|
+
// two real events, and `mergeContactInteraction` does not dedupe on
|
|
278
|
+
// `refId`.
|
|
279
|
+
//
|
|
280
|
+
// The amount is `amount_total`, the money that moved, per AGL-1698 and
|
|
281
|
+
// AGL-1711 — the booking document stores no price to re-derive it from.
|
|
282
|
+
if (booking['email']) {
|
|
283
|
+
var _booking_serviceName;
|
|
284
|
+
void captureHostContact(_extends({
|
|
285
|
+
hostId: String(hostId),
|
|
286
|
+
email: booking['email']
|
|
287
|
+
}, booking['name'] ? {
|
|
288
|
+
name: String(booking['name'])
|
|
289
|
+
} : {}, {
|
|
290
|
+
source: 'booking',
|
|
291
|
+
// Paid, so a customer (AGL-2612) — the request-time capture
|
|
292
|
+
// said `lead`, and this is the door that has seen the money.
|
|
293
|
+
initialLifecycleStage: 'customer'
|
|
294
|
+
}, serviceCents > 0 ? {
|
|
295
|
+
purchaseCents: serviceCents
|
|
296
|
+
} : {}, {
|
|
297
|
+
interaction: {
|
|
298
|
+
refId: String(bookingId),
|
|
299
|
+
summary: `Paid for "${String((_booking_serviceName = booking['serviceName']) != null ? _booking_serviceName : 'a service').slice(0, 60)}" ($${(serviceCents / 100).toFixed(2)})`
|
|
300
|
+
}
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
// Confirmation email now that payment cleared (env-gated inside
|
|
304
|
+
// sendEmail, which no-ops when Resend isn't configured).
|
|
305
|
+
if (booking['email']) {
|
|
306
|
+
var _booking_name, _booking_serviceName1, _booking_name1, _booking_serviceName2, _booking_timezone, _ref4, _booking_serviceName3;
|
|
307
|
+
var _this;
|
|
308
|
+
const when = new Date(Number(booking['startsAtMs'])).toLocaleString('en-US', {
|
|
309
|
+
dateStyle: 'full',
|
|
310
|
+
timeStyle: 'short'
|
|
311
|
+
});
|
|
312
|
+
const fallbackText = `Hi ${(_booking_name = booking['name']) != null ? _booking_name : ''},\n\nPayment received — ` + `"${(_booking_serviceName1 = booking['serviceName']) != null ? _booking_serviceName1 : 'your booking'}" is ` + `confirmed for ${when}.\n\nReference: ${bookingId}`;
|
|
313
|
+
// Site-owner-designed template when published (AGL-770); null keeps
|
|
314
|
+
// the built-in copy.
|
|
315
|
+
const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'booking-confirmed', {
|
|
316
|
+
name: String((_booking_name1 = booking['name']) != null ? _booking_name1 : ''),
|
|
317
|
+
'service.name': String((_booking_serviceName2 = booking['serviceName']) != null ? _booking_serviceName2 : ''),
|
|
318
|
+
when,
|
|
319
|
+
timezone: String((_booking_timezone = booking['timezone']) != null ? _booking_timezone : ''),
|
|
320
|
+
'booking.ref': String(bookingId)
|
|
321
|
+
});
|
|
322
|
+
// White-label sender identity (White-Label Phase 3), matching the
|
|
323
|
+
// free-booking path — the store's brand via the one shared resolver.
|
|
324
|
+
const branding = resolveBrandingProfile((_this = await getOrgForHost(String(hostId)).catch(()=>null)) == null ? void 0 : _this.org);
|
|
325
|
+
await sendEmail(_extends({
|
|
326
|
+
to: String(booking['email']),
|
|
327
|
+
subject: (_ref4 = designed == null ? void 0 : designed.subject) != null ? _ref4 : `Booking confirmed: ${(_booking_serviceName3 = booking['serviceName']) != null ? _booking_serviceName3 : ''}`,
|
|
328
|
+
text: (designed == null ? void 0 : designed.text) || fallbackText
|
|
329
|
+
}, (designed == null ? void 0 : designed.html) ? {
|
|
330
|
+
html: designed.html
|
|
331
|
+
} : {}, {
|
|
332
|
+
fromName: branding.fromName,
|
|
333
|
+
sendingIdentity: await hostSendingIdentity(String(hostId)),
|
|
334
|
+
audience: 'tenant',
|
|
335
|
+
context: 'paid booking confirmation'
|
|
336
|
+
}));
|
|
337
|
+
// Cost meter (AGL-1438), matching the free-booking path.
|
|
338
|
+
// Transactional: the guest has already paid.
|
|
339
|
+
await meterHostEmail(String(hostId));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
//# sourceMappingURL=billing-webhook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/billing-webhook.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\n// `after()`, never a bare `void promise` (AGL-2327). This handler runs inside\n// the console's `/api/billing/webhook` invocation, which AGL-1133 measured on\n// production is frozen the moment the response is sent — so fire-and-forget\n// work scheduled any other way simply does not run.\nimport { after } from 'next/server'\n\nimport type { BillingWebhookHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n sendGa4Purchase,\n} from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\nimport {\n bookingPlatformNetCents,\n shouldSendBookingPlatformPurchase,\n} from '../model/booking-purchase-analytics'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { pluginTaxProfile } from '@aglyn/aglyn/plugin-manager/plugin-tax-profile'\nimport { fileBookingOnCrm } from './booking-crm'\n\n/**\n * Paid-booking section of the platform Stripe webhook (AGL-170/418):\n * payment confirms the pendingPayment hold — relocated verbatim from the\n * console route; registered via registerBookingsConsoleApi.\n */\nexport const bookingsBillingWebhookHandler: BillingWebhookHandler = async ({\n type,\n object,\n}) => {\n // Paid bookings (AGL-170): payment confirms the pendingPayment hold.\n if (\n type === 'checkout.session.completed' &&\n object?.metadata?.type === 'booking-payment' &&\n object?.payment_status === 'paid'\n ) {\n const { hostId, bookingId } = object.metadata ?? {}\n if (hostId && bookingId) {\n const firestore = firebaseAdmin.app().firestore()\n const bookingRef = firestore\n .collection('hosts')\n .doc(String(hostId))\n .collection('bookings')\n .doc(String(bookingId))\n // WHAT THE CLIENT HANDED OVER, ALL OF IT. Tax included: this is the\n // ceiling `refund.ts` reverses against, and the client paid the tax\n // too — a net figure here would strand part of a refund.\n const paidAmountCents = Math.max(\n 0,\n Math.round(Number(object?.amount_total ?? 0)),\n )\n // THE MERCHANT'S SERVICE TAX, SEPARATED OUT (AGL-2028).\n //\n // `server.ts` charges the merchant's own service rate as an ordinary\n // `line_items[1]` Stripe is never told is tax (the AGL-1711\n // construction, which is what keeps the figure the MERCHANT's rather\n // than something computed against Aglyn's registrations). So\n // `amount_total` is service-plus-tax while Stripe's own tax fields\n // read zero, and the session's metadata is the only witness — exactly\n // as it is for a buy-now sale.\n const taxCents = Math.max(\n 0,\n Math.round(Number(object?.metadata?.taxCents ?? 0)),\n )\n // The merchant's REVENUE, which is the charge less the tax. Used for\n // the contact's lifetime value below: tax is collected on behalf of\n // an authority and is not money the business earned, so counting it\n // would over-state every service client's worth.\n const serviceCents = Math.max(0, paidAmountCents - taxCents)\n // WHAT A REFUND WILL NEED (AGL-2315). A paid booking is now a\n // destination charge to the merchant's connected account, and\n // reversing one requires the PaymentIntent — which lives only on this\n // event. The booking document recorded `paidAmountCents` and nothing\n // that identifies the charge, so a refund had no handle to pull and\n // the information was gone the moment the webhook returned. Unlike\n // most gaps this one cannot be backfilled from our own data later:\n // every booking taken before this line landed can only be refunded by\n // hand in the Stripe dashboard.\n //\n // `feeCents` is Aglyn's cut as CHARGED, read back off the session's\n // own metadata rather than re-derived from the org at refund time. The\n // rate follows the plan (AGL-2289's rule) and the plan moves, so\n // re-resolving it during a refund would reverse a share that was never\n // taken. `'0'` is a real recorded answer on the 0%-rate tiers.\n const paymentIntentId =\n typeof object?.payment_intent === 'string'\n ? object.payment_intent\n : String(object?.payment_intent?.id ?? '')\n const chargedFeeCents = Math.max(\n 0,\n Math.round(Number(object?.metadata?.feeCents ?? 0)),\n )\n // Redelivery guard (AGL-1755), the AGL-1748 shape. This was an\n // unconditional merge-set with no status check at all — idempotent\n // only by accident, because every value it wrote was fixed. A\n // redelivery re-sent the guest's confirmation and re-metered it, and\n // the moment this handler carries money to a contact the accident stops\n // holding: `purchaseCents` is a `FieldValue.increment`, so every retry\n // would inflate the customer's lifetime value.\n //\n // Keyed on the STATUS, not on the document existing: `server.ts` writes\n // the booking as `pendingPayment` BEFORE it opens the Stripe session,\n // so existence is guaranteed by the time any event arrives and proves\n // nothing — the AGL-1748 reasoning, not AGL-1732's `checkoutSessionId`\n // key, which existed because a sibling event wrote the same path.\n //\n // The key is \"not yet confirmed\" rather than \"is pendingPayment\" on\n // purpose. The lapsed-hold sweeper in `server.ts` cancels a\n // `pendingPayment` booking after 24h, and refusing a payment that\n // landed anyway would take the money and record nothing; `confirmed` is\n // reachable for a PAID booking only by this handler having already run,\n // so it is an exact \"already processed\" test. The existence check is\n // new and deliberate: the old merge-set would CREATE a stub booking\n // from a metadata `bookingId` that pointed at nothing.\n let booking: Record<string, any> = {}\n const confirmedNow = await firestore.runTransaction(\n async (transaction) => {\n const snapshot = await transaction.get(bookingRef)\n if (!snapshot.exists) return false\n if (snapshot.get('status') === 'confirmed') return false\n // \"Already processed\" is WIDER than `confirmed` once money can\n // flow back out (AGL-2315). A refunded booking is moved off\n // `confirmed`, so a redelivery arriving afterwards would otherwise\n // read as unprocessed and re-confirm an appointment the merchant\n // has already cancelled and refunded — re-sending the guest a\n // confirmation for it and re-metering the email. Any evidence this\n // handler has run before is the real test, and a refund counter or\n // a recorded PaymentIntent is exactly that evidence.\n if (\n Number(snapshot.get('refundedCents') ?? 0) > 0 ||\n snapshot.get('paymentIntentId')\n ) {\n return false\n }\n booking = (snapshot.data() as any) ?? {}\n transaction.set(\n bookingRef,\n {\n status: 'confirmed',\n paidAmountCents,\n // WHICH TAX REGIME THIS BOOKING CARRIED (AGL-2028), on the\n // document the merchant reads — the stamp every other\n // storefront money door already carries (AGL-2451).\n //\n // DERIVED from the one shared derivation, never a constant.\n // A hand-written \"manual when the metadata says so\" would be\n // a second rule that can drift from the `storefrontTaxCollected`\n // row filed for the same Stripe id, which is the exact\n // disagreement AGL-2451 exists to prevent.\n //\n // TWO-ARGUMENT, because the manual line makes Stripe report\n // `total_details.amount_tax: 0` on a booking that really did\n // charge the client tax; the one-argument form would stamp\n // `none` on precisely those. `absent` remains a fourth state\n // meaning \"recorded before this shipped\".\n taxMode: pluginTaxProfile().taxModeOf(object, taxCents),\n // The figure itself. Absent rather than a defaulted `0` — a\n // zero written through `merge` is the AGL-1758 shape, and\n // this handler can re-enter (see the widened guard above).\n ...(taxCents > 0 ? { taxCents } : {}),\n // The refund handles (AGL-2315). Written inside the same\n // transaction as the status, so a booking can never read\n // `confirmed` while the means to refund it are missing.\n ...(paymentIntentId ? { paymentIntentId } : {}),\n feeCents: chargedFeeCents,\n // WHICH CHECKOUT PAID FOR THIS (AGL-2481). The guest's browser\n // comes back from Stripe holding a session id and nothing\n // else — a booking id was never in the return URL — so this is\n // what lets `booking-analytics.ts` answer \"what did I just\n // buy?\" for the merchant-side `purchase`, and it is written\n // here because the session id first exists on this event.\n //\n // Inside the transaction with the status on purpose: the\n // lookup refuses anything that is not `confirmed`, so a\n // booking can never be findable by session id while reading as\n // unpaid.\n ...(object?.id ? { checkoutSessionId: String(object.id) } : {}),\n // NOTE the absent `refundedCents: 0`. Seeding it here would be\n // the AGL-1758 shape: a defaulted field written through\n // `merge` destroying the real one. This handler can re-enter\n // after a refund has already accumulated a counter (see the\n // widened guard above), and a zero written back over it would\n // re-open the full amount for refunding a second time. Absent\n // IS zero — every reader coerces with `?? 0`.\n expiresAtMs: firebaseAdmin.firestore.FieldValue.delete(),\n confirmedAt:\n firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return true\n },\n )\n if (!confirmedNow) return\n // THE RECORD'S MEETING (AGL-2660). Payment is what confirms a paid\n // booking, so this is where it reaches the CRM: the meeting on the\n // record's timeline and, when the service asks, the follow-up task.\n // Inside the idempotency gate for the same reason the GA4 purchase\n // below is — a redelivery must not file a second meeting — and\n // through `after()` for the same reason too: this invocation is\n // frozen the moment the response is sent (AGL-2327). Never thrown:\n // the writer swallows its own failures, and the catch is for the\n // scheduling itself.\n after(() =>\n fileBookingOnCrm(firestore, {\n hostId: String(hostId),\n bookingId: String(bookingId),\n booking,\n }).catch(() => undefined),\n )\n // AGLYN'S REVENUE ON THIS BOOKING (AGL-2481) — into OUR GA4 property.\n //\n // ## Why it is inside the idempotency gate rather than beside it\n //\n // This sits AFTER `if (!confirmedNow) return` and that placement is the\n // whole guard. Stripe redelivers `checkout.session.completed` for up to\n // three days after any 500, and this endpoint 500s on purpose; a\n // redelivery re-enters this handler, the transaction above sees a\n // `confirmed` status (or a refund counter, or a recorded PaymentIntent)\n // and answers false, and everything below — the contact, the email, and\n // now this — is skipped. A `purchase` fired before that gate, or beside\n // it, would inflate our own reported revenue by the redelivery rate.\n //\n // `transaction_id` is the checkout session id, which is the same key\n // the guard turns on, so GA4's own de-duplication is a second\n // independent line of defence: even if the Firestore guard were ever\n // bypassed, GA would collapse the repeats.\n //\n // ## Platform NET, and the fee is the measured one\n //\n // `value` is our fee and not the $95 the guest paid — the AGL-1639\n // settlement. This is our property, the subscription and marketplace\n // `purchase` events in it already mean \"what Aglyn was paid\", and a\n // gross booking figure sitting beside them would make every combined\n // total, ARPA and revenue audience wrong. The merchant's own gross goes\n // to the MERCHANT's property, client-side, and the two never meet.\n //\n // The figure comes off the session's `metadata.feeCents` — the fee\n // Stripe actually charged as `application_fee_amount` — never from the\n // plan's rate re-applied here. See `bookingPlatformNetCents`.\n //\n // ## Scheduling and failure posture\n //\n // `after()`, never a bare `void` (AGL-2327/AGL-2346): this runs inside\n // the console's `/api/billing/webhook` invocation, which is frozen the\n // moment the response is sent, so fire-and-forget work scheduled any\n // other way simply does not run — the exact way marketplace revenue\n // reported to nothing. And `.catch()`, because a throw here would\n // un-claim the Stripe event and turn a missed analytics hit into a\n // repeated billing side effect.\n if (shouldSendBookingPlatformPurchase(object)) {\n const netCents = bookingPlatformNetCents(object)\n after(() =>\n sendGa4Purchase({\n transactionId: String(object?.id ?? ''),\n value: netCents / 100,\n currency: String(object?.currency ?? 'usd'),\n items: [\n {\n // Ids only, no merchant free text. A service name is\n // merchant-authored and one edit away from carrying a\n // person's name into a dimension in OUR property; the id\n // already identifies it. The merchant's own property gets\n // the name, because there it is their own content.\n item_id: String(booking['serviceId'] ?? bookingId),\n item_name: String(booking['serviceId'] ?? bookingId),\n item_category: 'booking',\n // GA expects the items to sum to `value`; one item, one\n // price, and that price is our fee — not the service's.\n price: netCents / 100,\n quantity: 1,\n },\n ],\n // Booking sessions carry no `ga_client_id` today — nothing\n // captures one on a tenant site — so this is read for the day\n // one is stamped and the seed below is what actually resolves.\n clientId: object?.metadata?.ga_client_id,\n // ONE PAYING GUEST, ONE SYNTHETIC USER. Seeded from Stripe's\n // customer where there is one, and otherwise from the guest's\n // email, which `server.ts` passes as `customer_email` and Stripe\n // echoes on `customer_details`. The seed is hashed inside\n // `synthesizeClientId` and never transmitted.\n //\n // The booking id is the LAST resort deliberately: it is unique\n // per appointment, so seeding from it would turn a regular\n // client into a crowd of one-purchase strangers and make ARPA\n // nonsense — precisely what that helper's determinism exists to\n // prevent. Without any seed at all `sendGa4Purchase` returns\n // `no-client-id` and the event is silently never sent, which is\n // the void this whole change exists to close.\n stripeCustomerId:\n String(object?.customer ?? '') ||\n String(object?.customer_details?.email ?? '') ||\n String(bookingId),\n }).catch(() => undefined),\n )\n }\n // Contacts ingestion (AGL-1755). `server.ts` captures the contact at\n // REQUEST time, which is the right place — the booking is written\n // `pendingPayment` and at that moment no money has moved, so passing an\n // amount there would be a lie. But payment completes HERE, and this\n // handler never returned to the contact, so a paid booking's money\n // reached the booking document and nothing else. The same structural\n // shape as AGL-1748's draft branch: the completion handler does\n // everything except the contact.\n //\n // `source` stays `'booking'` — the same source the request-time capture\n // used — so the `sources` map still says where this person came from\n // and no schema change is needed to keep service revenue\n // distinguishable from product revenue. The second interaction is\n // deliberate rather than a duplicate: \"Booked X\" and \"Paid for X\" are\n // two real events, and `mergeContactInteraction` does not dedupe on\n // `refId`.\n //\n // The amount is `amount_total`, the money that moved, per AGL-1698 and\n // AGL-1711 — the booking document stores no price to re-derive it from.\n if (booking['email']) {\n void captureHostContact({\n hostId: String(hostId),\n email: booking['email'],\n ...(booking['name'] ? { name: String(booking['name']) } : {}),\n source: 'booking',\n // Paid, so a customer (AGL-2612) — the request-time capture\n // said `lead`, and this is the door that has seen the money.\n initialLifecycleStage: 'customer',\n // The SERVICE, not the charge (AGL-2028). `amount_total` now\n // includes the merchant's service tax where they set one, and\n // tax is collected for an authority rather than earned — booking\n // it as lifetime value would over-state what every service\n // client is worth. Identical to `paidAmountCents` on the default\n // store, which sets no rate.\n ...(serviceCents > 0 ? { purchaseCents: serviceCents } : {}),\n interaction: {\n refId: String(bookingId),\n summary: `Paid for \"${String(\n booking['serviceName'] ?? 'a service',\n ).slice(0, 60)}\" ($${(serviceCents / 100).toFixed(2)})`,\n },\n })\n }\n // Confirmation email now that payment cleared (env-gated inside\n // sendEmail, which no-ops when Resend isn't configured).\n if (booking['email']) {\n const when = new Date(\n Number(booking['startsAtMs']),\n ).toLocaleString('en-US', {\n dateStyle: 'full',\n timeStyle: 'short',\n })\n const fallbackText =\n `Hi ${booking['name'] ?? ''},\\n\\nPayment received — ` +\n `\"${booking['serviceName'] ?? 'your booking'}\" is ` +\n `confirmed for ${when}.\\n\\nReference: ${bookingId}`\n // Site-owner-designed template when published (AGL-770); null keeps\n // the built-in copy.\n const designed = await renderHostEmailWithTokens(\n firebaseAdmin.app().firestore(),\n String(hostId),\n 'booking-confirmed',\n {\n name: String(booking['name'] ?? ''),\n 'service.name': String(booking['serviceName'] ?? ''),\n when,\n timezone: String(booking['timezone'] ?? ''),\n 'booking.ref': String(bookingId),\n },\n )\n // White-label sender identity (White-Label Phase 3), matching the\n // free-booking path — the store's brand via the one shared resolver.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(String(hostId)).catch(() => null))?.org as never,\n )\n await sendEmail({\n to: String(booking['email']),\n subject:\n designed?.subject ??\n `Booking confirmed: ${booking['serviceName'] ?? ''}`,\n text: designed?.text || fallbackText,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(String(hostId)),\n audience: 'tenant',\n context: 'paid booking confirmation',\n })\n // Cost meter (AGL-1438), matching the free-booking path.\n // Transactional: the guest has already paid.\n await meterHostEmail(String(hostId))\n }\n }\n }\n}\n"],"names":["after","resolveBrandingProfile","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","sendGa4Purchase","captureHostContact","bookingPlatformNetCents","shouldSendBookingPlatformPurchase","sendEmail","pluginTaxProfile","fileBookingOnCrm","bookingsBillingWebhookHandler","type","object","metadata","payment_status","hostId","bookingId","firestore","app","bookingRef","collection","doc","String","paidAmountCents","Math","max","round","Number","amount_total","taxCents","serviceCents","paymentIntentId","payment_intent","id","chargedFeeCents","feeCents","booking","confirmedNow","runTransaction","transaction","snapshot","get","exists","data","set","status","taxMode","taxModeOf","checkoutSessionId","expiresAtMs","FieldValue","delete","confirmedAt","serverTimestamp","merge","catch","undefined","netCents","transactionId","value","currency","items","item_id","item_name","item_category","price","quantity","clientId","ga_client_id","stripeCustomerId","customer","customer_details","email","name","source","initialLifecycleStage","purchaseCents","interaction","refId","summary","slice","toFixed","when","Date","toLocaleString","dateStyle","timeStyle","fallbackText","designed","timezone","branding","org","to","subject","text","html","fromName","sendingIdentity","audience","context"],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
* The figures a merchant-side booking `purchase` needs, for one paid booking
|
|
20
|
+
* (AGL-2481). The bookings mirror of `order-analytics.ts`, and deliberately
|
|
21
|
+
* the same shape so the two revenue lines stay comparable.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the client cannot compute this itself
|
|
24
|
+
*
|
|
25
|
+
* The guest's browser knows what the widget quoted, which is not what Stripe
|
|
26
|
+
* charged: the merchant's service tax is added as a second line at session
|
|
27
|
+
* creation, and the authoritative `amount_total` only exists once the webhook
|
|
28
|
+
* has written it. Replaying the quoted price would understate every taxed
|
|
29
|
+
* booking and would drift from the charge the moment a service price is
|
|
30
|
+
* edited between session creation and webhook delivery.
|
|
31
|
+
*
|
|
32
|
+
* ## What authorises the read
|
|
33
|
+
*
|
|
34
|
+
* The Stripe Checkout Session id, which the webhook stamps on the booking as
|
|
35
|
+
* `checkoutSessionId`. It is high-entropy and unguessable, Stripe hands it
|
|
36
|
+
* only to the guest who paid — in the `success_url` — and it is scoped here to
|
|
37
|
+
* the host that owns the booking, so one site's id cannot address another's.
|
|
38
|
+
* The same bearer-by-opaque-id shape as `order-analytics`, `download` and
|
|
39
|
+
* `subscription-portal`.
|
|
40
|
+
*
|
|
41
|
+
* It is still a public read, so the response is a PROJECTION built by
|
|
42
|
+
* `toBookingPurchaseSource`, never the booking document: no guest email, no
|
|
43
|
+
* name, no appointment time, and not our `feeCents` either — a guest has no
|
|
44
|
+
* business learning Aglyn's take rate on their hairdresser. A booking document
|
|
45
|
+
* is considerably more sensitive than an order: it says where somebody will
|
|
46
|
+
* physically be and when.
|
|
47
|
+
*
|
|
48
|
+
* ## The webhook race
|
|
49
|
+
*
|
|
50
|
+
* The guest can return from Stripe before `checkout.session.completed` has
|
|
51
|
+
* been processed, in which case no booking carries this session id yet. That
|
|
52
|
+
* is answered with 404 and `retryable` rather than a synthesised booking — the
|
|
53
|
+
* client polls briefly. Reporting a purchase we cannot yet read would mean
|
|
54
|
+
* inventing the value.
|
|
55
|
+
*
|
|
56
|
+
* Never cached: a 404 during the race must not be stored in front of this, the
|
|
57
|
+
* negative-cache shape that has bitten host lookups before.
|
|
58
|
+
*
|
|
59
|
+
* ## Only a CONFIRMED booking is a purchase
|
|
60
|
+
*
|
|
61
|
+
* `checkoutSessionId` is written inside the same transaction as
|
|
62
|
+
* `status: 'confirmed'`, so in practice a findable booking is a paid one. The
|
|
63
|
+
* status is re-checked anyway rather than trusted: a booking that has since
|
|
64
|
+
* been refunded is moved OFF `confirmed`, and re-reporting one as revenue on a
|
|
65
|
+
* guest's stray refresh would put a sale in the merchant's report that has
|
|
66
|
+
* been reversed.
|
|
67
|
+
*/
|
|
68
|
+
export declare const bookingAnalyticsHandler: PluginApiHandler;
|
|
@@ -0,0 +1,115 @@
|
|
|
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 { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
17
|
+
import { toBookingPurchaseSource } from "../model/booking-purchase-analytics.js";
|
|
18
|
+
/** Stripe Checkout Session ids: `cs_` + live/test prefix + base58-ish body. */ const SESSION_ID = /^cs_[A-Za-z0-9_]{8,255}$/;
|
|
19
|
+
/**
|
|
20
|
+
* The figures a merchant-side booking `purchase` needs, for one paid booking
|
|
21
|
+
* (AGL-2481). The bookings mirror of `order-analytics.ts`, and deliberately
|
|
22
|
+
* the same shape so the two revenue lines stay comparable.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the client cannot compute this itself
|
|
25
|
+
*
|
|
26
|
+
* The guest's browser knows what the widget quoted, which is not what Stripe
|
|
27
|
+
* charged: the merchant's service tax is added as a second line at session
|
|
28
|
+
* creation, and the authoritative `amount_total` only exists once the webhook
|
|
29
|
+
* has written it. Replaying the quoted price would understate every taxed
|
|
30
|
+
* booking and would drift from the charge the moment a service price is
|
|
31
|
+
* edited between session creation and webhook delivery.
|
|
32
|
+
*
|
|
33
|
+
* ## What authorises the read
|
|
34
|
+
*
|
|
35
|
+
* The Stripe Checkout Session id, which the webhook stamps on the booking as
|
|
36
|
+
* `checkoutSessionId`. It is high-entropy and unguessable, Stripe hands it
|
|
37
|
+
* only to the guest who paid — in the `success_url` — and it is scoped here to
|
|
38
|
+
* the host that owns the booking, so one site's id cannot address another's.
|
|
39
|
+
* The same bearer-by-opaque-id shape as `order-analytics`, `download` and
|
|
40
|
+
* `subscription-portal`.
|
|
41
|
+
*
|
|
42
|
+
* It is still a public read, so the response is a PROJECTION built by
|
|
43
|
+
* `toBookingPurchaseSource`, never the booking document: no guest email, no
|
|
44
|
+
* name, no appointment time, and not our `feeCents` either — a guest has no
|
|
45
|
+
* business learning Aglyn's take rate on their hairdresser. A booking document
|
|
46
|
+
* is considerably more sensitive than an order: it says where somebody will
|
|
47
|
+
* physically be and when.
|
|
48
|
+
*
|
|
49
|
+
* ## The webhook race
|
|
50
|
+
*
|
|
51
|
+
* The guest can return from Stripe before `checkout.session.completed` has
|
|
52
|
+
* been processed, in which case no booking carries this session id yet. That
|
|
53
|
+
* is answered with 404 and `retryable` rather than a synthesised booking — the
|
|
54
|
+
* client polls briefly. Reporting a purchase we cannot yet read would mean
|
|
55
|
+
* inventing the value.
|
|
56
|
+
*
|
|
57
|
+
* Never cached: a 404 during the race must not be stored in front of this, the
|
|
58
|
+
* negative-cache shape that has bitten host lookups before.
|
|
59
|
+
*
|
|
60
|
+
* ## Only a CONFIRMED booking is a purchase
|
|
61
|
+
*
|
|
62
|
+
* `checkoutSessionId` is written inside the same transaction as
|
|
63
|
+
* `status: 'confirmed'`, so in practice a findable booking is a paid one. The
|
|
64
|
+
* status is re-checked anyway rather than trusted: a booking that has since
|
|
65
|
+
* been refunded is moved OFF `confirmed`, and re-reporting one as revenue on a
|
|
66
|
+
* guest's stray refresh would put a sale in the merchant's report that has
|
|
67
|
+
* been reversed.
|
|
68
|
+
*/ export const bookingAnalyticsHandler = async (req, res)=>{
|
|
69
|
+
var _req_query_hostId, _req_query_sessionId;
|
|
70
|
+
const hostId = String((_req_query_hostId = req.query.hostId) != null ? _req_query_hostId : '');
|
|
71
|
+
const sessionId = String((_req_query_sessionId = req.query.sessionId) != null ? _req_query_sessionId : '');
|
|
72
|
+
if (!hostId || !sessionId) {
|
|
73
|
+
return res.status(400).json({
|
|
74
|
+
error: 'Missing hostId or sessionId'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Shape-checked before it reaches Firestore: an unvalidated identifier in a
|
|
78
|
+
// query is how a lookup becomes something else.
|
|
79
|
+
if (!SESSION_ID.test(sessionId)) {
|
|
80
|
+
return res.status(400).json({
|
|
81
|
+
error: 'Invalid sessionId'
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
var _booking_data;
|
|
86
|
+
var _bookings_docs;
|
|
87
|
+
const bookings = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('bookings')// A single-field equality query, which Firestore serves from the
|
|
88
|
+
// automatic per-field indexes — no composite index to create here.
|
|
89
|
+
.where('checkoutSessionId', '==', sessionId).limit(1).get();
|
|
90
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
91
|
+
const booking = (_bookings_docs = bookings.docs) == null ? void 0 : _bookings_docs[0];
|
|
92
|
+
if (!booking) {
|
|
93
|
+
// The race, or an id that was never ours. Both are retryable from the
|
|
94
|
+
// client's point of view; it gives up after a bounded number of tries.
|
|
95
|
+
return res.status(404).json({
|
|
96
|
+
error: 'Not found',
|
|
97
|
+
retryable: true
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const data = (_booking_data = booking.data()) != null ? _booking_data : {};
|
|
101
|
+
if (data['status'] !== 'confirmed') {
|
|
102
|
+
return res.status(409).json({
|
|
103
|
+
error: 'Not payable'
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return res.status(200).json(toBookingPurchaseSource(sessionId, data));
|
|
107
|
+
} catch (unused) {
|
|
108
|
+
// Never leak the underlying error to a public caller.
|
|
109
|
+
return res.status(500).json({
|
|
110
|
+
error: 'Lookup failed'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
//# sourceMappingURL=booking-analytics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/booking-analytics.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 type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { toBookingPurchaseSource } from '../model/booking-purchase-analytics'\n\n/** Stripe Checkout Session ids: `cs_` + live/test prefix + base58-ish body. */\nconst SESSION_ID = /^cs_[A-Za-z0-9_]{8,255}$/\n\n/**\n * The figures a merchant-side booking `purchase` needs, for one paid booking\n * (AGL-2481). The bookings mirror of `order-analytics.ts`, and deliberately\n * the same shape so the two revenue lines stay comparable.\n *\n * ## Why the client cannot compute this itself\n *\n * The guest's browser knows what the widget quoted, which is not what Stripe\n * charged: the merchant's service tax is added as a second line at session\n * creation, and the authoritative `amount_total` only exists once the webhook\n * has written it. Replaying the quoted price would understate every taxed\n * booking and would drift from the charge the moment a service price is\n * edited between session creation and webhook delivery.\n *\n * ## What authorises the read\n *\n * The Stripe Checkout Session id, which the webhook stamps on the booking as\n * `checkoutSessionId`. It is high-entropy and unguessable, Stripe hands it\n * only to the guest who paid — in the `success_url` — and it is scoped here to\n * the host that owns the booking, so one site's id cannot address another's.\n * The same bearer-by-opaque-id shape as `order-analytics`, `download` and\n * `subscription-portal`.\n *\n * It is still a public read, so the response is a PROJECTION built by\n * `toBookingPurchaseSource`, never the booking document: no guest email, no\n * name, no appointment time, and not our `feeCents` either — a guest has no\n * business learning Aglyn's take rate on their hairdresser. A booking document\n * is considerably more sensitive than an order: it says where somebody will\n * physically be and when.\n *\n * ## The webhook race\n *\n * The guest can return from Stripe before `checkout.session.completed` has\n * been processed, in which case no booking carries this session id yet. That\n * is answered with 404 and `retryable` rather than a synthesised booking — the\n * client polls briefly. Reporting a purchase we cannot yet read would mean\n * inventing the value.\n *\n * Never cached: a 404 during the race must not be stored in front of this, the\n * negative-cache shape that has bitten host lookups before.\n *\n * ## Only a CONFIRMED booking is a purchase\n *\n * `checkoutSessionId` is written inside the same transaction as\n * `status: 'confirmed'`, so in practice a findable booking is a paid one. The\n * status is re-checked anyway rather than trusted: a booking that has since\n * been refunded is moved OFF `confirmed`, and re-reporting one as revenue on a\n * guest's stray refresh would put a sale in the merchant's report that has\n * been reversed.\n */\nexport const bookingAnalyticsHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const sessionId = String(req.query.sessionId ?? '')\n if (!hostId || !sessionId) {\n return res.status(400).json({ error: 'Missing hostId or sessionId' })\n }\n // Shape-checked before it reaches Firestore: an unvalidated identifier in a\n // query is how a lookup becomes something else.\n if (!SESSION_ID.test(sessionId)) {\n return res.status(400).json({ error: 'Invalid sessionId' })\n }\n try {\n const bookings = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('bookings')\n // A single-field equality query, which Firestore serves from the\n // automatic per-field indexes — no composite index to create here.\n .where('checkoutSessionId', '==', sessionId)\n .limit(1)\n .get()\n\n res.setHeader('Cache-Control', 'no-store')\n\n const booking = bookings.docs?.[0]\n if (!booking) {\n // The race, or an id that was never ours. Both are retryable from the\n // client's point of view; it gives up after a bounded number of tries.\n return res.status(404).json({ error: 'Not found', retryable: true })\n }\n const data = (booking.data() ?? {}) as Record<string, any>\n if (data['status'] !== 'confirmed') {\n return res.status(409).json({ error: 'Not payable' })\n }\n return res.status(200).json(toBookingPurchaseSource(sessionId, data))\n } catch {\n // Never leak the underlying error to a public caller.\n return res.status(500).json({ error: 'Lookup failed' })\n }\n}\n"],"names":["firebaseAdmin","toBookingPurchaseSource","SESSION_ID","bookingAnalyticsHandler","req","res","hostId","String","query","sessionId","status","json","error","test","booking","bookings","app","firestore","collection","doc","where","limit","get","setHeader","docs","retryable","data"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAASA,aAAa,QAAQ,2BAA0B;AACxD,SAASC,uBAAuB,QAAQ,yCAAqC;AAE7E,6EAA6E,GAC7E,MAAMC,aAAa;AAEnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiDC,GACD,OAAO,MAAMC,0BAA4C,OAAOC,KAAKC;QAC7CD,mBACGA;IADzB,MAAME,SAASC,QAAOH,oBAAAA,IAAII,KAAK,CAACF,MAAM,YAAhBF,oBAAoB;IAC1C,MAAMK,YAAYF,QAAOH,uBAAAA,IAAII,KAAK,CAACC,SAAS,YAAnBL,uBAAuB;IAChD,IAAI,CAACE,UAAU,CAACG,WAAW;QACzB,OAAOJ,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;IACrE;IACA,4EAA4E;IAC5E,gDAAgD;IAChD,IAAI,CAACV,WAAWW,IAAI,CAACJ,YAAY;QAC/B,OAAOJ,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAoB;IAC3D;IACA,IAAI;YAqBYE;YANEC;QAdhB,MAAMA,WAAW,MAAMf,cACpBgB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACb,QACJY,UAAU,CAAC,WACZ,iEAAiE;QACjE,mEAAmE;SAClEE,KAAK,CAAC,qBAAqB,MAAMX,WACjCY,KAAK,CAAC,GACNC,GAAG;QAENjB,IAAIkB,SAAS,CAAC,iBAAiB;QAE/B,MAAMT,WAAUC,iBAAAA,SAASS,IAAI,qBAAbT,cAAe,CAAC,EAAE;QAClC,IAAI,CAACD,SAAS;YACZ,sEAAsE;YACtE,uEAAuE;YACvE,OAAOT,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;gBAAaa,WAAW;YAAK;QACpE;QACA,MAAMC,QAAQZ,gBAAAA,QAAQY,IAAI,cAAZZ,gBAAkB,CAAC;QACjC,IAAIY,IAAI,CAAC,SAAS,KAAK,aAAa;YAClC,OAAOrB,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAc;QACrD;QACA,OAAOP,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAACV,wBAAwBQ,WAAWiB;IACjE,EAAE,eAAM;QACN,sDAAsD;QACtD,OAAOrB,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAgB;IACvD;AACF,EAAC"}
|