@aglyn/plugins-bookings 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +53 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/book-meeting-action.d.ts +91 -0
  8. package/src/lib/components/book-meeting-action.js +296 -0
  9. package/src/lib/components/book-meeting-action.js.map +1 -0
  10. package/src/lib/components/booking.d.ts +33 -0
  11. package/src/lib/components/booking.js +463 -0
  12. package/src/lib/components/booking.js.map +1 -0
  13. package/src/lib/components/bookings-console-page.d.ts +12 -0
  14. package/src/lib/components/bookings-console-page.js +743 -0
  15. package/src/lib/components/bookings-console-page.js.map +1 -0
  16. package/src/lib/constants/bundle-common.d.ts +26 -0
  17. package/src/lib/constants/bundle-common.js +26 -0
  18. package/src/lib/constants/bundle-common.js.map +1 -0
  19. package/src/lib/model/booking-purchase-analytics.d.ts +129 -0
  20. package/src/lib/model/booking-purchase-analytics.js +197 -0
  21. package/src/lib/model/booking-purchase-analytics.js.map +1 -0
  22. package/src/lib/model/bookings.d.ts +141 -0
  23. package/src/lib/model/bookings.js +124 -0
  24. package/src/lib/model/bookings.js.map +1 -0
  25. package/src/lib/model/index.d.ts +22 -0
  26. package/src/lib/model/index.js +22 -0
  27. package/src/lib/model/index.js.map +1 -0
  28. package/src/lib/plugin-config.d.ts +24 -0
  29. package/src/lib/plugin-config.js +51 -0
  30. package/src/lib/plugin-config.js.map +1 -0
  31. package/src/lib/plugin.d.ts +22 -0
  32. package/src/lib/plugin.js +70 -0
  33. package/src/lib/plugin.js.map +1 -0
  34. package/src/lib/server/billing-webhook.d.ts +23 -0
  35. package/src/lib/server/billing-webhook.js +345 -0
  36. package/src/lib/server/billing-webhook.js.map +1 -0
  37. package/src/lib/server/booking-analytics.d.ts +68 -0
  38. package/src/lib/server/booking-analytics.js +115 -0
  39. package/src/lib/server/booking-analytics.js.map +1 -0
  40. package/src/lib/server/booking-crm.d.ts +39 -0
  41. package/src/lib/server/booking-crm.js +67 -0
  42. package/src/lib/server/booking-crm.js.map +1 -0
  43. package/src/lib/server/booking-figures.d.ts +32 -0
  44. package/src/lib/server/booking-figures.js +155 -0
  45. package/src/lib/server/booking-figures.js.map +1 -0
  46. package/src/lib/server/refund.d.ts +65 -0
  47. package/src/lib/server/refund.js +292 -0
  48. package/src/lib/server/refund.js.map +1 -0
  49. package/src/lib/server.d.ts +63 -0
  50. package/src/lib/server.js +968 -0
  51. package/src/lib/server.js.map +1 -0
  52. package/src/lib/site.d.ts +27 -0
  53. package/src/lib/site.js +50 -0
  54. package/src/lib/site.js.map +1 -0
  55. package/src/lib/testing/stand-in-tax-profile.d.ts +29 -0
  56. package/src/lib/testing/stand-in-tax-profile.js +64 -0
  57. package/src/lib/testing/stand-in-tax-profile.js.map +1 -0
  58. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  59. package/src/lib/utils/generate-preset-id.js +25 -0
  60. package/src/lib/utils/generate-preset-id.js.map +1 -0
  61. package/src/lib/utils/use-booking-purchase-event.d.ts +65 -0
  62. package/src/lib/utils/use-booking-purchase-event.js +147 -0
  63. package/src/lib/utils/use-booking-purchase-event.js.map +1 -0
