@aglyn/plugins-commerce 1.0.0-beta.277 → 1.0.0-beta.282

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 (212) hide show
  1. package/package.json +17 -14
  2. package/src/lib/components/commerce-console-page.js +33 -1
  3. package/src/lib/components/commerce-console-page.js.map +1 -1
  4. package/src/lib/components/console/payments-settings-card.component.js +1 -1
  5. package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
  6. package/src/lib/components/console/pos/pos-hardware-api.d.ts +122 -0
  7. package/src/lib/components/console/pos/pos-hardware-api.js +90 -0
  8. package/src/lib/components/console/pos/pos-hardware-api.js.map +1 -0
  9. package/src/lib/components/console/pos/pos-reader-splash-api.d.ts +58 -0
  10. package/src/lib/components/console/pos/pos-reader-splash-api.js +101 -0
  11. package/src/lib/components/console/pos/pos-reader-splash-api.js.map +1 -0
  12. package/src/lib/components/console/pos-devices-card.component.js +5 -0
  13. package/src/lib/components/console/pos-devices-card.component.js.map +1 -1
  14. package/src/lib/components/console/pos-hardware-card.component.d.ts +26 -0
  15. package/src/lib/components/console/pos-hardware-card.component.js +456 -0
  16. package/src/lib/components/console/pos-hardware-card.component.js.map +1 -0
  17. package/src/lib/components/console/pos-hardware-orders-staff-page.component.d.ts +14 -0
  18. package/src/lib/components/console/pos-hardware-orders-staff-page.component.js +519 -0
  19. package/src/lib/components/console/pos-hardware-orders-staff-page.component.js.map +1 -0
  20. package/src/lib/components/console/pos-ops/pos-check-in-dialog.component.d.ts +27 -0
  21. package/src/lib/components/console/pos-ops/pos-check-in-dialog.component.js +444 -0
  22. package/src/lib/components/console/pos-ops/pos-check-in-dialog.component.js.map +1 -0
  23. package/src/lib/components/console/pos-ops/pos-check-in-zone.component.d.ts +9 -0
  24. package/src/lib/components/console/pos-ops/pos-check-in-zone.component.js +37 -0
  25. package/src/lib/components/console/pos-ops/pos-check-in-zone.component.js.map +1 -0
  26. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.d.ts +24 -0
  27. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js +346 -0
  28. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js.map +1 -0
  29. package/src/lib/components/console/pos-ops/pos-customer-lookup.component.d.ts +5 -1
  30. package/src/lib/components/console/pos-ops/pos-customer-lookup.component.js +83 -4
  31. package/src/lib/components/console/pos-ops/pos-customer-lookup.component.js.map +1 -1
  32. package/src/lib/components/console/pos-ops/pos-day-totals-card.component.d.ts +35 -0
  33. package/src/lib/components/console/pos-ops/pos-day-totals-card.component.js +546 -0
  34. package/src/lib/components/console/pos-ops/pos-day-totals-card.component.js.map +1 -0
  35. package/src/lib/components/console/pos-ops/pos-operations-bar.component.d.ts +2 -1
  36. package/src/lib/components/console/pos-ops/pos-operations-bar.component.js +20 -1
  37. package/src/lib/components/console/pos-ops/pos-operations-bar.component.js.map +1 -1
  38. package/src/lib/components/console/pos-ops/pos-ops-api.d.ts +1 -1
  39. package/src/lib/components/console/pos-ops/pos-ops-api.js.map +1 -1
  40. package/src/lib/components/console/pos-page.component.js +6 -3
  41. package/src/lib/components/console/pos-page.component.js.map +1 -1
  42. package/src/lib/components/console/pos-reader-splash-setting.component.d.ts +22 -0
  43. package/src/lib/components/console/pos-reader-splash-setting.component.js +275 -0
  44. package/src/lib/components/console/pos-reader-splash-setting.component.js.map +1 -0
  45. package/src/lib/components/console/pos-reader-splash-staff-page.component.d.ts +14 -0
  46. package/src/lib/components/console/pos-reader-splash-staff-page.component.js +301 -0
  47. package/src/lib/components/console/pos-reader-splash-staff-page.component.js.map +1 -0
  48. package/src/lib/components/console/pos-zones.d.ts +37 -3
  49. package/src/lib/components/console/pos-zones.js +11 -1
  50. package/src/lib/components/console/pos-zones.js.map +1 -1
  51. package/src/lib/components/console/product-check-in-fields.component.d.ts +20 -0
  52. package/src/lib/components/console/product-check-in-fields.component.js +172 -0
  53. package/src/lib/components/console/product-check-in-fields.component.js.map +1 -0
  54. package/src/lib/components/console/product-editor-dialog.component.js +48 -9
  55. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  56. package/src/lib/components/console/product-register-fields.component.d.ts +12 -0
  57. package/src/lib/components/console/product-register-fields.component.js +39 -1
  58. package/src/lib/components/console/product-register-fields.component.js.map +1 -1
  59. package/src/lib/components/console/products-hub-card.component.js +21 -6
  60. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  61. package/src/lib/components/console/reservations-card.component.js +239 -70
  62. package/src/lib/components/console/reservations-card.component.js.map +1 -1
  63. package/src/lib/components/reservation-widget.js +16 -0
  64. package/src/lib/components/reservation-widget.js.map +1 -1
  65. package/src/lib/constants/operator-alerts.d.ts +6 -0
  66. package/src/lib/constants/operator-alerts.js +20 -1
  67. package/src/lib/constants/operator-alerts.js.map +1 -1
  68. package/src/lib/declarations.console-server.js +7 -0
  69. package/src/lib/declarations.console-server.js.map +1 -1
  70. package/src/lib/declarations.d.ts +26 -0
  71. package/src/lib/declarations.js +34 -0
  72. package/src/lib/declarations.js.map +1 -0
  73. package/src/lib/model/commerce-check-in-product.d.ts +37 -0
  74. package/src/lib/model/commerce-check-in-product.js +49 -0
  75. package/src/lib/model/commerce-check-in-product.js.map +1 -0
  76. package/src/lib/model/commerce-check-in.d.ts +187 -0
  77. package/src/lib/model/commerce-check-in.js +468 -0
  78. package/src/lib/model/commerce-check-in.js.map +1 -0
  79. package/src/lib/model/commerce-pos-day-report.d.ts +209 -0
  80. package/src/lib/model/commerce-pos-day-report.js +572 -0
  81. package/src/lib/model/commerce-pos-day-report.js.map +1 -0
  82. package/src/lib/model/commerce-pos-offline.d.ts +65 -3
  83. package/src/lib/model/commerce-pos-offline.js +104 -7
  84. package/src/lib/model/commerce-pos-offline.js.map +1 -1
  85. package/src/lib/model/commerce-pos-ops.d.ts +3 -0
  86. package/src/lib/model/commerce-pos-ops.js +1 -0
  87. package/src/lib/model/commerce-pos-ops.js.map +1 -1
  88. package/src/lib/model/commerce-pos.d.ts +43 -3
  89. package/src/lib/model/commerce-pos.js +73 -5
  90. package/src/lib/model/commerce-pos.js.map +1 -1
  91. package/src/lib/model/commerce-reservations.d.ts +73 -7
  92. package/src/lib/model/commerce-reservations.js +90 -10
  93. package/src/lib/model/commerce-reservations.js.map +1 -1
  94. package/src/lib/model/commerce.d.ts +40 -0
  95. package/src/lib/model/commerce.js +22 -4
  96. package/src/lib/model/commerce.js.map +1 -1
  97. package/src/lib/model/index.d.ts +1 -0
  98. package/src/lib/model/index.js +1 -0
  99. package/src/lib/model/index.js.map +1 -1
  100. package/src/lib/model/plugin-permissions.d.ts +1 -1
  101. package/src/lib/model/plugin-permissions.js +2 -1
  102. package/src/lib/model/plugin-permissions.js.map +1 -1
  103. package/src/lib/model/pos-hardware.d.ts +288 -0
  104. package/src/lib/model/pos-hardware.js +499 -0
  105. package/src/lib/model/pos-hardware.js.map +1 -0
  106. package/src/lib/model/pos-offline-card-limits.d.ts +20 -0
  107. package/src/lib/model/pos-offline-card-limits.js +28 -0
  108. package/src/lib/model/pos-offline-card-limits.js.map +1 -0
  109. package/src/lib/model/pos-reader-splash.d.ts +114 -0
  110. package/src/lib/model/pos-reader-splash.js +141 -0
  111. package/src/lib/model/pos-reader-splash.js.map +1 -0
  112. package/src/lib/model/product-write.d.ts +6 -0
  113. package/src/lib/plugin-config.d.ts +10 -0
  114. package/src/lib/plugin-config.js +40 -0
  115. package/src/lib/plugin-config.js.map +1 -1
  116. package/src/lib/plugin.js +28 -0
  117. package/src/lib/plugin.js.map +1 -1
  118. package/src/lib/server/cart-checkout.js +3 -1
  119. package/src/lib/server/cart-checkout.js.map +1 -1
  120. package/src/lib/server/cart.js +12 -1
  121. package/src/lib/server/cart.js.map +1 -1
  122. package/src/lib/server/catalog.js +49 -12
  123. package/src/lib/server/catalog.js.map +1 -1
  124. package/src/lib/server/checkout-extras.js +1 -1
  125. package/src/lib/server/checkout-extras.js.map +1 -1
  126. package/src/lib/server/checkout.js +7 -0
  127. package/src/lib/server/checkout.js.map +1 -1
  128. package/src/lib/server/person-eraser.d.ts +8 -1
  129. package/src/lib/server/person-eraser.js +23 -1
  130. package/src/lib/server/person-eraser.js.map +1 -1
  131. package/src/lib/server/pos-check-in.d.ts +49 -0
  132. package/src/lib/server/pos-check-in.js +652 -0
  133. package/src/lib/server/pos-check-in.js.map +1 -0
  134. package/src/lib/server/pos-day-report.d.ts +46 -0
  135. package/src/lib/server/pos-day-report.js +299 -0
  136. package/src/lib/server/pos-day-report.js.map +1 -0
  137. package/src/lib/server/pos-hardware-admin.d.ts +50 -0
  138. package/src/lib/server/pos-hardware-admin.js +366 -0
  139. package/src/lib/server/pos-hardware-admin.js.map +1 -0
  140. package/src/lib/server/pos-hardware-notify.d.ts +27 -0
  141. package/src/lib/server/pos-hardware-notify.js +142 -0
  142. package/src/lib/server/pos-hardware-notify.js.map +1 -0
  143. package/src/lib/server/pos-hardware-user-eraser.d.ts +26 -0
  144. package/src/lib/server/pos-hardware-user-eraser.js +67 -0
  145. package/src/lib/server/pos-hardware-user-eraser.js.map +1 -0
  146. package/src/lib/server/pos-hardware-webhook.d.ts +31 -0
  147. package/src/lib/server/pos-hardware-webhook.js +176 -0
  148. package/src/lib/server/pos-hardware-webhook.js.map +1 -0
  149. package/src/lib/server/pos-hardware.d.ts +58 -0
  150. package/src/lib/server/pos-hardware.js +234 -0
  151. package/src/lib/server/pos-hardware.js.map +1 -0
  152. package/src/lib/server/pos-offline-card-reconcile.d.ts +38 -0
  153. package/src/lib/server/pos-offline-card-reconcile.js +58 -0
  154. package/src/lib/server/pos-offline-card-reconcile.js.map +1 -0
  155. package/src/lib/server/pos-offline-sync.d.ts +32 -0
  156. package/src/lib/server/pos-offline-sync.js +191 -15
  157. package/src/lib/server/pos-offline-sync.js.map +1 -1
  158. package/src/lib/server/pos-ops-routes.d.ts +4 -2
  159. package/src/lib/server/pos-ops-routes.js +14 -2
  160. package/src/lib/server/pos-ops-routes.js.map +1 -1
  161. package/src/lib/server/pos-order.js +39 -51
  162. package/src/lib/server/pos-order.js.map +1 -1
  163. package/src/lib/server/pos-payment.js +8 -4
  164. package/src/lib/server/pos-payment.js.map +1 -1
  165. package/src/lib/server/pos-reader-splash-admin.d.ts +36 -0
  166. package/src/lib/server/pos-reader-splash-admin.js +295 -0
  167. package/src/lib/server/pos-reader-splash-admin.js.map +1 -0
  168. package/src/lib/server/pos-reader-splash-render.d.ts +40 -0
  169. package/src/lib/server/pos-reader-splash-render.js +138 -0
  170. package/src/lib/server/pos-reader-splash-render.js.map +1 -0
  171. package/src/lib/server/pos-reader-splash-server.d.ts +32 -0
  172. package/src/lib/server/pos-reader-splash-server.js +32 -0
  173. package/src/lib/server/pos-reader-splash-server.js.map +1 -0
  174. package/src/lib/server/pos-reader-splash.d.ts +105 -0
  175. package/src/lib/server/pos-reader-splash.js +548 -0
  176. package/src/lib/server/pos-reader-splash.js.map +1 -0
  177. package/src/lib/server/pos-sale.d.ts +2 -1
  178. package/src/lib/server/pos-sale.js +3 -1
  179. package/src/lib/server/pos-sale.js.map +1 -1
  180. package/src/lib/server/pos-terminal.d.ts +67 -4
  181. package/src/lib/server/pos-terminal.js +239 -32
  182. package/src/lib/server/pos-terminal.js.map +1 -1
  183. package/src/lib/server/process-restock.js +4 -0
  184. package/src/lib/server/process-restock.js.map +1 -1
  185. package/src/lib/server/product-card.js +2 -2
  186. package/src/lib/server/product-card.js.map +1 -1
  187. package/src/lib/server/product-catalog.d.ts +2 -0
  188. package/src/lib/server/product-catalog.js +11 -4
  189. package/src/lib/server/product-catalog.js.map +1 -1
  190. package/src/lib/server/product.js +3 -1
  191. package/src/lib/server/product.js.map +1 -1
  192. package/src/lib/server/related.js +8 -1
  193. package/src/lib/server/related.js.map +1 -1
  194. package/src/lib/server/reservation-availability-query.d.ts +35 -0
  195. package/src/lib/server/reservation-availability-query.js +34 -0
  196. package/src/lib/server/reservation-availability-query.js.map +1 -0
  197. package/src/lib/server/reservation-availability.d.ts +21 -0
  198. package/src/lib/server/reservation-availability.js +64 -10
  199. package/src/lib/server/reservation-availability.js.map +1 -1
  200. package/src/lib/server/reservation-walk-in.d.ts +28 -0
  201. package/src/lib/server/reservation-walk-in.js +166 -0
  202. package/src/lib/server/reservation-walk-in.js.map +1 -0
  203. package/src/lib/server/reserve.js +13 -10
  204. package/src/lib/server/reserve.js.map +1 -1
  205. package/src/lib/server/reviews.js +3 -1
  206. package/src/lib/server/reviews.js.map +1 -1
  207. package/src/lib/server/site-page-resolver.js +5 -2
  208. package/src/lib/server/site-page-resolver.js.map +1 -1
  209. package/src/lib/server.js +26 -0
  210. package/src/lib/server.js.map +1 -1
  211. package/src/lib/testing/fake-firestore.js +72 -0
  212. package/src/lib/testing/fake-firestore.js.map +1 -1
