@aglyn/plugins-commerce 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 (417) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +10 -0
  3. package/package.json +57 -0
  4. package/src/index.d.ts +38 -0
  5. package/src/index.js +38 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/account.d.ts +31 -0
  8. package/src/lib/components/account.js +645 -0
  9. package/src/lib/components/account.js.map +1 -0
  10. package/src/lib/components/cart.d.ts +38 -0
  11. package/src/lib/components/cart.js +851 -0
  12. package/src/lib/components/cart.js.map +1 -0
  13. package/src/lib/components/commerce-console-page.d.ts +32 -0
  14. package/src/lib/components/commerce-console-page.js +296 -0
  15. package/src/lib/components/commerce-console-page.js.map +1 -0
  16. package/src/lib/components/commerce-console-sections.d.ts +34 -0
  17. package/src/lib/components/commerce-console-sections.js +62 -0
  18. package/src/lib/components/commerce-console-sections.js.map +1 -0
  19. package/src/lib/components/console/catalog-organization-card.component.d.ts +14 -0
  20. package/src/lib/components/console/catalog-organization-card.component.js +1087 -0
  21. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -0
  22. package/src/lib/components/console/commerce-analytics-card.component.d.ts +27 -0
  23. package/src/lib/components/console/commerce-analytics-card.component.js +310 -0
  24. package/src/lib/components/console/commerce-analytics-card.component.js.map +1 -0
  25. package/src/lib/components/console/commerce-glance-card.component.d.ts +20 -0
  26. package/src/lib/components/console/commerce-glance-card.component.js +340 -0
  27. package/src/lib/components/console/commerce-glance-card.component.js.map +1 -0
  28. package/src/lib/components/console/commerce-stat-tile.component.d.ts +37 -0
  29. package/src/lib/components/console/commerce-stat-tile.component.js +72 -0
  30. package/src/lib/components/console/commerce-stat-tile.component.js.map +1 -0
  31. package/src/lib/components/console/discounts-card.component.d.ts +14 -0
  32. package/src/lib/components/console/discounts-card.component.js +443 -0
  33. package/src/lib/components/console/discounts-card.component.js.map +1 -0
  34. package/src/lib/components/console/entitlement-gate.component.d.ts +76 -0
  35. package/src/lib/components/console/entitlement-gate.component.js +115 -0
  36. package/src/lib/components/console/entitlement-gate.component.js.map +1 -0
  37. package/src/lib/components/console/gift-cards-card.component.d.ts +29 -0
  38. package/src/lib/components/console/gift-cards-card.component.js +379 -0
  39. package/src/lib/components/console/gift-cards-card.component.js.map +1 -0
  40. package/src/lib/components/console/host-coupons-card.component.d.ts +14 -0
  41. package/src/lib/components/console/host-coupons-card.component.js +252 -0
  42. package/src/lib/components/console/host-coupons-card.component.js.map +1 -0
  43. package/src/lib/components/console/host-orders-card.component.d.ts +13 -0
  44. package/src/lib/components/console/host-orders-card.component.js +861 -0
  45. package/src/lib/components/console/host-orders-card.component.js.map +1 -0
  46. package/src/lib/components/console/locations-card.component.d.ts +14 -0
  47. package/src/lib/components/console/locations-card.component.js +256 -0
  48. package/src/lib/components/console/locations-card.component.js.map +1 -0
  49. package/src/lib/components/console/member-posts-card.component.d.ts +8 -0
  50. package/src/lib/components/console/member-posts-card.component.js +283 -0
  51. package/src/lib/components/console/member-posts-card.component.js.map +1 -0
  52. package/src/lib/components/console/newest-site-users-card.component.d.ts +36 -0
  53. package/src/lib/components/console/newest-site-users-card.component.js +126 -0
  54. package/src/lib/components/console/newest-site-users-card.component.js.map +1 -0
  55. package/src/lib/components/console/order-detail-dialog.component.d.ts +28 -0
  56. package/src/lib/components/console/order-detail-dialog.component.js +976 -0
  57. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -0
  58. package/src/lib/components/console/paid-media.d.ts +127 -0
  59. package/src/lib/components/console/paid-media.js +397 -0
  60. package/src/lib/components/console/paid-media.js.map +1 -0
  61. package/src/lib/components/console/payments-settings-card.component.d.ts +13 -0
  62. package/src/lib/components/console/payments-settings-card.component.js +295 -0
  63. package/src/lib/components/console/payments-settings-card.component.js.map +1 -0
  64. package/src/lib/components/console/pos-page.component.d.ts +13 -0
  65. package/src/lib/components/console/pos-page.component.js +916 -0
  66. package/src/lib/components/console/pos-page.component.js.map +1 -0
  67. package/src/lib/components/console/product-editor-dialog.component.d.ts +32 -0
  68. package/src/lib/components/console/product-editor-dialog.component.js +1374 -0
  69. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -0
  70. package/src/lib/components/console/products-hub-card.component.d.ts +16 -0
  71. package/src/lib/components/console/products-hub-card.component.js +1280 -0
  72. package/src/lib/components/console/products-hub-card.component.js.map +1 -0
  73. package/src/lib/components/console/products-hub-zone.component.d.ts +50 -0
  74. package/src/lib/components/console/products-hub-zone.component.js +200 -0
  75. package/src/lib/components/console/products-hub-zone.component.js.map +1 -0
  76. package/src/lib/components/console/recovery-queue-card.component.d.ts +39 -0
  77. package/src/lib/components/console/recovery-queue-card.component.js +262 -0
  78. package/src/lib/components/console/recovery-queue-card.component.js.map +1 -0
  79. package/src/lib/components/console/registers-card.component.d.ts +21 -0
  80. package/src/lib/components/console/registers-card.component.js +296 -0
  81. package/src/lib/components/console/registers-card.component.js.map +1 -0
  82. package/src/lib/components/console/reservations-card.component.d.ts +14 -0
  83. package/src/lib/components/console/reservations-card.component.js +650 -0
  84. package/src/lib/components/console/reservations-card.component.js.map +1 -0
  85. package/src/lib/components/console/reviews-moderation-card.component.d.ts +8 -0
  86. package/src/lib/components/console/reviews-moderation-card.component.js +235 -0
  87. package/src/lib/components/console/reviews-moderation-card.component.js.map +1 -0
  88. package/src/lib/components/console/shipping-settings-card.component.d.ts +38 -0
  89. package/src/lib/components/console/shipping-settings-card.component.js +560 -0
  90. package/src/lib/components/console/shipping-settings-card.component.js.map +1 -0
  91. package/src/lib/components/console/stock-movements-card.component.d.ts +31 -0
  92. package/src/lib/components/console/stock-movements-card.component.js +333 -0
  93. package/src/lib/components/console/stock-movements-card.component.js.map +1 -0
  94. package/src/lib/components/console/store-settings-card.component.d.ts +14 -0
  95. package/src/lib/components/console/store-settings-card.component.js +300 -0
  96. package/src/lib/components/console/store-settings-card.component.js.map +1 -0
  97. package/src/lib/components/console/storefront-tax-summary-card.component.d.ts +73 -0
  98. package/src/lib/components/console/storefront-tax-summary-card.component.js +408 -0
  99. package/src/lib/components/console/storefront-tax-summary-card.component.js.map +1 -0
  100. package/src/lib/components/console/suppliers-card.component.d.ts +8 -0
  101. package/src/lib/components/console/suppliers-card.component.js +328 -0
  102. package/src/lib/components/console/suppliers-card.component.js.map +1 -0
  103. package/src/lib/components/console/tax-settings-card.component.d.ts +14 -0
  104. package/src/lib/components/console/tax-settings-card.component.js +473 -0
  105. package/src/lib/components/console/tax-settings-card.component.js.map +1 -0
  106. package/src/lib/components/gate.d.ts +39 -0
  107. package/src/lib/components/gate.js +202 -0
  108. package/src/lib/components/gate.js.map +1 -0
  109. package/src/lib/components/gated-video.d.ts +40 -0
  110. package/src/lib/components/gated-video.js +366 -0
  111. package/src/lib/components/gated-video.js.map +1 -0
  112. package/src/lib/components/member-feed.d.ts +32 -0
  113. package/src/lib/components/member-feed.js +198 -0
  114. package/src/lib/components/member-feed.js.map +1 -0
  115. package/src/lib/components/member-recovery.d.ts +35 -0
  116. package/src/lib/components/member-recovery.js +313 -0
  117. package/src/lib/components/member-recovery.js.map +1 -0
  118. package/src/lib/components/member-signin.d.ts +35 -0
  119. package/src/lib/components/member-signin.js +257 -0
  120. package/src/lib/components/member-signin.js.map +1 -0
  121. package/src/lib/components/member-signup.d.ts +34 -0
  122. package/src/lib/components/member-signup.js +287 -0
  123. package/src/lib/components/member-signup.js.map +1 -0
  124. package/src/lib/components/newsletter-signup.d.ts +39 -0
  125. package/src/lib/components/newsletter-signup.js +229 -0
  126. package/src/lib/components/newsletter-signup.js.map +1 -0
  127. package/src/lib/components/product-detail.d.ts +38 -0
  128. package/src/lib/components/product-detail.js +1022 -0
  129. package/src/lib/components/product-detail.js.map +1 -0
  130. package/src/lib/components/product-grid.d.ts +67 -0
  131. package/src/lib/components/product-grid.js +909 -0
  132. package/src/lib/components/product-grid.js.map +1 -0
  133. package/src/lib/components/product-reviews.d.ts +32 -0
  134. package/src/lib/components/product-reviews.js +349 -0
  135. package/src/lib/components/product-reviews.js.map +1 -0
  136. package/src/lib/components/related-products.d.ts +33 -0
  137. package/src/lib/components/related-products.js +232 -0
  138. package/src/lib/components/related-products.js.map +1 -0
  139. package/src/lib/components/reservation-widget.d.ts +33 -0
  140. package/src/lib/components/reservation-widget.js +418 -0
  141. package/src/lib/components/reservation-widget.js.map +1 -0
  142. package/src/lib/components/storefront-payment-element-fallback.d.ts +18 -0
  143. package/src/lib/components/storefront-payment-element-fallback.js +51 -0
  144. package/src/lib/components/storefront-payment-element-fallback.js.map +1 -0
  145. package/src/lib/components/storefront-payment-element.d.ts +78 -0
  146. package/src/lib/components/storefront-payment-element.js +156 -0
  147. package/src/lib/components/storefront-payment-element.js.map +1 -0
  148. package/src/lib/components/wishlist.d.ts +46 -0
  149. package/src/lib/components/wishlist.js +304 -0
  150. package/src/lib/components/wishlist.js.map +1 -0
  151. package/src/lib/constants/bundle-common.d.ts +18 -0
  152. package/src/lib/constants/bundle-common.js +18 -0
  153. package/src/lib/constants/bundle-common.js.map +1 -0
  154. package/src/lib/constants/product-filters.d.ts +35 -0
  155. package/src/lib/constants/product-filters.js +109 -0
  156. package/src/lib/constants/product-filters.js.map +1 -0
  157. package/src/lib/model/commerce-cart.d.ts +52 -0
  158. package/src/lib/model/commerce-cart.js +102 -0
  159. package/src/lib/model/commerce-cart.js.map +1 -0
  160. package/src/lib/model/commerce-connect-transfer.d.ts +243 -0
  161. package/src/lib/model/commerce-connect-transfer.js +231 -0
  162. package/src/lib/model/commerce-connect-transfer.js.map +1 -0
  163. package/src/lib/model/commerce-discounts.d.ts +164 -0
  164. package/src/lib/model/commerce-discounts.js +231 -0
  165. package/src/lib/model/commerce-discounts.js.map +1 -0
  166. package/src/lib/model/commerce-dispute.d.ts +124 -0
  167. package/src/lib/model/commerce-dispute.js +225 -0
  168. package/src/lib/model/commerce-dispute.js.map +1 -0
  169. package/src/lib/model/commerce-gift-cards.d.ts +107 -0
  170. package/src/lib/model/commerce-gift-cards.js +121 -0
  171. package/src/lib/model/commerce-gift-cards.js.map +1 -0
  172. package/src/lib/model/commerce-io.d.ts +36 -0
  173. package/src/lib/model/commerce-io.js +245 -0
  174. package/src/lib/model/commerce-io.js.map +1 -0
  175. package/src/lib/model/commerce-orders.d.ts +1040 -0
  176. package/src/lib/model/commerce-orders.js +1022 -0
  177. package/src/lib/model/commerce-orders.js.map +1 -0
  178. package/src/lib/model/commerce-promotions.d.ts +188 -0
  179. package/src/lib/model/commerce-promotions.js +199 -0
  180. package/src/lib/model/commerce-promotions.js.map +1 -0
  181. package/src/lib/model/commerce-reservations.d.ts +175 -0
  182. package/src/lib/model/commerce-reservations.js +157 -0
  183. package/src/lib/model/commerce-reservations.js.map +1 -0
  184. package/src/lib/model/commerce-shipping.d.ts +316 -0
  185. package/src/lib/model/commerce-shipping.js +484 -0
  186. package/src/lib/model/commerce-shipping.js.map +1 -0
  187. package/src/lib/model/commerce-stock-holds.d.ts +199 -0
  188. package/src/lib/model/commerce-stock-holds.js +190 -0
  189. package/src/lib/model/commerce-stock-holds.js.map +1 -0
  190. package/src/lib/model/commerce-subscription-status.d.ts +67 -0
  191. package/src/lib/model/commerce-subscription-status.js +76 -0
  192. package/src/lib/model/commerce-subscription-status.js.map +1 -0
  193. package/src/lib/model/commerce-tax-decision.d.ts +222 -0
  194. package/src/lib/model/commerce-tax-decision.js +165 -0
  195. package/src/lib/model/commerce-tax-decision.js.map +1 -0
  196. package/src/lib/model/commerce-tax.d.ts +176 -0
  197. package/src/lib/model/commerce-tax.js +92 -0
  198. package/src/lib/model/commerce-tax.js.map +1 -0
  199. package/src/lib/model/commerce.d.ts +551 -0
  200. package/src/lib/model/commerce.js +739 -0
  201. package/src/lib/model/commerce.js.map +1 -0
  202. package/src/lib/model/index.d.ts +40 -0
  203. package/src/lib/model/index.js +40 -0
  204. package/src/lib/model/index.js.map +1 -0
  205. package/src/lib/model/order-figures.d.ts +48 -0
  206. package/src/lib/model/order-figures.js +74 -0
  207. package/src/lib/model/order-figures.js.map +1 -0
  208. package/src/lib/model/plugin-permissions.d.ts +23 -0
  209. package/src/lib/model/plugin-permissions.js +34 -0
  210. package/src/lib/model/plugin-permissions.js.map +1 -0
  211. package/src/lib/model/purchase-analytics.d.ts +186 -0
  212. package/src/lib/model/purchase-analytics.js +216 -0
  213. package/src/lib/model/purchase-analytics.js.map +1 -0
  214. package/src/lib/plugin-config.d.ts +62 -0
  215. package/src/lib/plugin-config.js +78 -0
  216. package/src/lib/plugin-config.js.map +1 -0
  217. package/src/lib/plugin.d.ts +28 -0
  218. package/src/lib/plugin.js +147 -0
  219. package/src/lib/plugin.js.map +1 -0
  220. package/src/lib/server/billing-webhook.d.ts +26 -0
  221. package/src/lib/server/billing-webhook.js +4011 -0
  222. package/src/lib/server/billing-webhook.js.map +1 -0
  223. package/src/lib/server/cancel-order.d.ts +87 -0
  224. package/src/lib/server/cancel-order.js +368 -0
  225. package/src/lib/server/cancel-order.js.map +1 -0
  226. package/src/lib/server/cart-checkout.d.ts +25 -0
  227. package/src/lib/server/cart-checkout.js +976 -0
  228. package/src/lib/server/cart-checkout.js.map +1 -0
  229. package/src/lib/server/cart-cookie.d.ts +32 -0
  230. package/src/lib/server/cart-cookie.js +77 -0
  231. package/src/lib/server/cart-cookie.js.map +1 -0
  232. package/src/lib/server/cart.d.ts +34 -0
  233. package/src/lib/server/cart.js +181 -0
  234. package/src/lib/server/cart.js.map +1 -0
  235. package/src/lib/server/catalog.d.ts +127 -0
  236. package/src/lib/server/catalog.js +277 -0
  237. package/src/lib/server/catalog.js.map +1 -0
  238. package/src/lib/server/checkout.d.ts +36 -0
  239. package/src/lib/server/checkout.js +915 -0
  240. package/src/lib/server/checkout.js.map +1 -0
  241. package/src/lib/server/connect.d.ts +26 -0
  242. package/src/lib/server/connect.js +238 -0
  243. package/src/lib/server/connect.js.map +1 -0
  244. package/src/lib/server/contact-refund.d.ts +117 -0
  245. package/src/lib/server/contact-refund.js +196 -0
  246. package/src/lib/server/contact-refund.js.map +1 -0
  247. package/src/lib/server/download.d.ts +39 -0
  248. package/src/lib/server/download.js +206 -0
  249. package/src/lib/server/download.js.map +1 -0
  250. package/src/lib/server/draft-order.d.ts +26 -0
  251. package/src/lib/server/draft-order.js +706 -0
  252. package/src/lib/server/draft-order.js.map +1 -0
  253. package/src/lib/server/feed.d.ts +24 -0
  254. package/src/lib/server/feed.js +59 -0
  255. package/src/lib/server/feed.js.map +1 -0
  256. package/src/lib/server/fulfill-order.d.ts +97 -0
  257. package/src/lib/server/fulfill-order.js +235 -0
  258. package/src/lib/server/fulfill-order.js.map +1 -0
  259. package/src/lib/server/gate.d.ts +36 -0
  260. package/src/lib/server/gate.js +112 -0
  261. package/src/lib/server/gate.js.map +1 -0
  262. package/src/lib/server/gift-cards.d.ts +42 -0
  263. package/src/lib/server/gift-cards.js +219 -0
  264. package/src/lib/server/gift-cards.js.map +1 -0
  265. package/src/lib/server/low-stock.d.ts +49 -0
  266. package/src/lib/server/low-stock.js +60 -0
  267. package/src/lib/server/low-stock.js.map +1 -0
  268. package/src/lib/server/manual-tax-rate.d.ts +82 -0
  269. package/src/lib/server/manual-tax-rate.js +113 -0
  270. package/src/lib/server/manual-tax-rate.js.map +1 -0
  271. package/src/lib/server/member-feed.d.ts +24 -0
  272. package/src/lib/server/member-feed.js +72 -0
  273. package/src/lib/server/member-feed.js.map +1 -0
  274. package/src/lib/server/member-name-search.d.ts +37 -0
  275. package/src/lib/server/member-name-search.js +42 -0
  276. package/src/lib/server/member-name-search.js.map +1 -0
  277. package/src/lib/server/member-post.d.ts +23 -0
  278. package/src/lib/server/member-post.js +166 -0
  279. package/src/lib/server/member-post.js.map +1 -0
  280. package/src/lib/server/membership-account.d.ts +37 -0
  281. package/src/lib/server/membership-account.js +148 -0
  282. package/src/lib/server/membership-account.js.map +1 -0
  283. package/src/lib/server/membership-admin-password.d.ts +33 -0
  284. package/src/lib/server/membership-admin-password.js +202 -0
  285. package/src/lib/server/membership-admin-password.js.map +1 -0
  286. package/src/lib/server/membership-content.d.ts +23 -0
  287. package/src/lib/server/membership-content.js +105 -0
  288. package/src/lib/server/membership-content.js.map +1 -0
  289. package/src/lib/server/membership-login.d.ts +19 -0
  290. package/src/lib/server/membership-login.js +137 -0
  291. package/src/lib/server/membership-login.js.map +1 -0
  292. package/src/lib/server/membership-logout.d.ts +19 -0
  293. package/src/lib/server/membership-logout.js +35 -0
  294. package/src/lib/server/membership-logout.js.map +1 -0
  295. package/src/lib/server/membership-recover.d.ts +60 -0
  296. package/src/lib/server/membership-recover.js +244 -0
  297. package/src/lib/server/membership-recover.js.map +1 -0
  298. package/src/lib/server/membership-register.d.ts +22 -0
  299. package/src/lib/server/membership-register.js +241 -0
  300. package/src/lib/server/membership-register.js.map +1 -0
  301. package/src/lib/server/membership-reset.d.ts +26 -0
  302. package/src/lib/server/membership-reset.js +88 -0
  303. package/src/lib/server/membership-reset.js.map +1 -0
  304. package/src/lib/server/membership-wishlist.d.ts +23 -0
  305. package/src/lib/server/membership-wishlist.js +89 -0
  306. package/src/lib/server/membership-wishlist.js.map +1 -0
  307. package/src/lib/server/membership.d.ts +96 -0
  308. package/src/lib/server/membership.js +222 -0
  309. package/src/lib/server/membership.js.map +1 -0
  310. package/src/lib/server/native-checkout.d.ts +170 -0
  311. package/src/lib/server/native-checkout.js +193 -0
  312. package/src/lib/server/native-checkout.js.map +1 -0
  313. package/src/lib/server/newsletter.d.ts +23 -0
  314. package/src/lib/server/newsletter.js +323 -0
  315. package/src/lib/server/newsletter.js.map +1 -0
  316. package/src/lib/server/notify-restock.d.ts +22 -0
  317. package/src/lib/server/notify-restock.js +84 -0
  318. package/src/lib/server/notify-restock.js.map +1 -0
  319. package/src/lib/server/order-analytics.d.ts +134 -0
  320. package/src/lib/server/order-analytics.js +263 -0
  321. package/src/lib/server/order-analytics.js.map +1 -0
  322. package/src/lib/server/order-figures.d.ts +33 -0
  323. package/src/lib/server/order-figures.js +220 -0
  324. package/src/lib/server/order-figures.js.map +1 -0
  325. package/src/lib/server/paid-media-delivery.d.ts +173 -0
  326. package/src/lib/server/paid-media-delivery.js +212 -0
  327. package/src/lib/server/paid-media-delivery.js.map +1 -0
  328. package/src/lib/server/pos-order.d.ts +54 -0
  329. package/src/lib/server/pos-order.js +1137 -0
  330. package/src/lib/server/pos-order.js.map +1 -0
  331. package/src/lib/server/process-abandoned.d.ts +54 -0
  332. package/src/lib/server/process-abandoned.js +222 -0
  333. package/src/lib/server/process-abandoned.js.map +1 -0
  334. package/src/lib/server/process-restock.d.ts +42 -0
  335. package/src/lib/server/process-restock.js +219 -0
  336. package/src/lib/server/process-restock.js.map +1 -0
  337. package/src/lib/server/product-card.d.ts +29 -0
  338. package/src/lib/server/product-card.js +54 -0
  339. package/src/lib/server/product-card.js.map +1 -0
  340. package/src/lib/server/product.d.ts +70 -0
  341. package/src/lib/server/product.js +105 -0
  342. package/src/lib/server/product.js.map +1 -0
  343. package/src/lib/server/promotion-hold.d.ts +144 -0
  344. package/src/lib/server/promotion-hold.js +174 -0
  345. package/src/lib/server/promotion-hold.js.map +1 -0
  346. package/src/lib/server/reconcile-stock.d.ts +106 -0
  347. package/src/lib/server/reconcile-stock.js +349 -0
  348. package/src/lib/server/reconcile-stock.js.map +1 -0
  349. package/src/lib/server/refund.d.ts +45 -0
  350. package/src/lib/server/refund.js +607 -0
  351. package/src/lib/server/refund.js.map +1 -0
  352. package/src/lib/server/related.d.ts +24 -0
  353. package/src/lib/server/related.js +104 -0
  354. package/src/lib/server/related.js.map +1 -0
  355. package/src/lib/server/reservation-availability.d.ts +23 -0
  356. package/src/lib/server/reservation-availability.js +111 -0
  357. package/src/lib/server/reservation-availability.js.map +1 -0
  358. package/src/lib/server/reserve-stock.d.ts +156 -0
  359. package/src/lib/server/reserve-stock.js +146 -0
  360. package/src/lib/server/reserve-stock.js.map +1 -0
  361. package/src/lib/server/reserve.d.ts +25 -0
  362. package/src/lib/server/reserve.js +426 -0
  363. package/src/lib/server/reserve.js.map +1 -0
  364. package/src/lib/server/restock-flag.d.ts +202 -0
  365. package/src/lib/server/restock-flag.js +449 -0
  366. package/src/lib/server/restock-flag.js.map +1 -0
  367. package/src/lib/server/reviews.d.ts +51 -0
  368. package/src/lib/server/reviews.js +132 -0
  369. package/src/lib/server/reviews.js.map +1 -0
  370. package/src/lib/server/site-page-enricher.d.ts +42 -0
  371. package/src/lib/server/site-page-enricher.js +149 -0
  372. package/src/lib/server/site-page-enricher.js.map +1 -0
  373. package/src/lib/server/site-page-resolver.d.ts +26 -0
  374. package/src/lib/server/site-page-resolver.js +223 -0
  375. package/src/lib/server/site-page-resolver.js.map +1 -0
  376. package/src/lib/server/stock-hold.d.ts +127 -0
  377. package/src/lib/server/stock-hold.js +258 -0
  378. package/src/lib/server/stock-hold.js.map +1 -0
  379. package/src/lib/server/storefront-tax-record.d.ts +22 -0
  380. package/src/lib/server/storefront-tax-record.js +218 -0
  381. package/src/lib/server/storefront-tax-record.js.map +1 -0
  382. package/src/lib/server/storefront-tax.d.ts +197 -0
  383. package/src/lib/server/storefront-tax.js +245 -0
  384. package/src/lib/server/storefront-tax.js.map +1 -0
  385. package/src/lib/server/stream.d.ts +25 -0
  386. package/src/lib/server/stream.js +191 -0
  387. package/src/lib/server/stream.js.map +1 -0
  388. package/src/lib/server/subscription-portal.d.ts +24 -0
  389. package/src/lib/server/subscription-portal.js +91 -0
  390. package/src/lib/server/subscription-portal.js.map +1 -0
  391. package/src/lib/server/supplier-outbox.d.ts +190 -0
  392. package/src/lib/server/supplier-outbox.js +342 -0
  393. package/src/lib/server/supplier-outbox.js.map +1 -0
  394. package/src/lib/server/supplier-update.d.ts +23 -0
  395. package/src/lib/server/supplier-update.js +289 -0
  396. package/src/lib/server/supplier-update.js.map +1 -0
  397. package/src/lib/server/tax-profile.d.ts +29 -0
  398. package/src/lib/server/tax-profile.js +40 -0
  399. package/src/lib/server/tax-profile.js.map +1 -0
  400. package/src/lib/server.d.ts +32 -0
  401. package/src/lib/server.js +311 -0
  402. package/src/lib/server.js.map +1 -0
  403. package/src/lib/site.d.ts +27 -0
  404. package/src/lib/site.js +134 -0
  405. package/src/lib/site.js.map +1 -0
  406. package/src/lib/utils/escape-html.d.ts +41 -0
  407. package/src/lib/utils/escape-html.js +41 -0
  408. package/src/lib/utils/escape-html.js.map +1 -0
  409. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  410. package/src/lib/utils/generate-preset-id.js +25 -0
  411. package/src/lib/utils/generate-preset-id.js.map +1 -0
  412. package/src/lib/utils/member-continue.d.ts +45 -0
  413. package/src/lib/utils/member-continue.js +49 -0
  414. package/src/lib/utils/member-continue.js.map +1 -0
  415. package/src/lib/utils/use-storefront-purchase-event.d.ts +60 -0
  416. package/src/lib/utils/use-storefront-purchase-event.js +142 -0
  417. package/src/lib/utils/use-storefront-purchase-event.js.map +1 -0