@@ -0,0 +1,292 @@
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
+ */ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
18
+ // Leaf import, not the barrel: the specs in this library mock
19
+ // `@aglyn/tenant-data-admin` wholesale, and a permissive stub would turn a
20
+ // reversal that never happened green.
21
+ import { reverseEmailAttributedRevenue } from "@aglyn/tenant-data-admin/server/email-revenue-attribution";
22
+ import { createHash } from "crypto";
23
+ /**
24
+ * Refund a paid booking (AGL-2315), full or partial, site-admin only.
25
+ *
26
+ * This route exists BECAUSE of the destination charge, not alongside it. While
27
+ * a paid booking settled in Aglyn's own balance there was nothing here to get
28
+ * wrong: cancelling a booking refunded the guest nothing at all, and any
29
+ * refund issued by hand simply gave back money Aglyn was already holding. Once
30
+ * the charge goes to the merchant's connected account that stops being true in
31
+ * two directions at once, and both are money:
32
+ *
33
+ * - **The guest.** Cancelling a paid booking wrote `status: 'canceled'` from
34
+ * the console and moved no money. The slot reopened, the appointment was
35
+ * gone, and the guest had paid for it. That was survivable only because the
36
+ * platform still held the funds and could be made to give them back; with
37
+ * the money at the merchant it is a silent, unrecoverable overcharge.
38
+ * - **Aglyn.** A refund on a destination charge that does NOT set
39
+ * `reverse_transfer` is paid entirely out of the PLATFORM's balance while
40
+ * the merchant keeps the transfer in full. The merchant cancels, the guest
41
+ * is made whole, and Aglyn funds the whole concession out of a cut that was
42
+ * at most 5% of it. This repo has shipped that exact bug once already, on a
43
+ * marketplace partial refund.
44
+ *
45
+ * So both switches are sent, and they are two different decisions:
46
+ *
47
+ * - `reverse_transfer` pulls the refunded share back OUT of the merchant's
48
+ * account. Stripe prorates it against a partial refund, so a $30 refund on
49
+ * a $75 booking claws back $30 of the merchant's $71.25, not all of it.
50
+ * - `refund_application_fee` returns Aglyn's commission on the refunded
51
+ * share. A refunded appointment earned the platform nothing, and this
52
+ * matches `commerce/refund.ts`, which has sent both since AGL-287. The
53
+ * DISPUTE path deliberately sends only the reversal — that asymmetry is
54
+ * AGL-1809's and is not copied here.
55
+ *
56
+ * Two SEPARATE controls guard the money, the AGL-1696 lesson from the order
57
+ * refund, and conflating them is how that one originally went wrong:
58
+ *
59
+ * - The idempotency key stops a DUPLICATE refund — one attempt sent twice
60
+ * because the response was lost or the admin double-clicked. It is minted
61
+ * per attempt by the console and deliberately not derived from the booking
62
+ * or the amount: two $10 refunds on a $75 booking are two real refunds.
63
+ * - The cap stops an OVER-refund — several partials summing past what was
64
+ * captured, including two admins refunding at once, where the attempts are
65
+ * genuinely distinct and no key can help. That needs the counter read and
66
+ * written inside one transaction, BEFORE Stripe is called, so a lost
67
+ * response leaves the amount counted and the retry refunds less, never
68
+ * more.
69
+ */ export const bookingRefundHandler = async (req, res)=>{
70
+ var _req_headers_authorization, _req_body, _body_hostId, _body_bookingId, _ref, _req_headers_idempotencykey;
71
+ if (req.method !== 'POST') {
72
+ return res.status(405).json({
73
+ error: 'Method not allowed'
74
+ });
75
+ }
76
+ if (!process.env.STRIPE_SECRET_KEY) {
77
+ return res.status(501).json({
78
+ error: 'Payments are not configured.'
79
+ });
80
+ }
81
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
82
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
83
+ if (!idToken) return res.status(401).json({
84
+ error: 'Unauthenticated'
85
+ });
86
+ const body = typeof req.body === 'string' ? JSON.parse(req.body) : (_req_body = req.body) != null ? _req_body : {};
87
+ const hostId = String((_body_hostId = body.hostId) != null ? _body_hostId : '');
88
+ const bookingId = String((_body_bookingId = body.bookingId) != null ? _body_bookingId : '');
89
+ // `null`/absent means "everything still refundable"; a number is a partial.
90
+ const amountCents = body.amountCents == null ? null : Number(body.amountCents);
91
+ // Node lowercases incoming headers, but read both spellings — the plugin API
92
+ // request type makes no promise about casing.
93
+ const idempotencyKey = String((_ref = (_req_headers_idempotencykey = req.headers['idempotency-key']) != null ? _req_headers_idempotencykey : req.headers['Idempotency-Key']) != null ? _ref : '').trim().slice(0, 200);
94
+ if (!hostId || !bookingId) {
95
+ return res.status(400).json({
96
+ error: 'Missing hostId or bookingId'
97
+ });
98
+ }
99
+ if (amountCents != null && !(Number.isFinite(amountCents) && amountCents > 0)) {
100
+ return res.status(400).json({
101
+ error: 'Invalid refund amount'
102
+ });
103
+ }
104
+ try {
105
+ var _hostSnapshot_get, _bookingSnapshot_get, _bookingSnapshot_get1;
106
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
107
+ const firestore = firebaseAdmin.app().firestore();
108
+ const hostRef = firestore.collection('hosts').doc(hostId);
109
+ const hostSnapshot = await hostRef.get();
110
+ if (!hostSnapshot.exists) {
111
+ return res.status(404).json({
112
+ error: 'Unknown site'
113
+ });
114
+ }
115
+ // Site ADMIN, not merely a member: this moves money. The same gate
116
+ // `commerce/refund.ts` applies.
117
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
118
+ if (memberRole !== 'admin') {
119
+ return res.status(403).json({
120
+ error: 'Refunds require a site admin'
121
+ });
122
+ }
123
+ const bookingRef = hostRef.collection('bookings').doc(bookingId);
124
+ const bookingSnapshot = await bookingRef.get();
125
+ if (!bookingSnapshot.exists) {
126
+ return res.status(404).json({
127
+ error: 'Unknown booking'
128
+ });
129
+ }
130
+ const paymentIntentId = String((_bookingSnapshot_get = bookingSnapshot.get('paymentIntentId')) != null ? _bookingSnapshot_get : '');
131
+ const paidCents = Math.max(0, Math.round(Number((_bookingSnapshot_get1 = bookingSnapshot.get('paidAmountCents')) != null ? _bookingSnapshot_get1 : 0)));
132
+ if (!paidCents) {
133
+ return res.status(409).json({
134
+ error: 'This booking was never paid'
135
+ });
136
+ }
137
+ if (!paymentIntentId) {
138
+ // A booking paid BEFORE the webhook began recording the PaymentIntent
139
+ // (AGL-2315). Refundable, but only by hand — and saying so beats a 500
140
+ // an admin cannot act on.
141
+ return res.status(409).json({
142
+ error: 'This booking was paid before refunds were supported here. ' + 'Refund it in the Stripe dashboard, and tick “Reverse transfer” ' + 'so the amount comes back from the merchant account.'
143
+ });
144
+ }
145
+ // Replay a settled attempt before anything else can reject it. This read
146
+ // is only a short-circuit, never the dedupe primitive — the atomic
147
+ // `create()` below is.
148
+ const claimRef = idempotencyKey ? firestore.collection('apiIdempotency').doc(createHash('sha256')// Scoped by the BOOKING, so a client that reused one key across
149
+ // two bookings cannot dedupe two legitimately distinct refunds.
150
+ // NOT by the amount: that would swallow a real second partial.
151
+ .update(`booking-refund:${hostId}:${bookingId}:${idempotencyKey}`).digest('hex')) : null;
152
+ if (claimRef) {
153
+ const prior = await claimRef.get();
154
+ const priorResponse = prior.get('response');
155
+ if (priorResponse) {
156
+ var _prior_get;
157
+ return res.status(Number((_prior_get = prior.get('responseStatus')) != null ? _prior_get : 200)).json(priorResponse);
158
+ }
159
+ try {
160
+ await claimRef.create({
161
+ status: 'pending',
162
+ startedAtMs: Date.now()
163
+ });
164
+ } catch (unused) {
165
+ // Another attempt with this key is in flight.
166
+ return res.status(409).json({
167
+ error: 'This refund is already being processed'
168
+ });
169
+ }
170
+ }
171
+ // RESERVE. Read and write the counter in ONE transaction so two concurrent
172
+ // refunds cannot both see the same `refundedCents` and each send a full
173
+ // amount to Stripe — the failure the order refund shipped with.
174
+ let refundCents = 0;
175
+ let alreadyRefunded = 0;
176
+ await firestore.runTransaction(async (transaction)=>{
177
+ var _fresh_get;
178
+ const fresh = await transaction.get(bookingRef);
179
+ alreadyRefunded = Math.max(0, Math.round(Number((_fresh_get = fresh.get('refundedCents')) != null ? _fresh_get : 0)));
180
+ const remaining = paidCents - alreadyRefunded;
181
+ refundCents = amountCents == null ? remaining : Math.min(Math.round(amountCents), remaining);
182
+ if (!(refundCents > 0)) {
183
+ refundCents = 0;
184
+ return;
185
+ }
186
+ transaction.set(bookingRef, {
187
+ refundedCents: alreadyRefunded + refundCents
188
+ }, {
189
+ merge: true
190
+ });
191
+ });
192
+ if (!(refundCents > 0)) {
193
+ await (claimRef == null ? void 0 : claimRef.delete().catch(()=>undefined));
194
+ return res.status(400).json({
195
+ error: 'Nothing left to refund'
196
+ });
197
+ }
198
+ const params = new URLSearchParams({
199
+ payment_intent: paymentIntentId,
200
+ amount: String(refundCents),
201
+ // THE SELLER SHARE COMES BACK. Without this the merchant keeps their
202
+ // transfer in full and Aglyn funds the entire refund.
203
+ reverse_transfer: 'true',
204
+ // ...and so does Aglyn's commission on the refunded share. A refunded
205
+ // appointment earned the platform nothing.
206
+ refund_application_fee: 'true'
207
+ });
208
+ const response = await fetch('https://api.stripe.com/v1/refunds', {
209
+ method: 'POST',
210
+ headers: _extends({
211
+ Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
212
+ 'Content-Type': 'application/x-www-form-urlencoded'
213
+ }, claimRef ? {
214
+ 'Idempotency-Key': claimRef.id
215
+ } : {}),
216
+ body: params.toString()
217
+ });
218
+ const refund = await response.json().catch(()=>({}));
219
+ if (!response.ok) {
220
+ console.error('Stripe booking refund error', refund == null ? void 0 : refund.error);
221
+ // Stripe said no, so we KNOW no money moved: give the reservation back
222
+ // and let the same attempt be retried.
223
+ await firestore.runTransaction(async (transaction)=>{
224
+ var _get;
225
+ const current = Number((_get = (await transaction.get(bookingRef)).get('refundedCents')) != null ? _get : 0);
226
+ transaction.set(bookingRef, {
227
+ refundedCents: Math.max(0, current - refundCents)
228
+ }, {
229
+ merge: true
230
+ });
231
+ }).catch(()=>undefined);
232
+ await (claimRef == null ? void 0 : claimRef.delete().catch(()=>undefined));
233
+ return res.status(502).json({
234
+ error: 'Refund failed'
235
+ });
236
+ }
237
+ // A FULL refund ends the appointment; a partial leaves it standing. The
238
+ // guest who got half their money back still has the slot, and moving the
239
+ // booking off `confirmed` would reopen a time the merchant is still
240
+ // committed to.
241
+ const totalRefunded = alreadyRefunded + refundCents;
242
+ const fullyRefunded = totalRefunded >= paidCents;
243
+ await bookingRef.set(_extends({
244
+ refundedCents: totalRefunded
245
+ }, fullyRefunded ? {
246
+ status: 'canceled'
247
+ } : {}), {
248
+ merge: true
249
+ }).catch(()=>undefined);
250
+ const payload = {
251
+ refundedCents: refundCents,
252
+ totalRefundedCents: totalRefunded,
253
+ fullyRefunded
254
+ };
255
+ await (claimRef == null ? void 0 : claimRef.set({
256
+ status: 'done',
257
+ responseStatus: 200,
258
+ response: payload,
259
+ settledAtMs: Date.now()
260
+ }, {
261
+ merge: true
262
+ }).catch(()=>undefined));
263
+ /*
264
+ * The campaign's side of the ledger.
265
+ *
266
+ * A paid booking is credited to the campaign that led to it — the sale
267
+ * announces itself through `upsertHostContact` with a `purchaseCents` and
268
+ * this booking's id, exactly as a store order does — so a refunded one
269
+ * has to stop counting. Without this, a booking site's campaign revenue
270
+ * could only ever rise.
271
+ *
272
+ * Keyed by the BOOKING id, which is what the credit was filed under, so
273
+ * this needs no email and works for a guest who never became a contact.
274
+ * Recorded beside the credit rather than subtracted from it, and swallowed
275
+ * whole: the money has already left the merchant's account and the booking
276
+ * already records it, so nothing here may fail a refund.
277
+ */ await reverseEmailAttributedRevenue({
278
+ hostId,
279
+ orderId: bookingId,
280
+ amountCents: refundCents,
281
+ closedTheOrder: fullyRefunded
282
+ });
283
+ return res.status(200).json(payload);
284
+ } catch (error) {
285
+ console.error(error);
286
+ return res.status(500).json({
287
+ error: 'Refund failed'
288
+ });
289
+ }
290
+ };
291
+
292
+ //# sourceMappingURL=refund.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/refund.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 { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n// Leaf import, not the barrel: the specs in this library mock\n// `@aglyn/tenant-data-admin` wholesale, and a permissive stub would turn a\n// reversal that never happened green.\nimport { reverseEmailAttributedRevenue } from '@aglyn/tenant-data-admin/server/email-revenue-attribution'\nimport { createHash } from 'crypto'\n\n/**\n * Refund a paid booking (AGL-2315), full or partial, site-admin only.\n *\n * This route exists BECAUSE of the destination charge, not alongside it. While\n * a paid booking settled in Aglyn's own balance there was nothing here to get\n * wrong: cancelling a booking refunded the guest nothing at all, and any\n * refund issued by hand simply gave back money Aglyn was already holding. Once\n * the charge goes to the merchant's connected account that stops being true in\n * two directions at once, and both are money:\n *\n * - **The guest.** Cancelling a paid booking wrote `status: 'canceled'` from\n * the console and moved no money. The slot reopened, the appointment was\n * gone, and the guest had paid for it. That was survivable only because the\n * platform still held the funds and could be made to give them back; with\n * the money at the merchant it is a silent, unrecoverable overcharge.\n * - **Aglyn.** A refund on a destination charge that does NOT set\n * `reverse_transfer` is paid entirely out of the PLATFORM's balance while\n * the merchant keeps the transfer in full. The merchant cancels, the guest\n * is made whole, and Aglyn funds the whole concession out of a cut that was\n * at most 5% of it. This repo has shipped that exact bug once already, on a\n * marketplace partial refund.\n *\n * So both switches are sent, and they are two different decisions:\n *\n * - `reverse_transfer` pulls the refunded share back OUT of the merchant's\n * account. Stripe prorates it against a partial refund, so a $30 refund on\n * a $75 booking claws back $30 of the merchant's $71.25, not all of it.\n * - `refund_application_fee` returns Aglyn's commission on the refunded\n * share. A refunded appointment earned the platform nothing, and this\n * matches `commerce/refund.ts`, which has sent both since AGL-287. The\n * DISPUTE path deliberately sends only the reversal — that asymmetry is\n * AGL-1809's and is not copied here.\n *\n * Two SEPARATE controls guard the money, the AGL-1696 lesson from the order\n * refund, and conflating them is how that one originally went wrong:\n *\n * - The idempotency key stops a DUPLICATE refund — one attempt sent twice\n * because the response was lost or the admin double-clicked. It is minted\n * per attempt by the console and deliberately not derived from the booking\n * or the amount: two $10 refunds on a $75 booking are two real refunds.\n * - The cap stops an OVER-refund — several partials summing past what was\n * captured, including two admins refunding at once, where the attempts are\n * genuinely distinct and no key can help. That needs the counter read and\n * written inside one transaction, BEFORE Stripe is called, so a lost\n * response leaves the amount counted and the retry refunds less, never\n * more.\n */\nexport const bookingRefundHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Payments are not configured.' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const bookingId = String(body.bookingId ?? '')\n // `null`/absent means \"everything still refundable\"; a number is a partial.\n const amountCents = body.amountCents == null ? null : Number(body.amountCents)\n // Node lowercases incoming headers, but read both spellings — the plugin API\n // request type makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!hostId || !bookingId) {\n return res.status(400).json({ error: 'Missing hostId or bookingId' })\n }\n if (amountCents != null && !(Number.isFinite(amountCents) && amountCents > 0)) {\n return res.status(400).json({ error: 'Invalid refund amount' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Site ADMIN, not merely a member: this moves money. The same gate\n // `commerce/refund.ts` applies.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin') {\n return res.status(403).json({ error: 'Refunds require a site admin' })\n }\n const bookingRef = hostRef.collection('bookings').doc(bookingId)\n const bookingSnapshot = await bookingRef.get()\n if (!bookingSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown booking' })\n }\n const paymentIntentId = String(bookingSnapshot.get('paymentIntentId') ?? '')\n const paidCents = Math.max(\n 0,\n Math.round(Number(bookingSnapshot.get('paidAmountCents') ?? 0)),\n )\n if (!paidCents) {\n return res.status(409).json({ error: 'This booking was never paid' })\n }\n if (!paymentIntentId) {\n // A booking paid BEFORE the webhook began recording the PaymentIntent\n // (AGL-2315). Refundable, but only by hand — and saying so beats a 500\n // an admin cannot act on.\n return res.status(409).json({\n error:\n 'This booking was paid before refunds were supported here. ' +\n 'Refund it in the Stripe dashboard, and tick “Reverse transfer” ' +\n 'so the amount comes back from the merchant account.',\n })\n }\n\n // Replay a settled attempt before anything else can reject it. This read\n // is only a short-circuit, never the dedupe primitive — the atomic\n // `create()` below is.\n const claimRef = idempotencyKey\n ? firestore.collection('apiIdempotency').doc(\n createHash('sha256')\n // Scoped by the BOOKING, so a client that reused one key across\n // two bookings cannot dedupe two legitimately distinct refunds.\n // NOT by the amount: that would swallow a real second partial.\n .update(`booking-refund:${hostId}:${bookingId}:${idempotencyKey}`)\n .digest('hex'),\n )\n : null\n if (claimRef) {\n const prior = await claimRef.get()\n const priorResponse = prior.get('response')\n if (priorResponse) {\n return res\n .status(Number(prior.get('responseStatus') ?? 200))\n .json(priorResponse)\n }\n try {\n await claimRef.create({ status: 'pending', startedAtMs: Date.now() })\n } catch {\n // Another attempt with this key is in flight.\n return res\n .status(409)\n .json({ error: 'This refund is already being processed' })\n }\n }\n\n // RESERVE. Read and write the counter in ONE transaction so two concurrent\n // refunds cannot both see the same `refundedCents` and each send a full\n // amount to Stripe — the failure the order refund shipped with.\n let refundCents = 0\n let alreadyRefunded = 0\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(bookingRef)\n alreadyRefunded = Math.max(\n 0,\n Math.round(Number(fresh.get('refundedCents') ?? 0)),\n )\n const remaining = paidCents - alreadyRefunded\n refundCents =\n amountCents == null\n ? remaining\n : Math.min(Math.round(amountCents), remaining)\n if (!(refundCents > 0)) {\n refundCents = 0\n return\n }\n transaction.set(\n bookingRef,\n { refundedCents: alreadyRefunded + refundCents },\n { merge: true },\n )\n })\n if (!(refundCents > 0)) {\n await claimRef?.delete().catch(() => undefined)\n return res.status(400).json({ error: 'Nothing left to refund' })\n }\n\n const params = new URLSearchParams({\n payment_intent: paymentIntentId,\n amount: String(refundCents),\n // THE SELLER SHARE COMES BACK. Without this the merchant keeps their\n // transfer in full and Aglyn funds the entire refund.\n reverse_transfer: 'true',\n // ...and so does Aglyn's commission on the refunded share. A refunded\n // appointment earned the platform nothing.\n refund_application_fee: 'true',\n })\n const response = await fetch('https://api.stripe.com/v1/refunds', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n ...(claimRef ? { 'Idempotency-Key': claimRef.id } : {}),\n },\n body: params.toString(),\n })\n const refund = await response.json().catch(() => ({}) as any)\n if (!response.ok) {\n console.error('Stripe booking refund error', (refund as any)?.error)\n // Stripe said no, so we KNOW no money moved: give the reservation back\n // and let the same attempt be retried.\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(bookingRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n bookingRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claimRef?.delete().catch(() => undefined)\n return res.status(502).json({ error: 'Refund failed' })\n }\n\n // A FULL refund ends the appointment; a partial leaves it standing. The\n // guest who got half their money back still has the slot, and moving the\n // booking off `confirmed` would reopen a time the merchant is still\n // committed to.\n const totalRefunded = alreadyRefunded + refundCents\n const fullyRefunded = totalRefunded >= paidCents\n await bookingRef\n .set(\n {\n refundedCents: totalRefunded,\n ...(fullyRefunded ? { status: 'canceled' } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n\n const payload = {\n refundedCents: refundCents,\n totalRefundedCents: totalRefunded,\n fullyRefunded,\n }\n await claimRef\n ?.set(\n {\n status: 'done',\n responseStatus: 200,\n response: payload,\n settledAtMs: Date.now(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n /*\n * The campaign's side of the ledger.\n *\n * A paid booking is credited to the campaign that led to it — the sale\n * announces itself through `upsertHostContact` with a `purchaseCents` and\n * this booking's id, exactly as a store order does — so a refunded one\n * has to stop counting. Without this, a booking site's campaign revenue\n * could only ever rise.\n *\n * Keyed by the BOOKING id, which is what the credit was filed under, so\n * this needs no email and works for a guest who never became a contact.\n * Recorded beside the credit rather than subtracted from it, and swallowed\n * whole: the money has already left the merchant's account and the booking\n * already records it, so nothing here may fail a refund.\n */\n await reverseEmailAttributedRevenue({\n hostId,\n orderId: bookingId,\n amountCents: refundCents,\n closedTheOrder: fullyRefunded,\n })\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Refund failed' })\n }\n}\n"],"names":["firebaseAdmin","reverseEmailAttributedRevenue","createHash","bookingRefundHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","bookingId","amountCents","Number","idempotencyKey","trim","isFinite","hostSnapshot","bookingSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","bookingRef","paymentIntentId","paidCents","Math","max","round","claimRef","update","digest","prior","priorResponse","create","startedAtMs","Date","now","refundCents","alreadyRefunded","runTransaction","transaction","fresh","remaining","min","set","refundedCents","merge","delete","catch","params","URLSearchParams","payment_intent","amount","reverse_transfer","refund_application_fee","response","fetch","Authorization","id","toString","refund","ok","console","current","totalRefunded","fullyRefunded","payload","totalRefundedCents","responseStatus","settledAtMs","orderId","closedTheOrder"],"mappings":"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"}
@@ -0,0 +1,63 @@
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 PluginJobHostGate } from '@aglyn/aglyn/server';
18
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
19
+ /**
20
+ * Public open-slot listing (AGL-159): visitors browse a service's next
21
+ * open times. Server-side because bookings (names/emails) must never be
22
+ * client-readable — only the derived busy intervals are used here.
23
+ */
24
+ export declare const slotsHandler: PluginApiHandler;
25
+ /**
26
+ * Booking creation (AGL-159): validates the slot against the service's
27
+ * windows AND stored bookings inside a transaction (double-booking safe),
28
+ * stores the booking, records a lead, emits the `booking` host event, and
29
+ * sends an env-gated Resend confirmation. Plan gate: the owning org
30
+ * needs the `bookings` flag (dark-launch workspaces pass).
31
+ */
32
+ /**
33
+ * Exported for spec (AGL-2000). The tax decision this handler takes lives in
34
+ * the ABSENCE of a parameter, and an absence is only a decision if something
35
+ * asserts it — see `server-booking-tax.spec.ts`.
36
+ */
37
+ export declare const bookHandler: PluginApiHandler;
38
+ /**
39
+ * One reminder pass (AGL-160), extracted from its HTTP door in AGL-2431.
40
+ *
41
+ * Finds confirmed bookings starting 23–25 hours out (collection-group query,
42
+ * so one call covers every host), emails each visitor through the env-gated
43
+ * Resend path, and stamps `reminderSentAt` so re-runs are idempotent.
44
+ *
45
+ * WHY THIS IS A FUNCTION NOW. It only ever existed as `POST bookings/reminders`
46
+ * behind `x-cron-secret`, and nothing anywhere POSTed to it — not
47
+ * `scheduled-crons.yml`, not `vercel.json` (which has no `crons` key), not
48
+ * `registerPluginJob`. The route's own docblock said "invoke hourly from the
49
+ * scheduler" for as long as it has existed. It never ran, for anyone. Same
50
+ * shape as the two commerce beats in AGL-2227, one plugin over.
51
+ */
52
+ export declare function scanBookingReminders(
53
+ /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */
54
+ gate: PluginJobHostGate): Promise<{
55
+ scanned: number;
56
+ sent: number;
57
+ skipped: number;
58
+ skippedLocked: number;
59
+ }>;
60
+ /** Registers the bookings plugin's public (site-facing) API routes (AGL-396). */
61
+ export declare function registerBookingsApi(): void;
62
+ /** Registers the bookings plugin's console-side API routes (AGL-396). */
63
+ export declare function registerBookingsConsoleApi(): void;