@@ -0,0 +1,28 @@
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
+ import * as CommerceModel from '../model';
19
+ import { type ProductsWriteDeps } from './products-write';
20
+ export declare const WALK_IN_GONE = "That resource is gone.";
21
+ export declare const WALK_IN_TAKEN_ONE_UNIT = "Those dates are taken";
22
+ export declare const WALK_IN_TAKEN_EVERY_UNIT = "Every unit is taken on at least one of those nights";
23
+ export declare const WALK_IN_PLUGIN_OFF = "Reservations are not turned on for this site";
24
+ export declare const WALK_IN_ID_CONFLICT = "That walk-in id belongs to another stay";
25
+ /** The refusal for dates with no free unit, worded for the resource. */
26
+ export declare function walkInTakenMessage(resource: CommerceModel.HostResource): string;
27
+ export declare function createReservationWalkInHandler(options?: ProductsWriteDeps): PluginApiHandler;
28
+ export declare const reservationWalkInHandler: PluginApiHandler;
@@ -0,0 +1,166 @@
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 { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
17
+ import { isHostPluginEnabled } from "@aglyn/aglyn/server";
18
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
19
+ import * as CommerceModel from "../model/index.js";
20
+ import { readLiveReservations, reservationAvailabilityQuery } from "./reservation-availability-query.js";
21
+ import { bodyOf, gate, resolved, validId } from "./products-write.js";
22
+ /*
23
+ * `POST /api/commerce/reservations/walk-in` (AGL-4046): a staff walk-in,
24
+ * race-safe.
25
+ *
26
+ * The console Reservations card and the native apps used to write a walk-in
27
+ * straight from the client after their own availability read. Two staff on two
28
+ * devices could both read the last free unit (the last RV hookup) and both
29
+ * write a stay into it. This route re-asks availability INSIDE the transaction
30
+ * that writes, on the same query and the same `isRangeAvailable` rule the
31
+ * storefront's `/api/commerce/reserve` uses, so the loser's transaction re-runs
32
+ * on the winner's write and is refused.
33
+ *
34
+ * Who may call it is who the Firestore rules let write a reservation from the
35
+ * console: a verified member with a content-write role on the site
36
+ * (`canWriteHostContent`: admin, editor, author), or staff, while the site,
37
+ * its org and the platform are not frozen. That is `products-write.ts`'s
38
+ * `gate`, reused rather than restated. The commerce plugin must also be on for
39
+ * the site.
40
+ *
41
+ * The stay is written with exactly the fields the card wrote: confirmed, no
42
+ * email, no deposit, nothing paid. Payment is collected at the register.
43
+ */ export const WALK_IN_GONE = 'That resource is gone.';
44
+ export const WALK_IN_TAKEN_ONE_UNIT = 'Those dates are taken';
45
+ export const WALK_IN_TAKEN_EVERY_UNIT = 'Every unit is taken on at least one of those nights';
46
+ export const WALK_IN_PLUGIN_OFF = 'Reservations are not turned on for this site';
47
+ export const WALK_IN_ID_CONFLICT = 'That walk-in id belongs to another stay';
48
+ /** The refusal for dates with no free unit, worded for the resource. */ export function walkInTakenMessage(resource) {
49
+ return CommerceModel.resourceUnits(resource) > 1 ? WALK_IN_TAKEN_EVERY_UNIT : WALK_IN_TAKEN_ONE_UNIT;
50
+ }
51
+ const dayOf = (value)=>{
52
+ const ms = Number(value);
53
+ return Number.isFinite(ms) && ms > 0 ? CommerceModel.toDayMs(ms) : 0;
54
+ };
55
+ export function createReservationWalkInHandler(options = {}) {
56
+ return async (req, res)=>{
57
+ if (req.method !== 'POST') {
58
+ return res.status(405).json({
59
+ error: 'Method not allowed'
60
+ });
61
+ }
62
+ const deps = resolved(options);
63
+ const body = bodyOf(req);
64
+ const hostId = body['hostId'];
65
+ const resourceId = body['resourceId'];
66
+ const checkInDayMs = dayOf(body['checkInDayMs']);
67
+ const checkOutDayMs = dayOf(body['checkOutDayMs']);
68
+ const guestName = typeof body['guestName'] === 'string' ? body['guestName'].trim().slice(0, 120) : '';
69
+ // Optional, client-minted: a retry of the same walk-in (a dropped
70
+ // response on a weak signal) finds its own stay instead of booking a
71
+ // second unit.
72
+ const requestedId = body['reservationId'];
73
+ if (!validId(hostId) || !validId(resourceId) || !checkInDayMs || !checkOutDayMs || requestedId !== undefined && requestedId !== null && !validId(requestedId)) {
74
+ return res.status(400).json({
75
+ error: 'Missing walk-in details'
76
+ });
77
+ }
78
+ try {
79
+ const door = await gate(req, res, hostId, deps);
80
+ if (!door) return;
81
+ if (!isHostPluginEnabled(door.org, door.host.data(), BUNDLE_ID)) {
82
+ return res.status(403).json({
83
+ error: WALK_IN_PLUGIN_OFF
84
+ });
85
+ }
86
+ const db = deps.firestore();
87
+ const hostRef = db.collection('hosts').doc(hostId);
88
+ const resourceSnapshot = await hostRef.collection('resources').doc(resourceId).get();
89
+ const resource = resourceSnapshot.data();
90
+ if (!resourceSnapshot.exists || !resource) {
91
+ return res.status(404).json({
92
+ error: WALK_IN_GONE
93
+ });
94
+ }
95
+ // The one rule for what can be booked (no nights, under the minimum
96
+ // stay), shared with the card, the widget and both native apps.
97
+ const quote = CommerceModel.computeReservationQuote(resource, checkInDayMs, checkOutDayMs);
98
+ if (quote.problem) return res.status(400).json({
99
+ error: quote.problem
100
+ });
101
+ const reservations = hostRef.collection('reservations');
102
+ const availability = reservationAvailabilityQuery(reservations, resourceId, checkInDayMs);
103
+ const reservationRef = reservations.doc(typeof requestedId === 'string' ? requestedId : createResourceUid());
104
+ const outcome = await db.runTransaction(async (transaction)=>{
105
+ const [existing, fresh] = await Promise.all([
106
+ transaction.get(reservationRef),
107
+ transaction.get(availability)
108
+ ]);
109
+ if (existing.exists) {
110
+ const same = existing.get('resourceId') === resourceId && Number(existing.get('checkInDayMs')) === checkInDayMs && Number(existing.get('checkOutDayMs')) === checkOutDayMs;
111
+ return same ? {
112
+ status: 200,
113
+ body: {
114
+ reservationId: reservationRef.id,
115
+ replayed: true
116
+ }
117
+ } : {
118
+ status: 409,
119
+ body: {
120
+ error: WALK_IN_ID_CONFLICT
121
+ }
122
+ };
123
+ }
124
+ // Re-timed inside the transaction: a retry under contention runs
125
+ // later, and a pending hold's 30-minute lapse is measured from now.
126
+ const nowMs = deps.now();
127
+ if (!CommerceModel.isRangeAvailable(resource, readLiveReservations(fresh), checkInDayMs, checkOutDayMs, nowMs)) {
128
+ return {
129
+ status: 409,
130
+ body: {
131
+ error: walkInTakenMessage(resource)
132
+ }
133
+ };
134
+ }
135
+ transaction.set(reservationRef, {
136
+ resourceId,
137
+ status: 'confirmed',
138
+ checkInDayMs,
139
+ checkOutDayMs,
140
+ guestName: guestName || null,
141
+ guestEmail: null,
142
+ nights: quote.nights,
143
+ totalCents: quote.totalCents,
144
+ depositCents: 0,
145
+ paidCents: 0,
146
+ createdAtMs: nowMs
147
+ });
148
+ return {
149
+ status: 200,
150
+ body: {
151
+ reservationId: reservationRef.id
152
+ }
153
+ };
154
+ });
155
+ return res.status(outcome.status).json(outcome.body);
156
+ } catch (error) {
157
+ console.error('Walk-in reservation failed', error);
158
+ return res.status(500).json({
159
+ error: 'Walk-in failed'
160
+ });
161
+ }
162
+ };
163
+ }
164
+ export const reservationWalkInHandler = createReservationWalkInHandler();
165
+
166
+ //# sourceMappingURL=reservation-walk-in.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reservation-walk-in.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 { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { isHostPluginEnabled, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport * as CommerceModel from '../model'\nimport {\n readLiveReservations,\n reservationAvailabilityQuery,\n} from './reservation-availability-query'\nimport { bodyOf, gate, resolved, validId, type ProductsWriteDeps } from './products-write'\n\n/*\n * `POST /api/commerce/reservations/walk-in` (AGL-4046): a staff walk-in,\n * race-safe.\n *\n * The console Reservations card and the native apps used to write a walk-in\n * straight from the client after their own availability read. Two staff on two\n * devices could both read the last free unit (the last RV hookup) and both\n * write a stay into it. This route re-asks availability INSIDE the transaction\n * that writes, on the same query and the same `isRangeAvailable` rule the\n * storefront's `/api/commerce/reserve` uses, so the loser's transaction re-runs\n * on the winner's write and is refused.\n *\n * Who may call it is who the Firestore rules let write a reservation from the\n * console: a verified member with a content-write role on the site\n * (`canWriteHostContent`: admin, editor, author), or staff, while the site,\n * its org and the platform are not frozen. That is `products-write.ts`'s\n * `gate`, reused rather than restated. The commerce plugin must also be on for\n * the site.\n *\n * The stay is written with exactly the fields the card wrote: confirmed, no\n * email, no deposit, nothing paid. Payment is collected at the register.\n */\n\nexport const WALK_IN_GONE = 'That resource is gone.'\nexport const WALK_IN_TAKEN_ONE_UNIT = 'Those dates are taken'\nexport const WALK_IN_TAKEN_EVERY_UNIT =\n 'Every unit is taken on at least one of those nights'\nexport const WALK_IN_PLUGIN_OFF = 'Reservations are not turned on for this site'\nexport const WALK_IN_ID_CONFLICT = 'That walk-in id belongs to another stay'\n\n/** The refusal for dates with no free unit, worded for the resource. */\nexport function walkInTakenMessage(resource: CommerceModel.HostResource): string {\n return CommerceModel.resourceUnits(resource) > 1\n ? WALK_IN_TAKEN_EVERY_UNIT\n : WALK_IN_TAKEN_ONE_UNIT\n}\n\nconst dayOf = (value: unknown): number => {\n const ms = Number(value)\n return Number.isFinite(ms) && ms > 0 ? CommerceModel.toDayMs(ms) : 0\n}\n\ntype Outcome =\n | { status: 200; body: { reservationId: string; replayed?: true } }\n | { status: 409; body: { error: string } }\n\nexport function createReservationWalkInHandler(\n options: ProductsWriteDeps = {},\n): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const deps = resolved(options)\n const body = bodyOf(req)\n const hostId = body['hostId']\n const resourceId = body['resourceId']\n const checkInDayMs = dayOf(body['checkInDayMs'])\n const checkOutDayMs = dayOf(body['checkOutDayMs'])\n const guestName =\n typeof body['guestName'] === 'string'\n ? body['guestName'].trim().slice(0, 120)\n : ''\n // Optional, client-minted: a retry of the same walk-in (a dropped\n // response on a weak signal) finds its own stay instead of booking a\n // second unit.\n const requestedId = body['reservationId']\n if (\n !validId(hostId) ||\n !validId(resourceId) ||\n !checkInDayMs ||\n !checkOutDayMs ||\n (requestedId !== undefined && requestedId !== null && !validId(requestedId))\n ) {\n return res.status(400).json({ error: 'Missing walk-in details' })\n }\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n if (!isHostPluginEnabled(door.org as never, door.host.data() as never, BUNDLE_ID)) {\n return res.status(403).json({ error: WALK_IN_PLUGIN_OFF })\n }\n const db = deps.firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const resourceSnapshot = await hostRef.collection('resources').doc(resourceId).get()\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resourceSnapshot.exists || !resource) {\n return res.status(404).json({ error: WALK_IN_GONE })\n }\n // The one rule for what can be booked (no nights, under the minimum\n // stay), shared with the card, the widget and both native apps.\n const quote = CommerceModel.computeReservationQuote(resource, checkInDayMs, checkOutDayMs)\n if (quote.problem) return res.status(400).json({ error: quote.problem })\n\n const reservations = hostRef.collection('reservations')\n const availability = reservationAvailabilityQuery(reservations, resourceId, checkInDayMs)\n const reservationRef = reservations.doc(\n typeof requestedId === 'string' ? requestedId : createResourceUid(),\n )\n const outcome: Outcome = await db.runTransaction(async (transaction) => {\n const [existing, fresh] = await Promise.all([\n transaction.get(reservationRef),\n transaction.get(availability),\n ])\n if (existing.exists) {\n const same =\n existing.get('resourceId') === resourceId &&\n Number(existing.get('checkInDayMs')) === checkInDayMs &&\n Number(existing.get('checkOutDayMs')) === checkOutDayMs\n return same\n ? { status: 200, body: { reservationId: reservationRef.id, replayed: true } }\n : { status: 409, body: { error: WALK_IN_ID_CONFLICT } }\n }\n // Re-timed inside the transaction: a retry under contention runs\n // later, and a pending hold's 30-minute lapse is measured from now.\n const nowMs = deps.now()\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n readLiveReservations(fresh),\n checkInDayMs,\n checkOutDayMs,\n nowMs,\n )\n ) {\n return { status: 409, body: { error: walkInTakenMessage(resource) } }\n }\n transaction.set(reservationRef, {\n resourceId,\n status: 'confirmed',\n checkInDayMs,\n checkOutDayMs,\n guestName: guestName || null,\n guestEmail: null,\n nights: quote.nights,\n totalCents: quote.totalCents,\n depositCents: 0,\n paidCents: 0,\n createdAtMs: nowMs,\n } satisfies CommerceModel.HostReservation)\n return { status: 200, body: { reservationId: reservationRef.id } }\n })\n return res.status(outcome.status).json(outcome.body)\n } catch (error) {\n console.error('Walk-in reservation failed', error)\n return res.status(500).json({ error: 'Walk-in failed' })\n }\n }\n}\n\nexport const reservationWalkInHandler = createReservationWalkInHandler()\n"],"names":["createResourceUid","isHostPluginEnabled","BUNDLE_ID","CommerceModel","readLiveReservations","reservationAvailabilityQuery","bodyOf","gate","resolved","validId","WALK_IN_GONE","WALK_IN_TAKEN_ONE_UNIT","WALK_IN_TAKEN_EVERY_UNIT","WALK_IN_PLUGIN_OFF","WALK_IN_ID_CONFLICT","walkInTakenMessage","resource","resourceUnits","dayOf","value","ms","Number","isFinite","toDayMs","createReservationWalkInHandler","options","req","res","method","status","json","error","deps","body","hostId","resourceId","checkInDayMs","checkOutDayMs","guestName","trim","slice","requestedId","undefined","door","org","host","data","db","firestore","hostRef","collection","doc","resourceSnapshot","get","exists","quote","computeReservationQuote","problem","reservations","availability","reservationRef","outcome","runTransaction","transaction","existing","fresh","Promise","all","same","reservationId","id","replayed","nowMs","now","isRangeAvailable","set","guestEmail","nights","totalCents","depositCents","paidCents","createdAtMs","console","reservationWalkInHandler"],"mappings":"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"}
@@ -21,6 +21,7 @@ import { claimAttempt } from "@aglyn/aglyn/server";
21
21
  import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
22
22
  import { merchantAccountIsReady } from "@aglyn/tenant-data-admin/server/payment-provider";
23
23
  import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admin/server/stripe-card-authentication";
24
+ import { readLiveReservations, reservationAvailabilityQuery } from "./reservation-availability-query.js";
24
25
  /**
25
26
  * Reserve a stay (AGL-310): server-side quote + availability check,
26
27
  * pending reservation doc, then a Stripe session for the deposit (or
@@ -137,7 +138,10 @@ import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admi
137
138
  // "500 bookings in this resource's lifetime", but it is not zero, and the
138
139
  // sweep that would keep expired `pending` holds from filling those slots
139
140
  // is a scheduled job this change does not add (see the commit).
140
- const availabilityQuery = hostRef.collection('reservations').where('resourceId', '==', resourceId).where('checkOutDayMs', '>', checkInDayMs).orderBy('checkOutDayMs').limit(500);
141
+ //
142
+ // The query itself lives in `reservation-availability-query.ts`, shared
143
+ // with the staff walk-in route (AGL-4046) so both doors ask one question.
144
+ const availabilityQuery = reservationAvailabilityQuery(hostRef.collection('reservations'), resourceId, checkInDayMs);
141
145
  const [resourceSnapshot, reservationsSnapshot, storeSnapshot] = await Promise.all([
142
146
  hostRef.collection('resources').doc(resourceId).get(),
143
147
  availabilityQuery.get(),
@@ -165,15 +169,8 @@ import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admi
165
169
  // transaction cannot drift apart (AGL-2450). Two copies of a filter that
166
170
  // decides whether a room is free is exactly the shape where one of them
167
171
  // quietly stops matching the other.
168
- const readLive = (snapshot)=>snapshot.docs.map((docSnapshot)=>{
169
- var _docSnapshot_get;
170
- return {
171
- checkInDayMs: Number(docSnapshot.get('checkInDayMs')),
172
- checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),
173
- status: String(docSnapshot.get('status')),
174
- createdAtMs: Number((_docSnapshot_get = docSnapshot.get('createdAtMs')) != null ? _docSnapshot_get : 0)
175
- };
176
- });
172
+ // The staff walk-in route reads through the same helper (AGL-4046).
173
+ const readLive = readLiveReservations;
177
174
  const now = Date.now();
178
175
  const live = readLive(reservationsSnapshot);
179
176
  if (!CommerceModel.isRangeAvailable(resource, live, checkInDayMs, checkOutDayMs, now)) {
@@ -319,6 +316,12 @@ import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admi
319
316
  // here: the conflicting write lands BEFORE Stripe is contacted, so this
320
317
  // door can legitimately refuse rather than take the money and withhold the
321
318
  // room.
319
+ //
320
+ // The same re-read is what keeps a several-unit resource (AGL-4044) from
321
+ // selling its last unit twice: `isRangeAvailable` counts the stays on
322
+ // each night against `resource.units`, so two guests racing for the last
323
+ // RV hookup both read eleven of twelve taken, and the loser's transaction
324
+ // re-runs on the winner's write, counts twelve, and refuses.
322
325
  const reservationRef = hostRef.collection('reservations').doc();
323
326
  const booked = await firestore.runTransaction(async (transaction)=>{
324
327
  const fresh = await transaction.get(availabilityQuery);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve.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 { readSiteReturnHost, siteReturnUrl } from '@aglyn/aglyn/app-utils/site-return-url'\nimport type { AttemptClaim, PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { claimAttempt } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { merchantAccountIsReady } from '@aglyn/tenant-data-admin/server/payment-provider'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\n\n/**\n * Reserve a stay (AGL-310): server-side quote + availability check,\n * pending reservation doc, then a Stripe session for the deposit (or\n * full amount) on the merchant's account. Webhook completion confirms\n * the reservation; abandoned pending holds expire after 30 minutes and\n * stop blocking the calendar.\n */\nexport const reserveHandler: 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 body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const resourceId = String(body.resourceId ?? '')\n const checkInDayMs = CommerceModel.toDayMs(Number(body.checkInDayMs ?? 0))\n const checkOutDayMs = CommerceModel.toDayMs(Number(body.checkOutDayMs ?? 0))\n const guestName = String(body.guestName ?? '').trim().slice(0, 120)\n const guestEmail = String(body.guestEmail ?? '').trim().toLowerCase().slice(0, 120)\n // One reservation attempt, minted by the widget (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n ).trim().slice(0, 200)\n if (!hostId || !resourceId || !checkInDayMs || !checkOutDayMs) {\n return res.status(400).json({ error: 'Missing reservation details' })\n }\n\n let claim: AttemptClaim | null = null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n // Owner + payment readiness, HOISTED above the claim (AGL-1697): both are\n // deterministic refusals a merchant fixes out of band, and neither should\n // burn the guest's attempt key.\n const ownerOrg = await getOrgForHost(hostId)\n // Plan gate, re-asked per request exactly as the storefront checkout\n // asks it (AGL-1873, the AGL-481 pattern): a lapsed org's storefront\n // must not keep taking reservation deposits — at the free plan's 0%\n // transaction fee — through the one shopper-facing commerce door that\n // skipped the entitlement. Deterministic and merchant-side, so it sits\n // here above the claim with the readiness check.\n if (!Aglyn.checkEntitlement(ownerOrg?.org as any, 'commerce')) {\n return res.status(403).json({ error: 'Reservations are not enabled' })\n }\n const ownerId = ownerOrg?.org?.ownerUid\n const ownerProfile = ownerId\n ? await firestore.collection('profiles').doc(String(ownerId)).get()\n : null\n const accountId = ownerProfile?.get('stripeAccountId')\n if (\n !merchantAccountIsReady(\n {\n accountId,\n chargesEnabled: ownerProfile?.get('stripeChargesEnabled'),\n accountLivemode: ownerProfile?.get('stripeAccountLivemode'),\n },\n { subject: `reserve host ${hostId}` },\n )\n ) {\n return res.status(409).json({ error: 'Payments are not set up yet' })\n }\n\n // The claim sits ABOVE the availability read, and that placement is the\n // fix for this handler's particular lie (AGL-1697): a retried attempt used\n // to collide with its OWN 30-minute pending hold and be told \"Those dates\n // just sold out\" — about dates the guest was holding — where the correct\n // answer was the original session URL. Replaying here means the retry\n // never reaches the availability check at all. Every refusal below the\n // claim releases it, because each is deterministic and retryable.\n const claimed = await claimAttempt(firestore, {\n kind: 'commerce-reserve',\n scopeId: hostId,\n orgId: String(ownerOrg?.org?.id ?? ''),\n key: idempotencyKey,\n busyMessage: 'This reservation is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n // ONLY STAYS THAT COULD STILL OVERLAP, NEAREST FIRST (AGL-2159).\n //\n // This was `.where('resourceId','==',resourceId).limit(500)` with no\n // ordering, so Firestore returned 500 documents in `__name__` order — an\n // arbitrary slice of the resource's ENTIRE booking history. Every stay the\n // resource has ever had competed for those 500 slots on equal terms with\n // the ones that matter, so a cottage that had taken 500 bookings began\n // silently dropping LIVE reservations out of its own availability check\n // and confirming a second guest into an occupied room. Nothing about that\n // failure is visible: `isRangeAvailable` is handed a short list and\n // answers, correctly, that the range is free.\n //\n // `checkOutDayMs > checkInDayMs` is the one inequality that removes the\n // whole of the past — a stay that ended before the requested arrival\n // cannot overlap it — and ordering by the same field puts the stays\n // nearest the requested window at the front of the limit rather than\n // whichever ones happen to sort early by id. Needs the composite index\n // `reservations (resourceId ASC, checkOutDayMs ASC)`, added to\n // `cloud/firebase-firestore.indexes.json` and pinned by\n // `reserve-availability-indexes.spec.ts` — an equality plus an `orderBy`\n // on a second field is exactly the shape that reads as free and is not\n // (AGL-1793), and here a missing index throws rather than under-reporting.\n //\n // RESIDUAL, stated: the cap can still bite when a single resource holds\n // more than 500 stays that all end between the requested arrival and the\n // end of a genuinely overlapping one. That is a far narrower window than\n // \"500 bookings in this resource's lifetime\", but it is not zero, and the\n // sweep that would keep expired `pending` holds from filling those slots\n // is a scheduled job this change does not add (see the commit).\n const availabilityQuery = hostRef\n .collection('reservations')\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>', checkInDayMs)\n .orderBy('checkOutDayMs')\n .limit(500)\n const [resourceSnapshot, reservationsSnapshot, storeSnapshot] =\n await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n availabilityQuery.get(),\n // The merchant's own lodging rate (AGL-1969). Read in the SAME round\n // trip as the availability query rather than as a fourth sequential\n // await — this path already runs three reads before it can answer, and\n // a tax lookup is not a reason to make a guest wait for a fourth.\n hostRef.collection('settings').doc('store').get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) {\n await claim.release()\n return res.status(404).json({ error: 'Unknown resource' })\n }\n\n // Pending holds block for 30 minutes only, so abandoned checkouts\n // release the dates. The lapse itself lives in\n // `reservationHoldsDates`, which `isRangeAvailable` applies — this reads\n // the fields that rule needs and leaves the rule to the model, so the\n // storefront's date-picker cannot answer a different question from the\n // door that takes the money.\n //\n // Extracted so the pre-read below and the re-read inside the booking\n // transaction cannot drift apart (AGL-2450). Two copies of a filter that\n // decides whether a room is free is exactly the shape where one of them\n // quietly stops matching the other.\n const readLive = (snapshot: {\n docs: any[]\n }): CommerceModel.HostReservation[] =>\n snapshot.docs.map((docSnapshot) => ({\n checkInDayMs: Number(docSnapshot.get('checkInDayMs')),\n checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),\n status: String(\n docSnapshot.get('status'),\n ) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n })) as CommerceModel.HostReservation[]\n const now = Date.now()\n const live = readLive(reservationsSnapshot)\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n live,\n checkInDayMs,\n checkOutDayMs,\n now,\n )\n ) {\n // A GENUINE sold-out (a keyed retry replayed before reaching here). The\n // guest picks other dates or the blocking stay is cancelled; either way\n // the key must survive the refusal.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n const quote = CommerceModel.computeReservationQuote(\n resource,\n checkInDayMs,\n checkOutDayMs,\n )\n if (quote.problem) {\n await claim.release()\n return res.status(400).json({ error: quote.problem })\n }\n\n const chargeCents = quote.depositCents || quote.totalCents\n\n // NO DISCOUNT IS APPLIED HERE, AND THAT IS AN OPEN QUESTION RATHER THAN AN\n // OVERSIGHT (AGL-305).\n //\n // `hosts/{hostId}/discounts` reaches the cart, buy-now, the draft-order\n // payment link and the register. This door was deliberately left out,\n // because unlike those four it does not take a payment for goods — it takes\n // a DEPOSIT against a stay that is settled later, and \"apply 10% off\" has\n // three different meanings against that shape:\n //\n // 1. Discount the STAY. `quote.totalCents` falls, the deposit falls with\n // it because it is a percentage of the subtotal, and the balance falls\n // too. This is the only reading that is actually a discount on the\n // booking — and it reprices a merchant's lodging inventory from a\n // storefront promo code, which is a larger claim than any of the four\n // goods doors makes.\n // 2. Discount the DEPOSIT only. Strictly worse than doing nothing: the\n // balance is computed downstream as `totalCents - paidCents`, so every\n // cent taken off at booking is billed straight back at check-out. The\n // guest would be shown a discount and then charged for it, which is\n // the silent-reversal shape this codebase refuses elsewhere.\n // 3. Discount the BALANCE. Nothing visible at booking; the guest finds\n // out at the register. A promotion nobody can see when they buy is not\n // a promotion.\n //\n // What makes this a product decision and not an implementation one is that\n // the balance is not collected by any door this repo can teach. It is read\n // off the console's reservations card as `totalCents - paidCents` and\n // collected BY A HUMAN at the till, so option 1 is the only one that\n // survives the hand-off — and it survives it by changing the stay's stored\n // price, which is the merchant's revenue basis and what their analytics,\n // their folio and their register all read.\n //\n // The tax makes it sharper still. `lodgingTax` below is an occupancy rate,\n // not a sales rate, and its basis here is already a STATED LIMITATION — it\n // is charged on what is collected (the deposit) rather than on the stay.\n // Layering a discount on top asks whether occupancy tax is owed on the\n // discounted room rate or the gross one, which differs by jurisdiction, is\n // the merchant's own regime to answer, and is not a question this handler\n // can decide from a promo code.\n //\n // So: refusing to guess. A shopper who types a code here is not told it\n // worked — this path never reads one, so there is nothing to fall through\n // silently, which is the failure mode that mattered. When the owner picks\n // a reading, option 1 is the one the machinery already supports: reduce\n // `quote.subtotalCents` before the deposit is derived from it, and the\n // deposit, the balance, the fee and the tax all follow without further\n // change.\n\n // LODGING TAX: the MERCHANT'S OWN RATE, off unless they set one\n // (AGL-1969, answering the decision AGL-1953 recorded here).\n //\n // What this path used to do, and why it was only ever a holding position:\n // it computed no tax in either store mode, on two stated grounds. The\n // first is still true and is the reason for the shape below — a stay is\n // not goods. The AGL-285 zone editor configures a SALES rate, lodging is\n // an occupancy/hotel regime with its own rates, its own registration and\n // its own return, and reading `rates[]` for a night would be confidently\n // wrong rather than merely absent. Stripe Tax has the mirror problem: it\n // needs a lodging tax code this handler does not send, so\n // `automatic_tax` here would compute a goods rate on a room. Neither of\n // those changes, and neither is used.\n //\n // What DID change is that the merchant now has a field of their own to\n // answer in (`tax.lodging`), so \"Aglyn does not know the right number\"\n // stops being a reason to collect nothing. They type the rate, we compute\n // it, charge it, record it and stamp the regime; the settings card states\n // plainly that determining what is owed and to whom is theirs. Aglyn\n // takes no tax position here — this is the manual LODGING rate, which\n // never touches Aglyn's registrations.\n //\n // ⚠️ This comment used to say \"Terms §10.3 ... untouched by this\". §10.3\n // CHANGED on 2026-08-24 (AGL-1956): Aglyn accepts marketplace-facilitator\n // status and new §10.7 makes storefront sales tax Aglyn's where it has a\n // collection obligation. Lodging tax is still the merchant's, so this\n // handler is unaffected — but §10.3 is no longer the authority for that.\n //\n // DEFAULT OFF, and that is load-bearing: `resolveFlatTaxCents` answers\n // zero for an absent, zero, negative or out-of-range rate, so no existing\n // reservation's charge moves because this shipped.\n //\n // THE BASIS IS WHAT IS CHARGED, AND THAT IS A STATED LIMITATION RATHER\n // THAN AN ANSWER. `chargeCents` is `depositCents || totalCents`, so on a\n // deposit-taking resource the tax collected here is the rate on the\n // DEPOSIT, not on the stay. Whether a given jurisdiction wants occupancy\n // tax on the full stay at booking, on the deposit only, or on redemption\n // at check-out is a tax question, it differs by jurisdiction, and this\n // code deliberately does NOT decide it — the amount the platform actually\n // moves is the only basis available to it, the settings card says so in\n // the merchant's own view, and the residual is on AGL-1969.\n const taxSettings = ((storeSnapshot.data() as any)?.tax ??\n {}) as CommerceModel.TaxSettings\n const lodgingTax = CommerceModel.resolveFlatTaxCents(\n taxSettings.lodging,\n chargeCents,\n 'Lodging tax',\n )\n\n // Platform take PLUS Stripe's card cost at cost (AGL-2152). This is a\n // destination charge, so Stripe debits its percentage plus 30¢ from the\n // PLATFORM's balance; with the old take-only figure every tier carrying a\n // 0% service rate paid Aglyn nothing and cost it that fee on each\n // reservation.\n //\n // THE TWO BASES ARE NOW DIFFERENT NUMBERS (AGL-1969), and the split is\n // AGL-2317's rule: the platform take is computed on the ITEMS — the stay\n // — while Stripe's cost is passed through on the WHOLE charge, because\n // that is what Stripe actually debits. Taking a percentage of the lodging\n // tax would be taking a cut of the state's money, which no storefront\n // path does. The previous single-argument form is what made this a real\n // risk: it read as \"the fee base is the charge\" and would have silently\n // started taxing the tax the moment a tax line appeared.\n const feeCents = Aglyn.resolveTransactionFeeCents(\n ownerOrg?.org as any,\n 'service',\n chargeCents,\n chargeCents + lodgingTax.taxCents,\n )\n\n // THE HOLD IS TAKEN IN A TRANSACTION (AGL-2450).\n //\n // The check above is a fast refusal on a stale read, and on its own that is\n // all it ever was: the availability query ran, the quote and the fee were\n // computed, and only then did the hold land — so two guests requesting the\n // same dates both read a reservation set that lacked the other's booking,\n // both passed, and both got a `pending` row and a real payment. Nothing\n // downstream caught it. The webhook's transaction guards double-CONFIRMATION\n // of one booking against Stripe redelivery; it never re-asks availability,\n // so both holds confirmed cleanly into two guests in one room.\n //\n // `claimAttempt` does not help either, and it is worth being explicit about\n // why: it is keyed on the guest's own `Idempotency-Key`, so it dedupes\n // retries of ONE attempt. Two different guests carry two different keys and\n // never contend.\n //\n // Re-asking inside the transaction that writes is the whole guard — the\n // same shape as `decrementVariantStock` (AGL-2320), and easier to justify\n // here: the conflicting write lands BEFORE Stripe is contacted, so this\n // door can legitimately refuse rather than take the money and withhold the\n // room.\n const reservationRef = hostRef.collection('reservations').doc()\n const booked = await firestore.runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(availabilityQuery)\n // Re-timed, not reusing `now`: a transaction that retries under\n // contention may run appreciably later, and the 30-minute pending window\n // has to be measured from when this attempt actually commits.\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n readLive(fresh),\n checkInDayMs,\n checkOutDayMs,\n Date.now(),\n )\n ) {\n return false\n }\n // `create()`, not `set()`: the ref is freshly minted, so an id collision\n // is a bug rather than an overwrite to absorb silently.\n transaction.create(reservationRef, {\n resourceId,\n status: 'pending',\n checkInDayMs,\n checkOutDayMs,\n guestName: guestName || null,\n guestEmail: guestEmail || null,\n nights: quote.nights,\n totalCents: quote.totalCents,\n depositCents: quote.depositCents,\n paidCents: 0,\n createdAtMs: now,\n } satisfies CommerceModel.HostReservation)\n return true\n })\n if (!booked) {\n // Lost the race for these dates. Same refusal as the pre-read's, and the\n // key survives it for the same reason: the guest picks other dates or the\n // blocking stay is cancelled.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n\n // The site's own page, never a header's say-so (AGL-3363): a caller\n // writes `Referer` and `Host`, and this URL is where Stripe sends the\n // payer — and what the receipt's download links are built from.\n const backUrl = siteReturnUrl({\n candidates: [String(req.headers.referer ?? ''), `https://${req.headers.host}`],\n site: await readSiteReturnHost(hostRef),\n requestHost: String(req.headers.host ?? ''),\n })\n const separator = backUrl.includes('?') ? '&' : '?'\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(chargeCents),\n 'line_items[0][price_data][product_data][name]': `${resource.name} — ${\n quote.nights\n } night${quote.nights === 1 ? '' : 's'}${\n quote.depositCents && quote.depositCents < quote.totalCents\n ? ' (deposit)'\n : ''\n }`.slice(0, 120),\n // The merchant's lodging tax as an ORDINARY product line Stripe is\n // never told is tax — the AGL-1711 construction every other manual rate\n // rides. That is what makes the derived `taxMode` read `manual`: the\n // figure is the merchant's own, computed from their own rate, and never\n // against Aglyn's registrations the way `automatic_tax` would be\n // (AGL-1904). Absent entirely when no rate is set, so a default store's\n // session carries no tax parameter of any kind.\n ...(lodgingTax.taxCents > 0\n ? {\n 'line_items[1][quantity]': '1',\n 'line_items[1][price_data][currency]': 'usd',\n 'line_items[1][price_data][unit_amount]': String(\n lodgingTax.taxCents,\n ),\n 'line_items[1][price_data][product_data][name]': lodgingTax.label,\n }\n : {}),\n ...(feeCents > 0\n ? { 'payment_intent_data[application_fee_amount]': String(feeCents) }\n : {}),\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n ...(guestEmail ? { customer_email: guestEmail } : {}),\n success_url: `${backUrl}${separator}reserved=1`,\n cancel_url: `${backUrl}${separator}reserved=0`,\n 'metadata[type]': 'commerce-reservation',\n 'metadata[hostId]': hostId,\n 'metadata[reservationId]': reservationRef.id,\n 'metadata[feeCents]': String(feeCents),\n // The only witness to the tax (AGL-1969). By the construction above\n // Stripe does not know the second line is tax, so it reports\n // `total_details.amount_tax: 0` — the session's own metadata is what\n // `storefront-tax-record.ts` and the confirmation branch read to record\n // the figure and derive the regime, exactly as `checkout.ts` stamps it\n // for a buy-now sale. Absent when there is no tax, never a reassuring\n // `'0'`: absent and zero are different facts on a back-book question.\n ...(lodgingTax.taxCents > 0\n ? {\n 'metadata[taxCents]': String(lodgingTax.taxCents),\n 'metadata[taxPct]': String(lodgingTax.pct),\n }\n : {}),\n })\n // The merchant's payment method choices hold for a stay too (AGL-3629).\n CommerceModel.appendStorefrontPaymentMethodParams(\n params,\n CommerceModel.resolveStorefrontPaymentMethodControls(\n CommerceModel.normalizeStorefrontPaymentMethodSettings(\n storeSnapshot.get('paymentMethods'),\n ),\n ),\n )\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money (AGL-1697). Stripe replays the\n // existing session for a repeated key instead of opening a second\n // one, covering the window where the claim is written but the\n // response never arrives.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe reservation error', session.error)\n await reservationRef.delete().catch(() => undefined)\n // The hold is rolled back, so the key must come back too — the guest\n // presses the same button and the retry re-derives the same digest,\n // which Stripe replays if the session did get created.\n await claim.release()\n return res.status(502).json({ error: 'Payment setup failed' })\n }\n const payload = { url: session.url }\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // (AGL-1691's rule): the hold may or may not have landed, and it expires\n // in 30 minutes either way.\n await claim?.release()\n return res.status(500).json({ error: 'Reservation failed' })\n }\n}\n"],"names":["readSiteReturnHost","siteReturnUrl","Aglyn","CommerceModel","claimAttempt","firebaseAdmin","getOrgForHost","merchantAccountIsReady","checkoutSessionCardAuthenticationParams","reserveHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","JSON","parse","hostId","String","resourceId","checkInDayMs","toDayMs","Number","checkOutDayMs","guestName","trim","slice","guestEmail","toLowerCase","idempotencyKey","headers","claim","ownerOrg","storeSnapshot","firestore","app","hostRef","collection","doc","checkEntitlement","org","ownerId","ownerUid","ownerProfile","get","accountId","chargesEnabled","accountLivemode","subject","claimed","kind","scopeId","orgId","id","key","busyMessage","replay","availabilityQuery","where","orderBy","limit","resourceSnapshot","reservationsSnapshot","Promise","all","resource","data","release","readLive","snapshot","docs","map","docSnapshot","createdAtMs","now","Date","live","isRangeAvailable","quote","computeReservationQuote","problem","chargeCents","depositCents","totalCents","taxSettings","tax","lodgingTax","resolveFlatTaxCents","lodging","feeCents","resolveTransactionFeeCents","taxCents","reservationRef","booked","runTransaction","transaction","fresh","create","nights","paidCents","backUrl","candidates","referer","host","site","requestHost","separator","includes","params","URLSearchParams","mode","name","label","customer_email","success_url","cancel_url","pct","appendStorefrontPaymentMethodParams","resolveStorefrontPaymentMethodControls","normalizeStorefrontPaymentMethodSettings","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","delete","catch","undefined","payload","record"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve.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 { readSiteReturnHost, siteReturnUrl } from '@aglyn/aglyn/app-utils/site-return-url'\nimport type { AttemptClaim, PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { claimAttempt } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { merchantAccountIsReady } from '@aglyn/tenant-data-admin/server/payment-provider'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\nimport {\n readLiveReservations,\n reservationAvailabilityQuery,\n} from './reservation-availability-query'\n\n/**\n * Reserve a stay (AGL-310): server-side quote + availability check,\n * pending reservation doc, then a Stripe session for the deposit (or\n * full amount) on the merchant's account. Webhook completion confirms\n * the reservation; abandoned pending holds expire after 30 minutes and\n * stop blocking the calendar.\n */\nexport const reserveHandler: 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 body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const resourceId = String(body.resourceId ?? '')\n const checkInDayMs = CommerceModel.toDayMs(Number(body.checkInDayMs ?? 0))\n const checkOutDayMs = CommerceModel.toDayMs(Number(body.checkOutDayMs ?? 0))\n const guestName = String(body.guestName ?? '').trim().slice(0, 120)\n const guestEmail = String(body.guestEmail ?? '').trim().toLowerCase().slice(0, 120)\n // One reservation attempt, minted by the widget (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n ).trim().slice(0, 200)\n if (!hostId || !resourceId || !checkInDayMs || !checkOutDayMs) {\n return res.status(400).json({ error: 'Missing reservation details' })\n }\n\n let claim: AttemptClaim | null = null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n // Owner + payment readiness, HOISTED above the claim (AGL-1697): both are\n // deterministic refusals a merchant fixes out of band, and neither should\n // burn the guest's attempt key.\n const ownerOrg = await getOrgForHost(hostId)\n // Plan gate, re-asked per request exactly as the storefront checkout\n // asks it (AGL-1873, the AGL-481 pattern): a lapsed org's storefront\n // must not keep taking reservation deposits — at the free plan's 0%\n // transaction fee — through the one shopper-facing commerce door that\n // skipped the entitlement. Deterministic and merchant-side, so it sits\n // here above the claim with the readiness check.\n if (!Aglyn.checkEntitlement(ownerOrg?.org as any, 'commerce')) {\n return res.status(403).json({ error: 'Reservations are not enabled' })\n }\n const ownerId = ownerOrg?.org?.ownerUid\n const ownerProfile = ownerId\n ? await firestore.collection('profiles').doc(String(ownerId)).get()\n : null\n const accountId = ownerProfile?.get('stripeAccountId')\n if (\n !merchantAccountIsReady(\n {\n accountId,\n chargesEnabled: ownerProfile?.get('stripeChargesEnabled'),\n accountLivemode: ownerProfile?.get('stripeAccountLivemode'),\n },\n { subject: `reserve host ${hostId}` },\n )\n ) {\n return res.status(409).json({ error: 'Payments are not set up yet' })\n }\n\n // The claim sits ABOVE the availability read, and that placement is the\n // fix for this handler's particular lie (AGL-1697): a retried attempt used\n // to collide with its OWN 30-minute pending hold and be told \"Those dates\n // just sold out\" — about dates the guest was holding — where the correct\n // answer was the original session URL. Replaying here means the retry\n // never reaches the availability check at all. Every refusal below the\n // claim releases it, because each is deterministic and retryable.\n const claimed = await claimAttempt(firestore, {\n kind: 'commerce-reserve',\n scopeId: hostId,\n orgId: String(ownerOrg?.org?.id ?? ''),\n key: idempotencyKey,\n busyMessage: 'This reservation is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n // ONLY STAYS THAT COULD STILL OVERLAP, NEAREST FIRST (AGL-2159).\n //\n // This was `.where('resourceId','==',resourceId).limit(500)` with no\n // ordering, so Firestore returned 500 documents in `__name__` order — an\n // arbitrary slice of the resource's ENTIRE booking history. Every stay the\n // resource has ever had competed for those 500 slots on equal terms with\n // the ones that matter, so a cottage that had taken 500 bookings began\n // silently dropping LIVE reservations out of its own availability check\n // and confirming a second guest into an occupied room. Nothing about that\n // failure is visible: `isRangeAvailable` is handed a short list and\n // answers, correctly, that the range is free.\n //\n // `checkOutDayMs > checkInDayMs` is the one inequality that removes the\n // whole of the past — a stay that ended before the requested arrival\n // cannot overlap it — and ordering by the same field puts the stays\n // nearest the requested window at the front of the limit rather than\n // whichever ones happen to sort early by id. Needs the composite index\n // `reservations (resourceId ASC, checkOutDayMs ASC)`, added to\n // `cloud/firebase-firestore.indexes.json` and pinned by\n // `reserve-availability-indexes.spec.ts` — an equality plus an `orderBy`\n // on a second field is exactly the shape that reads as free and is not\n // (AGL-1793), and here a missing index throws rather than under-reporting.\n //\n // RESIDUAL, stated: the cap can still bite when a single resource holds\n // more than 500 stays that all end between the requested arrival and the\n // end of a genuinely overlapping one. That is a far narrower window than\n // \"500 bookings in this resource's lifetime\", but it is not zero, and the\n // sweep that would keep expired `pending` holds from filling those slots\n // is a scheduled job this change does not add (see the commit).\n //\n // The query itself lives in `reservation-availability-query.ts`, shared\n // with the staff walk-in route (AGL-4046) so both doors ask one question.\n const availabilityQuery = reservationAvailabilityQuery(\n hostRef.collection('reservations'),\n resourceId,\n checkInDayMs,\n )\n const [resourceSnapshot, reservationsSnapshot, storeSnapshot] =\n await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n availabilityQuery.get(),\n // The merchant's own lodging rate (AGL-1969). Read in the SAME round\n // trip as the availability query rather than as a fourth sequential\n // await — this path already runs three reads before it can answer, and\n // a tax lookup is not a reason to make a guest wait for a fourth.\n hostRef.collection('settings').doc('store').get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) {\n await claim.release()\n return res.status(404).json({ error: 'Unknown resource' })\n }\n\n // Pending holds block for 30 minutes only, so abandoned checkouts\n // release the dates. The lapse itself lives in\n // `reservationHoldsDates`, which `isRangeAvailable` applies — this reads\n // the fields that rule needs and leaves the rule to the model, so the\n // storefront's date-picker cannot answer a different question from the\n // door that takes the money.\n //\n // Extracted so the pre-read below and the re-read inside the booking\n // transaction cannot drift apart (AGL-2450). Two copies of a filter that\n // decides whether a room is free is exactly the shape where one of them\n // quietly stops matching the other.\n // The staff walk-in route reads through the same helper (AGL-4046).\n const readLive = readLiveReservations\n const now = Date.now()\n const live = readLive(reservationsSnapshot)\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n live,\n checkInDayMs,\n checkOutDayMs,\n now,\n )\n ) {\n // A GENUINE sold-out (a keyed retry replayed before reaching here). The\n // guest picks other dates or the blocking stay is cancelled; either way\n // the key must survive the refusal.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n const quote = CommerceModel.computeReservationQuote(\n resource,\n checkInDayMs,\n checkOutDayMs,\n )\n if (quote.problem) {\n await claim.release()\n return res.status(400).json({ error: quote.problem })\n }\n\n const chargeCents = quote.depositCents || quote.totalCents\n\n // NO DISCOUNT IS APPLIED HERE, AND THAT IS AN OPEN QUESTION RATHER THAN AN\n // OVERSIGHT (AGL-305).\n //\n // `hosts/{hostId}/discounts` reaches the cart, buy-now, the draft-order\n // payment link and the register. This door was deliberately left out,\n // because unlike those four it does not take a payment for goods — it takes\n // a DEPOSIT against a stay that is settled later, and \"apply 10% off\" has\n // three different meanings against that shape:\n //\n // 1. Discount the STAY. `quote.totalCents` falls, the deposit falls with\n // it because it is a percentage of the subtotal, and the balance falls\n // too. This is the only reading that is actually a discount on the\n // booking — and it reprices a merchant's lodging inventory from a\n // storefront promo code, which is a larger claim than any of the four\n // goods doors makes.\n // 2. Discount the DEPOSIT only. Strictly worse than doing nothing: the\n // balance is computed downstream as `totalCents - paidCents`, so every\n // cent taken off at booking is billed straight back at check-out. The\n // guest would be shown a discount and then charged for it, which is\n // the silent-reversal shape this codebase refuses elsewhere.\n // 3. Discount the BALANCE. Nothing visible at booking; the guest finds\n // out at the register. A promotion nobody can see when they buy is not\n // a promotion.\n //\n // What makes this a product decision and not an implementation one is that\n // the balance is not collected by any door this repo can teach. It is read\n // off the console's reservations card as `totalCents - paidCents` and\n // collected BY A HUMAN at the till, so option 1 is the only one that\n // survives the hand-off — and it survives it by changing the stay's stored\n // price, which is the merchant's revenue basis and what their analytics,\n // their folio and their register all read.\n //\n // The tax makes it sharper still. `lodgingTax` below is an occupancy rate,\n // not a sales rate, and its basis here is already a STATED LIMITATION — it\n // is charged on what is collected (the deposit) rather than on the stay.\n // Layering a discount on top asks whether occupancy tax is owed on the\n // discounted room rate or the gross one, which differs by jurisdiction, is\n // the merchant's own regime to answer, and is not a question this handler\n // can decide from a promo code.\n //\n // So: refusing to guess. A shopper who types a code here is not told it\n // worked — this path never reads one, so there is nothing to fall through\n // silently, which is the failure mode that mattered. When the owner picks\n // a reading, option 1 is the one the machinery already supports: reduce\n // `quote.subtotalCents` before the deposit is derived from it, and the\n // deposit, the balance, the fee and the tax all follow without further\n // change.\n\n // LODGING TAX: the MERCHANT'S OWN RATE, off unless they set one\n // (AGL-1969, answering the decision AGL-1953 recorded here).\n //\n // What this path used to do, and why it was only ever a holding position:\n // it computed no tax in either store mode, on two stated grounds. The\n // first is still true and is the reason for the shape below — a stay is\n // not goods. The AGL-285 zone editor configures a SALES rate, lodging is\n // an occupancy/hotel regime with its own rates, its own registration and\n // its own return, and reading `rates[]` for a night would be confidently\n // wrong rather than merely absent. Stripe Tax has the mirror problem: it\n // needs a lodging tax code this handler does not send, so\n // `automatic_tax` here would compute a goods rate on a room. Neither of\n // those changes, and neither is used.\n //\n // What DID change is that the merchant now has a field of their own to\n // answer in (`tax.lodging`), so \"Aglyn does not know the right number\"\n // stops being a reason to collect nothing. They type the rate, we compute\n // it, charge it, record it and stamp the regime; the settings card states\n // plainly that determining what is owed and to whom is theirs. Aglyn\n // takes no tax position here — this is the manual LODGING rate, which\n // never touches Aglyn's registrations.\n //\n // ⚠️ This comment used to say \"Terms §10.3 ... untouched by this\". §10.3\n // CHANGED on 2026-08-24 (AGL-1956): Aglyn accepts marketplace-facilitator\n // status and new §10.7 makes storefront sales tax Aglyn's where it has a\n // collection obligation. Lodging tax is still the merchant's, so this\n // handler is unaffected — but §10.3 is no longer the authority for that.\n //\n // DEFAULT OFF, and that is load-bearing: `resolveFlatTaxCents` answers\n // zero for an absent, zero, negative or out-of-range rate, so no existing\n // reservation's charge moves because this shipped.\n //\n // THE BASIS IS WHAT IS CHARGED, AND THAT IS A STATED LIMITATION RATHER\n // THAN AN ANSWER. `chargeCents` is `depositCents || totalCents`, so on a\n // deposit-taking resource the tax collected here is the rate on the\n // DEPOSIT, not on the stay. Whether a given jurisdiction wants occupancy\n // tax on the full stay at booking, on the deposit only, or on redemption\n // at check-out is a tax question, it differs by jurisdiction, and this\n // code deliberately does NOT decide it — the amount the platform actually\n // moves is the only basis available to it, the settings card says so in\n // the merchant's own view, and the residual is on AGL-1969.\n const taxSettings = ((storeSnapshot.data() as any)?.tax ??\n {}) as CommerceModel.TaxSettings\n const lodgingTax = CommerceModel.resolveFlatTaxCents(\n taxSettings.lodging,\n chargeCents,\n 'Lodging tax',\n )\n\n // Platform take PLUS Stripe's card cost at cost (AGL-2152). This is a\n // destination charge, so Stripe debits its percentage plus 30¢ from the\n // PLATFORM's balance; with the old take-only figure every tier carrying a\n // 0% service rate paid Aglyn nothing and cost it that fee on each\n // reservation.\n //\n // THE TWO BASES ARE NOW DIFFERENT NUMBERS (AGL-1969), and the split is\n // AGL-2317's rule: the platform take is computed on the ITEMS — the stay\n // — while Stripe's cost is passed through on the WHOLE charge, because\n // that is what Stripe actually debits. Taking a percentage of the lodging\n // tax would be taking a cut of the state's money, which no storefront\n // path does. The previous single-argument form is what made this a real\n // risk: it read as \"the fee base is the charge\" and would have silently\n // started taxing the tax the moment a tax line appeared.\n const feeCents = Aglyn.resolveTransactionFeeCents(\n ownerOrg?.org as any,\n 'service',\n chargeCents,\n chargeCents + lodgingTax.taxCents,\n )\n\n // THE HOLD IS TAKEN IN A TRANSACTION (AGL-2450).\n //\n // The check above is a fast refusal on a stale read, and on its own that is\n // all it ever was: the availability query ran, the quote and the fee were\n // computed, and only then did the hold land — so two guests requesting the\n // same dates both read a reservation set that lacked the other's booking,\n // both passed, and both got a `pending` row and a real payment. Nothing\n // downstream caught it. The webhook's transaction guards double-CONFIRMATION\n // of one booking against Stripe redelivery; it never re-asks availability,\n // so both holds confirmed cleanly into two guests in one room.\n //\n // `claimAttempt` does not help either, and it is worth being explicit about\n // why: it is keyed on the guest's own `Idempotency-Key`, so it dedupes\n // retries of ONE attempt. Two different guests carry two different keys and\n // never contend.\n //\n // Re-asking inside the transaction that writes is the whole guard — the\n // same shape as `decrementVariantStock` (AGL-2320), and easier to justify\n // here: the conflicting write lands BEFORE Stripe is contacted, so this\n // door can legitimately refuse rather than take the money and withhold the\n // room.\n //\n // The same re-read is what keeps a several-unit resource (AGL-4044) from\n // selling its last unit twice: `isRangeAvailable` counts the stays on\n // each night against `resource.units`, so two guests racing for the last\n // RV hookup both read eleven of twelve taken, and the loser's transaction\n // re-runs on the winner's write, counts twelve, and refuses.\n const reservationRef = hostRef.collection('reservations').doc()\n const booked = await firestore.runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(availabilityQuery)\n // Re-timed, not reusing `now`: a transaction that retries under\n // contention may run appreciably later, and the 30-minute pending window\n // has to be measured from when this attempt actually commits.\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n readLive(fresh),\n checkInDayMs,\n checkOutDayMs,\n Date.now(),\n )\n ) {\n return false\n }\n // `create()`, not `set()`: the ref is freshly minted, so an id collision\n // is a bug rather than an overwrite to absorb silently.\n transaction.create(reservationRef, {\n resourceId,\n status: 'pending',\n checkInDayMs,\n checkOutDayMs,\n guestName: guestName || null,\n guestEmail: guestEmail || null,\n nights: quote.nights,\n totalCents: quote.totalCents,\n depositCents: quote.depositCents,\n paidCents: 0,\n createdAtMs: now,\n } satisfies CommerceModel.HostReservation)\n return true\n })\n if (!booked) {\n // Lost the race for these dates. Same refusal as the pre-read's, and the\n // key survives it for the same reason: the guest picks other dates or the\n // blocking stay is cancelled.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n\n // The site's own page, never a header's say-so (AGL-3363): a caller\n // writes `Referer` and `Host`, and this URL is where Stripe sends the\n // payer — and what the receipt's download links are built from.\n const backUrl = siteReturnUrl({\n candidates: [String(req.headers.referer ?? ''), `https://${req.headers.host}`],\n site: await readSiteReturnHost(hostRef),\n requestHost: String(req.headers.host ?? ''),\n })\n const separator = backUrl.includes('?') ? '&' : '?'\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(chargeCents),\n 'line_items[0][price_data][product_data][name]': `${resource.name} — ${\n quote.nights\n } night${quote.nights === 1 ? '' : 's'}${\n quote.depositCents && quote.depositCents < quote.totalCents\n ? ' (deposit)'\n : ''\n }`.slice(0, 120),\n // The merchant's lodging tax as an ORDINARY product line Stripe is\n // never told is tax — the AGL-1711 construction every other manual rate\n // rides. That is what makes the derived `taxMode` read `manual`: the\n // figure is the merchant's own, computed from their own rate, and never\n // against Aglyn's registrations the way `automatic_tax` would be\n // (AGL-1904). Absent entirely when no rate is set, so a default store's\n // session carries no tax parameter of any kind.\n ...(lodgingTax.taxCents > 0\n ? {\n 'line_items[1][quantity]': '1',\n 'line_items[1][price_data][currency]': 'usd',\n 'line_items[1][price_data][unit_amount]': String(\n lodgingTax.taxCents,\n ),\n 'line_items[1][price_data][product_data][name]': lodgingTax.label,\n }\n : {}),\n ...(feeCents > 0\n ? { 'payment_intent_data[application_fee_amount]': String(feeCents) }\n : {}),\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n ...(guestEmail ? { customer_email: guestEmail } : {}),\n success_url: `${backUrl}${separator}reserved=1`,\n cancel_url: `${backUrl}${separator}reserved=0`,\n 'metadata[type]': 'commerce-reservation',\n 'metadata[hostId]': hostId,\n 'metadata[reservationId]': reservationRef.id,\n 'metadata[feeCents]': String(feeCents),\n // The only witness to the tax (AGL-1969). By the construction above\n // Stripe does not know the second line is tax, so it reports\n // `total_details.amount_tax: 0` — the session's own metadata is what\n // `storefront-tax-record.ts` and the confirmation branch read to record\n // the figure and derive the regime, exactly as `checkout.ts` stamps it\n // for a buy-now sale. Absent when there is no tax, never a reassuring\n // `'0'`: absent and zero are different facts on a back-book question.\n ...(lodgingTax.taxCents > 0\n ? {\n 'metadata[taxCents]': String(lodgingTax.taxCents),\n 'metadata[taxPct]': String(lodgingTax.pct),\n }\n : {}),\n })\n // The merchant's payment method choices hold for a stay too (AGL-3629).\n CommerceModel.appendStorefrontPaymentMethodParams(\n params,\n CommerceModel.resolveStorefrontPaymentMethodControls(\n CommerceModel.normalizeStorefrontPaymentMethodSettings(\n storeSnapshot.get('paymentMethods'),\n ),\n ),\n )\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money (AGL-1697). Stripe replays the\n // existing session for a repeated key instead of opening a second\n // one, covering the window where the claim is written but the\n // response never arrives.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe reservation error', session.error)\n await reservationRef.delete().catch(() => undefined)\n // The hold is rolled back, so the key must come back too — the guest\n // presses the same button and the retry re-derives the same digest,\n // which Stripe replays if the session did get created.\n await claim.release()\n return res.status(502).json({ error: 'Payment setup failed' })\n }\n const payload = { url: session.url }\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // (AGL-1691's rule): the hold may or may not have landed, and it expires\n // in 30 minutes either way.\n await claim?.release()\n return res.status(500).json({ error: 'Reservation failed' })\n }\n}\n"],"names":["readSiteReturnHost","siteReturnUrl","Aglyn","CommerceModel","claimAttempt","firebaseAdmin","getOrgForHost","merchantAccountIsReady","checkoutSessionCardAuthenticationParams","readLiveReservations","reservationAvailabilityQuery","reserveHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","JSON","parse","hostId","String","resourceId","checkInDayMs","toDayMs","Number","checkOutDayMs","guestName","trim","slice","guestEmail","toLowerCase","idempotencyKey","headers","claim","ownerOrg","storeSnapshot","firestore","app","hostRef","collection","doc","checkEntitlement","org","ownerId","ownerUid","ownerProfile","get","accountId","chargesEnabled","accountLivemode","subject","claimed","kind","scopeId","orgId","id","key","busyMessage","replay","availabilityQuery","resourceSnapshot","reservationsSnapshot","Promise","all","resource","data","release","readLive","now","Date","live","isRangeAvailable","quote","computeReservationQuote","problem","chargeCents","depositCents","totalCents","taxSettings","tax","lodgingTax","resolveFlatTaxCents","lodging","feeCents","resolveTransactionFeeCents","taxCents","reservationRef","booked","runTransaction","transaction","fresh","create","nights","paidCents","createdAtMs","backUrl","candidates","referer","host","site","requestHost","separator","includes","params","URLSearchParams","mode","name","label","customer_email","success_url","cancel_url","pct","appendStorefrontPaymentMethodParams","resolveStorefrontPaymentMethodControls","normalizeStorefrontPaymentMethodSettings","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","delete","catch","undefined","payload","record"],"mappings":"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"}
@@ -112,7 +112,9 @@ import { inspectReviewToken, redeemReviewTokenIn } from "./review-tokens.js";
112
112
  for (const snapshot of snapshots){
113
113
  var _data_name, _data_slug;
114
114
  const data = snapshot.exists ? snapshot.data() : null;
115
- if (!data || data['deletedAt'] || data['status'] !== 'active') continue;
115
+ // A product sold in person only (AGL-4060) is named and linked nowhere
116
+ // online, as a draft is not.
117
+ if (!CommerceModel.productListedOnline(data)) continue;
116
118
  products.set(snapshot.id, {
117
119
  name: String((_data_name = data['name']) != null ? _data_name : ''),
118
120
  slug: String((_data_slug = data['slug']) != null ? _data_slug : '')