@@ -0,0 +1,4011 @@
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 { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import * as Aglyn from "@aglyn/aglyn/server";
18
+ import { findUserByUidAcrossPools, firebaseAdmin, getOrgForHost, meterHostEmail, notifyHostManagers, notifyStaff, renderHostEmailWithTokens, clearConnectPayoutFailure, recordConnectPayoutFailure, syncConnectAccountStatus, updateExisting, hostSendingIdentity } from "@aglyn/tenant-data-admin";
19
+ import { captureHostContact } from "@aglyn/tenant-runtime";
20
+ import { createHmac } from "crypto";
21
+ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
22
+ import * as CommerceModel from "../model/index.js";
23
+ import { recordContactRefund } from "./contact-refund.js";
24
+ // Leaf import, not the barrel, for the reason `contact-refund.ts` records: the
25
+ // specs in this library mock `@aglyn/tenant-data-admin` wholesale, and a
26
+ // permissive stub would turn a reversal that never happened green.
27
+ import { reverseEmailAttributedRevenue } from "@aglyn/tenant-data-admin/server/email-revenue-attribution";
28
+ import { mintDownloadToken, tokenSigningSecret } from "./download.js";
29
+ import { alertLowStockCrossing } from "./low-stock.js";
30
+ import { decrementVariantStock } from "./reserve-stock.js";
31
+ import { releaseStockHold } from "./stock-hold.js";
32
+ import { releasePromotionHold, settlePromotionSlot } from "./promotion-hold.js";
33
+ import { flagOrderRestock } from "./restock-flag.js";
34
+ import { storefrontTaxModeOf } from "./storefront-tax.js";
35
+ import { recordStorefrontTax } from "./storefront-tax-record.js";
36
+ import { enqueueSupplierDelivery } from "./supplier-outbox.js";
37
+ /**
38
+ * Assigns unassigned license keys for a digital product (AGL-308):
39
+ * stamps order/email on the key docs, returns the key strings, and
40
+ * nudges managers when the pool runs low.
41
+ *
42
+ * EACH KEY IS CLAIMED IN ITS OWN TRANSACTION (AGL-2149). The original shape was
43
+ * a query for `assignedAtMs == null` followed by a bare `set(…, { merge: true })`
44
+ * per document, with nothing between the read and the write. Two orders for the
45
+ * same product landing together — the ordinary case for a digital product that
46
+ * is selling — both read the same head of the pool and both stamped it, so the
47
+ * second write simply overwrote the first order's `orderId` and BOTH buyers
48
+ * were mailed the same key. That is a redeemable secret handed out twice, and
49
+ * it is invisible afterwards: the key doc records only the later order.
50
+ *
51
+ * The claim is a transaction rather than the `create()` primitive `refund.ts`
52
+ * uses for idempotency, because the document being claimed ALREADY EXISTS — the
53
+ * merchant uploaded the pool — so there is no create to lose. What a
54
+ * transaction gives is the same guarantee from the other side: Firestore aborts
55
+ * and retries the transaction if `assignedAtMs` changed between this
56
+ * transaction's read of the key and its commit, so exactly one of two racing
57
+ * orders can turn a `null` into a timestamp. The in-transaction re-read of
58
+ * `assignedAtMs` is the test that makes the abort observable to us rather than
59
+ * merely survived: a key another order took while we were queuing is skipped,
60
+ * not double-stamped.
61
+ *
62
+ * THE POOL QUERY OVER-FETCHES for the same reason. Asking for exactly
63
+ * `quantity` candidates and then losing three of them to a concurrent order
64
+ * would silently short the buyer, so the query takes headroom and the loop
65
+ * stops at `quantity` claims. A buyer can still be short-changed when the pool
66
+ * genuinely runs dry — that is the merchant's stock problem, and the low-pool
67
+ * nudge below is what tells them.
68
+ *
69
+ * The failure mode is still "fewer keys than paid for", never "someone else's
70
+ * key": swallowing stays, because the money has moved and a thrown assignment
71
+ * would take the receipt and the fulfilment down with it.
72
+ */ async function assignLicenseKeys(firestore, hostRef, hostId, productId, orderId, email, quantity) {
73
+ try {
74
+ const wanted = Math.max(1, quantity);
75
+ const pool = await hostRef.collection('licenseKeys').where('productId', '==', productId).where('assignedAtMs', '==', null)// Headroom for keys a concurrent order claims out from under this one.
76
+ .limit(wanted * 3).get();
77
+ const keys = [];
78
+ for (const docSnapshot of pool.docs){
79
+ if (keys.length >= wanted) break;
80
+ const claimed = await firestore.runTransaction(async (transaction)=>{
81
+ const fresh = await transaction.get(docSnapshot.ref);
82
+ if (!fresh.exists) return null;
83
+ // Re-asked INSIDE the transaction that writes it. This is the whole
84
+ // guard: the query's answer is a snapshot from before we queued.
85
+ if (fresh.get('assignedAtMs') != null) return null;
86
+ transaction.update(docSnapshot.ref, {
87
+ orderId,
88
+ email,
89
+ assignedAtMs: Date.now()
90
+ });
91
+ return String(fresh.get('key'));
92
+ }).catch((error)=>{
93
+ console.error('license key claim failed', error);
94
+ return null;
95
+ });
96
+ if (claimed) keys.push(claimed);
97
+ }
98
+ if (keys.length) {
99
+ const remaining = await hostRef.collection('licenseKeys').where('productId', '==', productId).where('assignedAtMs', '==', null).limit(6).get();
100
+ if (remaining.size < 5) {
101
+ void notifyHostManagers(hostId, {
102
+ type: 'content.lowStock',
103
+ title: 'License key pool running low',
104
+ body: `${remaining.size} keys left`,
105
+ link: `/${hostId}/products`
106
+ });
107
+ }
108
+ }
109
+ return keys;
110
+ } catch (error) {
111
+ console.error('license key assignment failed', error);
112
+ return [];
113
+ }
114
+ }
115
+ /**
116
+ * Applies a redemption to a coupon, gift card or discount that must ALREADY
117
+ * exist, and — when it does not — stamps the orphan on the order (AGL-1767).
118
+ *
119
+ * `set(..., { merge: true })` reads as "update if present" and means "create if
120
+ * absent", so a merchant who deleted the code between the shopper starting
121
+ * checkout and this webhook landing got a phantom back. The gift card is the
122
+ * one that corrupts an aggregate: `increment(-N)` on a missing document creates
123
+ * it holding `balanceCents: -N`, so any outstanding-liability figure summed over
124
+ * `giftCards` is understated by N and re-issuing the code later starts the new
125
+ * card in the hole. The coupon ghosts carry a redemption count a re-created
126
+ * coupon inherits against its `maxRedemptions` cap, and the discount ghost is
127
+ * worse than inert: it passes every gate in `commerce-discounts.ts`'s
128
+ * `applies()`, because each one skips when its constraint field is ABSENT, so it
129
+ * reaches the automatic-promotion loop as a candidate and shows in the console
130
+ * list as a nameless always-on promotion nobody created. The only thing keeping
131
+ * it from discounting anything is `valueCents()` returning 0 for an unrecognised
132
+ * `kind` — one default, not a guard.
133
+ *
134
+ * REFUSE-AND-RECORD, not a bare refusal, which is AGL-1760's rule: the shopper's
135
+ * discount really was applied by Stripe and the gift-card balance really was
136
+ * spent, so dropping the fact silently would be AGL-1732 inverted — money moved,
137
+ * recorded nowhere. The note goes on the ORDER's own `timeline`, which the
138
+ * console order dialog renders, the way `47d3bccc5` records `folio-unattached`.
139
+ * The merchant reading the order sees "$25 was applied against a gift card that
140
+ * no longer exists" instead of a negative number quietly leaving a liability
141
+ * total. The coupon/gift-card document is not the place — that document IS the
142
+ * defect — and a new collection would be invisible until someone built UI for
143
+ * it, which is the same failure with an extra step.
144
+ *
145
+ * WHY `null` IS NOT `false`. `updateExisting` reports absence ONLY for gRPC
146
+ * `NOT_FOUND` and rethrows everything else, which matters here because the note
147
+ * this writes claims absence by name. A permission denial, an App Check
148
+ * rejection or a transport failure must not become "the merchant deleted your
149
+ * coupon" on an order the merchant is reading. Those are logged and left
150
+ * unstamped. Nothing rethrows: `runBillingWebhookHandlers` lets the first throw
151
+ * propagate, and a 500 here would have Stripe redeliver into the AGL-498
152
+ * existence guard, which returns before this whole fan-out — so a throw does not
153
+ * retry the redemption, it abandons the fulfilment that follows it.
154
+ */ async function redeemExistingOrRecord(ref, patch, orderRef, detail) {
155
+ const applied = await updateExisting(ref, patch).catch((error)=>{
156
+ console.error('Redemption write failed', ref.path, error);
157
+ return null;
158
+ });
159
+ if (applied !== false) return;
160
+ console.error('Redemption against a missing document', ref.path);
161
+ await recordRedemptionOrphan(orderRef, detail);
162
+ }
163
+ /**
164
+ * Stamps a redemption that could not be recorded onto the order's own timeline.
165
+ *
166
+ * Split out of `redeemExistingOrRecord` (AGL-2449) so the gift-card settlement
167
+ * — which is a transaction rather than an `updateExisting`, and so cannot go
168
+ * through that helper — reports a missing card in exactly the same words, on
169
+ * exactly the same surface, as every other redemption. Two ways of telling a
170
+ * merchant the same fact is how one of them ends up never being built.
171
+ *
172
+ * `update()`, not a merge-set: the order was created by the transaction
173
+ * upstream (a redelivery returns before here), so it exists — and writing this
174
+ * note through a merge would mint an order stub on the one path where it does
175
+ * not.
176
+ */ async function recordRedemptionOrphan(orderRef, detail) {
177
+ await orderRef.update({
178
+ timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({
179
+ atMs: Date.now(),
180
+ event: 'redemption-unrecorded',
181
+ detail
182
+ })
183
+ }).catch(()=>undefined);
184
+ }
185
+ /**
186
+ * Count one promotion redemption (AGL-2453), settling the slot it reserved.
187
+ *
188
+ * This used to be a bare `redeemExistingOrRecord(ref, { redemptions:
189
+ * increment(1) }, …)` at three call sites, and it was the counting half of a
190
+ * cap that nothing re-checked: the increment always landed, minutes after a
191
+ * plain `.get()` at checkout had already let the shopper through. The counter
192
+ * told the truth afterwards and the discount was already given.
193
+ *
194
+ * Now the checkout HOLDS a slot and this settles it. Two properties matter:
195
+ *
196
+ * - **Idempotent under redelivery.** The redemption is owed by the presence of
197
+ * the hold, so a second delivery of the same event finds none and counts
198
+ * nothing. `reconcile-stock.ts:52-58` names these counters as sitting behind
199
+ * one non-idempotent `created` flag and defers the per-effect sweep — this
200
+ * is the per-effect answer for redemptions, not a reliance on that flag.
201
+ * - **A session with no hold still counts.** An UNCAPPED promotion reserves
202
+ * nothing (there is no slot to reserve), and a session minted before this
203
+ * deploy reserved nothing either. Both carry no `holdKey` and both take the
204
+ * original unconditional increment — dropping them would under-count real
205
+ * redemptions, which is the merchant's own record of their promotion.
206
+ */ async function settleRedemption(options) {
207
+ const { firestore, ref, holdKey, orderRef, label, detail } = options;
208
+ if (!holdKey) {
209
+ await redeemExistingOrRecord(ref, {
210
+ redemptions: firebaseAdmin.firestore.FieldValue.increment(1)
211
+ }, orderRef, detail);
212
+ return;
213
+ }
214
+ const settled = await settlePromotionSlot({
215
+ firestore,
216
+ ref,
217
+ holdKey,
218
+ label
219
+ });
220
+ // `missing` alone is the orphan: the merchant deleted the promotion between
221
+ // checkout and payment, so the redemption cannot be recorded anywhere and the
222
+ // order's own timeline is where that fact belongs. `already-settled` is a
223
+ // redelivery and is silent by design; `error` is transient — the hold stands,
224
+ // lapses on its own, and a note claiming the document is gone would be false.
225
+ if (settled === 'missing') {
226
+ await recordRedemptionOrphan(orderRef, detail);
227
+ }
228
+ }
229
+ /** gRPC `Status.FAILED_PRECONDITION` — Firestore's "this query needs an index". */ const GRPC_FAILED_PRECONDITION = 9;
230
+ async function findOrderForDispute(paymentIntentId) {
231
+ const matches = await firebaseAdmin.app().firestore().collectionGroup('orders').where('paymentIntentId', '==', paymentIntentId).limit(2).get().catch((error)=>{
232
+ if ((error == null ? void 0 : error.code) !== GRPC_FAILED_PRECONDITION) throw error;
233
+ console.error('Dispute lookup needs the collection-group index on ' + 'orders.paymentIntentId (AGL-1787)', error);
234
+ return null;
235
+ });
236
+ // THE THREE ANSWERS ARE NOW THREE (AGL-2161). All three used to be `null`,
237
+ // and the caller read `null` as "not a commerce dispute" and did nothing —
238
+ // so a collection-group index that had not deployed meant EVERY chargeback
239
+ // on the platform got no flag, no seller-share reversal and no merchant
240
+ // notification, while looking byte-for-byte like the routine case of a
241
+ // dispute against a marketplace or booking charge.
242
+ //
243
+ // Distinguishing them needs no knowledge of whether the index is in fact
244
+ // deployed, which is why this did not have to wait on a production probe:
245
+ // "the query could not run" and "the query ran and matched nothing" are
246
+ // different facts at the point they happen, and only one of them is ours to
247
+ // fix.
248
+ if (!matches) return {
249
+ kind: 'unresolved',
250
+ reason: 'missing-index'
251
+ };
252
+ if (matches.empty) return {
253
+ kind: 'not-ours'
254
+ };
255
+ if (matches.docs.length > 1) {
256
+ console.error('Dispute matched more than one order; reversing none', paymentIntentId);
257
+ return {
258
+ kind: 'unresolved',
259
+ reason: 'ambiguous'
260
+ };
261
+ }
262
+ return {
263
+ kind: 'order',
264
+ snapshot: matches.docs[0]
265
+ };
266
+ }
267
+ /**
268
+ * Tells STAFF that a chargeback could not be routed (AGL-2161).
269
+ *
270
+ * Staff, not the merchant: on the missing-index path there is no merchant to
271
+ * tell — the whole point is that the order could not be found — and a missing
272
+ * platform index is a platform fault affecting every host at once. `notifyStaff`
273
+ * is the same channel the analytics and abuse routes use for exactly this
274
+ * class, and it never throws.
275
+ *
276
+ * `system.announcement` rather than a new type: the AGL-1088 rule is that
277
+ * category is the prefix and `system` is the bucket nobody mutes to reduce
278
+ * noise, which is the property an alert about unrouted money needs.
279
+ */ async function reportUnresolvedDispute(reason, paymentIntentId, dispute) {
280
+ var _dispute_amount;
281
+ const amount = `$${(((_dispute_amount = dispute.amount) != null ? _dispute_amount : 0) / 100).toFixed(2)}`;
282
+ await notifyStaff({
283
+ type: 'system.announcement',
284
+ title: 'Chargeback could not be routed to an order',
285
+ body: reason === 'missing-index' ? `A ${amount} chargeback (${paymentIntentId}) could not be looked ` + 'up: the collection-group index on orders.paymentIntentId is not ' + 'deployed, so EVERY commerce chargeback is currently being ignored ' + '— no flag, no seller-share reversal, no merchant notification. ' + 'Deploy cloud/firebase-firestore.indexes.json.' : `A ${amount} chargeback (${paymentIntentId}) matched more than one ` + 'order, so none was reversed. The dispute is durable in Stripe and ' + 'needs reconciling by hand.',
286
+ link: '/admin'
287
+ });
288
+ }
289
+ /** The dispute record this event describes, before any outcome is known. */ function disputeFromEvent(dispute) {
290
+ var _ref, _ref1, _ref2, _ref3, _ref4;
291
+ var _dispute_evidence_details;
292
+ const dueBy = Number((_ref = dispute == null ? void 0 : (_dispute_evidence_details = dispute.evidence_details) == null ? void 0 : _dispute_evidence_details.due_by) != null ? _ref : 0);
293
+ return _extends({
294
+ id: String((_ref1 = dispute == null ? void 0 : dispute.id) != null ? _ref1 : ''),
295
+ status: String((_ref2 = dispute == null ? void 0 : dispute.status) != null ? _ref2 : '')
296
+ }, (dispute == null ? void 0 : dispute.reason) ? {
297
+ reason: String(dispute.reason)
298
+ } : {}, {
299
+ amountCents: Math.max(0, Math.round(Number((_ref3 = dispute == null ? void 0 : dispute.amount) != null ? _ref3 : 0))),
300
+ openedAtMs: Number((_ref4 = dispute == null ? void 0 : dispute.created) != null ? _ref4 : 0) * 1000 || Date.now()
301
+ }, dueBy > 0 ? {
302
+ evidenceDueByMs: dueBy * 1000
303
+ } : {});
304
+ }
305
+ /**
306
+ * `charge.dispute.created`: FLAG the order, reverse nothing (AGL-1787).
307
+ *
308
+ * A dispute can be WON, and this is the whole reason the reversal waits for
309
+ * `closed`. Nothing here un-writes: `recordContactRefund` is monotonic by
310
+ * construction (AGL-1754 chose counters over decrements precisely so a reader
311
+ * can never be handed a number that went backwards), so a reversal written on
312
+ * `created` and reversed again on a win would need a decrement the contact
313
+ * writer deliberately does not have. Waiting means a win has NOTHING to undo,
314
+ * which is a stronger guarantee than undoing correctly. AGL-1554 states the
315
+ * same rule for the marketplace side.
316
+ *
317
+ * What the merchant gets is the thing they actually need on day one: notice,
318
+ * with the evidence deadline, while there is still time to respond.
319
+ *
320
+ * The write is `update()` on the whole `dispute` field, not a merge into it.
321
+ * A merge recurses, so a SECOND dispute opened on the same charge after a win
322
+ * would inherit the first one's `outcome`, `closedAtMs` and `reversedCents` —
323
+ * an open dispute reading as already settled. `update()` replaces a nested map
324
+ * wholesale, which is the semantics this wants, and the transaction has just
325
+ * proven the document exists so it cannot `NOT_FOUND`.
326
+ */ async function recordDisputeOpened(snapshot, dispute) {
327
+ const record = disputeFromEvent(dispute);
328
+ const opened = await firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
329
+ var _fresh_data;
330
+ var _order_dispute;
331
+ const fresh = await transaction.get(snapshot.ref);
332
+ if (!fresh.exists) return false;
333
+ const order = CommerceModel.liftLegacyOrder((_fresh_data = fresh.data()) != null ? _fresh_data : {});
334
+ // Once-only, keyed on the DOCUMENT, which is how every section of this
335
+ // file guards a redelivery ("idempotent by doc key") and how AGL-1754
336
+ // made its status flip observable exactly once. A claim in
337
+ // `apiIdempotency` would be worse here, not better: its key would have to
338
+ // be the Stripe event id, which never changes, so a process killed
339
+ // between the claim and the record would strand it and the redelivery —
340
+ // the only retry a webhook has — would be turned away forever. An HTTP
341
+ // caller escapes a stranded claim with a fresh key; a webhook cannot.
342
+ // Reading the order inside the transaction that writes it has no such
343
+ // window.
344
+ //
345
+ // Any stored record of THIS dispute means `created` already landed, or
346
+ // `closed` beat it here; a DIFFERENT id is a genuine second dispute on
347
+ // the same charge and does replace it.
348
+ if (((_order_dispute = order.dispute) == null ? void 0 : _order_dispute.id) === record.id) return false;
349
+ const amount = `$${(record.amountCents / 100).toFixed(2)}`;
350
+ transaction.update(snapshot.ref, {
351
+ dispute: record,
352
+ timeline: CommerceModel.appendOrderEvent(order, 'dispute', `Chargeback opened for ${amount}` + (record.reason ? ` (${record.reason.replace(/_/g, ' ')})` : '') + (record.evidenceDueByMs ? ` — evidence due ${new Date(record.evidenceDueByMs).toISOString().slice(0, 10)}` : ''), record.openedAtMs)
353
+ });
354
+ return true;
355
+ });
356
+ return {
357
+ opened,
358
+ record
359
+ };
360
+ }
361
+ /**
362
+ * `charge.dispute.closed`: the only event that moves money (AGL-1787).
363
+ *
364
+ * `status` is `won`, `lost` or `warning_closed`. Only `lost` reverses; the
365
+ * other two record the outcome and leave every figure alone, which costs
366
+ * nothing precisely because `created` reversed nothing.
367
+ *
368
+ * A LOSS IS RECORDED AS A REFUND, and that is the decision. Money reversed is
369
+ * money reversed whichever door it left by, so it lands in the fields AGL-1754
370
+ * built for it — `refundedCents` on the order, `refundedCents` /
371
+ * `refundedOrdersCount` / `lastRefundAtMs` on the contact — and the order
372
+ * reaches `refunded`. What the merchant sees as DIFFERENT is the wording: the
373
+ * order timeline says "charged back", the contact timeline says "charged back"
374
+ * (the `kind` parameter AGL-1754 shipped for exactly this handler), and the
375
+ * `dispute` record beside the status carries the reason, the amount and the
376
+ * outcome. The alternative — a `disputed` status — is measured in
377
+ * `OrderDispute`'s comment: it would have escaped five gates that match on
378
+ * `'refunded'` and left the shopper their downloads.
379
+ *
380
+ * THE CAP is `refund.ts`'s: this attempt against what is LEFT, never the order
381
+ * total. A merchant who already refunded half and then loses a dispute for the
382
+ * full amount reverses only the remaining half — the buyer cannot be given the
383
+ * same money twice, and Stripe's own dispute is against the net charge.
384
+ *
385
+ * THE STATUS FLIP asks `canTransitionOrder` rather than forcing `refunded`, so
386
+ * a cancelled order records the loss without the chargeback rewriting a
387
+ * terminal state the merchant chose. `closedTheOrder` then needs no separate
388
+ * `!== 'refunded'` test: an order already in `refunded` has no legal transition
389
+ * out of it, so `canTransitionOrder` is false and the flip is observable
390
+ * exactly once — the AGL-1754 finding that `fullyRefunded` is NOT a once-only
391
+ * signal, inherited rather than rediscovered. Two partial refunds settling
392
+ * either side of this dispute can all three compute "fully refunded"; only the
393
+ * transaction that reads the status it is about to write may increment.
394
+ *
395
+ * THE DISPUTE FEE (typically $15) is not recorded anywhere on the order. These
396
+ * are DESTINATION charges — `payment_intent_data[transfer_data][destination]`
397
+ * with no `on_behalf_of` — so the dispute and its fee are debited from the
398
+ * PLATFORM's balance, not the merchant's. It is not money the customer handed
399
+ * over and not money the merchant lost, so it belongs neither in `ltvCents` nor
400
+ * in the order's totals, and stamping Aglyn's cost onto a merchant-facing
401
+ * document would assert a loss they did not take. Whether the platform should
402
+ * RECOVER the principal by reversing the transfer is a real policy question
403
+ * with a merchant-agreement answer attached, and is filed rather than guessed.
404
+ */ async function recordDisputeClosed(snapshot, dispute) {
405
+ var _ref;
406
+ const event = disputeFromEvent(dispute);
407
+ const outcome = String((_ref = dispute == null ? void 0 : dispute.status) != null ? _ref : '');
408
+ const lost = outcome === 'lost';
409
+ return firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
410
+ var _fresh_data, _ref, _order_amountCents, _order_refundedCents;
411
+ var _order_totals;
412
+ const idle = {
413
+ recorded: false,
414
+ lost,
415
+ reversedCents: 0,
416
+ closedTheOrder: false,
417
+ customerEmail: null
418
+ };
419
+ const fresh = await transaction.get(snapshot.ref);
420
+ if (!fresh.exists) return idle;
421
+ const order = CommerceModel.liftLegacyOrder((_fresh_data = fresh.data()) != null ? _fresh_data : {});
422
+ const stored = order.dispute;
423
+ // Already settled: a plain redelivery of this same `closed`.
424
+ if ((stored == null ? void 0 : stored.id) === event.id && stored.closedAtMs) return idle;
425
+ const totalCents = (_ref = (_order_totals = order.totals) == null ? void 0 : _order_totals.totalCents) != null ? _ref : Number((_order_amountCents = order.amountCents) != null ? _order_amountCents : 0);
426
+ const alreadyReversed = Number((_order_refundedCents = order.refundedCents) != null ? _order_refundedCents : 0);
427
+ const reversedCents = lost ? Math.max(0, Math.min(event.amountCents, totalCents - alreadyReversed)) : 0;
428
+ const reversedTotal = alreadyReversed + reversedCents;
429
+ const fullyReversed = reversedCents > 0 && reversedTotal >= totalCents;
430
+ const closedTheOrder = fullyReversed && CommerceModel.canTransitionOrder(order.status, 'refunded');
431
+ const closedAtMs = Date.now();
432
+ const amount = `$${(reversedCents / 100).toFixed(2)}`;
433
+ transaction.update(snapshot.ref, _extends({}, reversedCents > 0 ? {
434
+ refundedCents: reversedTotal
435
+ } : {}, closedTheOrder ? {
436
+ status: 'refunded'
437
+ } : {}, {
438
+ dispute: _extends({}, event, (stored == null ? void 0 : stored.id) === event.id && stored.openedAtMs ? {
439
+ openedAtMs: stored.openedAtMs
440
+ } : {}, {
441
+ status: outcome,
442
+ outcome,
443
+ closedAtMs,
444
+ reversedCents
445
+ }),
446
+ timeline: CommerceModel.appendOrderEvent(order, 'dispute', lost ? `${amount} charged back (dispute lost)` + (closedTheOrder ? ' (full)' : '') : `Dispute ${outcome.replace(/_/g, ' ')} — no money reversed`, closedAtMs)
447
+ }));
448
+ return {
449
+ recorded: true,
450
+ lost,
451
+ reversedCents,
452
+ closedTheOrder,
453
+ customerEmail: order.customerEmail
454
+ };
455
+ });
456
+ }
457
+ /**
458
+ * Stop a LAPSED storefront's recurring billing (AGL-2071).
459
+ *
460
+ * ## The money path this closes
461
+ *
462
+ * A storefront subscription is a DESTINATION CHARGE on Aglyn's platform
463
+ * account: `checkout.ts` sends `subscription_data[transfer_data][destination]`
464
+ * unconditionally and `subscription_data[application_fee_percent]` only when
465
+ * the plan's take rate is above zero. Stripe's processing fee (2.9% + 30¢) and
466
+ * any $15 dispute fee are debited from the PLATFORM's balance — stated at
467
+ * :356-359 of this file — so a cycle billed at a 0% take rate is strictly
468
+ * loss-making for Aglyn.
469
+ *
470
+ * Every shopper-facing door re-asks the plan per request and refuses a lapsed
471
+ * org: `checkout.ts:132`, `cart-checkout.ts:96`, `draft-order.ts:102`,
472
+ * `reserve.ts:70`, `pos-order.ts:94`. A RENEWAL has no door — Stripe bills the
473
+ * subscriber on its own schedule and this webhook is told afterwards. So the
474
+ * subscription created while the org could sell keeps billing forever after
475
+ * the org's own subscription dies, and `resolveEffectivePlan` has by then
476
+ * collapsed that org to `free`.
477
+ *
478
+ * ## Why this cancels rather than refuses
479
+ *
480
+ * The cycle in hand is money Stripe has ALREADY taken. Refusing to record it
481
+ * would be AGL-1732 in reverse — a payment collected and filed nowhere — and
482
+ * the subscriber's box still has to ship. So the ledger, the order and the
483
+ * fulfilment path are untouched; what changes is that no FURTHER cycle is
484
+ * billed.
485
+ *
486
+ * `cancel_at_period_end` rather than an outright cancel, for two reasons that
487
+ * both matter: the subscriber keeps the period they just paid for, and the
488
+ * flag is REVERSIBLE — the merchant has a full cycle to restore their Aglyn
489
+ * plan before anything is actually lost. An immediate cancel would take
490
+ * service away from a shopper who did nothing wrong.
491
+ *
492
+ * ## Once-only
493
+ *
494
+ * The marker is CLAIMED in a transaction before the Stripe call, so two
495
+ * deliveries racing cannot both notify the merchant. A Stripe refusal releases
496
+ * the claim, because a marker that outlives a failed call would leave the
497
+ * subscription billing forever with the books saying it had been stopped. The
498
+ * `Idempotency-Key` covers the window where the claim is written and the
499
+ * response never arrives.
500
+ */ async function stopLapsedStorefrontSubscription(hostId, subscriptionId, subscriptionRef) {
501
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
502
+ if (!stripeKey) {
503
+ console.error('Lapsed storefront subscription not stopped: STRIPE_SECRET_KEY is not set (AGL-2071)', {
504
+ hostId,
505
+ subscriptionId
506
+ });
507
+ return;
508
+ }
509
+ const claimed = await firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
510
+ const fresh = await transaction.get(subscriptionRef);
511
+ if (fresh.get('lapsedStopRequestedAtMs') != null) return false;
512
+ transaction.set(subscriptionRef, {
513
+ lapsedStopRequestedAtMs: Date.now()
514
+ }, {
515
+ merge: true
516
+ });
517
+ return true;
518
+ });
519
+ if (!claimed) return;
520
+ // `null`, not `FieldValue.delete()`: the guard above asks `!= null`, so
521
+ // both readings are identical to it, and a plain value keeps the release
522
+ // inside the same merge-set semantics every other write here uses.
523
+ const release = async ()=>{
524
+ await subscriptionRef.set({
525
+ lapsedStopRequestedAtMs: null
526
+ }, {
527
+ merge: true
528
+ }).catch(()=>undefined);
529
+ };
530
+ const response = await fetch(`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, {
531
+ method: 'POST',
532
+ headers: {
533
+ Authorization: `Bearer ${stripeKey}`,
534
+ 'Content-Type': 'application/x-www-form-urlencoded',
535
+ 'Idempotency-Key': `lapsed-stop-${subscriptionId}`
536
+ },
537
+ body: new URLSearchParams({
538
+ cancel_at_period_end: 'true'
539
+ }).toString()
540
+ }).catch(()=>null);
541
+ if (!response || !response.ok) {
542
+ await release();
543
+ if (!response || isTransientStripeStatus(response.status)) {
544
+ // Let Stripe redeliver: the ledger write above is idempotent on the
545
+ // invoice id, so a retry re-runs this and nothing else.
546
+ throw new Error(`Stripe refused to stop lapsed subscription ${subscriptionId} ` + `(${response ? response.status : 'network'})`);
547
+ }
548
+ console.error('Stripe refused to stop a lapsed storefront subscription (AGL-2071)', {
549
+ hostId,
550
+ subscriptionId,
551
+ status: response.status
552
+ });
553
+ return;
554
+ }
555
+ await subscriptionRef.set({
556
+ lapsedStopReason: 'plan',
557
+ cancelAtPeriodEnd: true
558
+ }, {
559
+ merge: true
560
+ }).catch(()=>undefined);
561
+ void notifyHostManagers(hostId, {
562
+ type: 'content.order',
563
+ title: 'A subscription stopped renewing — your plan no longer includes storefront subscriptions',
564
+ body: 'This subscriber keeps the period they have already paid for. ' + 'Restore your plan before it ends to keep billing them.',
565
+ link: `/${hostId}/products`
566
+ });
567
+ }
568
+ /** Stripe failures a redelivery can actually fix. */ function isTransientStripeStatus(status) {
569
+ return status === 429 || status >= 500;
570
+ }
571
+ /** One authorized GET against the Stripe API, body parsed either way. */ async function stripeGet(url, stripeKey) {
572
+ const response = await fetch(url, {
573
+ headers: {
574
+ Authorization: `Bearer ${stripeKey}`
575
+ }
576
+ });
577
+ const body = await response.json().catch(()=>null);
578
+ return {
579
+ ok: response.ok,
580
+ status: response.status,
581
+ body
582
+ };
583
+ }
584
+ /**
585
+ * Re-price a storefront subscription's platform fee to the merchant's CURRENT
586
+ * plan (AGL-2289) — and since AGL-2655 that figure is the plan's take plus
587
+ * the card cost as a rate, so the same pass carries a subscription sold
588
+ * before the pass-through existed onto it at its next paid invoice.
589
+ *
590
+ * `checkout.ts` sets `subscription_data[application_fee_percent]` ONCE, at the
591
+ * sale, and nothing has ever revisited it. `application_fee_percent` lives on
592
+ * the Stripe subscription and applies to every invoice it ever raises, so the
593
+ * rate a merchant was on the day a shopper subscribed is the rate they pay
594
+ * forever — in both directions, and both are wrong:
595
+ *
596
+ * - Sold on Starter (5% digital) then upgraded to Advanced (0%): Aglyn keeps
597
+ * taking 5% of every cycle from a merchant whose plan says it takes none.
598
+ * That is an over-charge to find and refund.
599
+ * - Sold on Advanced (the param omitted entirely) then downgraded to Starter:
600
+ * Aglyn keeps taking nothing, on a DESTINATION charge whose processing fee
601
+ * it pays out of its own balance. Every cycle is a loss.
602
+ *
603
+ * The renewal is the only place this can be corrected, because a renewal is
604
+ * the only event a subscription raises — there is no door for the merchant to
605
+ * walk through. It runs beside the AGL-2071 lapse stop for that reason.
606
+ *
607
+ * IDEMPOTENT BY A STORED VALUE, not by a Stripe read. `appliedFeePct` on our
608
+ * own `subscriptions/{id}` document records what was last SENT, so an ordinary
609
+ * renewal compares two numbers and makes no network call at all. A
610
+ * subscription from before this shipped carries no such field, which reads as
611
+ * "unknown" and re-prices once — the self-healing pass, and the reason this
612
+ * does not need a backfill.
613
+ *
614
+ * The value is written only AFTER Stripe accepts, so a failure re-tries on the
615
+ * next cycle rather than recording a rate that was never applied. A transient
616
+ * refusal throws so Stripe redelivers (the ledger write is keyed on the
617
+ * invoice id, so a redelivery re-runs this and nothing else); a definitive one
618
+ * is logged and let go, because no redelivery fixes it and a throw would have
619
+ * Stripe retry the whole invoice forever — the AGL-1743 lesson.
620
+ *
621
+ * A rate of 0 UNSETS the parameter rather than sending `0`: Stripe rejects a
622
+ * zero `application_fee_percent`, and an empty value is how its API clears an
623
+ * optional field. That is also what `checkout.ts` does by omitting the key.
624
+ */ async function repriceStorefrontSubscriptionFee(subscriptionRef, subscriptionId, desiredFeePct) {
625
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
626
+ if (!stripeKey || !subscriptionId) return;
627
+ const snapshot = await subscriptionRef.get().catch(()=>null);
628
+ if (!(snapshot == null ? void 0 : snapshot.exists)) return;
629
+ const stored = snapshot.get('appliedFeePct');
630
+ // `Number.isFinite` and not `!= null`: a malformed stored value must read as
631
+ // unknown and re-price, never as agreement with whatever it happens to be.
632
+ if (Number.isFinite(stored) && Number(stored) === desiredFeePct) return;
633
+ const response = await fetch(`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, {
634
+ method: 'POST',
635
+ headers: {
636
+ Authorization: `Bearer ${stripeKey}`,
637
+ 'Content-Type': 'application/x-www-form-urlencoded',
638
+ // Keyed on the RATE as well as the subscription, so a later change
639
+ // back is a new request rather than a replay of the old one.
640
+ 'Idempotency-Key': `fee-reprice-${subscriptionId}-${desiredFeePct}`
641
+ },
642
+ body: new URLSearchParams({
643
+ application_fee_percent: desiredFeePct > 0 ? String(desiredFeePct) : ''
644
+ }).toString()
645
+ }).catch(()=>null);
646
+ if (!response || !response.ok) {
647
+ if (!response || isTransientStripeStatus(response.status)) {
648
+ throw new Error(`Stripe refused to re-price subscription ${subscriptionId} ` + `(${response ? response.status : 'network'})`);
649
+ }
650
+ console.error(`Stripe refused the fee re-price for ${subscriptionId} (AGL-2289)`, await response.json().catch(()=>null));
651
+ return;
652
+ }
653
+ await subscriptionRef.set({
654
+ appliedFeePct: desiredFeePct
655
+ }, {
656
+ merge: true
657
+ }).catch(()=>undefined);
658
+ }
659
+ /**
660
+ * The charge a paid invoice settled against, on whichever spelling this
661
+ * endpoint's API version uses (AGL-2317).
662
+ *
663
+ * `invoice.charge` was removed in favour of `invoice.payments[]` on newer
664
+ * versions, and the version an endpoint speaks is dashboard configuration this
665
+ * repo cannot see — the same three-spelling problem `subscriptionMeta` and the
666
+ * tax fields already solve by reading every form. `payment_intent` is the third
667
+ * (the renewal order write at :1667 reads it), and it needs one more hop.
668
+ */ async function resolveInvoiceChargeId(invoice, stripeKey) {
669
+ var _ref;
670
+ var _invoice_payments;
671
+ const direct = invoice == null ? void 0 : invoice.charge;
672
+ if (typeof direct === 'string' && direct) return direct;
673
+ for (const payment of (_ref = invoice == null ? void 0 : (_invoice_payments = invoice.payments) == null ? void 0 : _invoice_payments.data) != null ? _ref : []){
674
+ var _payment_payment, _payment_payment1;
675
+ const charge = payment == null ? void 0 : (_payment_payment = payment.payment) == null ? void 0 : _payment_payment.charge;
676
+ if (typeof charge === 'string' && charge) return charge;
677
+ const nested = payment == null ? void 0 : (_payment_payment1 = payment.payment) == null ? void 0 : _payment_payment1.payment_intent;
678
+ if (typeof nested === 'string' && nested) {
679
+ var _intent_body;
680
+ const intent = await stripeGet(`https://api.stripe.com/v1/payment_intents/${encodeURIComponent(nested)}`, stripeKey);
681
+ const latest = intent.ok ? (_intent_body = intent.body) == null ? void 0 : _intent_body.latest_charge : '';
682
+ if (typeof latest === 'string' && latest) return latest;
683
+ }
684
+ }
685
+ const paymentIntentId = invoice == null ? void 0 : invoice.payment_intent;
686
+ if (typeof paymentIntentId === 'string' && paymentIntentId) {
687
+ var _intent_body1;
688
+ const intent = await stripeGet(`https://api.stripe.com/v1/payment_intents/${encodeURIComponent(paymentIntentId)}`, stripeKey);
689
+ const latest = intent.ok ? (_intent_body1 = intent.body) == null ? void 0 : _intent_body1.latest_charge : '';
690
+ if (typeof latest === 'string' && latest) return latest;
691
+ }
692
+ return '';
693
+ }
694
+ /**
695
+ * Take the platform's cut of a subscription cycle on ITEMS ONLY (AGL-2317).
696
+ *
697
+ * ## What was wrong
698
+ *
699
+ * `checkout.ts` sends `subscription_data[application_fee_percent]`, and Stripe
700
+ * applies that percentage to the WHOLE invoice — sales tax and shipping
701
+ * included. Every one-time door in this product computes an
702
+ * `application_fee_amount` in cents on post-discount items instead, and
703
+ * AGL-2315 deliberately did the same for bookings. So the recurring door was
704
+ * taking a percentage of money that belongs to a state revenue office, and the
705
+ * same merchant selling the same $30 good was charged 60¢ through one button
706
+ * and 65¢ through the other.
707
+ *
708
+ * This is a BASE correction, not a pricing change: the advertised commerce fee
709
+ * is a cut of sales, the one-time path already implements exactly that, and the
710
+ * subscription path was charging merchants MORE than the locked basis. No rate,
711
+ * tier boundary or metered price is touched — see
712
+ * `subscriptionInvoiceItemsOnlyFeeCents`, which derives the target by scaling
713
+ * the fee Stripe actually took and never names a rate at all.
714
+ *
715
+ * ## Why this runs on the PAID invoice and not on the draft
716
+ *
717
+ * Stripe offers no items-only base for `application_fee_percent` — it is a
718
+ * percentage of the invoice total, full stop — and a Subscription has no
719
+ * `application_fee_amount` field to set instead. The obvious repair is to
720
+ * subscribe `invoice.created` and write an exact cents amount onto each draft
721
+ * before it finalises. That covers RENEWALS and cannot cover the opening cycle:
722
+ * a subscription bought through Checkout has its first invoice created,
723
+ * finalised and paid inside the session, so the `invoice.created` delivery
724
+ * arrives against an already-paid invoice that Stripe will not let us modify.
725
+ * Dropping the percent and relying on the draft patch would therefore leave
726
+ * cycle 1 of every subscription — the whole first year of an annual one —
727
+ * collecting no fee at all.
728
+ *
729
+ * Nor can the opening fee be pre-computed as an equivalent percentage:
730
+ * `application_fee_percent` carries limited decimal places, and on a Stripe Tax
731
+ * merchant the tax is not even known when the session is created.
732
+ *
733
+ * So the correction is applied where the composition is finally knowable and no
734
+ * event is racing: the paid invoice. Stripe's own instrument for handing a
735
+ * platform fee back to the connected account is an application-fee refund, and
736
+ * the excess — the part taken on tax and shipping — is refunded to the
737
+ * merchant. Three properties made this the shape worth shipping inside the
738
+ * freeze:
739
+ *
740
+ * - **It rides an event we already receive.** `invoice.paid` is in
741
+ * `WEBHOOK_EVENTS` and is live. `invoice.created` is not, and adding it is a
742
+ * dashboard reconciliation someone has to run — a correctness fix that only
743
+ * works after an ops step is a fix that can silently not ship.
744
+ * - **It reaches the BACK-BOOK with no migration.** Existing subscriptions
745
+ * carry `application_fee_percent` on the Stripe object and will keep applying
746
+ * it; every cycle they raise is corrected here, from the first delivery
747
+ * onward, without touching a single Stripe subscription.
748
+ * - **It is a no-op with ZERO network calls on an untaxed invoice**, which is
749
+ * every subscription in a store that collects no tax and ships nothing.
750
+ *
751
+ * ## Idempotency
752
+ *
753
+ * Two ways, because the `invoice.paid` / `invoice.payment_succeeded` pair means
754
+ * the same payment arrives twice. The `Idempotency-Key` is the invoice id, and
755
+ * — independently of Stripe honouring it — the refundable amount is computed
756
+ * from the fee's own `amount_refunded`, so a second delivery finds nothing left
757
+ * to refund and makes no write. A redelivery after the ledger row exists
758
+ * re-runs this and only this, exactly like the AGL-2071 stop beside it.
759
+ *
760
+ * Returns the fee actually in force after the correction, so the recorded
761
+ * `totals.feeCents` is what Aglyn kept rather than what Stripe first debited.
762
+ * That closes AGL-2317's second-order half too: cycle 1 was recorded from
763
+ * `metadata[feeCents]` (items only) while Stripe had charged percent-of-total,
764
+ * so the same subscription's first and second cycles reported fees computed on
765
+ * different bases.
766
+ */ async function chargeSubscriptionFeeOnItemsOnly(invoice, hostId, invoiceId) {
767
+ var _ref, _fee_amount, _fee_amount_refunded;
768
+ var _fees_body_data, _fees_body;
769
+ const chargedCents = Math.max(0, Math.round(Number((_ref = invoice == null ? void 0 : invoice.application_fee_amount) != null ? _ref : 0)));
770
+ const desiredCents = CommerceModel.subscriptionInvoiceItemsOnlyFeeCents(invoice);
771
+ // No fee, or nothing but items on the invoice. The overwhelming majority of
772
+ // cycles land here and this function costs them nothing.
773
+ if (!Number.isFinite(chargedCents) || chargedCents <= desiredCents) {
774
+ return Number.isFinite(chargedCents) ? chargedCents : 0;
775
+ }
776
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
777
+ if (!stripeKey) {
778
+ console.error('Subscription fee not corrected to the items-only base: STRIPE_SECRET_KEY is not set (AGL-2317)', {
779
+ hostId,
780
+ invoiceId
781
+ });
782
+ return chargedCents;
783
+ }
784
+ const chargeId = await resolveInvoiceChargeId(invoice, stripeKey);
785
+ if (!chargeId) {
786
+ console.error('Subscription fee not corrected to the items-only base: the invoice names no charge (AGL-2317)', {
787
+ hostId,
788
+ invoiceId
789
+ });
790
+ return chargedCents;
791
+ }
792
+ const fees = await stripeGet(`https://api.stripe.com/v1/application_fees?limit=1&charge=${encodeURIComponent(chargeId)}`, stripeKey);
793
+ const fee = fees.ok ? (_fees_body = fees.body) == null ? void 0 : (_fees_body_data = _fees_body.data) == null ? void 0 : _fees_body_data[0] : null;
794
+ if (!(fee == null ? void 0 : fee.id)) {
795
+ if (!fees.ok && isTransientStripeStatus(fees.status)) {
796
+ throw new Error(`Stripe would not list the application fee for invoice ${invoiceId} ` + `(${fees.status})`);
797
+ }
798
+ console.error('Subscription fee not corrected to the items-only base: no application fee on the charge (AGL-2317)', {
799
+ hostId,
800
+ invoiceId,
801
+ chargeId,
802
+ status: fees.status
803
+ });
804
+ return chargedCents;
805
+ }
806
+ const feeAmount = Math.max(0, Math.round(Number((_fee_amount = fee.amount) != null ? _fee_amount : 0)));
807
+ const alreadyRefunded = Math.max(0, Math.round(Number((_fee_amount_refunded = fee.amount_refunded) != null ? _fee_amount_refunded : 0)));
808
+ // What is still standing against the items-only target. A previous delivery
809
+ // that already corrected this invoice leaves nothing here.
810
+ const refundCents = feeAmount - alreadyRefunded - desiredCents;
811
+ if (refundCents <= 0) return desiredCents;
812
+ const response = await fetch(`https://api.stripe.com/v1/application_fees/${encodeURIComponent(String(fee.id))}/refunds`, {
813
+ method: 'POST',
814
+ headers: {
815
+ Authorization: `Bearer ${stripeKey}`,
816
+ 'Content-Type': 'application/x-www-form-urlencoded',
817
+ 'Idempotency-Key': `fee-basis-${invoiceId}`
818
+ },
819
+ body: new URLSearchParams({
820
+ amount: String(refundCents)
821
+ }).toString()
822
+ }).catch(()=>null);
823
+ if (!response || !response.ok) {
824
+ if (!response || isTransientStripeStatus(response.status)) {
825
+ // Let Stripe redeliver — the ledger write is keyed on the invoice id, so
826
+ // a retry re-runs this and nothing else (the AGL-1743 discipline).
827
+ throw new Error(`Stripe refused the items-only fee correction for invoice ${invoiceId} ` + `(${response ? response.status : 'network'})`);
828
+ }
829
+ // Definitive: no redelivery fixes it, and throwing would have Stripe retry
830
+ // the whole invoice forever. Record what was actually taken.
831
+ console.error(`Stripe refused the items-only fee correction for ${invoiceId} (AGL-2317)`, await response.json().catch(()=>null));
832
+ return chargedCents;
833
+ }
834
+ return desiredCents;
835
+ }
836
+ /**
837
+ * THE SALES TAX ON A SUBSCRIPTION CYCLE, PULLED BACK TO THE PLATFORM
838
+ * (AGL-1956).
839
+ *
840
+ * ## The leak
841
+ *
842
+ * Aglyn is the merchant of record on every buyer-facing path: storefront
843
+ * charges are destination charges on Aglyn's own platform account, so where
844
+ * Stripe Tax computes against Aglyn's registrations Aglyn is the party that
845
+ * remits. `commerce-connect-transfer.ts` closes that for a ONE-OFF sale with a
846
+ * fixed `transfer_data[amount]`.
847
+ *
848
+ * A Stripe Subscription accepts no `transfer_data[amount]` — only
849
+ * `application_fee_percent` — so `checkout.ts` deliberately excludes
850
+ * subscriptions from that helper. On a destination charge the fee form
851
+ * transfers the WHOLE charge to the connected account and debits the fee at
852
+ * the destination, and the charge has the tax inside it. AGL-2317 then refunds
853
+ * the slice of the fee that was taken on tax and shipping back to the
854
+ * merchant, which is correct as a FEE BASIS and makes the tax position
855
+ * strictly worse: after both steps the merchant is holding **every cent** of a
856
+ * tax Aglyn owes a state revenue office.
857
+ *
858
+ * The money is therefore already IN the connected account, and the only
859
+ * instrument that moves it back is a TRANSFER REVERSAL. (Not a top-up — that
860
+ * is the fix for the opposite residual, the shopper-chosen shipping the
861
+ * one-off path under-transfers, and it points the other way.) The reversal
862
+ * leaves the merchant with `items + shipping − itemsOnlyFee`, the identical
863
+ * split `platformLiableTransferCents` fixes up front on the one-off path.
864
+ *
865
+ * ## Only where AGLYN is the liable party
866
+ *
867
+ * `subscriptionInvoiceTaxReversal` decides, off `automatic_tax.enabled` and
868
+ * `automatic_tax.liability.type` and NEVER off the tax lines. A manual-rate
869
+ * subscription bills a real Stripe Tax Rate (AGL-1751), so its cycles carry a
870
+ * populated `total_taxes[]` that looks exactly like a Stripe Tax one — reading
871
+ * the lines would reverse the merchant's own tax out of their payout, which is
872
+ * this bug pointing the other way and worse, because it takes money that is
873
+ * genuinely theirs.
874
+ *
875
+ * ## Idempotency: three independent guards, because a reversal applied twice
876
+ * ## takes the merchant's money twice
877
+ *
878
+ * 1. **Our own record.** `subscriptions/{id}/taxReversals/{invoiceId}` is
879
+ * claimed in a transaction, and a delivery that finds `reversedCents` set
880
+ * returns before any Stripe call. Stripe sends `invoice.paid` AND
881
+ * `invoice.payment_succeeded` for one payment and redelivers on top of
882
+ * that, so this is the guard that actually fires in practice.
883
+ * 2. **The transfer itself**, for the crash window between the POST landing
884
+ * and the record being written. The reversal carries
885
+ * `metadata[aglynTaxInvoiceId]`, so the next delivery finds it and ADOPTS
886
+ * it rather than creating a second one.
887
+ * 3. **A per-ATTEMPT `Idempotency-Key`**, `…-{invoiceId}-{attempt}`, so a
888
+ * duplicate of the SAME attempt is handed Stripe's stored response instead
889
+ * of moving money again.
890
+ *
891
+ * ## Why the key carries an attempt number — MEASURED, and it contradicts the docs
892
+ *
893
+ * The key used to be `…-{invoiceId}` alone. Measured in test mode against the
894
+ * live API, that is a permanent dead end for retries:
895
+ *
896
+ * - a reversal refused with 400 is **stored** under the key, and replaying
897
+ * the same request returns the same 400 with `Idempotent-Replayed: true`
898
+ * (`tr_3U7rqHDYHP4psn7h3M8c405O`);
899
+ * - retrying the same key with a CORRECTED body is refused outright with
900
+ * `idempotency_error` — "Keys for idempotent requests can only be used
901
+ * with the same parameters" — and the transfer stayed at
902
+ * `amount_reversed=0`.
903
+ *
904
+ * Stripe's documentation says results are only saved once an endpoint has
905
+ * begun executing and that validation failures are not saved. The observation
906
+ * disagrees, and the observation is what ships. With a fixed key, a reversal
907
+ * that failed once could NEVER succeed, and Aglyn would silently keep owing a
908
+ * state money it never clawed back — a worse failure than the double-reversal
909
+ * the key was defending against.
910
+ *
911
+ * So the key varies per attempt and the no-double-reversal guarantee moves
912
+ * entirely onto guards 1 and 2, which do not depend on Stripe at all:
913
+ *
914
+ * - two deliveries racing cannot both act, because the in-flight claim is
915
+ * taken in a TRANSACTION and only one can win it;
916
+ * - a delivery that crashed mid-flight releases its claim by staleness, and
917
+ * the next one finds its reversal on the transfer by metadata and ADOPTS
918
+ * it rather than creating a second.
919
+ *
920
+ * Guard 2 is therefore load-bearing rather than a nicety. The reversal
921
+ * metadata was confirmed to round-trip on `GET /v1/transfers/{id}`
922
+ * (`trr_1U7rrQDYHP4psn7ha7jtjgzj`), and the embedded `reversals` list is
923
+ * re-read in full when Stripe says it has more.
924
+ *
925
+ * The marker doc is deliberately NOT the invoice document. `invoices/{id}`
926
+ * existing IS the ledger's idempotency key, and a merge-set against a missing
927
+ * path CREATES it (the AGL-1763 shape) — recording the reversal there would
928
+ * make the cycle itself look already-recorded and lose the payment.
929
+ *
930
+ * ## Failure, and what a retry looks like
931
+ *
932
+ * TRANSIENT (network, 429, 5xx) throws: the marker is still unclaimed, so the
933
+ * redelivery Stripe sends IS the retry, and everything it replays is idle.
934
+ *
935
+ * DEFINITIVE refusals record `reversedCents: 0` with a reason and do not
936
+ * throw — no redelivery fixes them, and a throw would have Stripe retry the
937
+ * whole invoice forever (the AGL-1743 lesson).
938
+ *
939
+ * INSUFFICIENT DESTINATION BALANCE is the one that is neither. Stripe will not
940
+ * reverse more than the connected account holds, and money Aglyn owes a state
941
+ * and does not have must never settle silently. So it records
942
+ * `status: 'insufficient'` and `owedCents`, leaves `reversedCents` UNSET so
943
+ * every later delivery re-attempts, and alerts staff. A partial reversal
944
+ * records the same way, with `owedCents` carrying the shortfall.
945
+ */ async function reverseSubscriptionTaxToPlatform(invoice, hostId, subscriptionRef, invoiceId) {
946
+ if (!invoiceId) return;
947
+ const decision = CommerceModel.subscriptionInvoiceTaxReversal(invoice);
948
+ // The common cycle: a manual-rate store, an untaxed one, or a $0 /
949
+ // fully-discounted invoice. Zero network calls, zero writes.
950
+ if (decision.kind === 'skip') return;
951
+ const markerRef = subscriptionRef.collection('taxReversals').doc(invoiceId);
952
+ /**
953
+ * How long an in-flight claim is honoured before a later delivery may take
954
+ * it over. A webhook handler that has not finished inside this has died;
955
+ * guard 2 is what makes the takeover safe.
956
+ */ const CLAIM_STALE_MS = 10 * 60000;
957
+ /**
958
+ * Settles this invoice's reversal step exactly once.
959
+ *
960
+ * `reversedCents` present — 0 included — means settled, and every later
961
+ * delivery returns before touching Stripe. The in-flight claim is dropped in
962
+ * the same write so a settled row never looks busy.
963
+ */ const settle = async (reversedCents, fields)=>firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
964
+ const fresh = await transaction.get(markerRef);
965
+ if (fresh.exists && fresh.get('reversedCents') != null) return false;
966
+ transaction.set(markerRef, _extends({
967
+ invoiceId,
968
+ hostId,
969
+ reversedCents,
970
+ inFlightAtMs: null,
971
+ settledAtMs: Date.now()
972
+ }, fields), {
973
+ merge: true
974
+ });
975
+ return true;
976
+ });
977
+ /**
978
+ * Gives the claim back WITHOUT settling, so the next delivery takes a fresh
979
+ * attempt number and therefore a fresh idempotency key. This is the whole
980
+ * retry mechanism: a transient refusal and an insufficient destination
981
+ * balance both land here.
982
+ */ const release = async (fields = {})=>{
983
+ await markerRef.set(_extends({
984
+ invoiceId,
985
+ hostId,
986
+ inFlightAtMs: null
987
+ }, fields), {
988
+ merge: true
989
+ }).catch(()=>undefined);
990
+ };
991
+ /**
992
+ * Takes the in-flight claim, or answers 0 when this delivery must stand
993
+ * down. Only one of two racing deliveries can win the transaction, which is
994
+ * what stops both of them reversing.
995
+ */ const claim = async (taxCents)=>firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
996
+ var _fresh_get;
997
+ const fresh = await transaction.get(markerRef);
998
+ if (fresh.exists) {
999
+ var _fresh_get1;
1000
+ if (fresh.get('reversedCents') != null) return 0;
1001
+ const inFlight = Number((_fresh_get1 = fresh.get('inFlightAtMs')) != null ? _fresh_get1 : 0);
1002
+ if (Number.isFinite(inFlight) && inFlight > 0 && Date.now() - inFlight < CLAIM_STALE_MS) {
1003
+ return 0;
1004
+ }
1005
+ }
1006
+ // `strictNullChecks` is off repo-wide, so a malformed stored counter
1007
+ // would sail through as `NaN`, poison the idempotency key and be
1008
+ // rejected by Firestore. Guarded rather than trusted.
1009
+ const prior = Number((_fresh_get = fresh.get('attempt')) != null ? _fresh_get : 0);
1010
+ const attempt = (Number.isFinite(prior) ? Math.max(0, prior) : 0) + 1;
1011
+ transaction.set(markerRef, {
1012
+ invoiceId,
1013
+ hostId,
1014
+ taxCents,
1015
+ attempt,
1016
+ inFlightAtMs: Date.now()
1017
+ }, {
1018
+ merge: true
1019
+ });
1020
+ return attempt;
1021
+ });
1022
+ if (decision.kind === 'unreadable') {
1023
+ // LOUD, and on the books. An invoice that claims automatic tax while
1024
+ // stating no tax field is a Stripe API-version change this repo cannot
1025
+ // see, not a $0 cycle — and treating it as "no tax" would leak the whole
1026
+ // liability with nothing looking wrong.
1027
+ console.error('Subscription tax NOT reversed to the platform: the invoice states no readable tax (AGL-1956)', {
1028
+ hostId,
1029
+ invoiceId,
1030
+ reason: decision.reason
1031
+ });
1032
+ await settle(0, {
1033
+ status: 'unreadable',
1034
+ reason: decision.reason
1035
+ });
1036
+ void notifyStaff({
1037
+ type: 'system.announcement',
1038
+ title: 'A subscription invoice stated no readable tax',
1039
+ body: `Invoice ${invoiceId} enables automatic tax but names no tax field, so ` + `no reversal was made. Aglyn may owe tax it did not pull back. ` + `${decision.reason}`
1040
+ }).catch(()=>undefined);
1041
+ return;
1042
+ }
1043
+ const taxCents = decision.taxCents;
1044
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
1045
+ if (!stripeKey) {
1046
+ console.error('Subscription tax not reversed to the platform: STRIPE_SECRET_KEY is not set (AGL-1956)', {
1047
+ hostId,
1048
+ invoiceId
1049
+ });
1050
+ return;
1051
+ }
1052
+ // GUARD 1. Every ordinary redelivery stops here, before any Stripe call, and
1053
+ // two racing deliveries cannot both get past it.
1054
+ const attempt = await claim(taxCents);
1055
+ if (!attempt) return;
1056
+ try {
1057
+ await runReversal(attempt);
1058
+ } catch (error) {
1059
+ // The claim must not outlive a failed attempt, or the redelivery Stripe
1060
+ // sends — which IS the retry — would find the row busy and stand down.
1061
+ await release({
1062
+ status: 'retrying',
1063
+ taxCents,
1064
+ owedCents: taxCents
1065
+ });
1066
+ throw error;
1067
+ }
1068
+ return;
1069
+ async function runReversal(attemptNumber) {
1070
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
1071
+ var _charge_body, _transfer_body_reversals, _transfer_body, _transfer_body_reversals1, _transfer_body1, _transfer_body2, _transfer_body3;
1072
+ const chargeId = await resolveInvoiceChargeId(invoice, stripeKey);
1073
+ if (!chargeId) {
1074
+ // A taxed invoice settled entirely from a customer credit balance raises
1075
+ // no charge, so there is no transfer and nothing to pull back.
1076
+ console.error('Subscription tax not reversed: the invoice names no charge (AGL-1956)', {
1077
+ hostId,
1078
+ invoiceId
1079
+ });
1080
+ await settle(0, {
1081
+ status: 'no-charge',
1082
+ taxCents
1083
+ });
1084
+ return;
1085
+ }
1086
+ const charge = await stripeGet(`https://api.stripe.com/v1/charges/${encodeURIComponent(chargeId)}`, stripeKey);
1087
+ if (!charge.ok) {
1088
+ var _charge_body1;
1089
+ if (isTransientStripeStatus(charge.status)) {
1090
+ throw new Error(`Stripe charge read failed (${charge.status}) for invoice ${invoiceId} (AGL-1956)`);
1091
+ }
1092
+ console.error('Subscription tax not reversed: Stripe refused the charge read (AGL-1956)', {
1093
+ hostId,
1094
+ invoiceId,
1095
+ chargeId,
1096
+ error: (_charge_body1 = charge.body) == null ? void 0 : _charge_body1.error
1097
+ });
1098
+ await settle(0, {
1099
+ status: 'charge-unreadable',
1100
+ taxCents
1101
+ });
1102
+ return;
1103
+ }
1104
+ const transferId = String((_ref = (_charge_body = charge.body) == null ? void 0 : _charge_body.transfer) != null ? _ref : '');
1105
+ if (!transferId) {
1106
+ // No transfer means nothing was ever handed to the merchant — the tax is
1107
+ // already sitting on the platform balance.
1108
+ await settle(0, {
1109
+ status: 'no-transfer',
1110
+ taxCents,
1111
+ chargeId
1112
+ });
1113
+ return;
1114
+ }
1115
+ const transfer = await stripeGet(`https://api.stripe.com/v1/transfers/${encodeURIComponent(transferId)}`, stripeKey);
1116
+ if (!transfer.ok) {
1117
+ var _transfer_body4;
1118
+ if (isTransientStripeStatus(transfer.status)) {
1119
+ throw new Error(`Stripe transfer read failed (${transfer.status}) for invoice ${invoiceId} (AGL-1956)`);
1120
+ }
1121
+ console.error('Subscription tax not reversed: Stripe refused the transfer read (AGL-1956)', {
1122
+ hostId,
1123
+ invoiceId,
1124
+ transferId,
1125
+ error: (_transfer_body4 = transfer.body) == null ? void 0 : _transfer_body4.error
1126
+ });
1127
+ await settle(0, {
1128
+ status: 'transfer-unreadable',
1129
+ taxCents
1130
+ });
1131
+ return;
1132
+ }
1133
+ // GUARD 2 — the crash window, and LOAD-BEARING now that the idempotency key
1134
+ // varies per attempt. A previous attempt's POST landed and its record did
1135
+ // not; adopt it rather than reversing the tax a second time.
1136
+ //
1137
+ // The embedded list is capped (Stripe returns the first 10), so a transfer
1138
+ // carrying more reversals than that is re-read in full. Missing an existing
1139
+ // reversal here would double-reverse, which is the one outcome this whole
1140
+ // function is built to prevent — worth the extra call in the rare case.
1141
+ let reversalList = (_ref1 = (_transfer_body = transfer.body) == null ? void 0 : (_transfer_body_reversals = _transfer_body.reversals) == null ? void 0 : _transfer_body_reversals.data) != null ? _ref1 : [];
1142
+ if (((_transfer_body1 = transfer.body) == null ? void 0 : (_transfer_body_reversals1 = _transfer_body1.reversals) == null ? void 0 : _transfer_body_reversals1.has_more) === true) {
1143
+ var _ref6;
1144
+ var _full_body;
1145
+ const full = await stripeGet(`https://api.stripe.com/v1/transfers/${encodeURIComponent(transferId)}/reversals?limit=100`, stripeKey);
1146
+ if (!full.ok) {
1147
+ // Unknown is NOT "none": proceeding here could create a second reversal.
1148
+ throw new Error(`Stripe reversal list read failed (${full.status}) for invoice ${invoiceId} (AGL-1956)`);
1149
+ }
1150
+ reversalList = (_ref6 = (_full_body = full.body) == null ? void 0 : _full_body.data) != null ? _ref6 : [];
1151
+ }
1152
+ const adopted = reversalList.find((item)=>{
1153
+ var _ref;
1154
+ var _item_metadata;
1155
+ return String((_ref = item == null ? void 0 : (_item_metadata = item.metadata) == null ? void 0 : _item_metadata.aglynTaxInvoiceId) != null ? _ref : '') === invoiceId;
1156
+ });
1157
+ if (adopted) {
1158
+ var _adopted_amount, _adopted_id;
1159
+ const adoptedCents = Math.max(0, Math.round(Number((_adopted_amount = adopted.amount) != null ? _adopted_amount : 0)));
1160
+ await settle(adoptedCents, _extends({
1161
+ status: 'reversed',
1162
+ taxCents,
1163
+ transferId,
1164
+ reversalId: String((_adopted_id = adopted.id) != null ? _adopted_id : ''),
1165
+ adopted: true
1166
+ }, adoptedCents < taxCents ? {
1167
+ owedCents: taxCents - adoptedCents
1168
+ } : {}));
1169
+ return;
1170
+ }
1171
+ const transferCents = Math.max(0, Math.round(Number((_ref2 = (_transfer_body2 = transfer.body) == null ? void 0 : _transfer_body2.amount) != null ? _ref2 : 0)));
1172
+ const alreadyReversedCents = Math.max(0, Math.round(Number((_ref3 = (_transfer_body3 = transfer.body) == null ? void 0 : _transfer_body3.amount_reversed) != null ? _ref3 : 0)));
1173
+ const remainingCents = Math.max(0, transferCents - alreadyReversedCents);
1174
+ const reverseCents = Math.min(taxCents, remainingCents);
1175
+ if (!(reverseCents > 0)) {
1176
+ // Nothing left on the transfer — a refund with `reverse_transfer` got here
1177
+ // first. The money is back on the platform balance either way; what Aglyn
1178
+ // must NOT do is call this settled without saying so.
1179
+ console.error('Subscription tax not reversed: the transfer has nothing left (AGL-1956)', {
1180
+ hostId,
1181
+ invoiceId,
1182
+ transferId,
1183
+ transferCents,
1184
+ alreadyReversedCents
1185
+ });
1186
+ await settle(0, {
1187
+ status: 'nothing-left',
1188
+ taxCents,
1189
+ transferId,
1190
+ owedCents: taxCents
1191
+ });
1192
+ return;
1193
+ }
1194
+ const response = await fetch(`https://api.stripe.com/v1/transfers/${encodeURIComponent(transferId)}/reversals`, {
1195
+ method: 'POST',
1196
+ headers: {
1197
+ Authorization: `Bearer ${stripeKey}`,
1198
+ 'Content-Type': 'application/x-www-form-urlencoded',
1199
+ // GUARD 3, per ATTEMPT. Stripe stores a 4xx under the key and replays
1200
+ // it (measured — see the note on this function), so a key fixed on the
1201
+ // invoice alone would make a once-failed reversal permanently
1202
+ // unretryable. The attempt number comes from the claim above, so two
1203
+ // racing deliveries still cannot both reach this line.
1204
+ 'Idempotency-Key': `subscription-tax-reversal-${invoiceId}-${attemptNumber}`
1205
+ },
1206
+ body: new URLSearchParams({
1207
+ amount: String(reverseCents),
1208
+ 'metadata[aglynTaxInvoiceId]': invoiceId,
1209
+ 'metadata[hostId]': hostId
1210
+ }).toString()
1211
+ }).catch(()=>null);
1212
+ const body = response ? await response.json().catch(()=>null) : null;
1213
+ if (!response || !response.ok) {
1214
+ var _ref7, _ref8, _ref9;
1215
+ var _body_error, _body_error1, _body_error2;
1216
+ if (!response || isTransientStripeStatus(response.status)) {
1217
+ throw new Error(`Stripe refused the subscription tax reversal for invoice ${invoiceId} ` + `(${response ? response.status : 'network'}) (AGL-1956)`);
1218
+ }
1219
+ // THE ONE THAT IS NEITHER TRANSIENT NOR FINAL. An insufficient destination
1220
+ // balance is money Aglyn owes a state and does not have, so it must stay
1221
+ // retryable: the claim is RELEASED rather than settled, and the next
1222
+ // delivery takes attempt+1 — a fresh idempotency key, which is the only
1223
+ // thing that lets Stripe execute it at all.
1224
+ const insufficient = String((_ref7 = body == null ? void 0 : (_body_error = body.error) == null ? void 0 : _body_error.code) != null ? _ref7 : '') === 'balance_insufficient';
1225
+ console.error('Stripe refused the subscription tax reversal (AGL-1956)', {
1226
+ hostId,
1227
+ invoiceId,
1228
+ transferId,
1229
+ reverseCents,
1230
+ error: body == null ? void 0 : body.error
1231
+ });
1232
+ const failureFields = {
1233
+ taxCents,
1234
+ transferId,
1235
+ owedCents: taxCents,
1236
+ reason: String((_ref8 = body == null ? void 0 : (_body_error1 = body.error) == null ? void 0 : _body_error1.message) != null ? _ref8 : '')
1237
+ };
1238
+ if (insufficient) {
1239
+ await release(_extends({
1240
+ status: 'insufficient'
1241
+ }, failureFields));
1242
+ } else {
1243
+ await settle(0, _extends({
1244
+ status: 'refused'
1245
+ }, failureFields));
1246
+ }
1247
+ void notifyStaff({
1248
+ type: 'system.announcement',
1249
+ title: 'A subscription sales-tax reversal failed',
1250
+ body: `$${(taxCents / 100).toFixed(2)} of sales tax on invoice ${invoiceId} ` + `is still with the merchant and Aglyn owes it. ` + (insufficient ? 'The connected account had insufficient balance; this will retry.' : `Stripe refused: ${String((_ref9 = body == null ? void 0 : (_body_error2 = body.error) == null ? void 0 : _body_error2.message) != null ? _ref9 : 'no reason given')}`)
1251
+ }).catch(()=>undefined);
1252
+ return;
1253
+ }
1254
+ const reversedCents = Math.max(0, Math.round(Number((_ref4 = body == null ? void 0 : body.amount) != null ? _ref4 : reverseCents)));
1255
+ await settle(reversedCents, _extends({
1256
+ status: 'reversed',
1257
+ taxCents,
1258
+ transferId,
1259
+ reversalId: String((_ref5 = body == null ? void 0 : body.id) != null ? _ref5 : '')
1260
+ }, reversedCents < taxCents ? {
1261
+ owedCents: taxCents - reversedCents
1262
+ } : {}));
1263
+ }
1264
+ }
1265
+ /**
1266
+ * The seller's share of a LOST dispute, pulled back from the connected
1267
+ * account (AGL-1794).
1268
+ *
1269
+ * These are destination charges, so the disputed funds and the dispute fee
1270
+ * are debited from the PLATFORM's balance while the merchant keeps the
1271
+ * transfer — a merchant paid in full for a sale the shopper's bank took back,
1272
+ * with Aglyn out the principal. The decision (AGL-1794): the merchant eats
1273
+ * their share, by a `transfers/{id}/reversals` call keyed off the charge's
1274
+ * transfer. The platform still eats Stripe's dispute fee, DELIBERATELY: that
1275
+ * is the cost of owning the payment relationship, not the merchant's loss,
1276
+ * which is why no fee figure appears here or anywhere on the order.
1277
+ *
1278
+ * GROSS, NOT NET — the money question, settled on evidence (AGL-1794).
1279
+ *
1280
+ * Stripe transfers the FULL charge to the connected account and debits the
1281
+ * `application_fee_amount` at the DESTINATION, so `transfer.amount` equals
1282
+ * `charge.amount` and the merchant's own balance transaction reads
1283
+ * `amount 10000, fee 500, net 9500` (measured in test mode; AGL-1951 caught
1284
+ * the fixture here modelling it backwards). The proportional share below is
1285
+ * therefore the WHOLE principal, and the merchant hands back the gross while
1286
+ * Aglyn keeps its commission.
1287
+ *
1288
+ * ⚑ THAT EQUALITY IS NO LONGER UNIVERSAL (AGL-1956). A `mode: 'stripe'`
1289
+ * storefront sale now sends a FIXED `transfer_data[amount]` and no
1290
+ * application fee at all, because Stripe Tax on a platform session computes
1291
+ * against AGLYN's registrations and Aglyn remits it — the fee form would have
1292
+ * transferred the tax to the merchant. On those charges `transfer.amount` is
1293
+ * `goods + shipping − fee`, strictly LESS than `charge.amount`.
1294
+ *
1295
+ * Nothing below needed changing, and that is worth saying explicitly rather
1296
+ * than leaving to be rediscovered: the share is already computed as
1297
+ * `principal × transfer.amount ÷ charge.amount`, so the ratio simply stops
1298
+ * being 1 and the merchant hands back their share of the principal and no
1299
+ * part of the tax — which is correct, because the tax was never theirs and
1300
+ * Aglyn has to refund it to the shopper's bank out of its own balance. The
1301
+ * ratio was written for a partially-refunded transfer; it now earns its keep
1302
+ * on every taxed sale too.
1303
+ *
1304
+ * That asymmetry with the refund door is deliberate, not an oversight.
1305
+ * `refund.ts` sends `refund_application_fee: 'true'` alongside
1306
+ * `reverse_transfer`, so a REFUND returns Aglyn's commission; this door sends
1307
+ * no such flag, so a CHARGEBACK does not. The market was surveyed before
1308
+ * choosing: Shopify Payments, Etsy, eBay, PayPal and Square all take the gross
1309
+ * back from the seller and none return the platform's cut on a chargeback,
1310
+ * and most add a dispute fee on top ($15 Shopify, $20 eBay, $20 PayPal) that
1311
+ * Aglyn does not pass on. Stripe itself documents only the mechanism — the
1312
+ * `refund_application_fee` switch on a reversal — and takes no position on
1313
+ * which way to flip it, advising platforms to price the application fee to
1314
+ * absorb dispute costs rather than recover them per incident. Reversing NET
1315
+ * is the minority practice (Eventbrite is the clean example, and it absorbs
1316
+ * the bank fees too). **Do not add `refund_application_fee` to the POST below
1317
+ * without re-deciding the policy** — it is the one-parameter switch between
1318
+ * this and handing the commission back, and the reversal amount does not
1319
+ * change when it flips, so nothing else in the wire shape would give it away.
1320
+ *
1321
+ * PROPORTIONAL, NEVER MORE. The share of the reversed principal is
1322
+ * `reversedCents × transfer.amount ÷ charge.amount`, FLOORED, then capped
1323
+ * at what the transfer has left (`amount − amount_reversed`) — a transfer
1324
+ * partially reversed by an earlier `reverse_transfer` refund cannot be pulled
1325
+ * below its own remainder from here. The ratio is 1 on an untouched transfer,
1326
+ * by the measurement above; it earns its keep on a partially-refunded one.
1327
+ * The reversal CAN drive the connected account negative, which Stripe recovers
1328
+ * from future payouts; that is the policy, recorded on the order timeline in
1329
+ * words the merchant can read.
1330
+ *
1331
+ * IDEMPOTENT BY THE ORDER DOCUMENT, with the transfer itself as the crash
1332
+ * window's backstop. `dispute.reversedTransferCents` present — 0 included —
1333
+ * means this step settled, and a redelivery returns before any Stripe call.
1334
+ * A process killed between the POST landing and the record writing leaves
1335
+ * that marker unset, so the redelivery runs again: it finds the reversal
1336
+ * already sitting on the transfer (`metadata.disputeId`, stamped by the POST
1337
+ * below), ADOPTS it, and creates nothing. Under both, the POST carries an
1338
+ * `Idempotency-Key` derived from the dispute id, so even a delivery that
1339
+ * raced past both reads is handed Stripe's stored response rather than a
1340
+ * second reversal.
1341
+ *
1342
+ * DEFINITIVE failures record `reversedTransferCents: 0` with a timeline note
1343
+ * and DO NOT throw: a charge with no transfer on it, a transfer with nothing
1344
+ * left to reverse, Stripe refusing the request outright. No redelivery fixes
1345
+ * those, and a throw propagates through `runBillingWebhookHandlers` into a
1346
+ * 500 Stripe redelivers forever — the AGL-1743 lesson, applied the same way
1347
+ * `findOrderForDispute` applies it to the missing index. TRANSIENT failures
1348
+ * (a network reject, a 429, a 5xx) throw on purpose: the marker is still
1349
+ * unset, so the redelivery Stripe sends IS the retry, and the settle it
1350
+ * replays is idle so nothing else doubles.
1351
+ *
1352
+ * This runs OUTSIDE the settle transaction and re-reads the order itself,
1353
+ * because it must also run on a redelivery whose settle was idle — the
1354
+ * transient-failure path above depends on exactly that.
1355
+ *
1356
+ * RETURNS the cents this delivery pulled back and is therefore responsible
1357
+ * for announcing — 0 for every no-op, every definitive failure, and every
1358
+ * redelivery that found the step already settled. The caller's merchant
1359
+ * notification is keyed on it, so a non-zero answer must mean "the write
1360
+ * happened HERE, and nobody has told the merchant yet".
1361
+ */ async function reverseSellerShare(orderRef, dispute) {
1362
+ var _ref, _snapshot_data, _stored_reversedCents, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8;
1363
+ var _charge_body, _charge_body1, _transfer_body_reversals, _transfer_body, _transfer_body1, _transfer_body2;
1364
+ const disputeId = String((_ref = dispute == null ? void 0 : dispute.id) != null ? _ref : '');
1365
+ if (!disputeId) return 0;
1366
+ const snapshot = await orderRef.get();
1367
+ if (!snapshot.exists) return 0;
1368
+ const order = CommerceModel.liftLegacyOrder((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
1369
+ const stored = order.dispute;
1370
+ // A different dispute has replaced the record, or the outcome on the books
1371
+ // is not a loss: nothing here to recover.
1372
+ if ((stored == null ? void 0 : stored.id) !== disputeId || stored.outcome !== 'lost') return 0;
1373
+ // The settle marker — see the doc comment. 0 counts.
1374
+ if (stored.reversedTransferCents != null) return 0;
1375
+ const principalCents = Number((_stored_reversedCents = stored.reversedCents) != null ? _stored_reversedCents : 0);
1376
+ // The order had nothing left to reverse — it was already fully refunded,
1377
+ // and `refund.ts` sent `reverse_transfer=true` when it was, so the seller's
1378
+ // share already went back by the refund door. No marker and no Stripe call:
1379
+ // the short-circuit is as idempotent as the write and cheaper.
1380
+ if (!(principalCents > 0)) return 0;
1381
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
1382
+ if (!stripeKey) {
1383
+ console.error('Transfer reversal skipped: STRIPE_SECRET_KEY is not set (AGL-1794)');
1384
+ return 0;
1385
+ }
1386
+ /**
1387
+ * Settles the step exactly once, whatever it found, and reports whether
1388
+ * THIS delivery was the one that wrote.
1389
+ *
1390
+ * The boolean is what makes the merchant notification once-only. The
1391
+ * pre-read above turns away every ordinary redelivery, but two deliveries
1392
+ * racing can both pass it and only one can win the transaction — so the
1393
+ * caller must learn its outcome from the write, not from having reached
1394
+ * this far. A notification is not idempotent by any doc key of its own.
1395
+ */ const settle = async (reversedTransferCents, transferReversalId, note)=>{
1396
+ return firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
1397
+ var _fresh_data;
1398
+ const fresh = await transaction.get(orderRef);
1399
+ if (!fresh.exists) return false;
1400
+ const current = CommerceModel.liftLegacyOrder((_fresh_data = fresh.data()) != null ? _fresh_data : {});
1401
+ const record = current.dispute;
1402
+ if ((record == null ? void 0 : record.id) !== disputeId || record.reversedTransferCents != null) {
1403
+ return false;
1404
+ }
1405
+ transaction.update(orderRef, {
1406
+ // Written whole, the field's own rule — see `OrderDispute`.
1407
+ dispute: _extends({}, record, {
1408
+ reversedTransferCents
1409
+ }, transferReversalId ? {
1410
+ transferReversalId
1411
+ } : {}),
1412
+ timeline: CommerceModel.appendOrderEvent(current, 'dispute', note)
1413
+ });
1414
+ return true;
1415
+ });
1416
+ };
1417
+ const chargeId = String((_ref1 = dispute == null ? void 0 : dispute.charge) != null ? _ref1 : '');
1418
+ if (!chargeId) {
1419
+ console.error('Dispute carries no charge; seller share not reversed', {
1420
+ disputeId
1421
+ });
1422
+ await settle(0, null, 'Seller share not reversed — no charge on the dispute');
1423
+ return 0;
1424
+ }
1425
+ const charge = await stripeGet(`https://api.stripe.com/v1/charges/${chargeId}`, stripeKey);
1426
+ if (!charge.ok) {
1427
+ var _charge_body2;
1428
+ if (isTransientStripeStatus(charge.status)) {
1429
+ throw new Error(`Stripe charge read failed (${charge.status}) for dispute ${disputeId}`);
1430
+ }
1431
+ console.error('Stripe refused the charge read; seller share not reversed', (_charge_body2 = charge.body) == null ? void 0 : _charge_body2.error);
1432
+ await settle(0, null, 'Seller share not reversed — charge not found at Stripe');
1433
+ return 0;
1434
+ }
1435
+ const transferId = String((_ref2 = (_charge_body = charge.body) == null ? void 0 : _charge_body.transfer) != null ? _ref2 : '');
1436
+ const chargeAmountCents = Math.round(Number((_ref3 = (_charge_body1 = charge.body) == null ? void 0 : _charge_body1.amount) != null ? _ref3 : 0));
1437
+ if (!transferId || !(chargeAmountCents > 0)) {
1438
+ // An order from before destination charges, or a charge Stripe holds no
1439
+ // transfer for. Logged, recorded, let go.
1440
+ console.error('No transfer on the disputed charge; seller share not reversed', {
1441
+ disputeId,
1442
+ chargeId
1443
+ });
1444
+ await settle(0, null, 'Seller share not reversed — no transfer on the charge');
1445
+ return 0;
1446
+ }
1447
+ const transfer = await stripeGet(`https://api.stripe.com/v1/transfers/${transferId}`, stripeKey);
1448
+ if (!transfer.ok) {
1449
+ var _transfer_body3;
1450
+ if (isTransientStripeStatus(transfer.status)) {
1451
+ throw new Error(`Stripe transfer read failed (${transfer.status}) for dispute ${disputeId}`);
1452
+ }
1453
+ console.error('Stripe refused the transfer read; seller share not reversed', (_transfer_body3 = transfer.body) == null ? void 0 : _transfer_body3.error);
1454
+ await settle(0, null, 'Seller share not reversed — transfer not found at Stripe');
1455
+ return 0;
1456
+ }
1457
+ // The crash window's backstop: the POST landed on a previous delivery and
1458
+ // the record did not. Adopt what exists rather than creating a second one.
1459
+ const existing = ((_ref4 = (_transfer_body = transfer.body) == null ? void 0 : (_transfer_body_reversals = _transfer_body.reversals) == null ? void 0 : _transfer_body_reversals.data) != null ? _ref4 : []).find((item)=>{
1460
+ var _ref;
1461
+ var _item_metadata;
1462
+ return String((_ref = item == null ? void 0 : (_item_metadata = item.metadata) == null ? void 0 : _item_metadata.disputeId) != null ? _ref : '') === disputeId;
1463
+ });
1464
+ if (existing) {
1465
+ var _existing_amount, _existing_id;
1466
+ const adoptedCents = Math.round(Number((_existing_amount = existing.amount) != null ? _existing_amount : 0));
1467
+ // Announced on adoption too: the delivery that created this reversal died
1468
+ // before recording it, so it died before notifying. The money left the
1469
+ // connected account either way and the merchant has not been told yet.
1470
+ const wrote = await settle(adoptedCents, String((_existing_id = existing.id) != null ? _existing_id : ''), `$${(adoptedCents / 100).toFixed(2)} seller share reversed for lost dispute`);
1471
+ return wrote ? adoptedCents : 0;
1472
+ }
1473
+ const transferCents = Math.round(Number((_ref5 = (_transfer_body1 = transfer.body) == null ? void 0 : _transfer_body1.amount) != null ? _ref5 : 0));
1474
+ const alreadyReversedCents = Math.round(Number((_ref6 = (_transfer_body2 = transfer.body) == null ? void 0 : _transfer_body2.amount_reversed) != null ? _ref6 : 0));
1475
+ const remainingCents = Math.max(0, transferCents - alreadyReversedCents);
1476
+ const shareCents = Math.min(chargeAmountCents > 0 ? Math.floor(principalCents * transferCents / chargeAmountCents) : 0, remainingCents);
1477
+ if (!(shareCents > 0)) {
1478
+ console.error('Transfer has nothing left to reverse; seller share not reversed', {
1479
+ disputeId,
1480
+ transferId,
1481
+ transferCents,
1482
+ alreadyReversedCents
1483
+ });
1484
+ await settle(0, null, 'Seller share already reversed on the transfer — nothing left to pull back');
1485
+ return 0;
1486
+ }
1487
+ const params = new URLSearchParams({
1488
+ amount: String(shareCents),
1489
+ 'metadata[disputeId]': disputeId,
1490
+ 'metadata[orderId]': orderRef.id
1491
+ });
1492
+ const response = await fetch(`https://api.stripe.com/v1/transfers/${transferId}/reversals`, {
1493
+ method: 'POST',
1494
+ headers: {
1495
+ Authorization: `Bearer ${stripeKey}`,
1496
+ 'Content-Type': 'application/x-www-form-urlencoded',
1497
+ 'Idempotency-Key': `dispute-reversal-${disputeId}`
1498
+ },
1499
+ body: params.toString()
1500
+ });
1501
+ const reversal = await response.json().catch(()=>null);
1502
+ if (!response.ok) {
1503
+ if (isTransientStripeStatus(response.status)) {
1504
+ throw new Error(`Stripe transfer reversal failed (${response.status}) for dispute ${disputeId}`);
1505
+ }
1506
+ console.error('Stripe refused the transfer reversal', reversal == null ? void 0 : reversal.error);
1507
+ await settle(0, null, 'Seller share not reversed — Stripe refused the reversal');
1508
+ return 0;
1509
+ }
1510
+ const reversedTransferCents = Math.round(Number((_ref7 = reversal == null ? void 0 : reversal.amount) != null ? _ref7 : shareCents));
1511
+ const wrote = await settle(reversedTransferCents, String((_ref8 = reversal == null ? void 0 : reversal.id) != null ? _ref8 : ''), `$${(reversedTransferCents / 100).toFixed(2)} seller share reversed for lost dispute`);
1512
+ return wrote ? reversedTransferCents : 0;
1513
+ }
1514
+ /**
1515
+ * Commerce sections of the platform Stripe webhook (AGL-418): relocated
1516
+ * verbatim from the console route — subscriptions sync, reservations,
1517
+ * cart orders, draft orders, Commerce Starter orders, plus the license-key
1518
+ * assignment helper (AGL-308). Registered via registerCommerceConsoleApi;
1519
+ * every section is idempotent by doc key and self-selects on
1520
+ * `object.metadata.type`, exactly as the inline route sections did.
1521
+ */ export const commerceBillingWebhookHandler = async ({ type, object, event, requestHost })=>{
1522
+ var _object_metadata, _object_metadata1, _object_metadata2, _object_metadata3, _object_metadata4;
1523
+ // Connect readiness, kept fresh (AGL-1997). Every commerce money route —
1524
+ // checkout, cart checkout, draft orders, reservations, POS — gates the sale
1525
+ // on the CACHED `stripeChargesEnabled` written by the connect route. Nothing
1526
+ // refreshed it but the merchant reopening that route, so a merchant Stripe
1527
+ // later restricted kept selling on a stale `true` and the shopper met the
1528
+ // failure at payment time.
1529
+ //
1530
+ // FIRST and with an early return: this event shares nothing with the order
1531
+ // sections below, and returning here keeps it out of every `metadata.type`
1532
+ // test. `syncConnectAccountStatus` mirrors current state, so a redelivery is
1533
+ // harmless.
1534
+ // `event.livemode`, not `object.livemode` (AGL-2471): the Stripe Account
1535
+ // object carries no `livemode` field, but the event announcing it does, and
1536
+ // that is what lets a linkage whose mode was never recorded heal itself
1537
+ // instead of staying refused forever.
1538
+ if (type === 'account.updated') {
1539
+ await syncConnectAccountStatus('profiles', object, event == null ? void 0 : event.livemode);
1540
+ return;
1541
+ }
1542
+ // A PAYOUT OR TRANSFER THAT NEVER LANDED.
1543
+ //
1544
+ // Placed beside `account.updated` because it is the same kind of event —
1545
+ // account-level, nothing to do with the `metadata.type` order sections
1546
+ // below — and returns for the same reason.
1547
+ //
1548
+ // `payout.failed` is the CONNECTED account's balance failing to reach its
1549
+ // bank, so the account id is `event.account`: the Payout object's own
1550
+ // `destination` names the bank, not the Connect account. `transfer.failed`
1551
+ // is the platform's balance failing to reach the connected account, a
1552
+ // platform event whose `destination` IS the account.
1553
+ //
1554
+ // Recorded and surfaced, never retried: Stripe runs its own retry schedule
1555
+ // and a second transfer against an account that just refused one is how a
1556
+ // duplicate lands.
1557
+ if (type === 'payout.failed' || type === 'transfer.failed') {
1558
+ var _ref, _ref1, _ref2;
1559
+ var _object_destination;
1560
+ const failedAccountId = type === 'payout.failed' ? String((_ref = event == null ? void 0 : event.account) != null ? _ref : '') : String((_ref1 = (_ref2 = object == null ? void 0 : (_object_destination = object.destination) == null ? void 0 : _object_destination.id) != null ? _ref2 : object == null ? void 0 : object.destination) != null ? _ref1 : '');
1561
+ await recordConnectPayoutFailure('profiles', {
1562
+ kind: type === 'payout.failed' ? 'payout' : 'transfer',
1563
+ object,
1564
+ accountId: failedAccountId,
1565
+ livemode: event == null ? void 0 : event.livemode
1566
+ });
1567
+ return;
1568
+ }
1569
+ // A later success retires the warning the card shows. The history in
1570
+ // `connectPayoutFailures` is kept — "has this account failed before" is what
1571
+ // that record exists to answer — but a stale warning on a resolved problem
1572
+ // trains people to ignore the surface.
1573
+ if (type === 'payout.paid') {
1574
+ var _ref3;
1575
+ await clearConnectPayoutFailure('profiles', String((_ref3 = event == null ? void 0 : event.account) != null ? _ref3 : ''));
1576
+ return;
1577
+ }
1578
+ // A DEAD SESSION GIVES ITS RESERVATIONS BACK (AGL-2453).
1579
+ //
1580
+ // Stripe expires a Checkout Session 24 hours after creation, and emits this
1581
+ // when the shopper abandons it or cancels out of it — there is no separate
1582
+ // "cancelled" event, because `cancel_url` is a redirect and not a state
1583
+ // change. This is therefore the explicit release path for everything a
1584
+ // checkout reserved before the money moved.
1585
+ //
1586
+ // Holds also carry `expiresAtMs` and every read prunes them, so correctness
1587
+ // does NOT depend on this handler firing (and it will not fire at all unless
1588
+ // the endpoint subscribes to the event). But a merchant watching a cap of 100
1589
+ // sit at 100 for a day after one abandoned cart, with nothing in the product
1590
+ // able to explain why, is the failure the TTL alone leaves standing — an
1591
+ // invisible expiry is not a release anyone can reason about.
1592
+ //
1593
+ // Same early return as `account.updated`: this event carries no order and
1594
+ // shares nothing with the `checkout.session.completed` sections below.
1595
+ if (type === 'checkout.session.expired') {
1596
+ var _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10, _ref11;
1597
+ var _object_metadata5, _object_metadata6, _object_metadata7, _object_metadata8, _object_metadata9, _object_metadata10, _object_metadata11, _object_metadata12;
1598
+ const expiredHostId = String((_ref4 = object == null ? void 0 : (_object_metadata5 = object.metadata) == null ? void 0 : _object_metadata5.hostId) != null ? _ref4 : '');
1599
+ if (!expiredHostId) return;
1600
+ const expiredHostRef = firebaseAdmin.app().firestore().collection('hosts').doc(expiredHostId);
1601
+ // THE UNITS COME BACK FIRST (AGL-2356), because they are the reservation a
1602
+ // shopper is actually waiting on. A discount slot sitting against a cap is
1603
+ // a merchant-facing annoyance; a held unit is a sale the store cannot make,
1604
+ // and this event is what turns an abandoned checkout back into stock inside
1605
+ // a minute rather than at the TTL.
1606
+ //
1607
+ // Correctness does not depend on this firing — every read prunes lapsed
1608
+ // holds, so a unit is never stranded waiting on a webhook, and it will not
1609
+ // fire at all unless the endpoint subscribes to the event. What it buys is
1610
+ // that the shelf and the storefront agree PROMPTLY, which is the difference
1611
+ // between a hold a merchant can reason about and one they cannot.
1612
+ await releaseStockHold(expiredHostRef, String((_ref5 = object == null ? void 0 : (_object_metadata6 = object.metadata) == null ? void 0 : _object_metadata6.stockHoldKey) != null ? _ref5 : ''));
1613
+ const couponHoldKey = String((_ref6 = object == null ? void 0 : (_object_metadata7 = object.metadata) == null ? void 0 : _object_metadata7.couponHoldKey) != null ? _ref6 : '');
1614
+ const couponCode = String((_ref7 = object == null ? void 0 : (_object_metadata8 = object.metadata) == null ? void 0 : _object_metadata8.couponCode) != null ? _ref7 : '');
1615
+ if (couponHoldKey && couponCode) {
1616
+ await releasePromotionHold(expiredHostRef.collection('coupons').doc(couponCode), couponHoldKey);
1617
+ }
1618
+ const discountHoldKey = String((_ref8 = object == null ? void 0 : (_object_metadata9 = object.metadata) == null ? void 0 : _object_metadata9.discountHoldKey) != null ? _ref8 : '');
1619
+ const discountId = String((_ref9 = object == null ? void 0 : (_object_metadata10 = object.metadata) == null ? void 0 : _object_metadata10.discountId) != null ? _ref9 : '');
1620
+ if (discountHoldKey && discountId) {
1621
+ await releasePromotionHold(expiredHostRef.collection('discounts').doc(discountId), discountHoldKey);
1622
+ }
1623
+ // The gift-card hold rides along, because it is the same act on the same
1624
+ // event and AGL-2449 left it to the TTL alone. A shopper whose abandoned
1625
+ // checkout stands their own balance off for a day cannot be told why
1626
+ // either, and the release is the identical delete sentinel.
1627
+ const giftCardHoldKey = String((_ref10 = object == null ? void 0 : (_object_metadata11 = object.metadata) == null ? void 0 : _object_metadata11.giftCardHoldKey) != null ? _ref10 : '');
1628
+ const giftCardCode = String((_ref11 = object == null ? void 0 : (_object_metadata12 = object.metadata) == null ? void 0 : _object_metadata12.giftCardCode) != null ? _ref11 : '');
1629
+ if (giftCardHoldKey && giftCardCode) {
1630
+ await expiredHostRef.collection('giftCards').doc(giftCardCode).set({
1631
+ holds: {
1632
+ [giftCardHoldKey]: firebaseAdmin.firestore.FieldValue.delete()
1633
+ }
1634
+ }, {
1635
+ merge: true
1636
+ }).catch((error)=>{
1637
+ console.error('Gift card hold release failed', giftCardCode, error);
1638
+ });
1639
+ }
1640
+ return;
1641
+ }
1642
+ // White-label brand per host (White-Label Phase 3): every storefront email
1643
+ // this webhook sends — receipts, gift cards, reservation and sale notices,
1644
+ // supplier notices — reads as the store's brand. Resolved once per host from
1645
+ // the org doc through the one shared resolver, memoized for the event.
1646
+ const brandCache = new Map();
1647
+ const brandFor = async (hostId)=>{
1648
+ const key = String(hostId);
1649
+ const cached = brandCache.get(key);
1650
+ if (cached) return cached;
1651
+ const org = await getOrgForHost(key).catch(()=>null);
1652
+ const brand = Aglyn.resolveBrandingProfile(org == null ? void 0 : org.org);
1653
+ brandCache.set(key, brand);
1654
+ return brand;
1655
+ };
1656
+ // Storefront sales tax (AGL-1904), FIRST and unconditionally: a
1657
+ // `mode: 'stripe'` store's shopper is charged tax computed on AGLYN's own
1658
+ // registrations — the session is created on the platform account with no
1659
+ // `Stripe-Account` header and no `on_behalf_of`, and Stripe reports
1660
+ // `automatic_tax.liability: { type: 'self' }` on it (measured, see
1661
+ // `storefront-tax.ts`). That money settles into Aglyn's balance and Aglyn's
1662
+ // quarterly return could not see a cent of it.
1663
+ //
1664
+ // Ahead of the branch chain on purpose: every one of the sections below
1665
+ // early-returns on its own redelivery guard (`if (!created) return`), so a
1666
+ // recorder placed inside or after them would silently stop recording the
1667
+ // moment an order document already existed. This is idempotent by Stripe
1668
+ // object id, so running on every delivery is the correct behaviour.
1669
+ //
1670
+ // AWAITED and never allowed to throw: a lost row is an understated tax
1671
+ // return, and `recordStorefrontTax` swallows its own failures precisely so
1672
+ // this cannot turn a recording problem into a redelivered billing webhook.
1673
+ await recordStorefrontTax(String(type), object);
1674
+ if (type === 'customer.subscription.created' || type === 'customer.subscription.updated' || type === 'customer.subscription.deleted') {
1675
+ var _object_metadata13;
1676
+ // Storefront subscription status sync (AGL-303).
1677
+ if ((object == null ? void 0 : (_object_metadata13 = object.metadata) == null ? void 0 : _object_metadata13.type) === 'commerce-subscription') {
1678
+ var _object_metadata14;
1679
+ const subHostId = object == null ? void 0 : (_object_metadata14 = object.metadata) == null ? void 0 : _object_metadata14.hostId;
1680
+ if (subHostId) {
1681
+ var _ref12;
1682
+ await firebaseAdmin.app().firestore().collection('hosts').doc(String(subHostId)).collection('subscriptions').doc(String(object.id)).set({
1683
+ status: type === 'customer.subscription.deleted' ? 'canceled' : String((_ref12 = object == null ? void 0 : object.status) != null ? _ref12 : 'active'),
1684
+ currentPeriodEndMs: (object == null ? void 0 : object.current_period_end) ? object.current_period_end * 1000 : null
1685
+ }, {
1686
+ merge: true
1687
+ }).catch(()=>undefined);
1688
+ }
1689
+ }
1690
+ }
1691
+ // Storefront subscriptions (AGL-303): record the sub under the host;
1692
+ // status then follows customer.subscription.* events below.
1693
+ if (type === 'checkout.session.completed' && (object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.type) === 'commerce-subscription' && (object == null ? void 0 : object.subscription)) {
1694
+ var _object_metadata15;
1695
+ const { hostId, productId } = (_object_metadata15 = object.metadata) != null ? _object_metadata15 : {};
1696
+ if (hostId && productId) {
1697
+ var _productForSnapshot_data, _ref13, _productForSnapshot_get, _subTotals_totalCents, _ref14;
1698
+ var _object_metadata16, _liftedForSnapshot_subscription, _object_customer_details, _object_customer_details1, _object_customer_details2;
1699
+ const firestore = firebaseAdmin.app().firestore();
1700
+ const hostRef = firestore.collection('hosts').doc(String(hostId));
1701
+ // AGL-1732: this branch used to write the subscription doc and stop —
1702
+ // productId, email, name, customer id, status. NO money, anywhere. Not
1703
+ // as an order (subscriptions are deliberately not orders: the docs, the
1704
+ // console and the tenant account page all keep the two apart), but not
1705
+ // on the subscription doc either, and not in the manager notification
1706
+ // or the contact record. A merchant asking "what is this subscriber
1707
+ // paying me?" had exactly one answer available: log in to Stripe.
1708
+ //
1709
+ // The sale is now decomposed and stored ON THE SUBSCRIPTION, which is
1710
+ // the record this product already treats as the subscription's home.
1711
+ // `computeBuyNowOrder` is the right decomposition rather than a
1712
+ // parallel one: a subscription session is built by the SAME
1713
+ // `checkout.ts` function, carrying the same `unitAmountCents` /
1714
+ // `quantity` / `taxCents` / `discountCents` metadata snapshot, so the
1715
+ // two sessions differ only in `mode`. It reads Stripe's own
1716
+ // `total_details` through `computeCheckoutSessionTotals` — including
1717
+ // the `amount_shipping` AGL-1698 added — so nothing here re-derives a
1718
+ // figure Stripe already holds.
1719
+ //
1720
+ // This records the INITIAL charge only. Renewals arrive as
1721
+ // `invoice.payment_succeeded`, which this webhook does not handle at
1722
+ // all; see the follow-up filed with AGL-1732.
1723
+ const productForSnapshot = await hostRef.collection('products').doc(String(productId)).get();
1724
+ const liftedForSnapshot = CommerceModel.liftLegacyProduct((_productForSnapshot_data = productForSnapshot.data()) != null ? _productForSnapshot_data : {
1725
+ name: 'Product'
1726
+ });
1727
+ const soldVariant = ((_object_metadata16 = object.metadata) == null ? void 0 : _object_metadata16.variantId) ? liftedForSnapshot.variants.find((item)=>item.id === String(object.metadata.variantId)) : liftedForSnapshot.variants[0];
1728
+ const variantOptions = Object.values((_ref13 = soldVariant == null ? void 0 : soldVariant.options) != null ? _ref13 : {});
1729
+ const { lineItems: subLineItems, totals: subTotals } = CommerceModel.computeBuyNowOrder(object, _extends({
1730
+ name: String((_productForSnapshot_get = productForSnapshot.get('name')) != null ? _productForSnapshot_get : 'Product')
1731
+ }, variantOptions.length ? {
1732
+ variantLabel: variantOptions.join(' / ')
1733
+ } : {}, (soldVariant == null ? void 0 : soldVariant.sku) ? {
1734
+ sku: soldVariant.sku
1735
+ } : {}, liftedForSnapshot.type ? {
1736
+ productType: liftedForSnapshot.type
1737
+ } : {}));
1738
+ const subscriptionRef = hostRef.collection('subscriptions').doc(String(object.subscription));
1739
+ // Redelivery guard (AGL-1732, the AGL-498 shape). Stripe delivers at
1740
+ // least once, and the effects below this write are NOT idempotent —
1741
+ // `upsertHostContact`'s `purchaseCents` is a `FieldValue.increment`, so
1742
+ // a replay would inflate the subscriber's lifetime value and their
1743
+ // order count on every retry.
1744
+ //
1745
+ // Keyed on `checkoutSessionId` rather than on the document existing:
1746
+ // `customer.subscription.created` writes the SAME doc path (status and
1747
+ // period end only) and Stripe does not order the two events, so an
1748
+ // existence check would discard the sale record whenever that event
1749
+ // won the race.
1750
+ const recorded = await firestore.runTransaction(async (transaction)=>{
1751
+ var _ref, _ref1, _ref2, _ref3;
1752
+ var _object_metadata, _object_customer_details, _object_customer_details1, _liftedForSnapshot_subscription, _object_metadata1, _object_metadata2, _object_metadata3;
1753
+ const existing = await transaction.get(subscriptionRef);
1754
+ if (existing.get('checkoutSessionId') === String(object.id)) {
1755
+ return false;
1756
+ }
1757
+ transaction.set(subscriptionRef, _extends({
1758
+ productId: String(productId)
1759
+ }, ((_object_metadata = object.metadata) == null ? void 0 : _object_metadata.variantId) ? {
1760
+ variantId: String(object.metadata.variantId)
1761
+ } : {}, {
1762
+ customerEmail: (_ref = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.email) != null ? _ref : null,
1763
+ customerName: (_ref1 = object == null ? void 0 : (_object_customer_details1 = object.customer_details) == null ? void 0 : _object_customer_details1.name) != null ? _ref1 : null,
1764
+ stripeCustomerId: String((_ref2 = object == null ? void 0 : object.customer) != null ? _ref2 : '') || null,
1765
+ status: 'active',
1766
+ // What was bought, and for how much (AGL-1732). The interval
1767
+ // comes from the product doc because the amount alone is
1768
+ // ambiguous — $50 a month and $50 a year are the same number.
1769
+ lineItems: subLineItems,
1770
+ totals: subTotals
1771
+ }, ((_liftedForSnapshot_subscription = liftedForSnapshot.subscription) == null ? void 0 : _liftedForSnapshot_subscription.interval) ? {
1772
+ interval: liftedForSnapshot.subscription.interval
1773
+ } : {}, {
1774
+ checkoutSessionId: String(object.id)
1775
+ }, typeof (event == null ? void 0 : event.livemode) === 'boolean' ? {
1776
+ livemode: event.livemode
1777
+ } : {}, {
1778
+ createdAtMs: Date.now(),
1779
+ // WHICH TAX this subscription will bill, for as long as it
1780
+ // lives (AGL-2323).
1781
+ //
1782
+ // A subscription bills on its own, and the mechanism attached at
1783
+ // the sale is the one every future invoice uses. The record kept
1784
+ // none of it: what was bought and for how much, and nothing at
1785
+ // all about the regime that produced the tax inside that figure.
1786
+ //
1787
+ // That absence is what made AGL-2323 unanswerable rather than
1788
+ // merely unfixed. "Which subscriptions predate AGL-1751 and bill
1789
+ // untaxed from cycle two?" and "which subscribers still carry a
1790
+ // rate their merchant has since corrected?" are questions about
1791
+ // the back book, and the only place the answer lived was a live
1792
+ // Stripe enumeration — the mutation-adjacent operation nobody
1793
+ // wants to run to find out whether they need to run it.
1794
+ //
1795
+ // ONE derivation, in the two-argument form the cart, draft and
1796
+ // buy-now order doors already use (AGL-2451), so this record,
1797
+ // the order minted for each cycle and the
1798
+ // `storefrontTaxCollected` row filed for the same Stripe id
1799
+ // cannot state three different regimes. The second argument
1800
+ // carries the pre-AGL-1751 shape, where the manual tax rode
1801
+ // `metadata[taxCents]` and `total_details.amount_tax` read 0.
1802
+ taxMode: storefrontTaxModeOf(object, Number((_ref3 = object == null ? void 0 : (_object_metadata1 = object.metadata) == null ? void 0 : _object_metadata1.taxCents) != null ? _ref3 : 0))
1803
+ }, (object == null ? void 0 : (_object_metadata2 = object.metadata) == null ? void 0 : _object_metadata2.taxRateId) ? {
1804
+ taxRateId: String(object.metadata.taxRateId)
1805
+ } : {}, Number(object == null ? void 0 : (_object_metadata3 = object.metadata) == null ? void 0 : _object_metadata3.taxPct) > 0 ? {
1806
+ taxRatePct: Number(object.metadata.taxPct)
1807
+ } : {}), {
1808
+ merge: true
1809
+ });
1810
+ return true;
1811
+ });
1812
+ if (!recorded) return;
1813
+ const subscriptionCents = Number((_subTotals_totalCents = subTotals.totalCents) != null ? _subTotals_totalCents : 0);
1814
+ void notifyHostManagers(String(hostId), _extends({
1815
+ type: 'content.order',
1816
+ // The amount rides the title exactly as the order notification's
1817
+ // does (AGL-1732) — "New subscriber" alone never said what for.
1818
+ title: `New subscriber — $${(subscriptionCents / 100).toFixed(2)}${((_liftedForSnapshot_subscription = liftedForSnapshot.subscription) == null ? void 0 : _liftedForSnapshot_subscription.interval) ? `/${liftedForSnapshot.subscription.interval}` : ''}`
1819
+ }, (object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.email) ? {
1820
+ body: object.customer_details.email
1821
+ } : {}, {
1822
+ link: `/${hostId}/products`
1823
+ }));
1824
+ // AWAITED SINCE AGL-2473, here and at the five sibling call sites in
1825
+ // this file. `upsertHostContact` carries `purchaseCents`, so it is what
1826
+ // feeds `ltvCents` and the RFM ranking — a `void`ed one is a paying
1827
+ // customer quietly missing from the segment the merchant emails. It
1828
+ // touches only our own Firestore, which is the whole line AGL-2473
1829
+ // drew: Aglyn's own storage is awaited, a stranger's server is queued.
1830
+ // `refund.ts` already awaited its sibling for exactly this reason.
1831
+ await captureHostContact(_extends({
1832
+ hostId: String(hostId),
1833
+ email: object == null ? void 0 : (_object_customer_details1 = object.customer_details) == null ? void 0 : _object_customer_details1.email,
1834
+ name: (_ref14 = object == null ? void 0 : (_object_customer_details2 = object.customer_details) == null ? void 0 : _object_customer_details2.name) != null ? _ref14 : undefined,
1835
+ source: 'order',
1836
+ // Every order door names `customer` (AGL-2612): a floor, so the
1837
+ // stage fills or advances and never moves anybody back.
1838
+ initialLifecycleStage: 'customer'
1839
+ }, subscriptionCents > 0 ? {
1840
+ purchaseCents: subscriptionCents
1841
+ } : {}, {
1842
+ interaction: {
1843
+ refId: String(object.subscription),
1844
+ summary: `Started a subscription ($${(subscriptionCents / 100).toFixed(2)})`
1845
+ }
1846
+ }));
1847
+ }
1848
+ }
1849
+ // Storefront subscription RENEWALS (AGL-1743).
1850
+ //
1851
+ // `invoice.payment_succeeded` was unhandled repo-wide, so after AGL-1732
1852
+ // gave the INITIAL charge a home, month 2 onward still took the customer's
1853
+ // money and produced no record anywhere — not in orders, not in analytics,
1854
+ // not on the contact's `ltvCents`, not on the subscription document, whose
1855
+ // `totals` stayed frozen at the opening charge however the real one moved.
1856
+ //
1857
+ // ## What a cycle produces (AGL-1750 answered AGL-1743's open question)
1858
+ //
1859
+ // Every paid invoice is recorded as its own document under the
1860
+ // subscription — the ledger this product was missing — and rolled up onto
1861
+ // the subscription itself, which is the record the member drawer reads.
1862
+ //
1863
+ // A PHYSICAL subscription's cycle ALSO mints an order, on channel
1864
+ // `subscription` (AGL-1750). A monthly box is a shipment obligation, and
1865
+ // an invoice record is a receipt, not a work order: with no order there
1866
+ // is nothing to pick, pack, print a label against or mark fulfilled, and
1867
+ // recurring revenue is invisible to every surface that reads `orders`.
1868
+ // The opening cycle mints one too — its box ships like any other — while
1869
+ // its contact/notification fan-out stays with AGL-1732's branch. Digital
1870
+ // and service subscriptions still produce NO order: nothing ships, so
1871
+ // AGL-1732's "a subscription is not an order" stands for them, on the
1872
+ // same three sources (merchant docs, console separation, account page).
1873
+ //
1874
+ // ## Both invoice events, one record
1875
+ //
1876
+ // Stripe sends `invoice.paid` AND `invoice.payment_succeeded` for the same
1877
+ // payment, and which of them this endpoint receives is dashboard
1878
+ // configuration no code in this repo can see (the console route handles
1879
+ // `invoice.paid` for PLATFORM billing, which is the only evidence either
1880
+ // is enabled). Handling both means the branch fires whichever is on; the
1881
+ // invoice-id guard means having both on records the cycle once.
1882
+ if (type === 'invoice.payment_succeeded' || type === 'invoice.paid') {
1883
+ var _ref15, _ref16, _ref17, _ref18, _ref19, _ref20;
1884
+ var _object_subscription_details, _object_parent_subscription_details, _object_parent, _object_parent_subscription_details1, _object_parent1;
1885
+ // The SUBSCRIPTION's metadata, which is what `checkout.ts` sets
1886
+ // (`subscription_data[metadata]`) — the invoice's own `metadata` is a
1887
+ // different, empty bag. `parent.subscription_details` is where newer API
1888
+ // versions moved it; both are read because the endpoint's version is not
1889
+ // visible from here.
1890
+ const subscriptionMeta = (_ref15 = (_ref16 = object == null ? void 0 : (_object_subscription_details = object.subscription_details) == null ? void 0 : _object_subscription_details.metadata) != null ? _ref16 : object == null ? void 0 : (_object_parent = object.parent) == null ? void 0 : (_object_parent_subscription_details = _object_parent.subscription_details) == null ? void 0 : _object_parent_subscription_details.metadata) != null ? _ref15 : null;
1891
+ const invoiceId = String((_ref17 = object == null ? void 0 : object.id) != null ? _ref17 : '');
1892
+ const subscriptionId = String((_ref18 = (_ref19 = object == null ? void 0 : object.subscription) != null ? _ref19 : object == null ? void 0 : (_object_parent1 = object.parent) == null ? void 0 : (_object_parent_subscription_details1 = _object_parent1.subscription_details) == null ? void 0 : _object_parent_subscription_details1.subscription) != null ? _ref18 : '');
1893
+ const invoiceHostId = String((_ref20 = subscriptionMeta == null ? void 0 : subscriptionMeta.hostId) != null ? _ref20 : '');
1894
+ // Platform billing — Aglyn charging its own customers — runs through the
1895
+ // same endpoint and the same fan-out, and its invoices carry `orgId`
1896
+ // instead. Self-selection is on the same discriminator every other
1897
+ // section of this file uses.
1898
+ if ((subscriptionMeta == null ? void 0 : subscriptionMeta.type) === 'commerce-subscription' && invoiceHostId && subscriptionId && invoiceId) {
1899
+ var _soldSnapshot_get, _ref21, _ref22, _ref23, _ref24, _ref25;
1900
+ var _this, _this1, _object_status_transitions, _object_customer_shipping;
1901
+ const firestore = firebaseAdmin.app().firestore();
1902
+ const hostRef = firestore.collection('hosts').doc(invoiceHostId);
1903
+ const subscriptionRef = hostRef.collection('subscriptions').doc(subscriptionId);
1904
+ const invoiceRef = subscriptionRef.collection('invoices').doc(invoiceId);
1905
+ // The plan, RE-ASKED at the cycle (AGL-2071). `checkout.ts:132/149`
1906
+ // asked it when the subscription was SOLD, and that answer is all this
1907
+ // renewal would otherwise have — an answer that can be months stale
1908
+ // and is the wrong one the moment the merchant's own subscription
1909
+ // dies. The org doc is read whole (`getOrgDoc` projects nothing), so
1910
+ // `plan`, `subscriptionStatus` and `entitlements` are all present for
1911
+ // `resolveEffectivePlan` to collapse a dead subscription to `free`.
1912
+ //
1913
+ // BOTH flags, because `checkout.ts` required both to create this
1914
+ // subscription: `commerce` (Starter+) opens the storefront and
1915
+ // `storefrontSubscriptions` (Business+) is what makes a recurring
1916
+ // product sellable at all. A Business org that drops to Pro still has
1917
+ // a storefront and must still stop billing subscribers.
1918
+ //
1919
+ // A FAILED READ IS NOT AN ANSWER (AGL-2258). The `.catch(() => null)`
1920
+ // here made a transient Firestore failure indistinguishable from a
1921
+ // lapsed org: `checkEntitlement(null, …)` resolves through
1922
+ // `resolveOrgEntitlements(null)` to the FREE plan, so a moment of
1923
+ // unavailability cancelled a healthy merchant's subscriber at period
1924
+ // end and notified them their plan no longer covers subscriptions.
1925
+ //
1926
+ // Failing closed against a MISSING org is right and is kept — an
1927
+ // unindexed host has no storefront, and that is an answer. Failing
1928
+ // closed against an unreadable one is not: the two failure directions
1929
+ // are wildly asymmetric. Skipping the stop costs Aglyn one cycle at
1930
+ // the wrong take rate and the next cycle re-asks; taking it costs a
1931
+ // paying merchant a subscriber, irreversibly, on a question we never
1932
+ // actually asked.
1933
+ let renewalOrgUnreadable = false;
1934
+ const renewalOrg = (_this = await getOrgForHost(invoiceHostId).catch((error)=>{
1935
+ renewalOrgUnreadable = true;
1936
+ console.error('Renewal plan check could not read the org; the lapse stop is skipped for this cycle (AGL-2258)', {
1937
+ hostId: invoiceHostId,
1938
+ subscriptionId
1939
+ }, error);
1940
+ return null;
1941
+ })) == null ? void 0 : _this.org;
1942
+ const renewalEntitled = Aglyn.checkEntitlement(renewalOrg, 'commerce') && Aglyn.checkEntitlement(renewalOrg, 'storefrontSubscriptions');
1943
+ // The product identity comes from what the sale already recorded: an
1944
+ // invoice line knows a description and a price, never a productId, a
1945
+ // variant or a SKU. Subscriptions sold before AGL-1732 have no stored
1946
+ // line items, so those fall back to the product doc — one extra read,
1947
+ // and only for them.
1948
+ const soldSnapshot = await subscriptionRef.get();
1949
+ const soldLine = (_this1 = (_soldSnapshot_get = soldSnapshot.get('lineItems')) != null ? _soldSnapshot_get : []) == null ? void 0 : _this1[0];
1950
+ const productId = String((_ref21 = (_ref22 = (_ref23 = soldLine == null ? void 0 : soldLine.productId) != null ? _ref23 : soldSnapshot.get('productId')) != null ? _ref22 : subscriptionMeta == null ? void 0 : subscriptionMeta.productId) != null ? _ref21 : '');
1951
+ let snapshot;
1952
+ if (soldLine) {
1953
+ snapshot = _extends({
1954
+ productId
1955
+ }, soldLine.variantId ? {
1956
+ variantId: soldLine.variantId
1957
+ } : {}, {
1958
+ name: soldLine.name
1959
+ }, soldLine.variantLabel ? {
1960
+ variantLabel: soldLine.variantLabel
1961
+ } : {}, soldLine.sku ? {
1962
+ sku: soldLine.sku
1963
+ } : {}, soldLine.productType ? {
1964
+ productType: soldLine.productType
1965
+ } : {});
1966
+ } else {
1967
+ var _ref26, _soldSnapshot_get1, _ref27, _ref28;
1968
+ // AGL-1763: no product id, no read. `doc('__missing__')` was a
1969
+ // deliberate miss that Firestore does not permit — a document id
1970
+ // matching `__.*__` is RESERVED, so the backend rejects the path with
1971
+ // `INVALID_ARGUMENT` rather than returning an absent snapshot. And
1972
+ // `runBillingWebhookHandlers` lets the first throw propagate, so that
1973
+ // rejection would have dropped the whole renewal into the route's
1974
+ // error path with the money unrecorded and Stripe re-delivering into
1975
+ // the same throw. Narrow — `checkout.ts:323` always sets
1976
+ // `subscription_data[metadata][productId]` — but it is reachable for
1977
+ // exactly the population this branch now serves: a subscription
1978
+ // Aglyn has no record of, whose metadata nothing of ours wrote.
1979
+ const productSnapshot = productId ? await hostRef.collection('products').doc(productId).get() : null;
1980
+ const lifted = CommerceModel.liftLegacyProduct((_ref26 = productSnapshot == null ? void 0 : productSnapshot.data()) != null ? _ref26 : {
1981
+ name: 'Subscription'
1982
+ });
1983
+ const variantId = String((_soldSnapshot_get1 = soldSnapshot.get('variantId')) != null ? _soldSnapshot_get1 : '');
1984
+ const variant = variantId ? lifted.variants.find((item)=>item.id === variantId) : lifted.variants[0];
1985
+ const variantOptions = Object.values((_ref27 = variant == null ? void 0 : variant.options) != null ? _ref27 : {});
1986
+ snapshot = _extends({
1987
+ productId
1988
+ }, variantId ? {
1989
+ variantId
1990
+ } : {}, {
1991
+ name: String((_ref28 = productSnapshot == null ? void 0 : productSnapshot.get('name')) != null ? _ref28 : 'Subscription')
1992
+ }, variantOptions.length ? {
1993
+ variantLabel: variantOptions.join(' / ')
1994
+ } : {}, (variant == null ? void 0 : variant.sku) ? {
1995
+ sku: variant.sku
1996
+ } : {}, (productSnapshot == null ? void 0 : productSnapshot.exists) && lifted.type ? {
1997
+ productType: lifted.type
1998
+ } : {});
1999
+ }
2000
+ // ITEMS ONLY (AGL-2317), before anything is recorded. Stripe applied
2001
+ // `application_fee_percent` to the whole invoice; the part of that fee
2002
+ // taken on sales tax and shipping goes back to the merchant, and what
2003
+ // is left is the figure the ledger stores. A transient Stripe failure
2004
+ // throws here rather than filing a fee we did not end up charging —
2005
+ // the redelivery re-runs it behind the invoice-id guard below.
2006
+ const feeCentsInForce = await chargeSubscriptionFeeOnItemsOnly(object, invoiceHostId, invoiceId);
2007
+ const { lineItems: invoiceLineItems, totals: computedInvoiceTotals } = CommerceModel.computeSubscriptionInvoiceOrder(object, snapshot);
2008
+ // `computeSubscriptionInvoiceOrder` reads `application_fee_amount`
2009
+ // verbatim, which is what Stripe DEBITED, not what Aglyn kept.
2010
+ const invoiceTotals = _extends({}, computedInvoiceTotals, {
2011
+ feeCents: feeCentsInForce
2012
+ });
2013
+ const paidCents = Math.max(0, Math.round(Number((_ref24 = object == null ? void 0 : object.amount_paid) != null ? _ref24 : 0)));
2014
+ const billingReason = String((_ref25 = object == null ? void 0 : object.billing_reason) != null ? _ref25 : '');
2015
+ // The subscription's FIRST invoice is also a paid invoice, and
2016
+ // `checkout.session.completed` has already counted that money into the
2017
+ // contact's lifetime value and already told the managers about it
2018
+ // (AGL-1732). Recording it again would double every subscriber's
2019
+ // opening value. It is still written as an invoice document — that is
2020
+ // the ledger, and it must not have a hole where cycle 1 belongs.
2021
+ const isOpeningInvoice = billingReason === 'subscription_create';
2022
+ const interval = CommerceModel.subscriptionInvoiceInterval(object);
2023
+ const paidAtMs = (object == null ? void 0 : (_object_status_transitions = object.status_transitions) == null ? void 0 : _object_status_transitions.paid_at) ? Number(object.status_transitions.paid_at) * 1000 : Date.now();
2024
+ const periodEndMs = (object == null ? void 0 : object.period_end) ? Number(object.period_end) * 1000 : null;
2025
+ // A physical cycle's fulfilment artifact (AGL-1750). The order's doc
2026
+ // id IS the invoice id, so the invoice-existence guard inside the
2027
+ // transaction below covers it too and a Stripe redelivery cannot
2028
+ // double-create (`in_…` never matches the reserved `__.*__` pattern).
2029
+ // Gated on a KNOWN physical type: `snapshot.productType` comes from
2030
+ // the recorded sale line or a product document actually read, never
2031
+ // from a default — a guessed type would manufacture shipment work.
2032
+ // Gated on the invoice actually BILLING the product too: a trial's
2033
+ // opening invoice is $0 with no lines — nothing ships until the trial
2034
+ // converts — whereas a 100%-off cycle still lists the product line
2035
+ // (Stripe discounts at the invoice level), and its box still ships.
2036
+ const cycleLine = invoiceLineItems.find((line)=>line.productId === productId);
2037
+ const shipsPhysically = snapshot.productType === 'physical' && Boolean(productId) && Boolean(cycleLine);
2038
+ const orderRef = hostRef.collection('orders').doc(invoiceId);
2039
+ const counterRef = hostRef.collection('counters').doc('orders');
2040
+ // An invoice's shipping block is `customer_shipping` — it is NOT a
2041
+ // session's `shipping_details`, and an invoice has no
2042
+ // `customer_details` either. Fall back to the billing
2043
+ // `customer_address` + `customer_name` pair, the same
2044
+ // shipping-else-billing fallback the cart branch takes.
2045
+ const invoiceShipping = (object == null ? void 0 : (_object_customer_shipping = object.customer_shipping) == null ? void 0 : _object_customer_shipping.address) ? object.customer_shipping : (object == null ? void 0 : object.customer_address) ? {
2046
+ name: object == null ? void 0 : object.customer_name,
2047
+ address: object.customer_address
2048
+ } : null;
2049
+ // Hoisted alongside `periodEndMs` (AGL-1763): the reconstruction below
2050
+ // dates the record from it, so the two must not drift.
2051
+ const periodStartMs = (object == null ? void 0 : object.period_start) ? Number(object.period_start) * 1000 : null;
2052
+ // Idempotency, keyed on the INVOICE id (AGL-1743). Existence of the
2053
+ // invoice document IS that key — the doc id is the invoice id — and
2054
+ // unlike the AGL-1732 guard, which could not use existence because
2055
+ // `customer.subscription.created` writes the same subscription path,
2056
+ // nothing but this branch ever writes here. It also absorbs the
2057
+ // `invoice.paid` / `invoice.payment_succeeded` pair for one payment.
2058
+ //
2059
+ // The roll-up accumulates by reading inside the transaction rather
2060
+ // than with `FieldValue.increment`: the read is already happening for
2061
+ // the guard, and a lifetime total that can only be verified by
2062
+ // replaying every event is not a total a merchant can reconcile.
2063
+ const recorded = await firestore.runTransaction(async (transaction)=>{
2064
+ var _ref, _ref1, _ref2, _ref3, _currentSubscription_get, _currentSubscription_get1, _ref4, _ref5;
2065
+ const existingInvoice = await transaction.get(invoiceRef);
2066
+ if (existingInvoice.exists) return false;
2067
+ const currentSubscription = await transaction.get(subscriptionRef);
2068
+ // Read (Firestore transactions read before they write) only when a
2069
+ // number will actually be allocated — a digital cycle must not
2070
+ // advance the merchant's order sequence.
2071
+ const orderCounter = shipsPhysically ? await transaction.get(counterRef) : null;
2072
+ transaction.set(invoiceRef, _extends({
2073
+ invoiceId,
2074
+ subscriptionId,
2075
+ billingReason
2076
+ }, (object == null ? void 0 : object.number) ? {
2077
+ number: String(object.number)
2078
+ } : {}, {
2079
+ currency: String((_ref = object == null ? void 0 : object.currency) != null ? _ref : 'usd'),
2080
+ paidCents,
2081
+ /** Stripe's own total, which a credit balance can exceed. */ invoiceTotalCents: Math.max(0, Math.round(Number((_ref1 = object == null ? void 0 : object.total) != null ? _ref1 : paidCents))),
2082
+ lineItems: invoiceLineItems,
2083
+ totals: invoiceTotals
2084
+ }, interval ? {
2085
+ interval
2086
+ } : {}, {
2087
+ paidAtMs,
2088
+ periodStartMs,
2089
+ periodEndMs,
2090
+ customerEmail: (_ref2 = (_ref3 = object == null ? void 0 : object.customer_email) != null ? _ref3 : currentSubscription.get('customerEmail')) != null ? _ref2 : null
2091
+ }, (object == null ? void 0 : object.hosted_invoice_url) ? {
2092
+ hostedInvoiceUrl: String(object.hosted_invoice_url)
2093
+ } : {}));
2094
+ const rollup = _extends({
2095
+ lastInvoiceId: invoiceId,
2096
+ lastPaymentCents: paidCents,
2097
+ lastPaymentAtMs: paidAtMs
2098
+ }, periodEndMs ? {
2099
+ paidThroughMs: periodEndMs
2100
+ } : {}, {
2101
+ paidCents: Math.max(0, Number((_currentSubscription_get = currentSubscription.get('paidCents')) != null ? _currentSubscription_get : 0)) + paidCents,
2102
+ invoicesCount: Math.max(0, Number((_currentSubscription_get1 = currentSubscription.get('invoicesCount')) != null ? _currentSubscription_get1 : 0)) + 1
2103
+ }, !isOpeningInvoice && paidCents > 0 ? _extends({
2104
+ totals: invoiceTotals
2105
+ }, interval ? {
2106
+ interval
2107
+ } : {}) : {});
2108
+ // AGL-1763: `currentSubscription` was READ here and never asked
2109
+ // whether it EXISTS. The roll-up went out as an unguarded merge-set,
2110
+ // and a merge-set against a missing path CREATES the document — so a
2111
+ // renewal for a subscription Aglyn had no record of minted one out of
2112
+ // the roll-up alone: `paidCents`, `invoicesCount`, `lastPayment*` and
2113
+ // nothing else. No `productId`, no `customerEmail`, no
2114
+ // `stripeCustomerId`, no `status`, no `createdAtMs`.
2115
+ //
2116
+ // That is worse than it sounds, because EVERY reader of this
2117
+ // collection filters on a field the stub lacks, and so cannot see it
2118
+ // at all: `gate.ts:72` and `membership-account.ts:155` query
2119
+ // `where('customerEmail', '==', …)`, `subscription-portal.ts:49` does
2120
+ // the same and 404s "No subscription found", `member-post.ts:88`
2121
+ // filters on `status`, and `order-analytics.ts:187` queries
2122
+ // `checkoutSessionId`. The result was a money-bearing orphan — a
2123
+ // total climbing every cycle inside a document nothing in the product
2124
+ // can find — while the subscriber paying it got no content access and
2125
+ // no route to the billing portal that would let them cancel.
2126
+ //
2127
+ // WHY THIS ONE CREATES RATHER THAN REFUSES. A renewal is real money
2128
+ // that Stripe has already taken, so refusing it would be AGL-1732 in
2129
+ // reverse: a payment collected and recorded nowhere. And this id is
2130
+ // not caller-controlled the way AGL-1760's was — `object.subscription`
2131
+ // arrives inside a signature-verified Stripe payload
2132
+ // (`verifyStripeSignature`, the console route at :120), so there is no
2133
+ // typo and no attacker to refuse. What was wrong was never the create;
2134
+ // it was creating a stub. So the record is reconstructed with every
2135
+ // field the invoice can honestly supply, which is what makes it
2136
+ // FINDABLE by the readers above rather than merely non-empty. A
2137
+ // create that fills the required fields is not the defect a stub is.
2138
+ transaction.set(subscriptionRef, currentSubscription.exists ? rollup : _extends({}, productId ? {
2139
+ productId
2140
+ } : {}, {
2141
+ customerEmail: (_ref4 = object == null ? void 0 : object.customer_email) != null ? _ref4 : null
2142
+ }, (object == null ? void 0 : object.customer_name) ? {
2143
+ customerName: String(object.customer_name)
2144
+ } : {}, {
2145
+ stripeCustomerId: String((_ref5 = object == null ? void 0 : object.customer) != null ? _ref5 : '') || null,
2146
+ // An invoice states that a cycle was paid, not what Stripe
2147
+ // calls the subscription — that lives on the subscription
2148
+ // object. `active` is the honest reading of money arriving
2149
+ // for a cycle, and the `customer.subscription.*` sync at :132
2150
+ // replaces it with Stripe's own the moment one lands.
2151
+ status: 'active',
2152
+ lineItems: invoiceLineItems,
2153
+ totals: invoiceTotals
2154
+ }, interval ? {
2155
+ interval
2156
+ } : {}, {
2157
+ // NOT `Date.now()`. This subscription began before Aglyn knew
2158
+ // about it, and stamping "now" would date the sale to
2159
+ // whichever cycle happened to be the first one seen. The
2160
+ // start of THIS cycle is the earliest moment the invoice
2161
+ // actually proves.
2162
+ createdAtMs: periodStartMs != null ? periodStartMs : paidAtMs,
2163
+ // WHICH TAX this cycle carried, on the subscription too
2164
+ // (AGL-2323). The sale-side record has stamped `taxMode`
2165
+ // since AGL-2323 on the principle that `none` is an answer
2166
+ // and absent is not — and `strictNullChecks` is OFF
2167
+ // repo-wide, so an omitted field folds to falsy and reads
2168
+ // downstream as "no tax due", which is that issue's own bug
2169
+ // shape. Reconstructing every other field the invoice can
2170
+ // honestly supply and leaving this one out made a
2171
+ // reconstruction indistinguishable from a pre-AGL-2323 sale.
2172
+ //
2173
+ // The SAME one-argument derivation as the order minted from
2174
+ // this very invoice below, so the two documents one
2175
+ // `invoice.paid` writes agree by construction rather than by
2176
+ // two authors reaching the same conclusion — the invariant
2177
+ // AGL-2323 set out to hold ("cannot state three different
2178
+ // regimes"). The flag, never the tax lines: a manual-mode
2179
+ // subscription carries a real Tax Rate (AGL-1751), so its
2180
+ // renewal invoices arrive with a populated `total_taxes[]`
2181
+ // indistinguishable from a Stripe Tax one by amount alone.
2182
+ //
2183
+ // NO `taxRateId`/`taxRatePct`: those name WHICH merchant rate
2184
+ // and live on the subscription's own metadata, which an
2185
+ // invoice does not restate. A reconstruction genuinely does
2186
+ // not know them, `reconstructedFromInvoiceId` already says
2187
+ // the rate identity was never observed, and inventing one
2188
+ // would be the plausible zero the sale-side refuses.
2189
+ taxMode: storefrontTaxModeOf(object),
2190
+ // Provenance, so nothing downstream mistakes a reconstruction
2191
+ // for a recorded sale — and deliberately NO
2192
+ // `checkoutSessionId`: that field is AGL-1732's redelivery key
2193
+ // and `order-analytics.ts` resolves the opening purchase
2194
+ // through it, so inventing one would answer a question this
2195
+ // record cannot answer.
2196
+ reconstructedFromInvoiceId: invoiceId
2197
+ }, rollup), {
2198
+ merge: true
2199
+ });
2200
+ // The order itself (AGL-1750): `paid` and unfulfilled, so it enters
2201
+ // the same fulfilment flow as any one-time sale. Channel
2202
+ // `subscription`, not `online` — folding cycles into `online` would
2203
+ // silently rewrite every existing merchant's channel split.
2204
+ // Deliberately NO `checkoutSessionId`: that identity belongs to
2205
+ // AGL-1732's opening sale record, and `order-analytics.ts` resolves
2206
+ // the opening purchase through the SUBSCRIPTION's copy of it;
2207
+ // repeating it on an order would double the answer. `createdAtMs`
2208
+ // is when the cycle was PAID — dating a redelivered cycle "now"
2209
+ // would file it under the wrong day in every orders view.
2210
+ if (shipsPhysically) {
2211
+ var _ref6, _ref7, _ref8, _ref9, _ref10, _ref11, _ref12, _invoiceShipping_address_line1, _invoiceShipping_address_line2, _invoiceShipping_address_city, _invoiceShipping_address_state, _invoiceShipping_address_postal_code, _invoiceShipping_address_country, _invoiceTotals_feeCents;
2212
+ const orderNumber = Number((_ref6 = orderCounter == null ? void 0 : orderCounter.get('next')) != null ? _ref6 : 1);
2213
+ transaction.set(counterRef, {
2214
+ next: orderNumber + 1
2215
+ }, {
2216
+ merge: true
2217
+ });
2218
+ transaction.set(orderRef, _extends({
2219
+ number: orderNumber,
2220
+ status: 'paid',
2221
+ channel: 'subscription',
2222
+ lineItems: invoiceLineItems,
2223
+ totals: invoiceTotals,
2224
+ // WHICH TAX THIS CYCLE CARRIED (AGL-2451), from the invoice's own
2225
+ // `automatic_tax.enabled` and never from its tax lines. This is
2226
+ // the door the distinction matters most at: a MANUAL-mode
2227
+ // subscription carries a real Stripe Tax Rate so the recurring
2228
+ // tax survives (AGL-1751), so every renewal invoice arrives with
2229
+ // a populated `total_taxes[]` that looks exactly like a Stripe
2230
+ // Tax one. Reading the lines would stamp the merchant's own tax
2231
+ // as Aglyn-collected on every cycle they ever bill.
2232
+ taxMode: storefrontTaxModeOf(object),
2233
+ timeline: [
2234
+ {
2235
+ atMs: paidAtMs,
2236
+ event: 'paid'
2237
+ }
2238
+ ],
2239
+ paymentIntentId: String((_ref7 = object == null ? void 0 : object.payment_intent) != null ? _ref7 : '') || null,
2240
+ subscriptionId,
2241
+ invoiceId,
2242
+ customerName: (_ref8 = (_ref9 = object == null ? void 0 : object.customer_name) != null ? _ref9 : currentSubscription.get('customerName')) != null ? _ref8 : null,
2243
+ customerEmail: (_ref10 = (_ref11 = object == null ? void 0 : object.customer_email) != null ? _ref11 : currentSubscription.get('customerEmail')) != null ? _ref10 : null
2244
+ }, (invoiceShipping == null ? void 0 : invoiceShipping.address) ? {
2245
+ shippingAddress: {
2246
+ name: (_ref12 = invoiceShipping == null ? void 0 : invoiceShipping.name) != null ? _ref12 : undefined,
2247
+ line1: (_invoiceShipping_address_line1 = invoiceShipping.address.line1) != null ? _invoiceShipping_address_line1 : undefined,
2248
+ line2: (_invoiceShipping_address_line2 = invoiceShipping.address.line2) != null ? _invoiceShipping_address_line2 : undefined,
2249
+ city: (_invoiceShipping_address_city = invoiceShipping.address.city) != null ? _invoiceShipping_address_city : undefined,
2250
+ state: (_invoiceShipping_address_state = invoiceShipping.address.state) != null ? _invoiceShipping_address_state : undefined,
2251
+ postalCode: (_invoiceShipping_address_postal_code = invoiceShipping.address.postal_code) != null ? _invoiceShipping_address_postal_code : undefined,
2252
+ country: (_invoiceShipping_address_country = invoiceShipping.address.country) != null ? _invoiceShipping_address_country : undefined
2253
+ }
2254
+ } : {}, {
2255
+ amountCents: paidCents,
2256
+ feeCents: Number((_invoiceTotals_feeCents = invoiceTotals.feeCents) != null ? _invoiceTotals_feeCents : 0),
2257
+ createdAtMs: paidAtMs,
2258
+ createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
2259
+ }));
2260
+ }
2261
+ return true;
2262
+ });
2263
+ // THE SALES TAX COMES BACK TO THE PLATFORM (AGL-1956), and like the
2264
+ // stop below it runs BEFORE the `recorded` short-circuit: a cycle
2265
+ // already on the ledger is exactly the cycle whose reversal may still
2266
+ // be owed, and its own marker doc is what makes it once-only.
2267
+ //
2268
+ // AFTER the ledger write rather than before it, unlike the AGL-2317
2269
+ // fee correction above. That one feeds `totals.feeCents` and so must
2270
+ // resolve first; this one moves money the ledger does not restate, and
2271
+ // ordering it second means a transient Stripe refusal leaves the cycle
2272
+ // RECORDED — the payment is already collected, and AGL-1732's rule is
2273
+ // that money collected must never go unfiled. The redelivery then
2274
+ // re-runs the reversal alone.
2275
+ await reverseSubscriptionTaxToPlatform(object, invoiceHostId, subscriptionRef, invoiceId);
2276
+ // BEFORE the redelivery short-circuit, deliberately (AGL-2071): a
2277
+ // cycle Aglyn has already recorded is exactly the cycle whose renewal
2278
+ // still needs stopping, and gating the stop on `recorded` would mean a
2279
+ // Stripe redelivery — or a stop that failed once — never tries again.
2280
+ // Its own claim marker is what makes it once-only.
2281
+ // `renewalOrgUnreadable` and not `!renewalOrg` (AGL-2258): a null org
2282
+ // we successfully read still stops, which is AGL-2071's decision.
2283
+ if (!renewalEntitled && !renewalOrgUnreadable) {
2284
+ await stopLapsedStorefrontSubscription(invoiceHostId, subscriptionId, subscriptionRef);
2285
+ } else if (renewalEntitled) {
2286
+ // RE-PRICE THE PLATFORM FEE (AGL-2289). `application_fee_percent` was
2287
+ // set once at the sale and never revisited, and it lives on the
2288
+ // Stripe subscription — so the rate the merchant was on the day a
2289
+ // shopper subscribed was the rate they paid forever. A renewal is
2290
+ // the only event a subscription raises, so it is the only place this
2291
+ // can be corrected. Skipped entirely for a lapsed org (the stop
2292
+ // above is the answer there) and for an unreadable one, which knows
2293
+ // nothing about the plan to re-price against.
2294
+ //
2295
+ // `snapshot.productType` is the sale's own recorded type, never a
2296
+ // default — the same field `shipsPhysically` refuses to guess.
2297
+ //
2298
+ // The plan's take PLUS the card cost as a rate (AGL-2655), the
2299
+ // same figure `checkout.ts` sent at the sale — re-derived here so
2300
+ // a plan change re-adds the pass-through rather than reverting the
2301
+ // subscription to a bare take. The pass-through depends on the
2302
+ // recurring goods it will be applied to, and
2303
+ // `subscriptionRecurringBasisCents` says which figure that is for
2304
+ // this invoice's shape. When neither the invoice nor the sale can
2305
+ // name it, the rate stands as it is: a percent sized on nothing is
2306
+ // zero, and zero UNSETS the fee on Stripe — which would turn "could
2307
+ // not size it" into "charge nothing", the wrong direction for a
2308
+ // doubt. The next full cycle sizes it.
2309
+ const recurringBasisCents = CommerceModel.subscriptionRecurringBasisCents(object, soldSnapshot.get('lineItems'));
2310
+ if (recurringBasisCents > 0) {
2311
+ var _snapshot_productType;
2312
+ await repriceStorefrontSubscriptionFee(subscriptionRef, subscriptionId, Aglyn.resolveSubscriptionFeePercent(renewalOrg, (_snapshot_productType = snapshot.productType) != null ? _snapshot_productType : 'physical', recurringBasisCents));
2313
+ }
2314
+ }
2315
+ if (!recorded) return;
2316
+ // Inventory per cycle (AGL-1750, the AGL-281 semantics): the box that
2317
+ // ships this cycle comes off the shelf this cycle. Mirrors the cart
2318
+ // loop — tracked variants only, ledger row with `reason: 'sale'`
2319
+ // joined to the order, and the low-stock crossing alert — and sits
2320
+ // behind the transaction's invoice-id guard exactly as the cart's
2321
+ // decrement sits behind its `created` guard, so a redelivery never
2322
+ // decrements twice. One product per subscription, so no sibling-line
2323
+ // carry-forward (AGL-1830) is needed here.
2324
+ if (shipsPhysically) {
2325
+ const stockSnapshot = await hostRef.collection('products').doc(productId).get().catch(()=>null);
2326
+ if (stockSnapshot == null ? void 0 : stockSnapshot.exists) {
2327
+ var _snapshot_variantId;
2328
+ var _stocked_variants_;
2329
+ const stocked = CommerceModel.liftLegacyProduct(stockSnapshot.data());
2330
+ const stockVariantId = (_snapshot_variantId = snapshot.variantId) != null ? _snapshot_variantId : (_stocked_variants_ = stocked.variants[0]) == null ? void 0 : _stocked_variants_.id;
2331
+ const tracked = stocked.variants.some((variant)=>variant.id === stockVariantId && variant.inventory != null);
2332
+ if (stockVariantId && tracked) {
2333
+ var _ref29;
2334
+ const cycleQuantity = (_ref29 = cycleLine == null ? void 0 : cycleLine.quantity) != null ? _ref29 : 1;
2335
+ // Atomic since AGL-2320, like every other decrement: a renewal
2336
+ // batch bills many subscribers at once, so two cycles of the same
2337
+ // physical box are exactly the concurrent pair that lost a
2338
+ // decrement here. The `stockSnapshot` read above still resolves
2339
+ // the variant; the transaction re-reads before it writes.
2340
+ const moved = await decrementVariantStock({
2341
+ firestore,
2342
+ hostRef,
2343
+ hostId: invoiceHostId,
2344
+ productId,
2345
+ variantId: stockVariantId,
2346
+ quantity: cycleQuantity,
2347
+ ledger: {
2348
+ reason: 'sale',
2349
+ orderId: invoiceId
2350
+ }
2351
+ });
2352
+ if (moved.before && moved.after) {
2353
+ alertLowStockCrossing(invoiceHostId, moved.before, moved.after);
2354
+ }
2355
+ }
2356
+ }
2357
+ }
2358
+ if (!isOpeningInvoice && paidCents > 0) {
2359
+ var _ref30, _ref31;
2360
+ const renewalEmail = (_ref30 = (_ref31 = object == null ? void 0 : object.customer_email) != null ? _ref31 : soldSnapshot.get('customerEmail')) != null ? _ref30 : null;
2361
+ // A physical cycle now lands in Orders (AGL-1750), but the console
2362
+ // still has no Subscriptions tab, so for a digital or service
2363
+ // subscription this notification remains the only place a merchant
2364
+ // learns the money arrived at all.
2365
+ void notifyHostManagers(invoiceHostId, _extends({
2366
+ type: 'content.order',
2367
+ title: `Subscription renewed — $${(paidCents / 100).toFixed(2)}${interval ? `/${interval}` : ''}`
2368
+ }, renewalEmail ? {
2369
+ body: String(renewalEmail)
2370
+ } : {}, {
2371
+ link: `/${invoiceHostId}/products`
2372
+ }));
2373
+ // RFM (AGL-328): a subscriber in month 12 has paid twelve times, and
2374
+ // counting only the first charge ranks them as a one-purchase
2375
+ // customer forever. Keyed to the invoice so the guard above is what
2376
+ // stops a redelivery inflating it.
2377
+ await captureHostContact(_extends({
2378
+ hostId: invoiceHostId,
2379
+ email: renewalEmail
2380
+ }, soldSnapshot.get('customerName') ? {
2381
+ name: String(soldSnapshot.get('customerName'))
2382
+ } : {}, {
2383
+ source: 'order',
2384
+ initialLifecycleStage: 'customer',
2385
+ purchaseCents: paidCents,
2386
+ interaction: {
2387
+ refId: invoiceId,
2388
+ summary: `Subscription renewed ($${(paidCents / 100).toFixed(2)})`
2389
+ }
2390
+ }));
2391
+ }
2392
+ }
2393
+ }
2394
+ // Reservations (AGL-310): payment confirms the pending hold.
2395
+ if (type === 'checkout.session.completed' && (object == null ? void 0 : (_object_metadata1 = object.metadata) == null ? void 0 : _object_metadata1.type) === 'commerce-reservation' && (object == null ? void 0 : object.payment_status) === 'paid') {
2396
+ var _object_metadata17;
2397
+ const { hostId, reservationId } = (_object_metadata17 = object.metadata) != null ? _object_metadata17 : {};
2398
+ if (hostId && reservationId) {
2399
+ var _ref32, _ref33;
2400
+ var _object_metadata18;
2401
+ const firestore = firebaseAdmin.app().firestore();
2402
+ const reservationRef = firestore.collection('hosts').doc(String(hostId)).collection('reservations').doc(String(reservationId));
2403
+ // THE MERCHANT'S LODGING TAX, SEPARATED FROM THE STAY (AGL-1969).
2404
+ //
2405
+ // `reserve.ts` charges the merchant's own lodging rate as an ordinary
2406
+ // `line_items[1]` Stripe is never told is tax (the AGL-1711
2407
+ // construction, which is what keeps the figure the MERCHANT's rather
2408
+ // than something computed against Aglyn's registrations). So
2409
+ // `amount_total` is stay-plus-tax and Stripe's own tax fields read
2410
+ // zero; the session's metadata is the only witness, exactly as it is
2411
+ // for a buy-now sale.
2412
+ const taxCents = Math.max(0, Math.round(Number((_ref32 = object == null ? void 0 : (_object_metadata18 = object.metadata) == null ? void 0 : _object_metadata18.taxCents) != null ? _ref32 : 0)));
2413
+ // What the guest actually handed over, MINUS that tax. This is the
2414
+ // DEPOSIT when the resource has one (`reserve.ts` charges
2415
+ // `depositCents || totalCents`), never the stay's `totalCents` — see
2416
+ // the contact call below.
2417
+ //
2418
+ // Subtracting the tax is not cosmetic. `paidCents` is the money
2419
+ // applied to the STAY: `reservations-card` renders
2420
+ // `paidCents / totalCents` and computes the balance to collect at
2421
+ // check-out from the difference, so a tax-inclusive figure would
2422
+ // report a guest as further through paying for their stay than they
2423
+ // are and under-state what the merchant collects at the register. The
2424
+ // same figure feeds the guest's lifetime value, which tax is no part
2425
+ // of either (AGL-1755).
2426
+ const paidCents = Math.max(0, Math.round(Number((_ref33 = object == null ? void 0 : object.amount_total) != null ? _ref33 : 0)) - taxCents);
2427
+ // Redelivery guard (AGL-1755), the AGL-1748 shape. The `pending` ->
2428
+ // `confirmed` transition was always the guard, but it was a
2429
+ // read-then-write with every side effect hanging off it, so two
2430
+ // concurrent deliveries could both observe `pending` and both run them:
2431
+ // two manager notifications, two guest emails, two metered sends and —
2432
+ // now that this branch carries money to contacts — two
2433
+ // `FieldValue.increment` calls on the guest's lifetime value.
2434
+ //
2435
+ // Keyed on the STATUS, not on the document existing: `reserve.ts`
2436
+ // writes the reservation before it opens the Stripe session, so
2437
+ // existence is guaranteed by the time any event arrives and says
2438
+ // nothing about whether this delivery is the first. That is the same
2439
+ // reasoning as AGL-1748's draft branch, and the opposite of AGL-1732's
2440
+ // `checkoutSessionId` key, which existed because a SIBLING event wrote
2441
+ // the same path; no sibling writes reservations.
2442
+ let reserved = null;
2443
+ const confirmedNow = await firestore.runTransaction(async (transaction)=>{
2444
+ var _snapshot_data, _ref;
2445
+ const snapshot = await transaction.get(reservationRef);
2446
+ if (!snapshot.exists) return false;
2447
+ if (snapshot.get('status') !== 'pending') return false;
2448
+ reserved = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
2449
+ transaction.set(reservationRef, _extends({
2450
+ status: 'confirmed',
2451
+ paidCents,
2452
+ checkoutSessionId: String(object.id)
2453
+ }, typeof (event == null ? void 0 : event.livemode) === 'boolean' ? {
2454
+ livemode: event.livemode
2455
+ } : {}, {
2456
+ paymentIntentId: String((_ref = object == null ? void 0 : object.payment_intent) != null ? _ref : '') || null,
2457
+ // The regime this stay carried, on the record the merchant
2458
+ // reads (AGL-1969).
2459
+ //
2460
+ // This does NOT decide the lodging-tax question. `reserve.ts`
2461
+ // computes no tax by an explicit, reasoned decision — a stay
2462
+ // is not goods, the AGL-285 editor configures a SALES rate,
2463
+ // and this charge is usually a DEPOSIT — and that decision is
2464
+ // untouched. What the reservation lacked was any statement of
2465
+ // it: the fact lived only in the `storefrontTaxCollected` row
2466
+ // filed above, and nowhere a merchant looking at their own
2467
+ // booking could see it. Every other storefront money door
2468
+ // stamps this on the document the merchant reads (AGL-2451);
2469
+ // the reservation settled money and recorded no regime at all.
2470
+ //
2471
+ // DERIVED, never the constant `'none'` the current decision
2472
+ // happens to produce. A constant would keep answering `none`
2473
+ // on the day this path does compute lodging tax, which is the
2474
+ // failure this field exists to prevent — and would make the
2475
+ // eventual AGL-1969 answer a second change here rather than
2476
+ // none. `absent` remains a fourth state meaning "recorded
2477
+ // before this shipped", which a back-book question needs to
2478
+ // separate from a deliberate zero.
2479
+ //
2480
+ // TWO-ARGUMENT, now that AGL-1969 is answered and the merchant
2481
+ // can set a lodging rate. That rate rides an ordinary line
2482
+ // item, so Stripe's own `total_details.amount_tax` reads 0 on
2483
+ // a stay that really did charge the guest occupancy tax — the
2484
+ // one-argument form stamped `none` on exactly those. This is
2485
+ // the same form the cart, draft and buy-now order doors
2486
+ // already use (AGL-2451): still the one shared derivation,
2487
+ // handed the witness it needs.
2488
+ taxMode: storefrontTaxModeOf(object, taxCents)
2489
+ }, taxCents > 0 ? {
2490
+ taxCents
2491
+ } : {}), {
2492
+ merge: true
2493
+ });
2494
+ return true;
2495
+ });
2496
+ if (confirmedNow) {
2497
+ var _ref34, _ref35;
2498
+ var _object_customer_details3, _object_customer_details4, _object_customer_details5;
2499
+ const reservation = reserved != null ? reserved : {};
2500
+ void notifyHostManagers(String(hostId), _extends({
2501
+ type: 'content.booking',
2502
+ title: 'New reservation'
2503
+ }, (object == null ? void 0 : (_object_customer_details3 = object.customer_details) == null ? void 0 : _object_customer_details3.email) ? {
2504
+ body: object.customer_details.email
2505
+ } : {}, {
2506
+ link: `/${hostId}/products`
2507
+ }));
2508
+ // Contacts ingestion (AGL-1755): this branch stored `paidCents` from
2509
+ // `amount_total` and then called `upsertHostContact` with no amount,
2510
+ // so a guest who paid for a stay showed a lifetime value of zero. A
2511
+ // guest house's customers are customers; excluding them makes
2512
+ // `ltvCents` mean "product sales only", which is not what it is
2513
+ // called. Provenance survives without a schema change: `source` stays
2514
+ // `'booking'`, so the `sources` map still separates a stay from a
2515
+ // shop sale, and a reader that wants to split service revenue from
2516
+ // product revenue has the interaction timeline to do it with.
2517
+ //
2518
+ // The amount is `amount_total` — the money that moved — and NOT the
2519
+ // reservation's `totalCents`. That distinction is the whole
2520
+ // double-count question, and it resolves in favour of counting:
2521
+ //
2522
+ // * a deposit is charged once, here;
2523
+ // * a POS `folio` sale (AGL-317) charges a room extra as its OWN
2524
+ // paid order and already carries its own `purchaseCents` from
2525
+ // AGL-1748 — it appends to `reservations/{id}.folio` for display
2526
+ // and never touches `paidCents`;
2527
+ // * nothing ever settles a folio by charging again — check-out only
2528
+ // moves the status, and its dialog says as much ("already recorded
2529
+ // as paid POS orders");
2530
+ // * the unpaid stay balance is collected at the register, which is
2531
+ // likewise a separate POS order with its own amount.
2532
+ //
2533
+ // So the deposit and every folio line are disjoint sums, each counted
2534
+ // exactly once. Taking `totalCents` here is what WOULD double-count:
2535
+ // it would claim money the guest has not paid yet and will pay again
2536
+ // through the register.
2537
+ const guestContactEmail = (_ref34 = (_ref35 = object == null ? void 0 : (_object_customer_details4 = object.customer_details) == null ? void 0 : _object_customer_details4.email) != null ? _ref35 : reservation['guestEmail']) != null ? _ref34 : null;
2538
+ if (guestContactEmail) {
2539
+ var _ref36, _ref37;
2540
+ var _object_customer_details6;
2541
+ await captureHostContact(_extends({
2542
+ hostId: String(hostId),
2543
+ email: guestContactEmail,
2544
+ name: (_ref36 = (_ref37 = object == null ? void 0 : (_object_customer_details6 = object.customer_details) == null ? void 0 : _object_customer_details6.name) != null ? _ref37 : reservation['guestName']) != null ? _ref36 : undefined,
2545
+ source: 'booking',
2546
+ // A paid stay is a sale (AGL-2612), whatever the source says
2547
+ // about the kind of thing that was bought.
2548
+ initialLifecycleStage: 'customer'
2549
+ }, paidCents > 0 ? {
2550
+ purchaseCents: paidCents
2551
+ } : {}, {
2552
+ interaction: {
2553
+ refId: String(reservationId),
2554
+ summary: `Reserved a stay ($${(paidCents / 100).toFixed(2)})`
2555
+ }
2556
+ }));
2557
+ }
2558
+ const guestEmail = object == null ? void 0 : (_object_customer_details5 = object.customer_details) == null ? void 0 : _object_customer_details5.email;
2559
+ if (guestEmail) {
2560
+ var _reservation_nights, _ref38;
2561
+ const checkIn = new Date(Number(reservation['checkInDayMs'])).toUTCString();
2562
+ const paid = `$${(paidCents / 100).toFixed(2)}`;
2563
+ const checkInShort = checkIn.slice(0, 16);
2564
+ const fallbackText = `Your stay is confirmed!\n\nCheck-in: ${checkInShort}\n` + `Nights: ${reservation['nights']}\n` + `Paid today: ${paid}\n` + `Reference: ${reservationId}`;
2565
+ // Site-owner-designed template when published (AGL-771).
2566
+ const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'reservation-confirmed', {
2567
+ 'reservation.checkIn': checkInShort,
2568
+ 'reservation.nights': String((_reservation_nights = reservation['nights']) != null ? _reservation_nights : ''),
2569
+ 'reservation.paid': paid,
2570
+ 'reservation.ref': String(reservationId)
2571
+ });
2572
+ await sendEmail(_extends({
2573
+ to: guestEmail,
2574
+ subject: (_ref38 = designed == null ? void 0 : designed.subject) != null ? _ref38 : 'Reservation confirmed',
2575
+ text: (designed == null ? void 0 : designed.text) || fallbackText
2576
+ }, (designed == null ? void 0 : designed.html) ? {
2577
+ html: designed.html
2578
+ } : {}, {
2579
+ fromName: (await brandFor(hostId)).fromName,
2580
+ sendingIdentity: await hostSendingIdentity(String(hostId)),
2581
+ audience: 'tenant',
2582
+ context: 'reservation confirmation'
2583
+ }));
2584
+ // Cost meter (AGL-1438). Transactional: the guest has paid, and a
2585
+ // confirmation a quota refused reads as a failed reservation.
2586
+ await meterHostEmail(String(hostId));
2587
+ }
2588
+ }
2589
+ }
2590
+ }
2591
+ // Cart orders (AGL-293): one multi-line order from the cart doc;
2592
+ // clears the cart and decrements each line's stock.
2593
+ if (type === 'checkout.session.completed' && (object == null ? void 0 : (_object_metadata2 = object.metadata) == null ? void 0 : _object_metadata2.type) === 'commerce-cart' && (object == null ? void 0 : object.payment_status) === 'paid') {
2594
+ var _object_metadata19;
2595
+ const { hostId, cartId, feeCents, couponCode } = (_object_metadata19 = object.metadata) != null ? _object_metadata19 : {};
2596
+ if (hostId && cartId) {
2597
+ var _cartSnapshot_data, _ref39, _ref40, _ref41, _ref42, _ref43, _ref44;
2598
+ var _object_customer_details7, _object_metadata20, _object_customer_details8, _object_customer_details9, _object_customer_details10, _object_metadata21, _this2, _object_metadata22;
2599
+ const firestore = firebaseAdmin.app().firestore();
2600
+ const hostRef = firestore.collection('hosts').doc(String(hostId));
2601
+ const cartRef = hostRef.collection('carts').doc(String(cartId));
2602
+ const cartSnapshot = await cartRef.get();
2603
+ const cart = (_cartSnapshot_data = cartSnapshot.data()) != null ? _cartSnapshot_data : {
2604
+ lines: []
2605
+ };
2606
+ const orderRef = hostRef.collection('orders').doc(String(object.id));
2607
+ const counterRef = hostRef.collection('counters').doc('orders');
2608
+ const productSnapshots = await Promise.all([
2609
+ ...new Set(cart.lines.map((line)=>line.productId))
2610
+ ].map((id)=>hostRef.collection('products').doc(id).get()));
2611
+ const productsById = new Map(productSnapshots.map((snapshot)=>[
2612
+ snapshot.id,
2613
+ snapshot.exists ? CommerceModel.liftLegacyProduct(snapshot.data()) : null
2614
+ ]));
2615
+ // A cart line whose product was deleted between the shopper opening
2616
+ // the Stripe session and this webhook landing (AGL-2149). The line is
2617
+ // dropped from `lineItems` below and skipped by the inventory loop,
2618
+ // and until now that was SILENT: the order was written with fewer lines
2619
+ // than the shopper paid for while `amountCents` stayed the full
2620
+ // `amount_total`, so `itemsCents` and the charge disagreed and nothing
2621
+ // said why. A merchant reconciling that order sees a short order and no
2622
+ // explanation; a shopper sees goods they paid for missing.
2623
+ //
2624
+ // The upstream fix — snapshotting each line into `checkouts/{sessionId}`
2625
+ // at session creation so the webhook can price a deleted product from
2626
+ // the snapshot instead of the product doc — is a schema addition to the
2627
+ // recovery document that the abandoned-cart path also reads, and is NOT
2628
+ // done here. What is done is to make the loss LOUD: the unresolvable
2629
+ // lines are recorded on the order itself, stamped on the timeline the
2630
+ // console dialog renders, and pushed to the managers. A silent
2631
+ // discrepancy on a paid order is the worse outcome of the two.
2632
+ const unresolvedLines = cart.lines.filter((line)=>!productsById.get(line.productId)).map((line)=>_extends({
2633
+ productId: line.productId
2634
+ }, line.variantId ? {
2635
+ variantId: line.variantId
2636
+ } : {}, {
2637
+ quantity: line.quantity
2638
+ }));
2639
+ const lineItems = cart.lines.map((line)=>{
2640
+ var _variant_options, _variant_options1, _ref;
2641
+ const product = productsById.get(line.productId);
2642
+ if (!product) return null;
2643
+ const variant = line.variantId ? product.variants.find((item)=>item.id === line.variantId) : product.variants[0];
2644
+ return _extends({
2645
+ productId: line.productId
2646
+ }, line.variantId ? {
2647
+ variantId: line.variantId
2648
+ } : {}, {
2649
+ name: product.name
2650
+ }, variant && Object.keys((_variant_options = variant.options) != null ? _variant_options : {}).length ? {
2651
+ variantLabel: Object.values((_variant_options1 = variant.options) != null ? _variant_options1 : {}).join(' / ')
2652
+ } : {}, (variant == null ? void 0 : variant.sku) ? {
2653
+ sku: variant.sku
2654
+ } : {}, {
2655
+ productType: product.type
2656
+ }, product.supplierId ? {
2657
+ supplierId: product.supplierId
2658
+ } : {}, {
2659
+ quantity: line.quantity,
2660
+ unitAmountCents: Math.round(Number((_ref = variant == null ? void 0 : variant.priceUsd) != null ? _ref : 0) * 100)
2661
+ });
2662
+ }).filter(Boolean);
2663
+ const shipping = (_ref39 = object == null ? void 0 : object.shipping_details) != null ? _ref39 : object == null ? void 0 : object.customer_details;
2664
+ const created = await firestore.runTransaction(async (transaction)=>{
2665
+ var _counter_get, _ref, _ref1, _ref2, _ref3, _ref4, _shipping_address_line1, _shipping_address_line2, _shipping_address_city, _shipping_address_state, _shipping_address_postal_code, _shipping_address_country, _ref5;
2666
+ var _object_metadata, _object_customer_details, _object_customer_details1;
2667
+ const [existing, counter] = await Promise.all([
2668
+ transaction.get(orderRef),
2669
+ transaction.get(counterRef)
2670
+ ]);
2671
+ if (existing.exists) return false;
2672
+ const number = Number((_counter_get = counter.get('next')) != null ? _counter_get : 1);
2673
+ transaction.set(counterRef, {
2674
+ next: number + 1
2675
+ }, {
2676
+ merge: true
2677
+ });
2678
+ // AGL-1698: reads all THREE of `total_details` — the shipping used
2679
+ // to be dropped here, storing `shippingCents: 0` on every online
2680
+ // order while the shopper's shipping sat inside `amount_total`.
2681
+ const totals = CommerceModel.computeCheckoutSessionTotals(lineItems, object, {
2682
+ feeCents: Number(feeCents != null ? feeCents : 0)
2683
+ });
2684
+ transaction.set(orderRef, _extends({
2685
+ number,
2686
+ status: 'paid',
2687
+ channel: 'online',
2688
+ lineItems,
2689
+ totals,
2690
+ // WHICH TAX THIS SALE CARRIED (AGL-2451). `totals.taxCents` above
2691
+ // says how much; this says who computed it, which is the fact that
2692
+ // decides whose registration the money is held under. The same
2693
+ // resolver `recordStorefrontTax` used a few hundred lines up, so
2694
+ // the order and the filed row cannot disagree. A manual cart's tax
2695
+ // rides a real Stripe Tax Rate since AGL-1953 and therefore has a
2696
+ // populated breakdown — the flag is what tells the two apart.
2697
+ taxMode: storefrontTaxModeOf(object, Number((_ref = object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.taxCents) != null ? _ref : 0)),
2698
+ timeline: [
2699
+ {
2700
+ atMs: Date.now(),
2701
+ event: 'paid'
2702
+ },
2703
+ ...unresolvedLines.length ? [
2704
+ {
2705
+ atMs: Date.now(),
2706
+ event: 'line-unresolved',
2707
+ detail: `${unresolvedLines.length} paid ${unresolvedLines.length === 1 ? 'line' : 'lines'} could not be recorded because the product was ` + 'deleted during checkout, so this order is short of ' + 'what the shopper was charged. Refund the difference ' + 'or fulfil it by hand.'
2708
+ }
2709
+ ] : []
2710
+ ]
2711
+ }, unresolvedLines.length ? {
2712
+ unresolvedLines
2713
+ } : {}, {
2714
+ paymentIntentId: String((_ref1 = object == null ? void 0 : object.payment_intent) != null ? _ref1 : '') || null,
2715
+ checkoutSessionId: String(object.id)
2716
+ }, typeof (event == null ? void 0 : event.livemode) === 'boolean' ? {
2717
+ livemode: event.livemode
2718
+ } : {}, {
2719
+ customerName: (_ref2 = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.name) != null ? _ref2 : null,
2720
+ customerEmail: (_ref3 = object == null ? void 0 : (_object_customer_details1 = object.customer_details) == null ? void 0 : _object_customer_details1.email) != null ? _ref3 : null
2721
+ }, (shipping == null ? void 0 : shipping.address) ? {
2722
+ shippingAddress: {
2723
+ name: (_ref4 = shipping == null ? void 0 : shipping.name) != null ? _ref4 : undefined,
2724
+ line1: (_shipping_address_line1 = shipping.address.line1) != null ? _shipping_address_line1 : undefined,
2725
+ line2: (_shipping_address_line2 = shipping.address.line2) != null ? _shipping_address_line2 : undefined,
2726
+ city: (_shipping_address_city = shipping.address.city) != null ? _shipping_address_city : undefined,
2727
+ state: (_shipping_address_state = shipping.address.state) != null ? _shipping_address_state : undefined,
2728
+ postalCode: (_shipping_address_postal_code = shipping.address.postal_code) != null ? _shipping_address_postal_code : undefined,
2729
+ country: (_shipping_address_country = shipping.address.country) != null ? _shipping_address_country : undefined
2730
+ }
2731
+ } : {}, couponCode ? {
2732
+ couponCode
2733
+ } : {}, {
2734
+ amountCents: Number((_ref5 = object == null ? void 0 : object.amount_total) != null ? _ref5 : 0),
2735
+ feeCents: Number(feeCents != null ? feeCents : 0),
2736
+ createdAtMs: Date.now(),
2737
+ createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
2738
+ }));
2739
+ return true;
2740
+ });
2741
+ // Redelivery/replay guard (AGL-498): only fulfil when the order was
2742
+ // just created. A duplicate delivery finds it already there and skips
2743
+ // the non-idempotent effects below (inventory / coupon / gift-card
2744
+ // decrements) that would otherwise double-apply.
2745
+ if (!created) return;
2746
+ // AGL-2149: loud, not silent. Logged for the platform and pushed to the
2747
+ // merchant, once, behind the same `created` guard as every other
2748
+ // non-idempotent effect so a redelivery does not re-nag.
2749
+ if (unresolvedLines.length) {
2750
+ console.error('commerce cart order lost a paid line', {
2751
+ hostId: String(hostId),
2752
+ orderId: String(object.id),
2753
+ unresolvedLines
2754
+ });
2755
+ void notifyHostManagers(String(hostId), {
2756
+ type: 'content.order',
2757
+ title: 'A paid order is missing items',
2758
+ body: `Order ${object.id} was charged in full but ` + `${unresolvedLines.length} ${unresolvedLines.length === 1 ? 'line' : 'lines'} could not be recorded — the product was deleted during ` + 'checkout. Refund the difference or fulfil it by hand.',
2759
+ link: `/${hostId}/products`
2760
+ });
2761
+ }
2762
+ await cartRef.delete().catch(()=>undefined);
2763
+ // Recoverable checkout closes (AGL-296) so recovery emails stop;
2764
+ // the doc also carries the marketing opt-in (AGL-301).
2765
+ const checkoutRef = hostRef.collection('checkouts').doc(String(object.id));
2766
+ const checkoutSnapshot = await checkoutRef.get().catch(()=>null);
2767
+ const marketingOptIn = Boolean(checkoutSnapshot == null ? void 0 : checkoutSnapshot.get('marketingOptIn'));
2768
+ // AGL-1767: a plain refusal, and the only one of the five that needs no
2769
+ // record. The `.get()` above is the AGL-1760 shape exactly — its result
2770
+ // feeds `marketingOptIn` and is never asked `.exists` — and the merge-set
2771
+ // it guarded nothing of minted a `checkouts/{sessionId}` row holding
2772
+ // `status: 'completed'`. Harmless in itself (abandoned-cart recovery
2773
+ // wants that state anyway), but nothing occurred that a missing checkout
2774
+ // doc would strand: the order, the receipt and the fulfilment below are
2775
+ // all written elsewhere. `updateExisting`, not the read above, is the
2776
+ // check — it closes the window between them without a second round trip.
2777
+ await updateExisting(checkoutRef, {
2778
+ status: 'completed',
2779
+ completedAtMs: Date.now()
2780
+ }).catch(()=>undefined);
2781
+ // License keys (AGL-308) per digital line — OUTSIDE the receipt gate
2782
+ // (AGL-2149). These used to be assigned inside `if (isEmailConfigured()
2783
+ // && buyerEmailForReceipt)`, so a store with no SMTP configured, or a
2784
+ // buyer whose email Stripe did not hand back, produced a paid digital
2785
+ // order that never had a key assigned to it at all — not "the email did
2786
+ // not arrive", but "the key was never claimed", and nothing later
2787
+ // retries it because the `created` guard above turns a redelivery away.
2788
+ // The key is the GOODS on a digital order; the receipt is how they are
2789
+ // announced. Assignment is the part that must not be optional.
2790
+ //
2791
+ // Ordering is deliberate and is what the move had to preserve: the
2792
+ // receipt body below reads `licenseKeysByProduct`, so the assignment
2793
+ // still has to run BEFORE the mail is composed. Hoisting it (rather
2794
+ // than duplicating it into an else-branch) keeps that single ordering.
2795
+ // The buyer's email is stamped on each key when Stripe gave us one and
2796
+ // is `null` otherwise — the key doc's `orderId` is the join that
2797
+ // matters, and the order carries the buyer identity anyway.
2798
+ const licenseKeysByProduct = {};
2799
+ for (const line of lineItems){
2800
+ var _ref45;
2801
+ var _object_customer_details11;
2802
+ if (line.productType !== 'digital') continue;
2803
+ const keys = await assignLicenseKeys(firestore, hostRef, String(hostId), line.productId, String(object.id), (_ref45 = object == null ? void 0 : (_object_customer_details11 = object.customer_details) == null ? void 0 : _object_customer_details11.email) != null ? _ref45 : null, line.quantity);
2804
+ if (keys.length) licenseKeysByProduct[line.productId] = keys;
2805
+ }
2806
+ if (Object.keys(licenseKeysByProduct).length) {
2807
+ await orderRef.set({
2808
+ licenseKeys: licenseKeysByProduct
2809
+ }, {
2810
+ merge: true
2811
+ }).catch(()=>undefined);
2812
+ }
2813
+ // Branded receipt (AGL-296): env-gated like every outbound email.
2814
+ const buyerEmailForReceipt = object == null ? void 0 : (_object_customer_details7 = object.customer_details) == null ? void 0 : _object_customer_details7.email;
2815
+ if (isEmailConfigured() && buyerEmailForReceipt) {
2816
+ var _ref46, _ref47, _ref48, _ref49;
2817
+ const receiptSettings = await hostRef.collection('settings').doc('store').get().catch(()=>null);
2818
+ const receiptFooter = String((_ref46 = receiptSettings == null ? void 0 : receiptSettings.get('receiptFooter')) != null ? _ref46 : '');
2819
+ const linesText = lineItems.map((line)=>`${line.quantity}× ${line.name}${line.variantLabel ? ` (${line.variantLabel})` : ''} — $${(line.unitAmountCents * line.quantity / 100).toFixed(2)}`).join('\n');
2820
+ // The keys assigned above the gate (AGL-2149); the receipt only
2821
+ // reports them.
2822
+ const licenseText = Object.entries(licenseKeysByProduct).flatMap(([keyProductId, keys])=>{
2823
+ const line = lineItems.find((item)=>item.productId === keyProductId);
2824
+ return keys.map((key)=>{
2825
+ var _ref;
2826
+ return `License key (${(_ref = line == null ? void 0 : line.name) != null ? _ref : 'product'}): ${key}`;
2827
+ });
2828
+ }).join('\n');
2829
+ // Digital delivery links (AGL-302); reuse the canonical mint the
2830
+ // tenant download endpoint verifies so the secret can never drift.
2831
+ const downloadToken = mintDownloadToken(hostId, String(object.id));
2832
+ const siteOrigin = String((_ref47 = object == null ? void 0 : object.success_url) != null ? _ref47 : '').replace(/\/\?.*$|\?.*$/, '');
2833
+ const downloadLines = lineItems.filter((line)=>line.productType === 'digital').map((line)=>`Download ${line.name}: ${siteOrigin}/api/commerce/download` + `?hostId=${hostId}&orderId=${object.id}` + `&productId=${line.productId}&token=${downloadToken}`).join('\n');
2834
+ const orderTotal = `$${(Number((_ref48 = object == null ? void 0 : object.amount_total) != null ? _ref48 : 0) / 100).toFixed(2)}`;
2835
+ const orderSummary = [
2836
+ linesText,
2837
+ licenseText,
2838
+ downloadLines
2839
+ ].filter(Boolean).join('\n\n');
2840
+ const fallbackText = `Thanks for your purchase!\n\n${linesText}\n\n` + (licenseText ? `${licenseText}\n\n` : '') + (downloadLines ? `${downloadLines}\n\n` : '') + `Total: ${orderTotal}\n` + `Order reference: ${object.id}` + (receiptFooter ? `\n\n${receiptFooter}` : '');
2841
+ // Site-owner-designed template when published (AGL-771). The
2842
+ // license keys and download links ride in {{order.summary}}.
2843
+ const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'order-receipt', {
2844
+ 'order.summary': orderSummary,
2845
+ 'order.total': orderTotal,
2846
+ 'order.ref': String(object.id)
2847
+ });
2848
+ await sendEmail(_extends({
2849
+ to: buyerEmailForReceipt,
2850
+ subject: (_ref49 = designed == null ? void 0 : designed.subject) != null ? _ref49 : `Receipt for your order`,
2851
+ text: (designed == null ? void 0 : designed.text) || fallbackText
2852
+ }, (designed == null ? void 0 : designed.html) ? {
2853
+ html: designed.html
2854
+ } : {}, {
2855
+ fromName: (await brandFor(hostId)).fromName,
2856
+ sendingIdentity: await hostSendingIdentity(String(hostId)),
2857
+ audience: 'tenant',
2858
+ context: 'cart receipt'
2859
+ }));
2860
+ // Cost meter (AGL-1438). Transactional: a dropped receipt looks to
2861
+ // the buyer like an order that did not go through.
2862
+ await meterHostEmail(String(hostId));
2863
+ }
2864
+ // Inventory per line (AGL-281 semantics).
2865
+ for (const line of cart.lines){
2866
+ var _line_variantId;
2867
+ var _product_variants_;
2868
+ const product = productsById.get(line.productId);
2869
+ if (!product) continue;
2870
+ const variantId = (_line_variantId = line.variantId) != null ? _line_variantId : (_product_variants_ = product.variants[0]) == null ? void 0 : _product_variants_.id;
2871
+ const tracked = product.variants.some((variant)=>variant.id === variantId && variant.inventory != null);
2872
+ if (!variantId || !tracked) continue;
2873
+ // Two lines of one product must COMPOUND (AGL-1830): a cart holds
2874
+ // two VARIANTS of one product as two lines (`lineKey` merges on
2875
+ // product+variant), and recomputing each from the product as first
2876
+ // read would erase this decrement when the sibling line's merge-set
2877
+ // landed, while the ledger below recorded both.
2878
+ //
2879
+ // Since AGL-2320 the compounding is a PROPERTY of the transaction,
2880
+ // not of the carry-forward: each line re-reads the product inside its
2881
+ // own transaction, so it starts from the sibling line's committed
2882
+ // write — and from a CONCURRENT request's committed write too, which
2883
+ // the in-memory carry-forward could never see. `productsById` is
2884
+ // still refreshed, because the gift-card pass below reads it.
2885
+ const moved = await decrementVariantStock({
2886
+ firestore,
2887
+ hostRef,
2888
+ hostId: String(hostId),
2889
+ productId: line.productId,
2890
+ variantId,
2891
+ quantity: line.quantity,
2892
+ ledger: {
2893
+ reason: 'sale',
2894
+ orderId: String(object.id)
2895
+ }
2896
+ });
2897
+ if (!moved.before || !moved.after) continue;
2898
+ productsById.set(line.productId, moved.after);
2899
+ // Low-stock crossing alert (AGL-1826): the cart — the channel that
2900
+ // sells MORE units per order than the buy-now button — used to
2901
+ // cross the threshold silently. Same check, per product, on the
2902
+ // compounded pair; the `created` guard above bounds it on
2903
+ // redelivery. Fires per crossing line, which for a multi-product
2904
+ // basket is one nudge per product that breached.
2905
+ alertLowStockCrossing(String(hostId), moved.before, moved.after);
2906
+ }
2907
+ // SETTLEMENT: the reservation becomes the decrement (AGL-2356).
2908
+ //
2909
+ // AFTER the loop, never before it. Between the decrement and this
2910
+ // release the units are counted twice — once off the shelf, once still
2911
+ // held — so availability UNDER-reports for the few hundred milliseconds
2912
+ // it takes to get here. That is the safe direction: it can only refuse
2913
+ // a sale that a moment later succeeds. Releasing first would open the
2914
+ // opposite window, in which the unit is neither on the shelf nor
2915
+ // spoken for, and that window is the oversell itself.
2916
+ //
2917
+ // The decrement is deliberately NOT made conditional on the hold. A
2918
+ // paid order must decrement whether or not its reservation survived —
2919
+ // an expired hold, a session from before this deploy, a merchant who
2920
+ // saved the product editor mid-checkout — and `decrementVariantStock`
2921
+ // is byte-identical to what it was before this issue for exactly that
2922
+ // reason. The hold refuses the SECOND shopper; it never gates the
2923
+ // first one's goods.
2924
+ await releaseStockHold(hostRef, String((_ref40 = (_object_metadata20 = object.metadata) == null ? void 0 : _object_metadata20.stockHoldKey) != null ? _ref40 : ''));
2925
+ void notifyHostManagers(String(hostId), _extends({
2926
+ type: 'content.order',
2927
+ title: `New order — $${(Number((_ref41 = object == null ? void 0 : object.amount_total) != null ? _ref41 : 0) / 100).toFixed(2)}`
2928
+ }, (object == null ? void 0 : (_object_customer_details8 = object.customer_details) == null ? void 0 : _object_customer_details8.email) ? {
2929
+ body: `From ${object.customer_details.email}`
2930
+ } : {}, {
2931
+ link: `/${hostId}/products`
2932
+ }));
2933
+ await captureHostContact(_extends({
2934
+ hostId: String(hostId),
2935
+ email: object == null ? void 0 : (_object_customer_details9 = object.customer_details) == null ? void 0 : _object_customer_details9.email,
2936
+ name: (_ref42 = object == null ? void 0 : (_object_customer_details10 = object.customer_details) == null ? void 0 : _object_customer_details10.name) != null ? _ref42 : undefined,
2937
+ source: 'order',
2938
+ initialLifecycleStage: 'customer'
2939
+ }, marketingOptIn ? {
2940
+ marketingConsent: true
2941
+ } : {}, {
2942
+ purchaseCents: Number((_ref43 = object == null ? void 0 : object.amount_total) != null ? _ref43 : 0),
2943
+ interaction: {
2944
+ refId: String(object.id),
2945
+ summary: `Placed an order ($${(Number((_ref44 = object == null ? void 0 : object.amount_total) != null ? _ref44 : 0) / 100).toFixed(2)})`
2946
+ }
2947
+ }));
2948
+ if (couponCode) {
2949
+ var _ref50;
2950
+ var _object_metadata23;
2951
+ await settleRedemption({
2952
+ firestore,
2953
+ ref: hostRef.collection('coupons').doc(String(couponCode)),
2954
+ holdKey: String((_ref50 = (_object_metadata23 = object.metadata) == null ? void 0 : _object_metadata23.couponHoldKey) != null ? _ref50 : ''),
2955
+ orderRef,
2956
+ label: `coupon ${couponCode}`,
2957
+ detail: `Coupon ${couponCode} was applied to this order but no longer ` + 'exists, so the redemption is uncounted against its limit.'
2958
+ });
2959
+ }
2960
+ // Gift card balance settlement (AGL-322, made a settlement by AGL-2449).
2961
+ if ((_object_metadata21 = object.metadata) == null ? void 0 : _object_metadata21.giftCardCode) {
2962
+ var _ref51, _ref52;
2963
+ var _object_metadata24, _object_metadata25;
2964
+ const giftCardCents = Number((_ref51 = (_object_metadata24 = object.metadata) == null ? void 0 : _object_metadata24.giftCardCents) != null ? _ref51 : 0);
2965
+ const holdKey = String((_ref52 = (_object_metadata25 = object.metadata) == null ? void 0 : _object_metadata25.giftCardHoldKey) != null ? _ref52 : '');
2966
+ const cardRef = hostRef.collection('giftCards').doc(String(object.metadata.giftCardCode));
2967
+ const orphanNote = `$${(giftCardCents / 100).toFixed(2)} was applied from gift card ` + `${object.metadata.giftCardCode}, which no longer exists. The ` + 'balance was not deducted from any card.';
2968
+ if (!holdKey) {
2969
+ // A session minted BEFORE holds existed. It reserved nothing, so
2970
+ // the old unconditional decrement is still the only thing that can
2971
+ // settle it — the shopper was given the discount and the merchant
2972
+ // must be paid out of the card. Kept deliberately rather than
2973
+ // refusing: in-flight sessions outlive the deploy by up to 24h, and
2974
+ // dropping them would hand out free money instead of double-spent
2975
+ // money. It ages out on its own once the last pre-deploy session
2976
+ // completes.
2977
+ await redeemExistingOrRecord(cardRef, {
2978
+ balanceCents: firebaseAdmin.firestore.FieldValue.increment(-giftCardCents),
2979
+ lastUsedAtMs: Date.now()
2980
+ }, orderRef, orphanNote);
2981
+ } else {
2982
+ // The hold IS the authority, not the metadata: `giftCardCents` is
2983
+ // a copy the session carries for the receipt, and settling against
2984
+ // the reservation is what makes this idempotent under redelivery —
2985
+ // the second delivery finds no hold and takes nothing.
2986
+ const settled = await firestore.runTransaction(async (transaction)=>{
2987
+ var _fresh_data, _card_balanceCents;
2988
+ const fresh = await transaction.get(cardRef);
2989
+ if (!fresh.exists) return null;
2990
+ const card = (_fresh_data = fresh.data()) != null ? _fresh_data : {};
2991
+ const take = CommerceModel.giftCardSettlementCents(card, holdKey, Date.now());
2992
+ transaction.set(cardRef, _extends({
2993
+ balanceCents: Math.max(0, Number((_card_balanceCents = card.balanceCents) != null ? _card_balanceCents : 0) - take),
2994
+ // `FieldValue.delete()`, NOT a locally-pruned copy of the
2995
+ // map. `set(…, { merge: true })` merges nested maps rather
2996
+ // than replacing them, so writing back an object with the
2997
+ // key removed leaves the stored key exactly where it was —
2998
+ // and a redelivery would then find the hold still standing
2999
+ // and settle it a SECOND time, taking the balance twice for
3000
+ // one payment. The sentinel is what makes this settlement
3001
+ // idempotent.
3002
+ holds: {
3003
+ [holdKey]: firebaseAdmin.firestore.FieldValue.delete()
3004
+ }
3005
+ }, take > 0 ? {
3006
+ lastUsedAtMs: Date.now()
3007
+ } : {}), {
3008
+ merge: true
3009
+ });
3010
+ return take;
3011
+ }).catch((error)=>{
3012
+ console.error('Gift card settlement failed', holdKey, error);
3013
+ // Undefined, not null: null means "the card is gone", which is
3014
+ // the orphan note below. A transport failure is neither — the
3015
+ // hold stands and lapses on its own, and the merchant is short
3016
+ // rather than the customer double-charged.
3017
+ return undefined;
3018
+ });
3019
+ if (settled === null) {
3020
+ await recordRedemptionOrphan(orderRef, orphanNote);
3021
+ } else if (settled != null && settled < giftCardCents) {
3022
+ // The card could not cover what the session discounted, which
3023
+ // after a hold means the merchant voided or hand-adjusted it
3024
+ // mid-flight. Money moved on a discount the card did not fund,
3025
+ // so it is recorded where the merchant reads it rather than
3026
+ // quietly absorbed.
3027
+ await recordRedemptionOrphan(orderRef, `$${(giftCardCents / 100).toFixed(2)} was discounted against ` + `gift card ${object.metadata.giftCardCode}, but only $${(settled / 100).toFixed(2)} could be taken from its balance.`);
3028
+ }
3029
+ }
3030
+ }
3031
+ // Gift card issuance (AGL-322): each purchased gift-card line
3032
+ // mints a code for its unit price and emails it to the buyer.
3033
+ // Defense in depth (AGL-470): checkout already blocks gift-card
3034
+ // sales without the Business entitlement; re-check here so a doc
3035
+ // edited between checkout and webhook can't mint codes.
3036
+ const giftCardLines = lineItems.filter((line)=>{
3037
+ var _productsById_get;
3038
+ return (_productsById_get = productsById.get(line.productId)) == null ? void 0 : _productsById_get.giftCard;
3039
+ });
3040
+ const giftCardsEntitled = giftCardLines.length > 0 && Aglyn.checkEntitlement((_this2 = await getOrgForHost(String(hostId))) == null ? void 0 : _this2.org, 'giftCards');
3041
+ for (const line of giftCardsEntitled ? giftCardLines : []){
3042
+ const lineProduct = productsById.get(line.productId);
3043
+ if (!(lineProduct == null ? void 0 : lineProduct.giftCard)) continue;
3044
+ for(let unit = 0; unit < line.quantity; unit += 1){
3045
+ var _ref53;
3046
+ var _object_customer_details12, _object_customer_details13;
3047
+ const code = `GC-${createHmac('sha256', String(object.id)).update(`${line.productId}:${unit}:${Date.now()}`).digest('hex').slice(0, 12).toUpperCase()}`;
3048
+ // THE EMAIL IS GATED ON THE WRITE (AGL-2161). This `.set()` used
3049
+ // to `.catch(() => undefined)` and fall straight through to the
3050
+ // send below, so a failed write shipped the buyer a real-looking
3051
+ // `GC-XXXXXXXXXXXX` for a document that does not exist — and
3052
+ // `meterHostEmail` billed the merchant for delivering it. The
3053
+ // buyer only discovers it at checkout, where `cart-checkout.ts`
3054
+ // finds `!fresh.exists`, places no hold, and applies nothing.
3055
+ //
3056
+ // The redemption side already went transactional (AGL-2449); this
3057
+ // is the minting side catching up, and it deliberately EXTENDS
3058
+ // that path rather than adding a second mechanism: a card that was
3059
+ // never written is the same "card that isn't there" the settlement
3060
+ // orphan note describes, reached from the other end.
3061
+ const issued = await hostRef.collection('giftCards').doc(code).set({
3062
+ initialCents: line.unitAmountCents,
3063
+ balanceCents: line.unitAmountCents,
3064
+ recipientEmail: (_ref53 = object == null ? void 0 : (_object_customer_details12 = object.customer_details) == null ? void 0 : _object_customer_details12.email) != null ? _ref53 : null,
3065
+ orderId: String(object.id),
3066
+ createdAtMs: Date.now()
3067
+ }).then(()=>true).catch((error)=>{
3068
+ console.error('Gift card issuance failed', hostId, code, error);
3069
+ return false;
3070
+ });
3071
+ if (!issued) {
3072
+ // LOUD, and on the surface a merchant already watches. The buyer
3073
+ // has paid, so somebody must act: the console's hand-issue route
3074
+ // (`gift-cards.ts`) is how they make it right. Sending nothing is
3075
+ // the recoverable half — an unissued card is a support ticket, a
3076
+ // phantom code is a customer who thinks they hold value.
3077
+ // `content.order` and `/products`: the Gift cards card lives on
3078
+ // the commerce console page the `/products` nav item opens, and
3079
+ // this is an order-shaped fact. A new notification type would
3080
+ // need a label, a category and a mute of its own for one edge —
3081
+ // reuse is the AGL-1088 call, same as `content.lowStock` is for
3082
+ // the oversell alert next door.
3083
+ void notifyHostManagers(String(hostId), {
3084
+ type: 'content.order',
3085
+ title: 'Gift card paid for but not issued',
3086
+ body: `A $${(line.unitAmountCents / 100).toFixed(2)} gift card on ` + `order ${String(object.id)} could not be written, so no code ` + 'was emailed to the buyer. Issue one by hand from Gift cards.',
3087
+ link: `/${hostId}/products`
3088
+ });
3089
+ continue;
3090
+ }
3091
+ const giftTo = object == null ? void 0 : (_object_customer_details13 = object.customer_details) == null ? void 0 : _object_customer_details13.email;
3092
+ if (giftTo) {
3093
+ var _ref54;
3094
+ const giftValue = `$${(line.unitAmountCents / 100).toFixed(2)}`;
3095
+ const fallbackText = `Gift card code: ${code}\n` + `Value: ${giftValue}\n\n` + 'Enter it at checkout to apply the balance.';
3096
+ // Site-owner-designed template when published (AGL-771).
3097
+ const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'gift-card', {
3098
+ 'giftcard.code': code,
3099
+ 'giftcard.value': giftValue
3100
+ });
3101
+ await sendEmail(_extends({
3102
+ to: giftTo,
3103
+ subject: (_ref54 = designed == null ? void 0 : designed.subject) != null ? _ref54 : 'Your gift card',
3104
+ text: (designed == null ? void 0 : designed.text) || fallbackText
3105
+ }, (designed == null ? void 0 : designed.html) ? {
3106
+ html: designed.html
3107
+ } : {}, {
3108
+ fromName: (await brandFor(hostId)).fromName,
3109
+ sendingIdentity: await hostSendingIdentity(String(hostId)),
3110
+ audience: 'tenant',
3111
+ context: 'gift card'
3112
+ }));
3113
+ // Cost meter (AGL-1438). Transactional: this email IS the
3114
+ // purchased goods.
3115
+ await meterHostEmail(String(hostId));
3116
+ }
3117
+ }
3118
+ }
3119
+ // Discounts engine redemptions (AGL-305).
3120
+ if ((_object_metadata22 = object.metadata) == null ? void 0 : _object_metadata22.discountId) {
3121
+ var _ref55;
3122
+ var _object_metadata26;
3123
+ await settleRedemption({
3124
+ firestore,
3125
+ ref: hostRef.collection('discounts').doc(String(object.metadata.discountId)),
3126
+ holdKey: String((_ref55 = (_object_metadata26 = object.metadata) == null ? void 0 : _object_metadata26.discountHoldKey) != null ? _ref55 : ''),
3127
+ orderRef,
3128
+ label: `discount ${object.metadata.discountId}`,
3129
+ detail: `Discount ${object.metadata.discountId} was applied to this ` + 'order but no longer exists, so the redemption is uncounted ' + 'against its limit.'
3130
+ });
3131
+ }
3132
+ }
3133
+ }
3134
+ // Draft orders (AGL-287): the console pre-created the doc; completion
3135
+ // flips it to paid, stamps the intent, and decrements stock.
3136
+ if (type === 'checkout.session.completed' && (object == null ? void 0 : (_object_metadata3 = object.metadata) == null ? void 0 : _object_metadata3.type) === 'commerce-draft' && (object == null ? void 0 : object.payment_status) === 'paid') {
3137
+ var _object_metadata27;
3138
+ const { hostId, orderId, productId } = (_object_metadata27 = object.metadata) != null ? _object_metadata27 : {};
3139
+ if (hostId && orderId) {
3140
+ var _ref56, _ref57, _ref58;
3141
+ var _object_total_details, _object_total_details1;
3142
+ const firestore = firebaseAdmin.app().firestore();
3143
+ const hostRef = firestore.collection('hosts').doc(String(hostId));
3144
+ const orderRef = hostRef.collection('orders').doc(String(orderId));
3145
+ // Redelivery guard (AGL-1748), the AGL-1732/AGL-498 shape. The
3146
+ // `pending` -> `paid` transition was always the guard, but it used to
3147
+ // be a read-then-write with every side effect below hanging off it, so
3148
+ // two concurrent deliveries could both observe `pending` and both run
3149
+ // them — a doubled manager notification, a doubled inventory decrement
3150
+ // and, now that this branch feeds contacts, a doubled
3151
+ // `FieldValue.increment` on the buyer's lifetime value.
3152
+ //
3153
+ // Keyed on the STATUS rather than on the document existing, which is
3154
+ // where this differs from AGL-1732: there, a sibling event wrote the
3155
+ // same path, so an existence check would have discarded the sale
3156
+ // record. Here the console (or the POS card path) pre-creates the order
3157
+ // before the session exists, so existence is guaranteed and says
3158
+ // nothing; only the transition distinguishes the first delivery.
3159
+ let paidOrder = null;
3160
+ // Shipping (AGL-1792). This branch wrote no `totals` at all: the
3161
+ // console froze them when the draft was composed, and while no draft
3162
+ // session could charge postage there was nothing to add. Now
3163
+ // `draft-order.ts` declares `shipping_options`, so the buyer picks a
3164
+ // rate and pays an amount that arrives only inside `amount_total` —
3165
+ // leaving the stored order alone would recreate AGL-1698 against real
3166
+ // money, which is the ordering constraint AGL-1707 wrote down.
3167
+ //
3168
+ // ADDITIVE, never a rebuild. `computeCheckoutSessionTotals` is the
3169
+ // obvious reach and is wrong here: this same branch completes a POS
3170
+ // card sale, whose tax and discount are priced into one "In-store
3171
+ // purchase" line and are therefore absent from Stripe's
3172
+ // `total_details`, so rebuilding would zero them. Only the part Stripe
3173
+ // alone knows is folded in, and a session that charged no shipping
3174
+ // leaves the document byte-identical — which is every draft that
3175
+ // exists today and every counter sale.
3176
+ //
3177
+ // The whole map is written rather than one field of it, so a
3178
+ // `merge`-set's nested-map merge and a plain replace agree.
3179
+ const shippingCents = Math.max(0, Math.round(Number((_ref56 = object == null ? void 0 : (_object_total_details = object.total_details) == null ? void 0 : _object_total_details.amount_shipping) != null ? _ref56 : 0) || 0));
3180
+ // Tax joins shipping in the fold (AGL-1953). `draft-order.ts` now
3181
+ // declares tax — a Stripe Tax Rate for a `manual` store, whose figure
3182
+ // it already froze into `totals`, or `automatic_tax` for a `stripe`
3183
+ // one, whose figure ONLY Stripe knows and which would otherwise arrive
3184
+ // inside `amount_total` with nothing recording it. The same AGL-1698
3185
+ // shape shipping was added for.
3186
+ //
3187
+ // Byte-identical for everything that came before: a POS card sale
3188
+ // prices its tax into the single "In-store purchase" line and reports
3189
+ // `amount_tax` 0 here (its own figure stays on the order, and its
3190
+ // AGL-1953 witness is metadata, not this field), and a draft composed
3191
+ // by a store with no tax reports 0 too.
3192
+ const stripeTaxCents = Math.max(0, Math.round(Number((_ref57 = object == null ? void 0 : (_object_total_details1 = object.total_details) == null ? void 0 : _object_total_details1.amount_tax) != null ? _ref57 : 0) || 0));
3193
+ const chargedTotalCents = Number((_ref58 = object == null ? void 0 : object.amount_total) != null ? _ref58 : NaN);
3194
+ // `shipping_details` ONLY, unlike the cart branch's
3195
+ // `?? customer_details` fallback: that address is the BILLING one, and
3196
+ // a card sale rung up at a register completes here too — it must not
3197
+ // acquire a destination nobody entered and nothing will ship to.
3198
+ // Stripe populates this exactly when the session asked for an address,
3199
+ // which is exactly when a parcel was priced.
3200
+ const shipTo = object == null ? void 0 : object.shipping_details;
3201
+ // A payment that arrived for an order NOT in `pending` — see the
3202
+ // transaction below. Carried out so the money can be reported after
3203
+ // the transaction commits.
3204
+ let paidAfterCancel = false;
3205
+ const flipped = await firestore.runTransaction(async (transaction)=>{
3206
+ var _snapshot_data, _ref, _ref1, _ref2, _ref3, _lifted_totals, _ref4, _shipTo_name, _shipTo_address_line1, _shipTo_address_line2, _shipTo_address_city, _shipTo_address_state, _shipTo_address_postal_code, _shipTo_address_country;
3207
+ var _object_metadata, _object_customer_details, _lifted_totals1;
3208
+ const snapshot = await transaction.get(orderRef);
3209
+ if (!snapshot.exists) return false;
3210
+ const lifted = CommerceModel.liftLegacyOrder((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
3211
+ if (lifted.status !== 'pending') {
3212
+ var _lifted_timeline;
3213
+ // NOT ALL NON-`pending` ORDERS ARE REDELIVERIES (AGL-2244), and
3214
+ // returning false for both is how a real capture went unrecorded.
3215
+ // A `cancelled` order whose payment link was paid anyway is money
3216
+ // that LANDED in the merchant's Stripe account with nothing in the
3217
+ // product to show for it: no order, no stock move, no contact, no
3218
+ // notification, not even a timeline line, so the merchant's books
3219
+ // and Stripe's disagree and nobody is told which is right.
3220
+ //
3221
+ // `cancel-order.ts` now expires the session, which stops the
3222
+ // ordinary case; this is the backstop for the window between the
3223
+ // cancel and the expiry, and for an expiry Stripe refused. Stamped
3224
+ // once, keyed on the same status the transaction reads and writes:
3225
+ // a redelivery finds `cancelled` too, so the guard is the timeline
3226
+ // entry itself, appended only when one is not already there.
3227
+ const alreadyNoted = ((_lifted_timeline = lifted.timeline) != null ? _lifted_timeline : []).some((entry)=>(entry == null ? void 0 : entry.event) === 'paid-after-cancel');
3228
+ if (lifted.status === 'cancelled' && !alreadyNoted) {
3229
+ paidAfterCancel = true;
3230
+ // `set(..., { merge: true })`, the same call the paying branch
3231
+ // below makes on this document. Nothing here is a nested map, so
3232
+ // merge and replace agree, and the read above has just proven
3233
+ // the document exists — the only thing `update()` would add.
3234
+ transaction.set(orderRef, {
3235
+ paidAfterCancel: true,
3236
+ timeline: CommerceModel.appendOrderEvent(lifted, 'paid-after-cancel', 'This canceled order was paid anyway — the payment link ' + 'was still live. The money is in Stripe and this order ' + 'records no sale; refund it in Stripe, or reconcile it ' + 'by hand.')
3237
+ }, {
3238
+ merge: true
3239
+ });
3240
+ }
3241
+ return false;
3242
+ }
3243
+ paidOrder = lifted;
3244
+ transaction.set(orderRef, _extends({
3245
+ status: 'paid',
3246
+ // WHICH TAX THIS ORDER CARRIED (AGL-2451), resolved from the
3247
+ // session that actually charged it rather than from the draft's
3248
+ // frozen composition. `draft-order.ts` stamps its own reading at
3249
+ // compose time and this is the authoritative restatement: a
3250
+ // Stripe Tax draft freezes `taxCents: 0` and only the paid
3251
+ // session knows the figure, so only the paid session can say the
3252
+ // regime with the tax in hand.
3253
+ //
3254
+ // The metadata witness matters here more than anywhere: a POS
3255
+ // CARD sale completes through this same branch with the whole
3256
+ // basket sent as one opaque `In-store purchase` line, so Stripe
3257
+ // states no tax at all and `metadata[taxCents]` is the only
3258
+ // record that any of the charge was tax (AGL-1953). Without it
3259
+ // every register card sale would stamp `none` beside an order
3260
+ // plainly carrying tax.
3261
+ taxMode: storefrontTaxModeOf(object, Number((_ref = object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.taxCents) != null ? _ref : 0)),
3262
+ paymentIntentId: String((_ref1 = object == null ? void 0 : object.payment_intent) != null ? _ref1 : '') || null,
3263
+ customerEmail: (_ref2 = (_ref3 = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.email) != null ? _ref3 : lifted.customerEmail) != null ? _ref2 : null,
3264
+ timeline: CommerceModel.appendOrderEvent(lifted, 'paid')
3265
+ }, shippingCents > 0 || stripeTaxCents > 0 ? {
3266
+ totals: _extends({}, (_lifted_totals = lifted.totals) != null ? _lifted_totals : {}, shippingCents > 0 ? {
3267
+ shippingCents
3268
+ } : {}, stripeTaxCents > 0 ? {
3269
+ taxCents: stripeTaxCents
3270
+ } : {}, {
3271
+ // Stripe's own figure, for the AGL-1698 reason: the
3272
+ // frozen total is what the draft was priced at, and only
3273
+ // `amount_total` is the money that moved.
3274
+ totalCents: Number.isFinite(chargedTotalCents) ? chargedTotalCents : Number((_ref4 = (_lifted_totals1 = lifted.totals) == null ? void 0 : _lifted_totals1.totalCents) != null ? _ref4 : 0) + shippingCents
3275
+ })
3276
+ } : {}, (shipTo == null ? void 0 : shipTo.address) ? {
3277
+ shippingAddress: {
3278
+ name: (_shipTo_name = shipTo.name) != null ? _shipTo_name : undefined,
3279
+ line1: (_shipTo_address_line1 = shipTo.address.line1) != null ? _shipTo_address_line1 : undefined,
3280
+ line2: (_shipTo_address_line2 = shipTo.address.line2) != null ? _shipTo_address_line2 : undefined,
3281
+ city: (_shipTo_address_city = shipTo.address.city) != null ? _shipTo_address_city : undefined,
3282
+ state: (_shipTo_address_state = shipTo.address.state) != null ? _shipTo_address_state : undefined,
3283
+ postalCode: (_shipTo_address_postal_code = shipTo.address.postal_code) != null ? _shipTo_address_postal_code : undefined,
3284
+ country: (_shipTo_address_country = shipTo.address.country) != null ? _shipTo_address_country : undefined
3285
+ }
3286
+ } : {}), {
3287
+ merge: true
3288
+ });
3289
+ return true;
3290
+ });
3291
+ // Told to a human, because nothing else will be (AGL-2244). The
3292
+ // timeline line above is only findable by someone already looking at
3293
+ // the order they have no reason to open; this is money sitting in
3294
+ // Stripe against a sale the product does not have.
3295
+ if (paidAfterCancel && hostId) {
3296
+ await notifyHostManagers(String(hostId), {
3297
+ type: 'content.order',
3298
+ title: 'A canceled order was paid',
3299
+ body: `Order ${orderId} was canceled, but its payment link was still ` + 'live and has been paid. The money is in Stripe and no sale was ' + 'recorded — refund it in Stripe or reconcile it by hand.',
3300
+ link: `/${hostId}/orders`
3301
+ });
3302
+ }
3303
+ if (flipped) {
3304
+ var _ref59, _ref60;
3305
+ var _object_metadata28, _object_customer_details14;
3306
+ const order = paidOrder;
3307
+ // Discounts engine redemptions (AGL-305), for this branch's BOTH
3308
+ // tenants: a console draft order and a POS card sale, which carry the
3309
+ // same `commerce-draft` metadata type. The cart branch has settled
3310
+ // its redemptions since AGL-305 and these two counted nothing, so a
3311
+ // capped promotion was bounded on the website and unbounded through a
3312
+ // merchant's payment link and their own register.
3313
+ //
3314
+ // INSIDE the `flipped` guard, and that placement is load-bearing: the
3315
+ // no-`holdKey` path in `settleRedemption` is an unconditional
3316
+ // `increment(1)` for uncapped promotions and sessions minted before
3317
+ // the hold existed, which Stripe's at-least-once delivery would run
3318
+ // twice. The transition is what makes it once.
3319
+ if ((_object_metadata28 = object.metadata) == null ? void 0 : _object_metadata28.discountId) {
3320
+ var _ref61;
3321
+ var _object_metadata29;
3322
+ await settleRedemption({
3323
+ firestore,
3324
+ ref: hostRef.collection('discounts').doc(String(object.metadata.discountId)),
3325
+ holdKey: String((_ref61 = (_object_metadata29 = object.metadata) == null ? void 0 : _object_metadata29.discountHoldKey) != null ? _ref61 : ''),
3326
+ orderRef,
3327
+ label: `discount ${object.metadata.discountId}`,
3328
+ detail: `Discount ${object.metadata.discountId} was applied to this ` + 'order but no longer exists, so the redemption is uncounted ' + 'against its limit.'
3329
+ });
3330
+ }
3331
+ void notifyHostManagers(String(hostId), {
3332
+ type: 'content.order',
3333
+ title: `Draft order paid — ${CommerceModel.formatOrderNumber(order, String(orderId))}`,
3334
+ link: `/${hostId}/products`
3335
+ });
3336
+ // Contacts ingestion (AGL-1748): this branch flipped the order,
3337
+ // notified managers and decremented stock, but never reached
3338
+ // `upsertHostContact` — so a buyer who paid a merchant-sent payment
3339
+ // link never became a contact AT ALL, and neither did a POS card
3340
+ // customer, because `pos-order.ts` completes its QR sale through this
3341
+ // same `commerce-draft` branch rather than through its own handler.
3342
+ //
3343
+ // The amount is what Stripe charged (`amount_total`), for the
3344
+ // AGL-1698/AGL-1711 reason — the stored `totals.totalCents` is the
3345
+ // figure the draft was priced at, and the two agree by construction,
3346
+ // but only one of them is the money that moved. Stored totals remain
3347
+ // the fallback for a session shape that reports no total.
3348
+ const draftEmail = (_ref59 = (_ref60 = object == null ? void 0 : (_object_customer_details14 = object.customer_details) == null ? void 0 : _object_customer_details14.email) != null ? _ref60 : order.customerEmail) != null ? _ref59 : null;
3349
+ if (draftEmail) {
3350
+ var _ref62, _ref63, _ref64;
3351
+ var _order_totals, _object_customer_details15;
3352
+ const chargedCents = Number((_ref62 = object == null ? void 0 : object.amount_total) != null ? _ref62 : 0) || Number((_ref63 = (_order_totals = order.totals) == null ? void 0 : _order_totals.totalCents) != null ? _ref63 : 0);
3353
+ await captureHostContact(_extends({
3354
+ hostId: String(hostId),
3355
+ email: draftEmail,
3356
+ name: (_ref64 = object == null ? void 0 : (_object_customer_details15 = object.customer_details) == null ? void 0 : _object_customer_details15.name) != null ? _ref64 : undefined,
3357
+ source: 'order',
3358
+ initialLifecycleStage: 'customer'
3359
+ }, chargedCents > 0 ? {
3360
+ purchaseCents: chargedCents
3361
+ } : {}, {
3362
+ interaction: {
3363
+ refId: String(orderId),
3364
+ summary: `Paid ${CommerceModel.formatOrderNumber(order, String(orderId))} ($${(chargedCents / 100).toFixed(2)})`
3365
+ }
3366
+ }));
3367
+ }
3368
+ if (productId) {
3369
+ var _productSnapshot_data, _ref65, _ref66;
3370
+ var _object_metadata30, _lifted_variants_, _order_lineItems_, _order_lineItems;
3371
+ const productRef = hostRef.collection('products').doc(String(productId));
3372
+ const productSnapshot = await productRef.get();
3373
+ const lifted = CommerceModel.liftLegacyProduct((_productSnapshot_data = productSnapshot.data()) != null ? _productSnapshot_data : {
3374
+ name: 'Product'
3375
+ });
3376
+ const soldVariantId = String((_ref65 = (_object_metadata30 = object.metadata) == null ? void 0 : _object_metadata30.variantId) != null ? _ref65 : '') || ((_lifted_variants_ = lifted.variants[0]) == null ? void 0 : _lifted_variants_.id);
3377
+ const quantity = (_ref66 = (_order_lineItems = order.lineItems) == null ? void 0 : (_order_lineItems_ = _order_lineItems[0]) == null ? void 0 : _order_lineItems_.quantity) != null ? _ref66 : 1;
3378
+ if (soldVariantId) {
3379
+ // Atomic since AGL-2320.
3380
+ const moved = await decrementVariantStock({
3381
+ firestore,
3382
+ hostRef,
3383
+ hostId: String(hostId),
3384
+ productId: String(productId),
3385
+ variantId: soldVariantId,
3386
+ quantity,
3387
+ ledger: {
3388
+ reason: 'sale',
3389
+ orderId: String(orderId)
3390
+ }
3391
+ });
3392
+ // `before`/`after` absent means nothing was written — a missing
3393
+ // product, an untracked variant, or a failed commit — and a
3394
+ // ledger row would then claim a movement the count never made.
3395
+ if (moved.before && moved.after) {
3396
+ // The AGL-1807 ledger row now rides inside the decrement's
3397
+ // own transaction (AGL-2161), so the `orderId` above is the
3398
+ // ORDER doc id from the metadata rather than `object.id` —
3399
+ // unlike the siblings, this branch's session id names no order
3400
+ // document, and `cancel-order.ts` looks the row up by that id.
3401
+ // Low-stock crossing alert (AGL-1826): a merchant-sent payment
3402
+ // link used to sell a product down to its threshold in
3403
+ // silence. The `pending` -> `paid` flip bounds redelivery.
3404
+ alertLowStockCrossing(String(hostId), moved.before, moved.after);
3405
+ }
3406
+ }
3407
+ } else {
3408
+ var _order_lineItems1;
3409
+ // POS card sale (AGL-1825). This branch's other tenant: the
3410
+ // register's QR session carries `{type, hostId, orderId}` and no
3411
+ // `productId`, so the decrement above was unreachable for every
3412
+ // card sale — the sale completed, contacts and totals were
3413
+ // recorded, and the shelf count never moved, while the SAME basket
3414
+ // paid in cash decremented per line in `pos-order.ts`. The order
3415
+ // document already holds the server-priced `lineItems`, so the
3416
+ // per-line loop runs here, in the AGL-281 shape, behind the same
3417
+ // `pending` -> `paid` guard that bounds the contact increment.
3418
+ //
3419
+ // Location-aware (AGL-286): the register's chosen bucket rides on
3420
+ // the order (`pos-order.ts` stores it on the pending write), and
3421
+ // by webhook time it exists nowhere else. An order minted before
3422
+ // that write carries none and falls back to the flat count.
3423
+ //
3424
+ // An `else`, not a second loop: a console draft carries BOTH
3425
+ // `productId` metadata and `lineItems`, and running both paths
3426
+ // would decrement it twice.
3427
+ const paidProducts = new Map();
3428
+ for (const line of (_order_lineItems1 = order.lineItems) != null ? _order_lineItems1 : []){
3429
+ var _ref67, _ref68, _line_variantId1;
3430
+ var _lineProduct_variants_;
3431
+ const lineProductId = String((_ref67 = line == null ? void 0 : line.productId) != null ? _ref67 : '');
3432
+ const soldQty = Math.round(Number((_ref68 = line == null ? void 0 : line.quantity) != null ? _ref68 : 0));
3433
+ if (!lineProductId || !(soldQty > 0)) continue;
3434
+ // Firestore reserves `__…__` ids and `.doc()` throws
3435
+ // synchronously on one; a corrupt line must not fail the
3436
+ // webhook (the restock-flag reader applies the same guard).
3437
+ if (/^__.*__$/.test(lineProductId)) continue;
3438
+ if (!paidProducts.has(lineProductId)) {
3439
+ var _productSnapshot_data1;
3440
+ const productSnapshot = await hostRef.collection('products').doc(lineProductId).get();
3441
+ paidProducts.set(lineProductId, CommerceModel.liftLegacyProduct((_productSnapshot_data1 = productSnapshot.data()) != null ? _productSnapshot_data1 : {
3442
+ name: 'Product'
3443
+ }));
3444
+ }
3445
+ const lineProduct = paidProducts.get(lineProductId);
3446
+ const soldVariantId = (_line_variantId1 = line.variantId) != null ? _line_variantId1 : (_lineProduct_variants_ = lineProduct.variants[0]) == null ? void 0 : _lineProduct_variants_.id;
3447
+ if (!soldVariantId || !lineProduct.variants.some((variant)=>variant.id === soldVariantId && variant.inventory != null)) {
3448
+ continue;
3449
+ }
3450
+ // Two lines of one product must COMPOUND: the next line starts
3451
+ // from these variants, not from the product as first read —
3452
+ // recomputing from the original would erase this decrement
3453
+ // when the sibling line's write landed. Since AGL-2320 the
3454
+ // transaction's own re-read is what compounds them, and it also
3455
+ // sees a concurrent request's write, which this map never could.
3456
+ const moved = await decrementVariantStock({
3457
+ firestore,
3458
+ hostRef,
3459
+ hostId: String(hostId),
3460
+ productId: lineProductId,
3461
+ variantId: soldVariantId,
3462
+ quantity: soldQty,
3463
+ locationId: order.locationId || undefined,
3464
+ ledger: {
3465
+ reason: 'sale',
3466
+ orderId: String(orderId)
3467
+ }
3468
+ });
3469
+ if (!moved.before || !moved.after) continue;
3470
+ paidProducts.set(lineProductId, moved.after);
3471
+ // The AGL-1807 ledger row is written by the decrement itself
3472
+ // now (AGL-2161), joined to the ORDER doc (the session id names
3473
+ // no order) and carrying the location the units left. That join
3474
+ // is what lets `cancel-order.ts` tell a decremented card sale
3475
+ // from one that predates it — and, because the row can no longer
3476
+ // be lost on its own, absence of a row is now proof the count
3477
+ // never moved rather than a coin flip.
3478
+ // Low-stock crossing alert (AGL-1826): the register is the
3479
+ // channel most likely to be selling down the last few units of
3480
+ // physical shelf stock, and crossed in silence. The compounded
3481
+ // pair means two lines of one product cross exactly once, on
3482
+ // the line that breaches; the `pending` -> `paid` flip bounds
3483
+ // redelivery.
3484
+ alertLowStockCrossing(String(hostId), moved.before, moved.after);
3485
+ }
3486
+ }
3487
+ }
3488
+ }
3489
+ }
3490
+ // Commerce Starter orders (AGL-90): recorded under the selling host.
3491
+ if (type === 'checkout.session.completed' && (object == null ? void 0 : (_object_metadata4 = object.metadata) == null ? void 0 : _object_metadata4.type) === 'commerce-order' && (object == null ? void 0 : object.payment_status) === 'paid') {
3492
+ var _object_metadata31;
3493
+ const { hostId, productId, feeCents, couponCode } = (_object_metadata31 = object.metadata) != null ? _object_metadata31 : {};
3494
+ if (hostId && productId) {
3495
+ var _ref69, _productForSnapshot_get1, _productForSnapshot_data1, _ref70, _ref71, _ref72, _ref73, _ref74, _ref75, _ref76;
3496
+ var _object_metadata32, _buyNowLineItems_, _object_customer_details16, _object_customer_details17, _object_customer_details18, _object_metadata33;
3497
+ const firestore = firebaseAdmin.app().firestore();
3498
+ const hostRef = firestore.collection('hosts').doc(String(hostId));
3499
+ // Orders v1 (AGL-283): line-item snapshot + totals + timeline with
3500
+ // a per-host sequential number; legacy flat fields stay for old
3501
+ // rows/readers. Transaction keeps numbers gapless per webhook
3502
+ // delivery (replays reuse the same order doc id, so re-numbering
3503
+ // is bounded to Stripe's at-least-once edge).
3504
+ const orderRef = hostRef.collection('orders').doc(String(object.id));
3505
+ const counterRef = hostRef.collection('counters').doc('orders');
3506
+ const amountCents = Number((_ref69 = object == null ? void 0 : object.amount_total) != null ? _ref69 : 0);
3507
+ const productForSnapshot = await hostRef.collection('products').doc(String(productId)).get();
3508
+ const snapshotName = String((_productForSnapshot_get1 = productForSnapshot.get('name')) != null ? _productForSnapshot_get1 : 'Product');
3509
+ // AGL-1711: the line item and totals used to be fabricated from
3510
+ // `amount_total` alone — one unit, priced at the whole charge, with tax
3511
+ // and discount recorded as 0. `computeBuyNowOrder` rebuilds the real
3512
+ // decomposition from Stripe's `total_details` plus the two components
3513
+ // our own session shape hides from it (the manual tax line item and the
3514
+ // coupon priced into the unit amount), both carried in the metadata.
3515
+ const liftedForSnapshot = CommerceModel.liftLegacyProduct((_productForSnapshot_data1 = productForSnapshot.data()) != null ? _productForSnapshot_data1 : {
3516
+ name: snapshotName
3517
+ });
3518
+ const soldVariant = ((_object_metadata32 = object.metadata) == null ? void 0 : _object_metadata32.variantId) ? liftedForSnapshot.variants.find((item)=>item.id === String(object.metadata.variantId)) : liftedForSnapshot.variants[0];
3519
+ const variantOptions = Object.values((_ref70 = soldVariant == null ? void 0 : soldVariant.options) != null ? _ref70 : {});
3520
+ const { lineItems: buyNowLineItems, totals: buyNowTotals } = CommerceModel.computeBuyNowOrder(object, _extends({
3521
+ name: snapshotName
3522
+ }, variantOptions.length ? {
3523
+ variantLabel: variantOptions.join(' / ')
3524
+ } : {}, (soldVariant == null ? void 0 : soldVariant.sku) ? {
3525
+ sku: soldVariant.sku
3526
+ } : {}, liftedForSnapshot.type ? {
3527
+ productType: liftedForSnapshot.type
3528
+ } : {}, liftedForSnapshot.supplierId ? {
3529
+ supplierId: liftedForSnapshot.supplierId
3530
+ } : {}));
3531
+ const soldQuantity = (_ref71 = (_buyNowLineItems_ = buyNowLineItems[0]) == null ? void 0 : _buyNowLineItems_.quantity) != null ? _ref71 : 1;
3532
+ const created = await firestore.runTransaction(async (transaction)=>{
3533
+ var _counter_get, _ref, _ref1, _ref2, _ref3;
3534
+ var _object_metadata, _object_customer_details, _object_customer_details1;
3535
+ const [existing, counter] = await Promise.all([
3536
+ transaction.get(orderRef),
3537
+ transaction.get(counterRef)
3538
+ ]);
3539
+ if (existing.exists) return false;
3540
+ const number = Number((_counter_get = counter.get('next')) != null ? _counter_get : 1);
3541
+ transaction.set(counterRef, {
3542
+ next: number + 1
3543
+ }, {
3544
+ merge: true
3545
+ });
3546
+ transaction.set(orderRef, _extends({
3547
+ number,
3548
+ status: 'paid',
3549
+ channel: 'online',
3550
+ lineItems: buyNowLineItems,
3551
+ totals: buyNowTotals,
3552
+ // WHICH TAX THIS SALE CARRIED (AGL-2451). Buy-now's manual tax
3553
+ // goes over as an ordinary `line_items[1]` Stripe is never told is
3554
+ // tax (AGL-1711), so the session reports `amount_tax: 0` and
3555
+ // `metadata[taxCents]` — the key `checkout.ts` writes — is the only
3556
+ // witness. Same resolver as the filed tax row, same witness.
3557
+ taxMode: storefrontTaxModeOf(object, Number((_ref = object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.taxCents) != null ? _ref : 0)),
3558
+ timeline: [
3559
+ {
3560
+ atMs: Date.now(),
3561
+ event: 'paid'
3562
+ }
3563
+ ],
3564
+ paymentIntentId: String((_ref1 = object == null ? void 0 : object.payment_intent) != null ? _ref1 : '') || null,
3565
+ checkoutSessionId: String(object.id)
3566
+ }, typeof (event == null ? void 0 : event.livemode) === 'boolean' ? {
3567
+ livemode: event.livemode
3568
+ } : {}, {
3569
+ customerName: (_ref2 = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.name) != null ? _ref2 : null,
3570
+ createdAtMs: Date.now(),
3571
+ // Legacy Commerce Starter fields (AGL-90).
3572
+ productId,
3573
+ amountCents,
3574
+ feeCents: Number(feeCents != null ? feeCents : 0),
3575
+ customerEmail: (_ref3 = object == null ? void 0 : (_object_customer_details1 = object.customer_details) == null ? void 0 : _object_customer_details1.email) != null ? _ref3 : null
3576
+ }, couponCode ? {
3577
+ couponCode
3578
+ } : {}, {
3579
+ createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
3580
+ }));
3581
+ return true;
3582
+ });
3583
+ // Redelivery guard (AGL-498): skip the notification + fulfilment side
3584
+ // effects when this order already existed.
3585
+ if (!created) return;
3586
+ // In-app order notification (wave v6): host managers see sales
3587
+ // in the bell, not just the owner's email.
3588
+ void notifyHostManagers(String(hostId), _extends({
3589
+ type: 'content.order',
3590
+ title: `New order — $${(Number((_ref72 = object == null ? void 0 : object.amount_total) != null ? _ref72 : 0) / 100).toFixed(2)}`
3591
+ }, (object == null ? void 0 : (_object_customer_details16 = object.customer_details) == null ? void 0 : _object_customer_details16.email) ? {
3592
+ body: `From ${object.customer_details.email}`
3593
+ } : {}, {
3594
+ link: `/${hostId}/products`
3595
+ }));
3596
+ // Dropship routing (AGL-289): paid lines with a supplier notify
3597
+ // it (signed webhook and/or email) and stash a callback token so
3598
+ // the supplier can post tracking back. Plan-gated; failures never
3599
+ // fail the webhook.
3600
+ //
3601
+ // AWAITED SINCE AGL-2473, and the reason it can be is that the
3602
+ // supplier POST is no longer in it. This was `void (async () => …)()`
3603
+ // whose last act was `fetch(supplier.webhookUrl)` — a merchant-typed
3604
+ // endpoint on a host we do not run — and Vercel freezes the container
3605
+ // when the response is written, so a slow supplier was a supplier
3606
+ // never told, with nothing written down to say a notification was
3607
+ // owed. AGL-2161 declined to await it because a supplier timing out
3608
+ // would push this handler past Stripe's window and buy a DUPLICATED
3609
+ // order in exchange; that objection is now moot, because what is left
3610
+ // here touches only Aglyn's own Firestore and Aglyn's own mail
3611
+ // provider — the same things `refund.ts` awaits, for the same reason.
3612
+ // The one call to a stranger's server is a queued row that
3613
+ // `supplier-outbox.ts` retries out of band.
3614
+ await (async ()=>{
3615
+ try {
3616
+ var _routedProduct_get, _ref, _ref1;
3617
+ var _object_customer_details, _object_customer_details1;
3618
+ const routedOrg = await getOrgForHost(String(hostId));
3619
+ if (!Aglyn.checkEntitlement(routedOrg == null ? void 0 : routedOrg.org, 'dropshipRouting')) {
3620
+ return;
3621
+ }
3622
+ const routedProduct = await hostRef.collection('products').doc(String(productId)).get();
3623
+ const supplierId = routedProduct.get('supplierId');
3624
+ if (!supplierId) return;
3625
+ const supplierSnapshot = await hostRef.collection('suppliers').doc(String(supplierId)).get();
3626
+ const supplier = supplierSnapshot.data();
3627
+ if (!supplier) return;
3628
+ const supplierToken = createHmac('sha256', tokenSigningSecret()).update(`${hostId}:${object.id}:${supplierId}`).digest('hex').slice(0, 32);
3629
+ const orderReference = hostRef.collection('orders').doc(String(object.id));
3630
+ await orderReference.set({
3631
+ supplierToken,
3632
+ timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({
3633
+ atMs: Date.now(),
3634
+ event: 'routed',
3635
+ detail: `Sent to supplier ${supplier.name}`
3636
+ })
3637
+ }, {
3638
+ merge: true
3639
+ });
3640
+ const payload = {
3641
+ hostId: String(hostId),
3642
+ orderId: String(object.id),
3643
+ productId: String(productId),
3644
+ productName: String((_routedProduct_get = routedProduct.get('name')) != null ? _routedProduct_get : 'Product'),
3645
+ // AGL-1711: the supplier was told to ship one unit however many
3646
+ // the buyer paid for.
3647
+ quantity: soldQuantity,
3648
+ customerEmail: (_ref = object == null ? void 0 : (_object_customer_details = object.customer_details) == null ? void 0 : _object_customer_details.email) != null ? _ref : null,
3649
+ shippingName: (_ref1 = object == null ? void 0 : (_object_customer_details1 = object.customer_details) == null ? void 0 : _object_customer_details1.name) != null ? _ref1 : null,
3650
+ updateUrl: `https://${requestHost}/api/commerce/supplier-update` + `?hostId=${hostId}&orderId=${object.id}&token=${supplierToken}`
3651
+ };
3652
+ if (supplier.webhookUrl) {
3653
+ var _supplier_name;
3654
+ // QUEUED, NOT POSTED (AGL-2473). One Firestore write, then the
3655
+ // job beat owns the delivery: retries with backoff, and a dead
3656
+ // letter that stamps this order's timeline and rings the bell
3657
+ // when a supplier stays unreachable. The BODY is frozen here
3658
+ // because `updateUrl` is built from `requestHost`, which is a
3659
+ // property of this request that no later pass can recover; the
3660
+ // endpoint and the signing secret are deliberately NOT frozen,
3661
+ // so a merchant correcting a typo'd URL fixes what is already
3662
+ // queued and no shared secret is copied into a second document.
3663
+ await enqueueSupplierDelivery({
3664
+ firestore,
3665
+ hostId: String(hostId),
3666
+ orderId: String(object.id),
3667
+ supplierId: String(supplierId),
3668
+ supplierName: String((_supplier_name = supplier.name) != null ? _supplier_name : ''),
3669
+ url: String(supplier.webhookUrl),
3670
+ body: JSON.stringify(payload)
3671
+ });
3672
+ }
3673
+ if (supplier.email) {
3674
+ var _ref2, _payload_shippingName;
3675
+ await sendEmail({
3676
+ to: supplier.email,
3677
+ subject: `New order to fulfill: ${payload.productName}`,
3678
+ text: `${payload.quantity}× ${payload.productName}\n` + `Ship to: ${(_ref2 = (_payload_shippingName = payload.shippingName) != null ? _payload_shippingName : payload.customerEmail) != null ? _ref2 : 'see order'}\n\n` + `Add tracking: ${payload.updateUrl}&trackingNumber=TRACKING&carrier=CARRIER`,
3679
+ fromName: (await brandFor(hostId)).fromName,
3680
+ sendingIdentity: await hostSendingIdentity(String(hostId)),
3681
+ audience: 'tenant',
3682
+ context: 'dropship supplier notice'
3683
+ });
3684
+ // Cost meter (AGL-1438). Transactional: without it the order is
3685
+ // never fulfilled.
3686
+ await meterHostEmail(String(hostId));
3687
+ }
3688
+ } catch (routingError) {
3689
+ console.error('Dropship routing failed', routingError);
3690
+ }
3691
+ })();
3692
+ // Contacts ingestion (AGL-197): buyers become contacts.
3693
+ await captureHostContact({
3694
+ hostId: String(hostId),
3695
+ email: object == null ? void 0 : (_object_customer_details17 = object.customer_details) == null ? void 0 : _object_customer_details17.email,
3696
+ name: (_ref73 = object == null ? void 0 : (_object_customer_details18 = object.customer_details) == null ? void 0 : _object_customer_details18.name) != null ? _ref73 : undefined,
3697
+ source: 'order',
3698
+ initialLifecycleStage: 'customer',
3699
+ purchaseCents: Number((_ref74 = object == null ? void 0 : object.amount_total) != null ? _ref74 : 0),
3700
+ interaction: {
3701
+ refId: String(object.id),
3702
+ summary: `Placed an order ($${(Number((_ref75 = object == null ? void 0 : object.amount_total) != null ? _ref75 : 0) / 100).toFixed(2)})`
3703
+ }
3704
+ });
3705
+ const productRef = hostRef.collection('products').doc(String(productId));
3706
+ const productSnapshot = await productRef.get();
3707
+ // Inventory decrement (AGL-281): variant-aware with an adjustment
3708
+ // log; the checkout guard makes negative stock a race-window edge,
3709
+ // and the helper floors at zero. Legacy flat `inventory` stays
3710
+ // denormalized for the Product block.
3711
+ //
3712
+ // ATOMIC since AGL-2320. The read that decided the new count used to be
3713
+ // this branch's own `productSnapshot`, taken before the order write,
3714
+ // the licence keys and the receipt email — a wide window in which a
3715
+ // second sale's write was simply overwritten. `decrementVariantStock`
3716
+ // re-reads and writes inside one transaction, so concurrent sales
3717
+ // serialize; the snapshot above is still read, for the product NAME on
3718
+ // the receipt below, and no longer decides any count.
3719
+ {
3720
+ var _productSnapshot_data2, _ref77;
3721
+ var _object_metadata34, _lifted_variants_1;
3722
+ const lifted = CommerceModel.liftLegacyProduct((_productSnapshot_data2 = productSnapshot.data()) != null ? _productSnapshot_data2 : {
3723
+ name: 'Product'
3724
+ });
3725
+ const soldVariantId = String((_ref77 = (_object_metadata34 = object.metadata) == null ? void 0 : _object_metadata34.variantId) != null ? _ref77 : '') || ((_lifted_variants_1 = lifted.variants[0]) == null ? void 0 : _lifted_variants_1.id);
3726
+ if (soldVariantId) {
3727
+ // AGL-1711: `-1` regardless of how many units were bought, so a
3728
+ // 3-unit buy-now sale decremented stock by one and the difference
3729
+ // was silently oversellable. `canPurchase` already gated the full
3730
+ // quantity at checkout, so this is the only place it was dropped.
3731
+ const moved = await decrementVariantStock({
3732
+ firestore,
3733
+ hostRef,
3734
+ hostId: String(hostId),
3735
+ productId: String(productId),
3736
+ variantId: soldVariantId,
3737
+ quantity: soldQuantity,
3738
+ ledger: {
3739
+ reason: 'sale',
3740
+ orderId: String(object.id)
3741
+ }
3742
+ });
3743
+ if (moved.before && moved.after) {
3744
+ // Low-stock alert (AGL-281): fires on the crossing sale only,
3745
+ // so managers get one nudge per threshold breach, not one per
3746
+ // order after it. The check lived inline here — the one branch
3747
+ // of four that had it — until AGL-1826 extracted it to sit
3748
+ // beside every decrement; semantics unchanged.
3749
+ alertLowStockCrossing(String(hostId), moved.before, moved.after);
3750
+ }
3751
+ }
3752
+ }
3753
+ // Settlement, the same way round and for the same reason as the cart
3754
+ // branch above (AGL-2356): the decrement lands first, then the
3755
+ // reservation is dropped.
3756
+ await releaseStockHold(hostRef, String((_ref76 = (_object_metadata33 = object.metadata) == null ? void 0 : _object_metadata33.stockHoldKey) != null ? _ref76 : ''));
3757
+ if (couponCode) {
3758
+ var _ref78;
3759
+ var _object_metadata35;
3760
+ await settleRedemption({
3761
+ firestore,
3762
+ ref: hostRef.collection('coupons').doc(String(couponCode)),
3763
+ holdKey: String((_ref78 = (_object_metadata35 = object.metadata) == null ? void 0 : _object_metadata35.couponHoldKey) != null ? _ref78 : ''),
3764
+ orderRef,
3765
+ label: `coupon ${couponCode}`,
3766
+ detail: `Coupon ${couponCode} was applied to this order but no longer ` + 'exists, so the redemption is uncounted against its limit.'
3767
+ });
3768
+ }
3769
+ // Receipt + seller notification (AGL-96): env-gated like every
3770
+ // other outbound email; failures never fail the webhook.
3771
+ if (isEmailConfigured()) {
3772
+ var _productSnapshot_get, _ref79;
3773
+ var _object_customer_details19, _org, _this3;
3774
+ const productName = String((_productSnapshot_get = productSnapshot.get('name')) != null ? _productSnapshot_get : 'your purchase');
3775
+ const amount = (Number((_ref79 = object == null ? void 0 : object.amount_total) != null ? _ref79 : 0) / 100).toFixed(2);
3776
+ const buyerEmail = object == null ? void 0 : (_object_customer_details19 = object.customer_details) == null ? void 0 : _object_customer_details19.email;
3777
+ const orderTotal = `$${amount}`;
3778
+ if (buyerEmail) {
3779
+ var _ref80;
3780
+ const fallbackText = `Thanks for your purchase!\n\n${productName} — $${amount}` + `\nOrder reference: ${object.id}`;
3781
+ // Site-owner-designed template when published (AGL-771).
3782
+ const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'order-receipt', {
3783
+ 'order.summary': productName,
3784
+ 'order.total': orderTotal,
3785
+ 'order.ref': String(object.id)
3786
+ });
3787
+ await sendEmail(_extends({
3788
+ to: String(buyerEmail),
3789
+ subject: (_ref80 = designed == null ? void 0 : designed.subject) != null ? _ref80 : `Receipt: ${productName}`,
3790
+ text: (designed == null ? void 0 : designed.text) || fallbackText
3791
+ }, (designed == null ? void 0 : designed.html) ? {
3792
+ html: designed.html
3793
+ } : {}, {
3794
+ fromName: (await brandFor(hostId)).fromName,
3795
+ sendingIdentity: await hostSendingIdentity(String(hostId)),
3796
+ audience: 'tenant',
3797
+ context: 'receipt'
3798
+ }));
3799
+ // Cost meter (AGL-1438). Transactional, as the cart receipt above.
3800
+ await meterHostEmail(String(hostId));
3801
+ }
3802
+ const hostSnapshot = await hostRef.get();
3803
+ const sellerUid = (_this3 = await getOrgForHost(String(hostId))) == null ? void 0 : (_org = _this3.org) == null ? void 0 : _org.ownerUid;
3804
+ if (sellerUid) {
3805
+ var _this4;
3806
+ // Across pools (AGL-1144/AGL-1122). This was a project-level
3807
+ // `getUser`, which THROWS `auth/user-not-found` for a seller who
3808
+ // signs in through SSO — their record lives in their org's GCIP
3809
+ // tenant. The `.catch(() => null)` then skipped the block
3810
+ // entirely, so an SSO merchant was never told they had made a
3811
+ // sale, on any order, ever, with nothing logged.
3812
+ //
3813
+ // The order itself was never at risk: this runs after payment,
3814
+ // the buyer's receipt above uses the address from the order, and
3815
+ // no payout logic reads this. It is a notification, and it was
3816
+ // silently absent for exactly the customers on the plan that has
3817
+ // SSO.
3818
+ const seller = (_this4 = await findUserByUidAcrossPools(sellerUid).catch(()=>null)) == null ? void 0 : _this4.record;
3819
+ if (seller == null ? void 0 : seller.email) {
3820
+ var _hostSnapshot_get, _ref81;
3821
+ const siteName = String((_hostSnapshot_get = hostSnapshot.get('displayName')) != null ? _hostSnapshot_get : hostId);
3822
+ const fallbackText = `You made a sale on ${siteName}!\n\n${productName} — $${amount}` + (buyerEmail ? `\nBuyer: ${buyerEmail}` : '') + `\nOrder reference: ${object.id}`;
3823
+ // Site-owner-designed template when published (AGL-771).
3824
+ const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'sale-notification', {
3825
+ 'site.name': siteName,
3826
+ 'order.summary': `${productName} — $${amount}`,
3827
+ 'order.total': orderTotal,
3828
+ 'buyer.email': String(buyerEmail != null ? buyerEmail : ''),
3829
+ 'order.ref': String(object.id)
3830
+ });
3831
+ await sendEmail(_extends({
3832
+ to: seller.email,
3833
+ subject: (_ref81 = designed == null ? void 0 : designed.subject) != null ? _ref81 : `New order: ${productName}`,
3834
+ text: (designed == null ? void 0 : designed.text) || fallbackText
3835
+ }, (designed == null ? void 0 : designed.html) ? {
3836
+ html: designed.html
3837
+ } : {}, {
3838
+ fromName: (await brandFor(hostId)).fromName,
3839
+ sendingIdentity: await hostSendingIdentity(String(hostId)),
3840
+ audience: 'tenant',
3841
+ context: 'seller order notice'
3842
+ }));
3843
+ // Cost meter (AGL-1438). Transactional: the seller learns about
3844
+ // the order here.
3845
+ await meterHostEmail(String(hostId));
3846
+ }
3847
+ }
3848
+ }
3849
+ }
3850
+ }
3851
+ // Card disputes against a merchant's store (AGL-1787).
3852
+ //
3853
+ // Commerce subscribed no `charge.dispute.*` event at all, so a shopper
3854
+ // chargeback reversed NOTHING: the order stayed `paid`, `refundedCents`
3855
+ // stayed 0 so the orders CSV reported the sale as kept (AGL-1747), the
3856
+ // buyer's contact kept its full `ltvCents` with no reversal beside it
3857
+ // (AGL-1754), the shopper kept every download and membership the five
3858
+ // `'refunded'` gates would have withdrawn, and no manager was told a dispute
3859
+ // existed while there was still time to answer it.
3860
+ //
3861
+ // TWO events, and only one of them touches money. `created` flags and
3862
+ // notifies; `closed` settles, reversing only on `status: 'lost'`. The
3863
+ // lifecycle's other events are deliberately NOT subscribed:
3864
+ // `charge.dispute.funds_withdrawn` and `funds_reinstated` describe the
3865
+ // PLATFORM's balance moving on a destination charge, not the merchant's, so
3866
+ // stamping them on a merchant's order would assert a movement that did not
3867
+ // happen in their account; `charge.dispute.updated` is evidence-submission
3868
+ // churn with no ledger consequence.
3869
+ //
3870
+ // The endpoint must subscribe both events for any of this to run —
3871
+ // `tools/scripts/setup-stripe.mjs` now creates them, and the EXISTING live
3872
+ // endpoint needs them added by hand (that script never edits an endpoint it
3873
+ // finds). AGL-1554 will add its own `closed` branch for the marketplace
3874
+ // side; the two coexist because each self-selects by finding its own record.
3875
+ if (type === 'charge.dispute.created' || type === 'charge.dispute.closed') {
3876
+ var _dispute_payment_intent;
3877
+ const dispute = object != null ? object : {};
3878
+ const paymentIntentId = String((_dispute_payment_intent = dispute.payment_intent) != null ? _dispute_payment_intent : '');
3879
+ const lookup = paymentIntentId ? await findOrderForDispute(paymentIntentId) : {
3880
+ kind: 'not-ours'
3881
+ };
3882
+ // UNRESOLVED IS NOT NOT-OURS (AGL-2161). A lookup that could not run is
3883
+ // the platform's problem and is reported; a lookup that ran and matched
3884
+ // nothing is a marketplace, booking or platform-billing dispute, and
3885
+ // silence is the correct answer — see `findOrderForDispute`.
3886
+ if (lookup.kind === 'unresolved') {
3887
+ await reportUnresolvedDispute(lookup.reason, paymentIntentId, dispute);
3888
+ }
3889
+ // TELL THE ROUTE WE RECOGNISED IT (AGL-2429). A storefront chargeback is
3890
+ // the ORDINARY answer to "no platform revenue row matched", and the route
3891
+ // could not tell it apart from a dispute that nothing at all handled —
3892
+ // so it stayed silent about both. Claiming here is what buys the route
3893
+ // the right to alert on the ones nobody claimed.
3894
+ //
3895
+ // `unresolved` claims too: the lookup could not run, which
3896
+ // `reportUnresolvedDispute` has just raised with staff by name. A second,
3897
+ // vaguer alert from the route would describe the same incident twice.
3898
+ const claimed = lookup.kind === 'order' || lookup.kind === 'unresolved';
3899
+ const snapshot = lookup.kind === 'order' ? lookup.snapshot : null;
3900
+ if (snapshot) {
3901
+ var _ref82;
3902
+ var _snapshot_ref_parent_parent;
3903
+ const hostId = String((_ref82 = (_snapshot_ref_parent_parent = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent.id) != null ? _ref82 : '');
3904
+ if (type === 'charge.dispute.created') {
3905
+ const { opened, record } = await recordDisputeOpened(snapshot, dispute);
3906
+ if (opened && hostId) {
3907
+ // Time-critical — Stripe's evidence window is days, not weeks — so
3908
+ // this is awaited rather than fired off with `void`: the handler is
3909
+ // serverless and work left running past the response is work the
3910
+ // container may be frozen before it finishes. `notifyHostManagers`
3911
+ // never throws.
3912
+ await notifyHostManagers(hostId, {
3913
+ type: 'content.order',
3914
+ title: `Chargeback opened — $${(record.amountCents / 100).toFixed(2)}`,
3915
+ body: `Order ${snapshot.id} was disputed` + (record.reason ? ` (${record.reason.replace(/_/g, ' ')})` : '') + (record.evidenceDueByMs ? `. Evidence is due ${new Date(record.evidenceDueByMs).toISOString().slice(0, 10)}.` : '. Respond in Stripe.'),
3916
+ link: `/${hostId}/orders`
3917
+ });
3918
+ }
3919
+ } else {
3920
+ var _ref83;
3921
+ const settled = await recordDisputeClosed(snapshot, dispute);
3922
+ if (settled.recorded && hostId) {
3923
+ await notifyHostManagers(hostId, {
3924
+ type: 'content.order',
3925
+ title: settled.lost ? `Chargeback lost — $${(settled.reversedCents / 100).toFixed(2)} reversed` : 'Chargeback resolved in your favor',
3926
+ body: `Order ${snapshot.id}`,
3927
+ link: `/${hostId}/orders`
3928
+ });
3929
+ // The customer's side of the ledger (AGL-1754), through the same
3930
+ // writer a refund uses and with `kind` set so the contact's timeline
3931
+ // says "charged back". Skipped when nothing was reversed: a won
3932
+ // dispute moved no money, and a lost one that found nothing left to
3933
+ // reverse would otherwise record a $0 entry against the buyer.
3934
+ if (settled.reversedCents > 0) {
3935
+ await recordContactRefund({
3936
+ hostId,
3937
+ orderId: snapshot.id,
3938
+ email: settled.customerEmail,
3939
+ amountCents: settled.reversedCents,
3940
+ closedTheOrder: settled.closedTheOrder,
3941
+ kind: 'chargeback'
3942
+ });
3943
+ // The shelf's side (AGL-1797), the SAME door the admin-initiated
3944
+ // refund writes so the two cannot diverge the way the contact
3945
+ // ledger did before AGL-1754. It flags rather than releases, and a
3946
+ // chargeback is the clearest do-not-restock case there is — the
3947
+ // shopper kept the item and took the money — so `kind` only changes
3948
+ // the wording the merchant reads. A WON dispute never reaches here
3949
+ // (`reversedCents` is 0), which is right: nothing was reversed, so
3950
+ // nothing is missing from the shelf.
3951
+ await flagOrderRestock({
3952
+ hostId,
3953
+ orderId: snapshot.id,
3954
+ kind: 'chargeback',
3955
+ closedTheOrder: settled.closedTheOrder
3956
+ });
3957
+ // The campaign's side, through the same door an admin-initiated
3958
+ // refund writes. Money reversed is money reversed whichever way it
3959
+ // left, so a campaign credited with this order stops being paid
3960
+ // for it — and the reversal is recorded beside the credit rather
3961
+ // than subtracted from it, exactly as the contact ledger above
3962
+ // records `refundedCents` beside `ltvCents`.
3963
+ await reverseEmailAttributedRevenue({
3964
+ hostId,
3965
+ orderId: snapshot.id,
3966
+ amountCents: settled.reversedCents,
3967
+ closedTheOrder: settled.closedTheOrder,
3968
+ kind: 'chargeback'
3969
+ });
3970
+ }
3971
+ }
3972
+ // The platform's side of the money (AGL-1794): pull the seller's
3973
+ // share back from the connected account. OUTSIDE the `recorded` guard
3974
+ // on purpose — a redelivery whose settle was idle is exactly how a
3975
+ // transiently-failed reversal gets its retry — and gated inside on
3976
+ // the order's own `reversedTransferCents` marker, so it runs at most
3977
+ // once per dispute however many times Stripe delivers.
3978
+ if (String((_ref83 = dispute == null ? void 0 : dispute.status) != null ? _ref83 : '') === 'lost') {
3979
+ const pulledBackCents = await reverseSellerShare(snapshot.ref, dispute);
3980
+ // The merchant-experience half of the AGL-1794 decision, and not an
3981
+ // optional extra: the reversal takes money out of the connected
3982
+ // account, and when the balance does not cover it Stripe carries a
3983
+ // NEGATIVE balance and recovers it from future payouts. A merchant
3984
+ // who learns that from a short payout weeks later has been told by
3985
+ // the wrong party.
3986
+ //
3987
+ // The outcome notice above is a different message with a different
3988
+ // number: it reports what the SHOPPER's bank took back, which on a
3989
+ // destination charge left the platform's balance, not theirs. This
3990
+ // one reports the seller share that left THEIRS. Only sent when the
3991
+ // transfer reversal actually settled on this delivery — a redelivery
3992
+ // and every definitive no-op return 0, so the merchant is told once
3993
+ // and never told about money that did not move.
3994
+ if (pulledBackCents > 0 && hostId) {
3995
+ await notifyHostManagers(hostId, {
3996
+ type: 'content.order',
3997
+ title: `Payout adjusted — $${(pulledBackCents / 100).toFixed(2)} recovered for a lost chargeback`,
3998
+ body: `Order ${snapshot.id}: the amount transferred to you for this sale has been ` + `reversed. If your balance does not cover it, Stripe recovers the remainder ` + `from your future payouts.`,
3999
+ link: `/${hostId}/orders`
4000
+ });
4001
+ }
4002
+ }
4003
+ }
4004
+ }
4005
+ return {
4006
+ claimed
4007
+ };
4008
+ }
4009
+ };
4010
+
4011
+ //# sourceMappingURL=billing-webhook.js.map