@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.
- package/LICENSE +201 -0
- package/README.md +10 -0
- package/package.json +57 -0
- package/src/index.d.ts +38 -0
- package/src/index.js +38 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/account.d.ts +31 -0
- package/src/lib/components/account.js +645 -0
- package/src/lib/components/account.js.map +1 -0
- package/src/lib/components/cart.d.ts +38 -0
- package/src/lib/components/cart.js +851 -0
- package/src/lib/components/cart.js.map +1 -0
- package/src/lib/components/commerce-console-page.d.ts +32 -0
- package/src/lib/components/commerce-console-page.js +296 -0
- package/src/lib/components/commerce-console-page.js.map +1 -0
- package/src/lib/components/commerce-console-sections.d.ts +34 -0
- package/src/lib/components/commerce-console-sections.js +62 -0
- package/src/lib/components/commerce-console-sections.js.map +1 -0
- package/src/lib/components/console/catalog-organization-card.component.d.ts +14 -0
- package/src/lib/components/console/catalog-organization-card.component.js +1087 -0
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -0
- package/src/lib/components/console/commerce-analytics-card.component.d.ts +27 -0
- package/src/lib/components/console/commerce-analytics-card.component.js +310 -0
- package/src/lib/components/console/commerce-analytics-card.component.js.map +1 -0
- package/src/lib/components/console/commerce-glance-card.component.d.ts +20 -0
- package/src/lib/components/console/commerce-glance-card.component.js +340 -0
- package/src/lib/components/console/commerce-glance-card.component.js.map +1 -0
- package/src/lib/components/console/commerce-stat-tile.component.d.ts +37 -0
- package/src/lib/components/console/commerce-stat-tile.component.js +72 -0
- package/src/lib/components/console/commerce-stat-tile.component.js.map +1 -0
- package/src/lib/components/console/discounts-card.component.d.ts +14 -0
- package/src/lib/components/console/discounts-card.component.js +443 -0
- package/src/lib/components/console/discounts-card.component.js.map +1 -0
- package/src/lib/components/console/entitlement-gate.component.d.ts +76 -0
- package/src/lib/components/console/entitlement-gate.component.js +115 -0
- package/src/lib/components/console/entitlement-gate.component.js.map +1 -0
- package/src/lib/components/console/gift-cards-card.component.d.ts +29 -0
- package/src/lib/components/console/gift-cards-card.component.js +379 -0
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -0
- package/src/lib/components/console/host-coupons-card.component.d.ts +14 -0
- package/src/lib/components/console/host-coupons-card.component.js +252 -0
- package/src/lib/components/console/host-coupons-card.component.js.map +1 -0
- package/src/lib/components/console/host-orders-card.component.d.ts +13 -0
- package/src/lib/components/console/host-orders-card.component.js +861 -0
- package/src/lib/components/console/host-orders-card.component.js.map +1 -0
- package/src/lib/components/console/locations-card.component.d.ts +14 -0
- package/src/lib/components/console/locations-card.component.js +256 -0
- package/src/lib/components/console/locations-card.component.js.map +1 -0
- package/src/lib/components/console/member-posts-card.component.d.ts +8 -0
- package/src/lib/components/console/member-posts-card.component.js +283 -0
- package/src/lib/components/console/member-posts-card.component.js.map +1 -0
- package/src/lib/components/console/newest-site-users-card.component.d.ts +36 -0
- package/src/lib/components/console/newest-site-users-card.component.js +126 -0
- package/src/lib/components/console/newest-site-users-card.component.js.map +1 -0
- package/src/lib/components/console/order-detail-dialog.component.d.ts +28 -0
- package/src/lib/components/console/order-detail-dialog.component.js +976 -0
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -0
- package/src/lib/components/console/paid-media.d.ts +127 -0
- package/src/lib/components/console/paid-media.js +397 -0
- package/src/lib/components/console/paid-media.js.map +1 -0
- package/src/lib/components/console/payments-settings-card.component.d.ts +13 -0
- package/src/lib/components/console/payments-settings-card.component.js +295 -0
- package/src/lib/components/console/payments-settings-card.component.js.map +1 -0
- package/src/lib/components/console/pos-page.component.d.ts +13 -0
- package/src/lib/components/console/pos-page.component.js +916 -0
- package/src/lib/components/console/pos-page.component.js.map +1 -0
- package/src/lib/components/console/product-editor-dialog.component.d.ts +32 -0
- package/src/lib/components/console/product-editor-dialog.component.js +1374 -0
- package/src/lib/components/console/product-editor-dialog.component.js.map +1 -0
- package/src/lib/components/console/products-hub-card.component.d.ts +16 -0
- package/src/lib/components/console/products-hub-card.component.js +1280 -0
- package/src/lib/components/console/products-hub-card.component.js.map +1 -0
- package/src/lib/components/console/products-hub-zone.component.d.ts +50 -0
- package/src/lib/components/console/products-hub-zone.component.js +200 -0
- package/src/lib/components/console/products-hub-zone.component.js.map +1 -0
- package/src/lib/components/console/recovery-queue-card.component.d.ts +39 -0
- package/src/lib/components/console/recovery-queue-card.component.js +262 -0
- package/src/lib/components/console/recovery-queue-card.component.js.map +1 -0
- package/src/lib/components/console/registers-card.component.d.ts +21 -0
- package/src/lib/components/console/registers-card.component.js +296 -0
- package/src/lib/components/console/registers-card.component.js.map +1 -0
- package/src/lib/components/console/reservations-card.component.d.ts +14 -0
- package/src/lib/components/console/reservations-card.component.js +650 -0
- package/src/lib/components/console/reservations-card.component.js.map +1 -0
- package/src/lib/components/console/reviews-moderation-card.component.d.ts +8 -0
- package/src/lib/components/console/reviews-moderation-card.component.js +235 -0
- package/src/lib/components/console/reviews-moderation-card.component.js.map +1 -0
- package/src/lib/components/console/shipping-settings-card.component.d.ts +38 -0
- package/src/lib/components/console/shipping-settings-card.component.js +560 -0
- package/src/lib/components/console/shipping-settings-card.component.js.map +1 -0
- package/src/lib/components/console/stock-movements-card.component.d.ts +31 -0
- package/src/lib/components/console/stock-movements-card.component.js +333 -0
- package/src/lib/components/console/stock-movements-card.component.js.map +1 -0
- package/src/lib/components/console/store-settings-card.component.d.ts +14 -0
- package/src/lib/components/console/store-settings-card.component.js +300 -0
- package/src/lib/components/console/store-settings-card.component.js.map +1 -0
- package/src/lib/components/console/storefront-tax-summary-card.component.d.ts +73 -0
- package/src/lib/components/console/storefront-tax-summary-card.component.js +408 -0
- package/src/lib/components/console/storefront-tax-summary-card.component.js.map +1 -0
- package/src/lib/components/console/suppliers-card.component.d.ts +8 -0
- package/src/lib/components/console/suppliers-card.component.js +328 -0
- package/src/lib/components/console/suppliers-card.component.js.map +1 -0
- package/src/lib/components/console/tax-settings-card.component.d.ts +14 -0
- package/src/lib/components/console/tax-settings-card.component.js +473 -0
- package/src/lib/components/console/tax-settings-card.component.js.map +1 -0
- package/src/lib/components/gate.d.ts +39 -0
- package/src/lib/components/gate.js +202 -0
- package/src/lib/components/gate.js.map +1 -0
- package/src/lib/components/gated-video.d.ts +40 -0
- package/src/lib/components/gated-video.js +366 -0
- package/src/lib/components/gated-video.js.map +1 -0
- package/src/lib/components/member-feed.d.ts +32 -0
- package/src/lib/components/member-feed.js +198 -0
- package/src/lib/components/member-feed.js.map +1 -0
- package/src/lib/components/member-recovery.d.ts +35 -0
- package/src/lib/components/member-recovery.js +313 -0
- package/src/lib/components/member-recovery.js.map +1 -0
- package/src/lib/components/member-signin.d.ts +35 -0
- package/src/lib/components/member-signin.js +257 -0
- package/src/lib/components/member-signin.js.map +1 -0
- package/src/lib/components/member-signup.d.ts +34 -0
- package/src/lib/components/member-signup.js +287 -0
- package/src/lib/components/member-signup.js.map +1 -0
- package/src/lib/components/newsletter-signup.d.ts +39 -0
- package/src/lib/components/newsletter-signup.js +229 -0
- package/src/lib/components/newsletter-signup.js.map +1 -0
- package/src/lib/components/product-detail.d.ts +38 -0
- package/src/lib/components/product-detail.js +1022 -0
- package/src/lib/components/product-detail.js.map +1 -0
- package/src/lib/components/product-grid.d.ts +67 -0
- package/src/lib/components/product-grid.js +909 -0
- package/src/lib/components/product-grid.js.map +1 -0
- package/src/lib/components/product-reviews.d.ts +32 -0
- package/src/lib/components/product-reviews.js +349 -0
- package/src/lib/components/product-reviews.js.map +1 -0
- package/src/lib/components/related-products.d.ts +33 -0
- package/src/lib/components/related-products.js +232 -0
- package/src/lib/components/related-products.js.map +1 -0
- package/src/lib/components/reservation-widget.d.ts +33 -0
- package/src/lib/components/reservation-widget.js +418 -0
- package/src/lib/components/reservation-widget.js.map +1 -0
- package/src/lib/components/storefront-payment-element-fallback.d.ts +18 -0
- package/src/lib/components/storefront-payment-element-fallback.js +51 -0
- package/src/lib/components/storefront-payment-element-fallback.js.map +1 -0
- package/src/lib/components/storefront-payment-element.d.ts +78 -0
- package/src/lib/components/storefront-payment-element.js +156 -0
- package/src/lib/components/storefront-payment-element.js.map +1 -0
- package/src/lib/components/wishlist.d.ts +46 -0
- package/src/lib/components/wishlist.js +304 -0
- package/src/lib/components/wishlist.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +18 -0
- package/src/lib/constants/bundle-common.js +18 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/constants/product-filters.d.ts +35 -0
- package/src/lib/constants/product-filters.js +109 -0
- package/src/lib/constants/product-filters.js.map +1 -0
- package/src/lib/model/commerce-cart.d.ts +52 -0
- package/src/lib/model/commerce-cart.js +102 -0
- package/src/lib/model/commerce-cart.js.map +1 -0
- package/src/lib/model/commerce-connect-transfer.d.ts +243 -0
- package/src/lib/model/commerce-connect-transfer.js +231 -0
- package/src/lib/model/commerce-connect-transfer.js.map +1 -0
- package/src/lib/model/commerce-discounts.d.ts +164 -0
- package/src/lib/model/commerce-discounts.js +231 -0
- package/src/lib/model/commerce-discounts.js.map +1 -0
- package/src/lib/model/commerce-dispute.d.ts +124 -0
- package/src/lib/model/commerce-dispute.js +225 -0
- package/src/lib/model/commerce-dispute.js.map +1 -0
- package/src/lib/model/commerce-gift-cards.d.ts +107 -0
- package/src/lib/model/commerce-gift-cards.js +121 -0
- package/src/lib/model/commerce-gift-cards.js.map +1 -0
- package/src/lib/model/commerce-io.d.ts +36 -0
- package/src/lib/model/commerce-io.js +245 -0
- package/src/lib/model/commerce-io.js.map +1 -0
- package/src/lib/model/commerce-orders.d.ts +1040 -0
- package/src/lib/model/commerce-orders.js +1022 -0
- package/src/lib/model/commerce-orders.js.map +1 -0
- package/src/lib/model/commerce-promotions.d.ts +188 -0
- package/src/lib/model/commerce-promotions.js +199 -0
- package/src/lib/model/commerce-promotions.js.map +1 -0
- package/src/lib/model/commerce-reservations.d.ts +175 -0
- package/src/lib/model/commerce-reservations.js +157 -0
- package/src/lib/model/commerce-reservations.js.map +1 -0
- package/src/lib/model/commerce-shipping.d.ts +316 -0
- package/src/lib/model/commerce-shipping.js +484 -0
- package/src/lib/model/commerce-shipping.js.map +1 -0
- package/src/lib/model/commerce-stock-holds.d.ts +199 -0
- package/src/lib/model/commerce-stock-holds.js +190 -0
- package/src/lib/model/commerce-stock-holds.js.map +1 -0
- package/src/lib/model/commerce-subscription-status.d.ts +67 -0
- package/src/lib/model/commerce-subscription-status.js +76 -0
- package/src/lib/model/commerce-subscription-status.js.map +1 -0
- package/src/lib/model/commerce-tax-decision.d.ts +222 -0
- package/src/lib/model/commerce-tax-decision.js +165 -0
- package/src/lib/model/commerce-tax-decision.js.map +1 -0
- package/src/lib/model/commerce-tax.d.ts +176 -0
- package/src/lib/model/commerce-tax.js +92 -0
- package/src/lib/model/commerce-tax.js.map +1 -0
- package/src/lib/model/commerce.d.ts +551 -0
- package/src/lib/model/commerce.js +739 -0
- package/src/lib/model/commerce.js.map +1 -0
- package/src/lib/model/index.d.ts +40 -0
- package/src/lib/model/index.js +40 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/order-figures.d.ts +48 -0
- package/src/lib/model/order-figures.js +74 -0
- package/src/lib/model/order-figures.js.map +1 -0
- package/src/lib/model/plugin-permissions.d.ts +23 -0
- package/src/lib/model/plugin-permissions.js +34 -0
- package/src/lib/model/plugin-permissions.js.map +1 -0
- package/src/lib/model/purchase-analytics.d.ts +186 -0
- package/src/lib/model/purchase-analytics.js +216 -0
- package/src/lib/model/purchase-analytics.js.map +1 -0
- package/src/lib/plugin-config.d.ts +62 -0
- package/src/lib/plugin-config.js +78 -0
- package/src/lib/plugin-config.js.map +1 -0
- package/src/lib/plugin.d.ts +28 -0
- package/src/lib/plugin.js +147 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/billing-webhook.d.ts +26 -0
- package/src/lib/server/billing-webhook.js +4011 -0
- package/src/lib/server/billing-webhook.js.map +1 -0
- package/src/lib/server/cancel-order.d.ts +87 -0
- package/src/lib/server/cancel-order.js +368 -0
- package/src/lib/server/cancel-order.js.map +1 -0
- package/src/lib/server/cart-checkout.d.ts +25 -0
- package/src/lib/server/cart-checkout.js +976 -0
- package/src/lib/server/cart-checkout.js.map +1 -0
- package/src/lib/server/cart-cookie.d.ts +32 -0
- package/src/lib/server/cart-cookie.js +77 -0
- package/src/lib/server/cart-cookie.js.map +1 -0
- package/src/lib/server/cart.d.ts +34 -0
- package/src/lib/server/cart.js +181 -0
- package/src/lib/server/cart.js.map +1 -0
- package/src/lib/server/catalog.d.ts +127 -0
- package/src/lib/server/catalog.js +277 -0
- package/src/lib/server/catalog.js.map +1 -0
- package/src/lib/server/checkout.d.ts +36 -0
- package/src/lib/server/checkout.js +915 -0
- package/src/lib/server/checkout.js.map +1 -0
- package/src/lib/server/connect.d.ts +26 -0
- package/src/lib/server/connect.js +238 -0
- package/src/lib/server/connect.js.map +1 -0
- package/src/lib/server/contact-refund.d.ts +117 -0
- package/src/lib/server/contact-refund.js +196 -0
- package/src/lib/server/contact-refund.js.map +1 -0
- package/src/lib/server/download.d.ts +39 -0
- package/src/lib/server/download.js +206 -0
- package/src/lib/server/download.js.map +1 -0
- package/src/lib/server/draft-order.d.ts +26 -0
- package/src/lib/server/draft-order.js +706 -0
- package/src/lib/server/draft-order.js.map +1 -0
- package/src/lib/server/feed.d.ts +24 -0
- package/src/lib/server/feed.js +59 -0
- package/src/lib/server/feed.js.map +1 -0
- package/src/lib/server/fulfill-order.d.ts +97 -0
- package/src/lib/server/fulfill-order.js +235 -0
- package/src/lib/server/fulfill-order.js.map +1 -0
- package/src/lib/server/gate.d.ts +36 -0
- package/src/lib/server/gate.js +112 -0
- package/src/lib/server/gate.js.map +1 -0
- package/src/lib/server/gift-cards.d.ts +42 -0
- package/src/lib/server/gift-cards.js +219 -0
- package/src/lib/server/gift-cards.js.map +1 -0
- package/src/lib/server/low-stock.d.ts +49 -0
- package/src/lib/server/low-stock.js +60 -0
- package/src/lib/server/low-stock.js.map +1 -0
- package/src/lib/server/manual-tax-rate.d.ts +82 -0
- package/src/lib/server/manual-tax-rate.js +113 -0
- package/src/lib/server/manual-tax-rate.js.map +1 -0
- package/src/lib/server/member-feed.d.ts +24 -0
- package/src/lib/server/member-feed.js +72 -0
- package/src/lib/server/member-feed.js.map +1 -0
- package/src/lib/server/member-name-search.d.ts +37 -0
- package/src/lib/server/member-name-search.js +42 -0
- package/src/lib/server/member-name-search.js.map +1 -0
- package/src/lib/server/member-post.d.ts +23 -0
- package/src/lib/server/member-post.js +166 -0
- package/src/lib/server/member-post.js.map +1 -0
- package/src/lib/server/membership-account.d.ts +37 -0
- package/src/lib/server/membership-account.js +148 -0
- package/src/lib/server/membership-account.js.map +1 -0
- package/src/lib/server/membership-admin-password.d.ts +33 -0
- package/src/lib/server/membership-admin-password.js +202 -0
- package/src/lib/server/membership-admin-password.js.map +1 -0
- package/src/lib/server/membership-content.d.ts +23 -0
- package/src/lib/server/membership-content.js +105 -0
- package/src/lib/server/membership-content.js.map +1 -0
- package/src/lib/server/membership-login.d.ts +19 -0
- package/src/lib/server/membership-login.js +137 -0
- package/src/lib/server/membership-login.js.map +1 -0
- package/src/lib/server/membership-logout.d.ts +19 -0
- package/src/lib/server/membership-logout.js +35 -0
- package/src/lib/server/membership-logout.js.map +1 -0
- package/src/lib/server/membership-recover.d.ts +60 -0
- package/src/lib/server/membership-recover.js +244 -0
- package/src/lib/server/membership-recover.js.map +1 -0
- package/src/lib/server/membership-register.d.ts +22 -0
- package/src/lib/server/membership-register.js +241 -0
- package/src/lib/server/membership-register.js.map +1 -0
- package/src/lib/server/membership-reset.d.ts +26 -0
- package/src/lib/server/membership-reset.js +88 -0
- package/src/lib/server/membership-reset.js.map +1 -0
- package/src/lib/server/membership-wishlist.d.ts +23 -0
- package/src/lib/server/membership-wishlist.js +89 -0
- package/src/lib/server/membership-wishlist.js.map +1 -0
- package/src/lib/server/membership.d.ts +96 -0
- package/src/lib/server/membership.js +222 -0
- package/src/lib/server/membership.js.map +1 -0
- package/src/lib/server/native-checkout.d.ts +170 -0
- package/src/lib/server/native-checkout.js +193 -0
- package/src/lib/server/native-checkout.js.map +1 -0
- package/src/lib/server/newsletter.d.ts +23 -0
- package/src/lib/server/newsletter.js +323 -0
- package/src/lib/server/newsletter.js.map +1 -0
- package/src/lib/server/notify-restock.d.ts +22 -0
- package/src/lib/server/notify-restock.js +84 -0
- package/src/lib/server/notify-restock.js.map +1 -0
- package/src/lib/server/order-analytics.d.ts +134 -0
- package/src/lib/server/order-analytics.js +263 -0
- package/src/lib/server/order-analytics.js.map +1 -0
- package/src/lib/server/order-figures.d.ts +33 -0
- package/src/lib/server/order-figures.js +220 -0
- package/src/lib/server/order-figures.js.map +1 -0
- package/src/lib/server/paid-media-delivery.d.ts +173 -0
- package/src/lib/server/paid-media-delivery.js +212 -0
- package/src/lib/server/paid-media-delivery.js.map +1 -0
- package/src/lib/server/pos-order.d.ts +54 -0
- package/src/lib/server/pos-order.js +1137 -0
- package/src/lib/server/pos-order.js.map +1 -0
- package/src/lib/server/process-abandoned.d.ts +54 -0
- package/src/lib/server/process-abandoned.js +222 -0
- package/src/lib/server/process-abandoned.js.map +1 -0
- package/src/lib/server/process-restock.d.ts +42 -0
- package/src/lib/server/process-restock.js +219 -0
- package/src/lib/server/process-restock.js.map +1 -0
- package/src/lib/server/product-card.d.ts +29 -0
- package/src/lib/server/product-card.js +54 -0
- package/src/lib/server/product-card.js.map +1 -0
- package/src/lib/server/product.d.ts +70 -0
- package/src/lib/server/product.js +105 -0
- package/src/lib/server/product.js.map +1 -0
- package/src/lib/server/promotion-hold.d.ts +144 -0
- package/src/lib/server/promotion-hold.js +174 -0
- package/src/lib/server/promotion-hold.js.map +1 -0
- package/src/lib/server/reconcile-stock.d.ts +106 -0
- package/src/lib/server/reconcile-stock.js +349 -0
- package/src/lib/server/reconcile-stock.js.map +1 -0
- package/src/lib/server/refund.d.ts +45 -0
- package/src/lib/server/refund.js +607 -0
- package/src/lib/server/refund.js.map +1 -0
- package/src/lib/server/related.d.ts +24 -0
- package/src/lib/server/related.js +104 -0
- package/src/lib/server/related.js.map +1 -0
- package/src/lib/server/reservation-availability.d.ts +23 -0
- package/src/lib/server/reservation-availability.js +111 -0
- package/src/lib/server/reservation-availability.js.map +1 -0
- package/src/lib/server/reserve-stock.d.ts +156 -0
- package/src/lib/server/reserve-stock.js +146 -0
- package/src/lib/server/reserve-stock.js.map +1 -0
- package/src/lib/server/reserve.d.ts +25 -0
- package/src/lib/server/reserve.js +426 -0
- package/src/lib/server/reserve.js.map +1 -0
- package/src/lib/server/restock-flag.d.ts +202 -0
- package/src/lib/server/restock-flag.js +449 -0
- package/src/lib/server/restock-flag.js.map +1 -0
- package/src/lib/server/reviews.d.ts +51 -0
- package/src/lib/server/reviews.js +132 -0
- package/src/lib/server/reviews.js.map +1 -0
- package/src/lib/server/site-page-enricher.d.ts +42 -0
- package/src/lib/server/site-page-enricher.js +149 -0
- package/src/lib/server/site-page-enricher.js.map +1 -0
- package/src/lib/server/site-page-resolver.d.ts +26 -0
- package/src/lib/server/site-page-resolver.js +223 -0
- package/src/lib/server/site-page-resolver.js.map +1 -0
- package/src/lib/server/stock-hold.d.ts +127 -0
- package/src/lib/server/stock-hold.js +258 -0
- package/src/lib/server/stock-hold.js.map +1 -0
- package/src/lib/server/storefront-tax-record.d.ts +22 -0
- package/src/lib/server/storefront-tax-record.js +218 -0
- package/src/lib/server/storefront-tax-record.js.map +1 -0
- package/src/lib/server/storefront-tax.d.ts +197 -0
- package/src/lib/server/storefront-tax.js +245 -0
- package/src/lib/server/storefront-tax.js.map +1 -0
- package/src/lib/server/stream.d.ts +25 -0
- package/src/lib/server/stream.js +191 -0
- package/src/lib/server/stream.js.map +1 -0
- package/src/lib/server/subscription-portal.d.ts +24 -0
- package/src/lib/server/subscription-portal.js +91 -0
- package/src/lib/server/subscription-portal.js.map +1 -0
- package/src/lib/server/supplier-outbox.d.ts +190 -0
- package/src/lib/server/supplier-outbox.js +342 -0
- package/src/lib/server/supplier-outbox.js.map +1 -0
- package/src/lib/server/supplier-update.d.ts +23 -0
- package/src/lib/server/supplier-update.js +289 -0
- package/src/lib/server/supplier-update.js.map +1 -0
- package/src/lib/server/tax-profile.d.ts +29 -0
- package/src/lib/server/tax-profile.js +40 -0
- package/src/lib/server/tax-profile.js.map +1 -0
- package/src/lib/server.d.ts +32 -0
- package/src/lib/server.js +311 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/site.d.ts +27 -0
- package/src/lib/site.js +134 -0
- package/src/lib/site.js.map +1 -0
- package/src/lib/utils/escape-html.d.ts +41 -0
- package/src/lib/utils/escape-html.js +41 -0
- package/src/lib/utils/escape-html.js.map +1 -0
- package/src/lib/utils/generate-preset-id.d.ts +19 -0
- package/src/lib/utils/generate-preset-id.js +25 -0
- package/src/lib/utils/generate-preset-id.js.map +1 -0
- package/src/lib/utils/member-continue.d.ts +45 -0
- package/src/lib/utils/member-continue.js +49 -0
- package/src/lib/utils/member-continue.js.map +1 -0
- package/src/lib/utils/use-storefront-purchase-event.d.ts +60 -0
- package/src/lib/utils/use-storefront-purchase-event.js +142 -0
- 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
|