@mmailaender/convex-creem 0.1.0
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 +1176 -0
- package/dist/client/helpers.d.ts +17 -0
- package/dist/client/helpers.d.ts.map +1 -0
- package/dist/client/helpers.js +43 -0
- package/dist/client/helpers.js.map +1 -0
- package/dist/client/index.d.ts +1041 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +1068 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/parsers.d.ts +45 -0
- package/dist/client/parsers.d.ts.map +1 -0
- package/dist/client/parsers.js +138 -0
- package/dist/client/parsers.js.map +1 -0
- package/dist/client/polyfill.d.ts +2 -0
- package/dist/client/polyfill.d.ts.map +1 -0
- package/dist/client/polyfill.js +3 -0
- package/dist/client/polyfill.js.map +1 -0
- package/dist/component/_generated/api.d.ts +36 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +542 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/lib.d.ts +1005 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +647 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/schema.d.ts +191 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +104 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/util.d.ts +61 -0
- package/dist/component/util.d.ts.map +1 -0
- package/dist/component/util.js +142 -0
- package/dist/component/util.js.map +1 -0
- package/dist/core/catalog.d.ts +18 -0
- package/dist/core/catalog.d.ts.map +1 -0
- package/dist/core/catalog.js +82 -0
- package/dist/core/catalog.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +9 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/markdown.d.ts +12 -0
- package/dist/core/markdown.d.ts.map +1 -0
- package/dist/core/markdown.js +26 -0
- package/dist/core/markdown.js.map +1 -0
- package/dist/core/payments.d.ts +11 -0
- package/dist/core/payments.d.ts.map +1 -0
- package/dist/core/payments.js +27 -0
- package/dist/core/payments.js.map +1 -0
- package/dist/core/pendingCheckout.d.ts +15 -0
- package/dist/core/pendingCheckout.d.ts.map +1 -0
- package/dist/core/pendingCheckout.js +40 -0
- package/dist/core/pendingCheckout.js.map +1 -0
- package/dist/core/resolver.d.ts +11 -0
- package/dist/core/resolver.d.ts.map +1 -0
- package/dist/core/resolver.js +106 -0
- package/dist/core/resolver.js.map +1 -0
- package/dist/core/selectors.d.ts +12 -0
- package/dist/core/selectors.d.ts.map +1 -0
- package/dist/core/selectors.js +18 -0
- package/dist/core/selectors.js.map +1 -0
- package/dist/core/subscriptionUpdate.d.ts +20 -0
- package/dist/core/subscriptionUpdate.d.ts.map +1 -0
- package/dist/core/subscriptionUpdate.js +64 -0
- package/dist/core/subscriptionUpdate.js.map +1 -0
- package/dist/core/types.d.ts +170 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +15 -0
- package/dist/core/types.js.map +1 -0
- package/dist/design-system/colors/color-utils.d.ts +10 -0
- package/dist/design-system/colors/color-utils.d.ts.map +1 -0
- package/dist/design-system/colors/color-utils.js +91 -0
- package/dist/design-system/colors/color-utils.js.map +1 -0
- package/dist/design-system/colors/config.d.ts +33 -0
- package/dist/design-system/colors/config.d.ts.map +1 -0
- package/dist/design-system/colors/config.js +224 -0
- package/dist/design-system/colors/config.js.map +1 -0
- package/dist/design-system/colors/index.d.ts +3 -0
- package/dist/design-system/colors/index.d.ts.map +1 -0
- package/dist/design-system/colors/index.js +3 -0
- package/dist/design-system/colors/index.js.map +1 -0
- package/dist/design-system/rounded/config.d.ts +31 -0
- package/dist/design-system/rounded/config.d.ts.map +1 -0
- package/dist/design-system/rounded/config.js +76 -0
- package/dist/design-system/rounded/config.js.map +1 -0
- package/dist/design-system/rounded/index.d.ts +2 -0
- package/dist/design-system/rounded/index.d.ts.map +1 -0
- package/dist/design-system/rounded/index.js +2 -0
- package/dist/design-system/rounded/index.js.map +1 -0
- package/dist/design-system/typography/config.d.ts +55 -0
- package/dist/design-system/typography/config.d.ts.map +1 -0
- package/dist/design-system/typography/config.js +308 -0
- package/dist/design-system/typography/config.js.map +1 -0
- package/dist/design-system/typography/index.d.ts +3 -0
- package/dist/design-system/typography/index.d.ts.map +1 -0
- package/dist/design-system/typography/index.js +3 -0
- package/dist/design-system/typography/index.js.map +1 -0
- package/dist/design-system/typography/tokens.d.ts +23 -0
- package/dist/design-system/typography/tokens.d.ts.map +1 -0
- package/dist/design-system/typography/tokens.js +99 -0
- package/dist/design-system/typography/tokens.js.map +1 -0
- package/dist/react/hooks/useCheckoutSuccessParams.d.ts +2 -0
- package/dist/react/hooks/useCheckoutSuccessParams.d.ts.map +1 -0
- package/dist/react/hooks/useCheckoutSuccessParams.js +5 -0
- package/dist/react/hooks/useCheckoutSuccessParams.js.map +1 -0
- package/dist/react/index.d.ts +25 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +22 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/primitives/BillingGate.d.ts +8 -0
- package/dist/react/primitives/BillingGate.d.ts.map +1 -0
- package/dist/react/primitives/BillingGate.js +13 -0
- package/dist/react/primitives/BillingGate.js.map +1 -0
- package/dist/react/primitives/BillingToggle.d.ts +8 -0
- package/dist/react/primitives/BillingToggle.d.ts.map +1 -0
- package/dist/react/primitives/BillingToggle.js +12 -0
- package/dist/react/primitives/BillingToggle.js.map +1 -0
- package/dist/react/primitives/CheckoutButton.d.ts +11 -0
- package/dist/react/primitives/CheckoutButton.d.ts.map +1 -0
- package/dist/react/primitives/CheckoutButton.js +21 -0
- package/dist/react/primitives/CheckoutButton.js.map +1 -0
- package/dist/react/primitives/CheckoutSuccessSummary.d.ts +7 -0
- package/dist/react/primitives/CheckoutSuccessSummary.d.ts.map +1 -0
- package/dist/react/primitives/CheckoutSuccessSummary.js +11 -0
- package/dist/react/primitives/CheckoutSuccessSummary.js.map +1 -0
- package/dist/react/primitives/CustomerPortalButton.d.ts +8 -0
- package/dist/react/primitives/CustomerPortalButton.d.ts.map +1 -0
- package/dist/react/primitives/CustomerPortalButton.js +21 -0
- package/dist/react/primitives/CustomerPortalButton.js.map +1 -0
- package/dist/react/primitives/NumberInput.d.ts +11 -0
- package/dist/react/primitives/NumberInput.d.ts.map +1 -0
- package/dist/react/primitives/NumberInput.js +18 -0
- package/dist/react/primitives/NumberInput.js.map +1 -0
- package/dist/react/primitives/OneTimeCheckoutButton.d.ts +11 -0
- package/dist/react/primitives/OneTimeCheckoutButton.d.ts.map +1 -0
- package/dist/react/primitives/OneTimeCheckoutButton.js +4 -0
- package/dist/react/primitives/OneTimeCheckoutButton.js.map +1 -0
- package/dist/react/primitives/OneTimePaymentStatusBadge.d.ts +6 -0
- package/dist/react/primitives/OneTimePaymentStatusBadge.d.ts.map +1 -0
- package/dist/react/primitives/OneTimePaymentStatusBadge.js +11 -0
- package/dist/react/primitives/OneTimePaymentStatusBadge.js.map +1 -0
- package/dist/react/primitives/PaymentWarningBanner.d.ts +7 -0
- package/dist/react/primitives/PaymentWarningBanner.d.ts.map +1 -0
- package/dist/react/primitives/PaymentWarningBanner.js +18 -0
- package/dist/react/primitives/PaymentWarningBanner.js.map +1 -0
- package/dist/react/primitives/PricingCard.d.ts +37 -0
- package/dist/react/primitives/PricingCard.d.ts.map +1 -0
- package/dist/react/primitives/PricingCard.js +125 -0
- package/dist/react/primitives/PricingCard.js.map +1 -0
- package/dist/react/primitives/PricingSection.d.ts +39 -0
- package/dist/react/primitives/PricingSection.d.ts.map +1 -0
- package/dist/react/primitives/PricingSection.js +24 -0
- package/dist/react/primitives/PricingSection.js.map +1 -0
- package/dist/react/primitives/ScheduledChangeBanner.d.ts +8 -0
- package/dist/react/primitives/ScheduledChangeBanner.d.ts.map +1 -0
- package/dist/react/primitives/ScheduledChangeBanner.js +13 -0
- package/dist/react/primitives/ScheduledChangeBanner.js.map +1 -0
- package/dist/react/primitives/SegmentControl.d.ts +11 -0
- package/dist/react/primitives/SegmentControl.d.ts.map +1 -0
- package/dist/react/primitives/SegmentControl.js +8 -0
- package/dist/react/primitives/SegmentControl.js.map +1 -0
- package/dist/react/primitives/SegmentGroup.d.ts +14 -0
- package/dist/react/primitives/SegmentGroup.d.ts.map +1 -0
- package/dist/react/primitives/SegmentGroup.js +11 -0
- package/dist/react/primitives/SegmentGroup.js.map +1 -0
- package/dist/react/primitives/TrialLimitBanner.d.ts +7 -0
- package/dist/react/primitives/TrialLimitBanner.d.ts.map +1 -0
- package/dist/react/primitives/TrialLimitBanner.js +14 -0
- package/dist/react/primitives/TrialLimitBanner.js.map +1 -0
- package/dist/react/shared.d.ts +28 -0
- package/dist/react/shared.d.ts.map +1 -0
- package/dist/react/shared.js +109 -0
- package/dist/react/shared.js.map +1 -0
- package/dist/react/widgets/BillingPortal.d.ts +9 -0
- package/dist/react/widgets/BillingPortal.d.ts.map +1 -0
- package/dist/react/widgets/BillingPortal.js +30 -0
- package/dist/react/widgets/BillingPortal.js.map +1 -0
- package/dist/react/widgets/ProductItem.d.ts +8 -0
- package/dist/react/widgets/ProductItem.d.ts.map +1 -0
- package/dist/react/widgets/ProductItem.js +14 -0
- package/dist/react/widgets/ProductItem.js.map +1 -0
- package/dist/react/widgets/ProductRoot.d.ts +16 -0
- package/dist/react/widgets/ProductRoot.d.ts.map +1 -0
- package/dist/react/widgets/ProductRoot.js +171 -0
- package/dist/react/widgets/ProductRoot.js.map +1 -0
- package/dist/react/widgets/SubscriptionItem.d.ts +27 -0
- package/dist/react/widgets/SubscriptionItem.d.ts.map +1 -0
- package/dist/react/widgets/SubscriptionItem.js +32 -0
- package/dist/react/widgets/SubscriptionItem.js.map +1 -0
- package/dist/react/widgets/SubscriptionRoot.d.ts +16 -0
- package/dist/react/widgets/SubscriptionRoot.d.ts.map +1 -0
- package/dist/react/widgets/SubscriptionRoot.js +405 -0
- package/dist/react/widgets/SubscriptionRoot.js.map +1 -0
- package/dist/react/widgets/index.d.ts +19 -0
- package/dist/react/widgets/index.d.ts.map +1 -0
- package/dist/react/widgets/index.js +16 -0
- package/dist/react/widgets/index.js.map +1 -0
- package/dist/react/widgets/productGroupContext.d.ts +6 -0
- package/dist/react/widgets/productGroupContext.d.ts.map +1 -0
- package/dist/react/widgets/productGroupContext.js +3 -0
- package/dist/react/widgets/productGroupContext.js.map +1 -0
- package/dist/react/widgets/subscriptionContext.d.ts +6 -0
- package/dist/react/widgets/subscriptionContext.d.ts.map +1 -0
- package/dist/react/widgets/subscriptionContext.js +3 -0
- package/dist/react/widgets/subscriptionContext.js.map +1 -0
- package/dist/react/widgets/types.d.ts +171 -0
- package/dist/react/widgets/types.d.ts.map +1 -0
- package/dist/react/widgets/types.js +2 -0
- package/dist/react/widgets/types.js.map +1 -0
- package/dist/svelte/index.d.ts +22 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +20 -0
- package/dist/svelte/index.js.map +1 -0
- package/dist/svelte/primitives/BillingGate.svelte +28 -0
- package/dist/svelte/primitives/BillingToggle.svelte +27 -0
- package/dist/svelte/primitives/CheckoutButton.svelte +60 -0
- package/dist/svelte/primitives/CheckoutSuccessSummary.svelte +34 -0
- package/dist/svelte/primitives/CustomerPortalButton.svelte +60 -0
- package/dist/svelte/primitives/NumberInput.svelte +71 -0
- package/dist/svelte/primitives/OneTimeCheckoutButton.svelte +37 -0
- package/dist/svelte/primitives/OneTimePaymentStatusBadge.svelte +20 -0
- package/dist/svelte/primitives/PaymentWarningBanner.svelte +30 -0
- package/dist/svelte/primitives/PricingCard.svelte +356 -0
- package/dist/svelte/primitives/PricingSection.svelte +121 -0
- package/dist/svelte/primitives/ScheduledChangeBanner.svelte +46 -0
- package/dist/svelte/primitives/SegmentControl.svelte +38 -0
- package/dist/svelte/primitives/SegmentGroup.svelte +52 -0
- package/dist/svelte/primitives/TrialLimitBanner.svelte +32 -0
- package/dist/svelte/primitives/shared.d.ts +13 -0
- package/dist/svelte/primitives/shared.d.ts.map +1 -0
- package/dist/svelte/primitives/shared.js +87 -0
- package/dist/svelte/primitives/shared.js.map +1 -0
- package/dist/svelte/widgets/BillingPortal.svelte +55 -0
- package/dist/svelte/widgets/Product.svelte +35 -0
- package/dist/svelte/widgets/ProductRoot.svelte +428 -0
- package/dist/svelte/widgets/Subscription.svelte +52 -0
- package/dist/svelte/widgets/SubscriptionRoot.svelte +690 -0
- package/dist/svelte/widgets/index.d.ts +19 -0
- package/dist/svelte/widgets/index.d.ts.map +1 -0
- package/dist/svelte/widgets/index.js +16 -0
- package/dist/svelte/widgets/index.js.map +1 -0
- package/dist/svelte/widgets/productGroupContext.d.ts +6 -0
- package/dist/svelte/widgets/productGroupContext.d.ts.map +1 -0
- package/dist/svelte/widgets/productGroupContext.js +2 -0
- package/dist/svelte/widgets/productGroupContext.js.map +1 -0
- package/dist/svelte/widgets/subscriptionContext.d.ts +6 -0
- package/dist/svelte/widgets/subscriptionContext.d.ts.map +1 -0
- package/dist/svelte/widgets/subscriptionContext.js +2 -0
- package/dist/svelte/widgets/subscriptionContext.js.map +1 -0
- package/dist/svelte/widgets/types.d.ts +171 -0
- package/dist/svelte/widgets/types.d.ts.map +1 -0
- package/dist/svelte/widgets/types.js +2 -0
- package/dist/svelte/widgets/types.js.map +1 -0
- package/package.json +182 -0
- package/src/client/helpers.test.ts +139 -0
- package/src/client/helpers.ts +51 -0
- package/src/client/index.test.ts +1554 -0
- package/src/client/index.ts +1504 -0
- package/src/client/parsers.test.ts +1017 -0
- package/src/client/parsers.ts +182 -0
- package/src/client/polyfill.ts +2 -0
- package/src/component/_generated/api.ts +52 -0
- package/src/component/_generated/component.ts +619 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/lib.test.ts +1359 -0
- package/src/component/lib.ts +726 -0
- package/src/component/schema.ts +112 -0
- package/src/component/util.test.ts +281 -0
- package/src/component/util.ts +228 -0
- package/src/core/catalog.test.ts +212 -0
- package/src/core/catalog.ts +119 -0
- package/src/core/index.ts +8 -0
- package/src/core/markdown.test.ts +43 -0
- package/src/core/markdown.ts +26 -0
- package/src/core/payments.test.ts +69 -0
- package/src/core/payments.ts +33 -0
- package/src/core/pendingCheckout.test.ts +44 -0
- package/src/core/pendingCheckout.ts +40 -0
- package/src/core/resolver.test.ts +283 -0
- package/src/core/resolver.ts +160 -0
- package/src/core/selectors.test.ts +119 -0
- package/src/core/selectors.ts +35 -0
- package/src/core/subscriptionUpdate.test.ts +164 -0
- package/src/core/subscriptionUpdate.ts +102 -0
- package/src/core/types.ts +220 -0
- package/src/design-system/README.md +40 -0
- package/src/design-system/base.css +27 -0
- package/src/design-system/colors/color-utils.ts +110 -0
- package/src/design-system/colors/config.ts +282 -0
- package/src/design-system/colors/index.ts +2 -0
- package/src/design-system/colors/utilities.css +2328 -0
- package/src/design-system/components/badges.css +65 -0
- package/src/design-system/components/buttons.css +256 -0
- package/src/design-system/components/dialog.css +218 -0
- package/src/design-system/components/icon-buttons.css +115 -0
- package/src/design-system/components/inputs.css +94 -0
- package/src/design-system/components/links.css +53 -0
- package/src/design-system/components/prose.css +67 -0
- package/src/design-system/components/segment-control.css +303 -0
- package/src/design-system/index.css +21 -0
- package/src/design-system/rounded/config.ts +91 -0
- package/src/design-system/rounded/index.ts +1 -0
- package/src/design-system/rounded/utilities.css +37 -0
- package/src/design-system/typography/config.ts +340 -0
- package/src/design-system/typography/index.ts +2 -0
- package/src/design-system/typography/tokens.ts +148 -0
- package/src/design-system/typography/utilities.css +728 -0
- package/src/library.css +20 -0
- package/src/react/hooks/useCheckoutSuccessParams.ts +7 -0
- package/src/react/index.tsx +47 -0
- package/src/react/primitives/BillingGate.tsx +26 -0
- package/src/react/primitives/BillingToggle.tsx +29 -0
- package/src/react/primitives/CheckoutButton.tsx +47 -0
- package/src/react/primitives/CheckoutSuccessSummary.tsx +36 -0
- package/src/react/primitives/CustomerPortalButton.tsx +50 -0
- package/src/react/primitives/NumberInput.tsx +83 -0
- package/src/react/primitives/OneTimeCheckoutButton.tsx +27 -0
- package/src/react/primitives/OneTimePaymentStatusBadge.tsx +18 -0
- package/src/react/primitives/PaymentWarningBanner.tsx +33 -0
- package/src/react/primitives/PricingCard.tsx +421 -0
- package/src/react/primitives/PricingSection.tsx +129 -0
- package/src/react/primitives/ScheduledChangeBanner.tsx +52 -0
- package/src/react/primitives/SegmentControl.tsx +32 -0
- package/src/react/primitives/SegmentGroup.tsx +53 -0
- package/src/react/primitives/TrialLimitBanner.tsx +32 -0
- package/src/react/shared.ts +138 -0
- package/src/react/widgets/BillingPortal.tsx +56 -0
- package/src/react/widgets/ProductItem.tsx +26 -0
- package/src/react/widgets/ProductRoot.tsx +441 -0
- package/src/react/widgets/SubscriptionItem.tsx +71 -0
- package/src/react/widgets/SubscriptionRoot.tsx +759 -0
- package/src/react/widgets/index.ts +36 -0
- package/src/react/widgets/productGroupContext.ts +10 -0
- package/src/react/widgets/subscriptionContext.ts +10 -0
- package/src/react/widgets/types.ts +179 -0
- package/src/svelte/index.ts +43 -0
- package/src/svelte/primitives/BillingGate.svelte +28 -0
- package/src/svelte/primitives/BillingToggle.svelte +27 -0
- package/src/svelte/primitives/CheckoutButton.svelte +60 -0
- package/src/svelte/primitives/CheckoutSuccessSummary.svelte +34 -0
- package/src/svelte/primitives/CustomerPortalButton.svelte +60 -0
- package/src/svelte/primitives/NumberInput.svelte +71 -0
- package/src/svelte/primitives/OneTimeCheckoutButton.svelte +37 -0
- package/src/svelte/primitives/OneTimePaymentStatusBadge.svelte +20 -0
- package/src/svelte/primitives/PaymentWarningBanner.svelte +30 -0
- package/src/svelte/primitives/PricingCard.svelte +356 -0
- package/src/svelte/primitives/PricingSection.svelte +121 -0
- package/src/svelte/primitives/ScheduledChangeBanner.svelte +46 -0
- package/src/svelte/primitives/SegmentControl.svelte +38 -0
- package/src/svelte/primitives/SegmentGroup.svelte +52 -0
- package/src/svelte/primitives/TrialLimitBanner.svelte +32 -0
- package/src/svelte/primitives/shared.ts +113 -0
- package/src/svelte/svelte.d.ts +6 -0
- package/src/svelte/widgets/BillingPortal.svelte +55 -0
- package/src/svelte/widgets/Product.svelte +35 -0
- package/src/svelte/widgets/ProductRoot.svelte +428 -0
- package/src/svelte/widgets/Subscription.svelte +52 -0
- package/src/svelte/widgets/SubscriptionRoot.svelte +690 -0
- package/src/svelte/widgets/index.ts +36 -0
- package/src/svelte/widgets/productGroupContext.ts +7 -0
- package/src/svelte/widgets/subscriptionContext.ts +7 -0
- package/src/svelte/widgets/types.ts +179 -0
- package/src/tailwind.css +6 -0
- package/src/test.ts +18 -0
|
@@ -0,0 +1,1504 @@
|
|
|
1
|
+
import "./polyfill.js";
|
|
2
|
+
import { Creem as CreemSDK } from "creem";
|
|
3
|
+
import type {
|
|
4
|
+
CheckoutEntity,
|
|
5
|
+
CustomerEntity,
|
|
6
|
+
SubscriptionEntity,
|
|
7
|
+
} from "creem/models/components";
|
|
8
|
+
import { Webhook, WebhookVerificationError } from "standardwebhooks";
|
|
9
|
+
import {
|
|
10
|
+
getEntityId,
|
|
11
|
+
lowerCaseHeaders,
|
|
12
|
+
toHex,
|
|
13
|
+
constantTimeEqual,
|
|
14
|
+
normalizeSignature,
|
|
15
|
+
} from "./helpers.js";
|
|
16
|
+
import {
|
|
17
|
+
type CreemWebhookEvent,
|
|
18
|
+
getEventType,
|
|
19
|
+
getEventData,
|
|
20
|
+
getCustomerId,
|
|
21
|
+
getConvexEntityId,
|
|
22
|
+
parseSubscription,
|
|
23
|
+
parseCheckout,
|
|
24
|
+
parseProduct,
|
|
25
|
+
} from "./parsers.js";
|
|
26
|
+
import {
|
|
27
|
+
type FunctionReference,
|
|
28
|
+
type HttpRouter,
|
|
29
|
+
actionGeneric,
|
|
30
|
+
httpActionGeneric,
|
|
31
|
+
mutationGeneric,
|
|
32
|
+
queryGeneric,
|
|
33
|
+
} from "convex/server";
|
|
34
|
+
import { ConvexError, type Infer, v } from "convex/values";
|
|
35
|
+
import schema from "../component/schema.js";
|
|
36
|
+
import {
|
|
37
|
+
type RunMutationCtx,
|
|
38
|
+
type RunSchedulerMutationCtx,
|
|
39
|
+
type RunQueryCtx,
|
|
40
|
+
convertToDatabaseProduct,
|
|
41
|
+
convertToDatabaseSubscription,
|
|
42
|
+
convertToOrder,
|
|
43
|
+
type RunActionCtx,
|
|
44
|
+
} from "../component/util.js";
|
|
45
|
+
import type { ComponentApi } from "../component/_generated/component.js";
|
|
46
|
+
import { resolveBillingSnapshot as defaultResolveBillingSnapshot } from "../core/resolver.js";
|
|
47
|
+
import type {
|
|
48
|
+
BillingSnapshot,
|
|
49
|
+
PaymentSnapshot,
|
|
50
|
+
SubscriptionSnapshot,
|
|
51
|
+
} from "../core/types.js";
|
|
52
|
+
|
|
53
|
+
export * from "../core/index.js";
|
|
54
|
+
export type { RunSchedulerMutationCtx } from "../component/util.js";
|
|
55
|
+
export {
|
|
56
|
+
getEntityId,
|
|
57
|
+
lowerCaseHeaders,
|
|
58
|
+
toHex,
|
|
59
|
+
constantTimeEqual,
|
|
60
|
+
normalizeSignature,
|
|
61
|
+
} from "./helpers.js";
|
|
62
|
+
export {
|
|
63
|
+
type CreemWebhookEvent,
|
|
64
|
+
getEventType,
|
|
65
|
+
getEventData,
|
|
66
|
+
getCustomerId,
|
|
67
|
+
getConvexEntityId,
|
|
68
|
+
parseSubscription,
|
|
69
|
+
parseCheckout,
|
|
70
|
+
parseProduct,
|
|
71
|
+
manualParseSubscription,
|
|
72
|
+
} from "./parsers.js";
|
|
73
|
+
|
|
74
|
+
/** Convex validator for the `subscriptions` table. Use with `v.object(subscriptionValidator.fields)` in custom functions. */
|
|
75
|
+
export const subscriptionValidator = schema.tables.subscriptions.validator;
|
|
76
|
+
/** TypeScript type for a subscription document (inferred from the Convex schema). */
|
|
77
|
+
export type Subscription = Infer<typeof subscriptionValidator>;
|
|
78
|
+
|
|
79
|
+
// ── Shared arg validators for custom actions / mutations ──────────────
|
|
80
|
+
// Use these when writing your own Convex functions that wrap creem methods
|
|
81
|
+
// (e.g. for RBAC). They match exactly what the connected widgets send.
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Convex arg validator for checkout creation.
|
|
85
|
+
* Matches the args sent by `<Subscription.Root>` and `<Product.Root>` widgets.
|
|
86
|
+
* Use in your own `action()` definitions for custom RBAC wrappers.
|
|
87
|
+
*/
|
|
88
|
+
export const checkoutCreateArgs = {
|
|
89
|
+
productId: v.string(),
|
|
90
|
+
successUrl: v.optional(v.string()),
|
|
91
|
+
fallbackSuccessUrl: v.optional(v.string()),
|
|
92
|
+
units: v.optional(v.number()),
|
|
93
|
+
metadata: v.optional(v.record(v.string(), v.string())),
|
|
94
|
+
discountCode: v.optional(v.string()),
|
|
95
|
+
theme: v.optional(v.union(v.literal("light"), v.literal("dark"))),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convex arg validator for subscription updates (plan switch or seat change).
|
|
100
|
+
* Matches the args sent by `<Subscription.Root>` widgets.
|
|
101
|
+
*/
|
|
102
|
+
export const subscriptionUpdateArgs = {
|
|
103
|
+
subscriptionId: v.optional(v.string()),
|
|
104
|
+
productId: v.optional(v.string()),
|
|
105
|
+
units: v.optional(v.number()),
|
|
106
|
+
updateBehavior: v.optional(
|
|
107
|
+
v.union(
|
|
108
|
+
v.literal("proration-charge-immediately"),
|
|
109
|
+
v.literal("proration-charge"),
|
|
110
|
+
v.literal("proration-none"),
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convex arg validator for subscription cancellation.
|
|
117
|
+
* Matches the args sent by `<Subscription.Root>` cancel button.
|
|
118
|
+
*/
|
|
119
|
+
export const subscriptionCancelArgs = {
|
|
120
|
+
subscriptionId: v.optional(v.string()),
|
|
121
|
+
revokeImmediately: v.optional(v.boolean()),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convex arg validator for subscription resume.
|
|
126
|
+
* Matches the args sent by `<Subscription.Root>` resume button.
|
|
127
|
+
*/
|
|
128
|
+
export const subscriptionResumeArgs = {
|
|
129
|
+
subscriptionId: v.optional(v.string()),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Convex arg validator for subscription pause.
|
|
134
|
+
* Matches the args sent by `<Subscription.Root>` pause button.
|
|
135
|
+
*/
|
|
136
|
+
export const subscriptionPauseArgs = {
|
|
137
|
+
subscriptionId: v.optional(v.string()),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Function reference type for internal mutations that receive a subscription document. */
|
|
141
|
+
export type SubscriptionHandler = FunctionReference<
|
|
142
|
+
"mutation",
|
|
143
|
+
"internal",
|
|
144
|
+
{ subscription: Subscription }
|
|
145
|
+
>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Map of webhook event type → handler function.
|
|
149
|
+
* Handlers run **after** the component's built-in processing (customer/subscription/order upserts).
|
|
150
|
+
* The `ctx` is a Convex mutation context — you can read/write to your own tables.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* creem.registerRoutes(http, {
|
|
155
|
+
* events: {
|
|
156
|
+
* "checkout.completed": async (ctx, event) => {
|
|
157
|
+
* // Grant entitlements, send emails, log analytics
|
|
158
|
+
* },
|
|
159
|
+
* },
|
|
160
|
+
* });
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export type WebhookEventHandlers = Record<
|
|
164
|
+
string,
|
|
165
|
+
(ctx: RunMutationCtx, event: CreemWebhookEvent) => Promise<void> | void
|
|
166
|
+
>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Callback that resolves the authenticated user for `creem.api({ resolve })`.
|
|
170
|
+
* Called on every generated Convex function to determine the billing entity.
|
|
171
|
+
*
|
|
172
|
+
* - `userId` — your app's user ID (stored in checkout metadata as `convexUserId`)
|
|
173
|
+
* - `email` — user's email (passed to Creem for customer creation)
|
|
174
|
+
* - `entityId` — billing entity ID. For personal billing, same as `userId`.
|
|
175
|
+
* For org billing, return the org ID so all billing scopes to the organization.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* const resolve: ApiResolver = async (ctx) => {
|
|
180
|
+
* const user = await ctx.runQuery(api.users.currentUser);
|
|
181
|
+
* return { userId: user._id, email: user.email, entityId: user._id };
|
|
182
|
+
* };
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export type ApiResolver = (ctx: RunQueryCtx) => Promise<{
|
|
186
|
+
userId: string;
|
|
187
|
+
email: string;
|
|
188
|
+
entityId: string;
|
|
189
|
+
}>;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Configuration for the Creem Convex component.
|
|
193
|
+
* All fields are optional — environment variables are used as fallbacks.
|
|
194
|
+
*/
|
|
195
|
+
type CreemConfig = {
|
|
196
|
+
/**
|
|
197
|
+
* Default cancel mode for subscriptions.
|
|
198
|
+
* - `"immediate"` — cancel and revoke access now
|
|
199
|
+
* - `"scheduled"` — cancel at end of current billing period
|
|
200
|
+
* - Omit to use Creem's store-level default.
|
|
201
|
+
*/
|
|
202
|
+
cancelMode?: "immediate" | "scheduled";
|
|
203
|
+
/** Creem API key. Falls back to `CREEM_API_KEY` env var. */
|
|
204
|
+
apiKey?: string;
|
|
205
|
+
/** Creem webhook signing secret. Falls back to `CREEM_WEBHOOK_SECRET` env var. */
|
|
206
|
+
webhookSecret?: string;
|
|
207
|
+
/** Creem SDK server index (for non-default endpoints). Falls back to `CREEM_SERVER_IDX` env var. */
|
|
208
|
+
serverIdx?: number;
|
|
209
|
+
/** Creem SDK server URL override (for test/staging). Falls back to `CREEM_SERVER_URL` env var. */
|
|
210
|
+
serverURL?: string;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Main entry point for the Creem–Convex integration.
|
|
215
|
+
*
|
|
216
|
+
* Instantiate once in your `convex/billing.ts` and use its methods
|
|
217
|
+
* to manage subscriptions, checkouts, products, customers, and orders.
|
|
218
|
+
*
|
|
219
|
+
* **Two usage patterns:**
|
|
220
|
+
* 1. **Quick start** — call `creem.api({ resolve })` to generate ready-to-export Convex functions
|
|
221
|
+
* 2. **Full control** — use namespace getters (`creem.subscriptions.*`, `creem.checkouts.*`, etc.)
|
|
222
|
+
* directly in your own Convex functions for custom auth/RBAC
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* import { Creem } from "@mmailaender/convex-creem";
|
|
227
|
+
* import { components } from "./_generated/api";
|
|
228
|
+
*
|
|
229
|
+
* export const creem = new Creem(components.creem);
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
export class Creem {
|
|
233
|
+
/** Direct access to the Creem SDK client, pre-configured with your API key. Use for resources without webhook sync (licenses, discounts, transactions). */
|
|
234
|
+
public sdk: CreemSDK;
|
|
235
|
+
private apiKey: string;
|
|
236
|
+
private webhookSecret: string;
|
|
237
|
+
private serverIdx?: number;
|
|
238
|
+
private serverURL?: string;
|
|
239
|
+
|
|
240
|
+
constructor(
|
|
241
|
+
public component: ComponentApi,
|
|
242
|
+
private config: CreemConfig = {},
|
|
243
|
+
) {
|
|
244
|
+
this.apiKey = config.apiKey ?? process.env["CREEM_API_KEY"] ?? "";
|
|
245
|
+
this.webhookSecret =
|
|
246
|
+
config.webhookSecret ?? process.env["CREEM_WEBHOOK_SECRET"] ?? "";
|
|
247
|
+
this.serverIdx =
|
|
248
|
+
config.serverIdx ??
|
|
249
|
+
(process.env["CREEM_SERVER_IDX"]
|
|
250
|
+
? Number(process.env["CREEM_SERVER_IDX"])
|
|
251
|
+
: undefined);
|
|
252
|
+
this.serverURL = config.serverURL ?? process.env["CREEM_SERVER_URL"];
|
|
253
|
+
|
|
254
|
+
this.sdk = new CreemSDK({
|
|
255
|
+
apiKey: this.apiKey,
|
|
256
|
+
...(this.serverIdx !== undefined ? { serverIdx: this.serverIdx } : {}),
|
|
257
|
+
...(this.serverURL ? { serverURL: this.serverURL } : {}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
private getCustomerByEntityId(ctx: RunQueryCtx, entityId: string) {
|
|
261
|
+
return ctx.runQuery(this.component.lib.getCustomerByEntityId, { entityId });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Pull all products from the Creem API into the Convex database. Typically called once via `internalAction` or the CLI. */
|
|
265
|
+
async syncProducts(ctx: RunActionCtx) {
|
|
266
|
+
await ctx.runAction(this.component.lib.syncProducts, {
|
|
267
|
+
apiKey: this.apiKey,
|
|
268
|
+
serverIdx: this.serverIdx,
|
|
269
|
+
serverURL: this.serverURL,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async createCheckoutSession(
|
|
274
|
+
ctx: RunMutationCtx,
|
|
275
|
+
{
|
|
276
|
+
productId,
|
|
277
|
+
entityId,
|
|
278
|
+
userId,
|
|
279
|
+
email,
|
|
280
|
+
successUrl,
|
|
281
|
+
units,
|
|
282
|
+
metadata,
|
|
283
|
+
}: {
|
|
284
|
+
productId: string;
|
|
285
|
+
entityId: string;
|
|
286
|
+
userId: string;
|
|
287
|
+
email: string;
|
|
288
|
+
successUrl?: string;
|
|
289
|
+
units?: number;
|
|
290
|
+
metadata?: Record<string, string>;
|
|
291
|
+
},
|
|
292
|
+
): Promise<CheckoutEntity> {
|
|
293
|
+
const dbCustomer = await ctx.runQuery(
|
|
294
|
+
this.component.lib.getCustomerByEntityId,
|
|
295
|
+
{
|
|
296
|
+
entityId,
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const checkout = await this.sdk.checkouts.create({
|
|
301
|
+
productId,
|
|
302
|
+
...(successUrl ? { successUrl } : {}),
|
|
303
|
+
units,
|
|
304
|
+
metadata: {
|
|
305
|
+
...(metadata ?? {}),
|
|
306
|
+
convexUserId: userId,
|
|
307
|
+
convexBillingEntityId: entityId,
|
|
308
|
+
},
|
|
309
|
+
customer: dbCustomer ? { id: dbCustomer.id } : { email },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (!dbCustomer) {
|
|
313
|
+
const customerId = getEntityId(checkout.customer);
|
|
314
|
+
if (customerId) {
|
|
315
|
+
const customerObj =
|
|
316
|
+
typeof checkout.customer === "object" ? checkout.customer : undefined;
|
|
317
|
+
await ctx.runMutation(this.component.lib.insertCustomer, {
|
|
318
|
+
id: customerId,
|
|
319
|
+
entityId,
|
|
320
|
+
email: customerObj?.email,
|
|
321
|
+
name: customerObj?.name ?? undefined,
|
|
322
|
+
country: customerObj?.country,
|
|
323
|
+
mode: customerObj?.mode,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return checkout;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async createCustomerPortalSession(
|
|
332
|
+
ctx: RunActionCtx,
|
|
333
|
+
{ entityId }: { entityId: string },
|
|
334
|
+
) {
|
|
335
|
+
const customer = await ctx.runQuery(
|
|
336
|
+
this.component.lib.getCustomerByEntityId,
|
|
337
|
+
{ entityId },
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
if (!customer) {
|
|
341
|
+
throw new ConvexError("Customer not found");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const portal = await this.sdk.customers.generateBillingLinks({
|
|
345
|
+
customerId: customer.id,
|
|
346
|
+
});
|
|
347
|
+
return { url: portal.customerPortalLink };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private listProducts(
|
|
351
|
+
ctx: RunQueryCtx,
|
|
352
|
+
{ includeArchived }: { includeArchived?: boolean } = {},
|
|
353
|
+
) {
|
|
354
|
+
return ctx.runQuery(this.component.lib.listProducts, {
|
|
355
|
+
includeArchived,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
private async getCurrentSubscription(
|
|
359
|
+
ctx: RunQueryCtx,
|
|
360
|
+
{ entityId }: { entityId: string },
|
|
361
|
+
) {
|
|
362
|
+
const subscription = await ctx.runQuery(
|
|
363
|
+
this.component.lib.getCurrentSubscription,
|
|
364
|
+
{
|
|
365
|
+
entityId,
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
if (!subscription) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
const product = await ctx.runQuery(this.component.lib.getProduct, {
|
|
372
|
+
id: subscription.productId,
|
|
373
|
+
});
|
|
374
|
+
if (!product) {
|
|
375
|
+
throw new ConvexError("Product not found");
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
...subscription,
|
|
379
|
+
product,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/** Return active subscriptions for an entity, excluding ended and expired trials. */
|
|
383
|
+
private listUserSubscriptions(
|
|
384
|
+
ctx: RunQueryCtx,
|
|
385
|
+
{ entityId }: { entityId: string },
|
|
386
|
+
) {
|
|
387
|
+
return ctx.runQuery(this.component.lib.listUserSubscriptions, {
|
|
388
|
+
entityId,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** Return paid one-time orders for an entity. */
|
|
392
|
+
private listUserOrders(ctx: RunQueryCtx, { entityId }: { entityId: string }) {
|
|
393
|
+
return ctx.runQuery(this.component.lib.listUserOrders, {
|
|
394
|
+
entityId,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/** Return all subscriptions for an entity, including ended and expired trials. */
|
|
398
|
+
private listAllUserSubscriptions(
|
|
399
|
+
ctx: RunQueryCtx,
|
|
400
|
+
{ entityId }: { entityId: string },
|
|
401
|
+
) {
|
|
402
|
+
return ctx.runQuery(this.component.lib.listAllUserSubscriptions, {
|
|
403
|
+
entityId,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
private getProduct(ctx: RunQueryCtx, { productId }: { productId: string }) {
|
|
407
|
+
return ctx.runQuery(this.component.lib.getProduct, { id: productId });
|
|
408
|
+
}
|
|
409
|
+
private toSubscriptionSnapshot(
|
|
410
|
+
subscription: Subscription,
|
|
411
|
+
): SubscriptionSnapshot {
|
|
412
|
+
return {
|
|
413
|
+
id: subscription.id,
|
|
414
|
+
productId: subscription.productId,
|
|
415
|
+
status: subscription.status,
|
|
416
|
+
recurringInterval: subscription.recurringInterval,
|
|
417
|
+
seats: subscription.seats,
|
|
418
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
419
|
+
currentPeriodEnd: subscription.currentPeriodEnd,
|
|
420
|
+
trialEnd: subscription.trialEnd ?? null,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Resolve the current billing state for a billing entity.
|
|
426
|
+
* Returns plan, status, available actions, subscription metadata, etc.
|
|
427
|
+
* Used internally by `getBillingModel` and exposed for custom billing UIs.
|
|
428
|
+
*/
|
|
429
|
+
async getBillingSnapshot(
|
|
430
|
+
ctx: RunQueryCtx,
|
|
431
|
+
{
|
|
432
|
+
entityId,
|
|
433
|
+
payment,
|
|
434
|
+
}: {
|
|
435
|
+
entityId: string;
|
|
436
|
+
payment?: PaymentSnapshot | null;
|
|
437
|
+
},
|
|
438
|
+
): Promise<BillingSnapshot> {
|
|
439
|
+
const [currentSubscription, allSubscriptions] = await Promise.all([
|
|
440
|
+
this.getCurrentSubscription(ctx, { entityId }),
|
|
441
|
+
this.listAllUserSubscriptions(ctx, { entityId }),
|
|
442
|
+
]);
|
|
443
|
+
|
|
444
|
+
return defaultResolveBillingSnapshot({
|
|
445
|
+
currentSubscription: currentSubscription
|
|
446
|
+
? this.toSubscriptionSnapshot(currentSubscription)
|
|
447
|
+
: null,
|
|
448
|
+
allSubscriptions: allSubscriptions.map((subscription) =>
|
|
449
|
+
this.toSubscriptionSnapshot(subscription),
|
|
450
|
+
),
|
|
451
|
+
payment: payment ?? null,
|
|
452
|
+
userContext: undefined,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async verifyWebhook(body: string, headers: Record<string, string>) {
|
|
457
|
+
if (!this.webhookSecret) {
|
|
458
|
+
throw new ConvexError("Missing CREEM_WEBHOOK_SECRET");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const normalized = lowerCaseHeaders(headers);
|
|
462
|
+
const webhookId = normalized["webhook-id"];
|
|
463
|
+
const webhookTimestamp = normalized["webhook-timestamp"];
|
|
464
|
+
const webhookSignature = normalized["webhook-signature"];
|
|
465
|
+
|
|
466
|
+
if (webhookId && webhookTimestamp && webhookSignature) {
|
|
467
|
+
new Webhook(this.webhookSecret).verify(body, {
|
|
468
|
+
"webhook-id": webhookId,
|
|
469
|
+
"webhook-timestamp": webhookTimestamp,
|
|
470
|
+
"webhook-signature": webhookSignature,
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const creemSignature =
|
|
476
|
+
normalized["creem-signature"] ?? normalized["x-creem-signature"];
|
|
477
|
+
if (!creemSignature) {
|
|
478
|
+
throw new WebhookVerificationError("Missing webhook signature");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const key = await crypto.subtle.importKey(
|
|
482
|
+
"raw",
|
|
483
|
+
new TextEncoder().encode(this.webhookSecret),
|
|
484
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
485
|
+
false,
|
|
486
|
+
["sign"],
|
|
487
|
+
);
|
|
488
|
+
const digest = await crypto.subtle.sign(
|
|
489
|
+
"HMAC",
|
|
490
|
+
key,
|
|
491
|
+
new TextEncoder().encode(body),
|
|
492
|
+
);
|
|
493
|
+
const expected = toHex(new Uint8Array(digest));
|
|
494
|
+
if (!constantTimeEqual(normalizeSignature(creemSignature), expected)) {
|
|
495
|
+
throw new WebhookVerificationError("Invalid webhook signature");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Upsert a customer record if we have both entityId and customerId. */
|
|
500
|
+
private async upsertCustomerFromWebhook(
|
|
501
|
+
ctx: RunMutationCtx,
|
|
502
|
+
customerId: string | null,
|
|
503
|
+
entityId: string | null,
|
|
504
|
+
customerEntity?: CustomerEntity | null,
|
|
505
|
+
) {
|
|
506
|
+
if (!customerId || !entityId) return;
|
|
507
|
+
try {
|
|
508
|
+
await ctx.runMutation(this.component.lib.insertCustomer, {
|
|
509
|
+
id: customerId,
|
|
510
|
+
entityId,
|
|
511
|
+
email: customerEntity?.email,
|
|
512
|
+
name: customerEntity?.name ?? undefined,
|
|
513
|
+
country: customerEntity?.country,
|
|
514
|
+
mode: customerEntity?.mode,
|
|
515
|
+
createdAt: customerEntity?.createdAt
|
|
516
|
+
? customerEntity.createdAt instanceof Date
|
|
517
|
+
? customerEntity.createdAt.toISOString()
|
|
518
|
+
: String(customerEntity.createdAt)
|
|
519
|
+
: undefined,
|
|
520
|
+
updatedAt: customerEntity?.updatedAt
|
|
521
|
+
? customerEntity.updatedAt instanceof Date
|
|
522
|
+
? customerEntity.updatedAt.toISOString()
|
|
523
|
+
: String(customerEntity.updatedAt)
|
|
524
|
+
: undefined,
|
|
525
|
+
});
|
|
526
|
+
} catch {
|
|
527
|
+
// insertCustomer is idempotent; ignore duplicate errors
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Namespace getters (public API) ─────────────────────────
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Subscription management namespace.
|
|
535
|
+
*
|
|
536
|
+
* All methods take explicit `entityId` — use them directly in your own
|
|
537
|
+
* Convex functions, or let `creem.api({ resolve })` handle auth for you.
|
|
538
|
+
*
|
|
539
|
+
* - `.getCurrent()` — current active subscription with product join (Convex DB)
|
|
540
|
+
* - `.list()` — active subscriptions, excludes ended + expired trials (Convex DB)
|
|
541
|
+
* - `.listAll()` — all subscriptions including ended (Convex DB)
|
|
542
|
+
* - `.update()` — plan switch (`productId`) or seat change (`units`) (Creem API, optimistic)
|
|
543
|
+
* - `.cancel()` — cancel subscription (Creem API, optimistic)
|
|
544
|
+
* - `.pause()` — pause an active subscription (Creem API, optimistic)
|
|
545
|
+
* - `.resume()` — resume a paused or scheduled-cancel subscription (Creem API, optimistic)
|
|
546
|
+
*/
|
|
547
|
+
get subscriptions() {
|
|
548
|
+
type UpdateBehavior =
|
|
549
|
+
| "proration-charge-immediately"
|
|
550
|
+
| "proration-charge"
|
|
551
|
+
| "proration-none";
|
|
552
|
+
return {
|
|
553
|
+
getCurrent: (ctx: RunQueryCtx, { entityId }: { entityId: string }) =>
|
|
554
|
+
this.getCurrentSubscription(ctx, { entityId }),
|
|
555
|
+
list: (ctx: RunQueryCtx, { entityId }: { entityId: string }) =>
|
|
556
|
+
this.listUserSubscriptions(ctx, { entityId }),
|
|
557
|
+
listAll: (ctx: RunQueryCtx, { entityId }: { entityId: string }) =>
|
|
558
|
+
this.listAllUserSubscriptions(ctx, { entityId }),
|
|
559
|
+
update: async (
|
|
560
|
+
ctx: RunSchedulerMutationCtx,
|
|
561
|
+
args: {
|
|
562
|
+
entityId: string;
|
|
563
|
+
subscriptionId?: string;
|
|
564
|
+
productId?: string;
|
|
565
|
+
units?: number;
|
|
566
|
+
updateBehavior?: UpdateBehavior;
|
|
567
|
+
},
|
|
568
|
+
) => {
|
|
569
|
+
if (args.productId && args.units)
|
|
570
|
+
throw new ConvexError("Provide productId OR units, not both");
|
|
571
|
+
if (!args.productId && !args.units)
|
|
572
|
+
throw new ConvexError("Provide productId or units");
|
|
573
|
+
|
|
574
|
+
// Resolve current subscription
|
|
575
|
+
const subscription = args.subscriptionId
|
|
576
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
577
|
+
id: args.subscriptionId,
|
|
578
|
+
})
|
|
579
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
580
|
+
entityId: args.entityId,
|
|
581
|
+
});
|
|
582
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
583
|
+
|
|
584
|
+
// Write optimistic state
|
|
585
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
586
|
+
subscriptionId: subscription.id,
|
|
587
|
+
...(args.units != null ? { seats: args.units } : {}),
|
|
588
|
+
...(args.productId ? { productId: args.productId } : {}),
|
|
589
|
+
...(args.productId && args.units == null
|
|
590
|
+
? { seats: subscription.seats ?? null }
|
|
591
|
+
: {}),
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Schedule the Creem API call (runs async, reverts on error)
|
|
595
|
+
await ctx.scheduler.runAfter(
|
|
596
|
+
0,
|
|
597
|
+
this.component.lib.executeSubscriptionUpdate,
|
|
598
|
+
{
|
|
599
|
+
apiKey: this.apiKey,
|
|
600
|
+
serverIdx: this.serverIdx,
|
|
601
|
+
serverURL: this.serverURL,
|
|
602
|
+
subscriptionId: subscription.id,
|
|
603
|
+
productId: args.productId,
|
|
604
|
+
units: args.units,
|
|
605
|
+
updateBehavior: args.updateBehavior,
|
|
606
|
+
previousSeats: subscription.seats ?? undefined,
|
|
607
|
+
previousProductId: subscription.productId,
|
|
608
|
+
},
|
|
609
|
+
);
|
|
610
|
+
},
|
|
611
|
+
cancel: async (
|
|
612
|
+
ctx: RunSchedulerMutationCtx,
|
|
613
|
+
args: {
|
|
614
|
+
entityId: string;
|
|
615
|
+
subscriptionId?: string;
|
|
616
|
+
revokeImmediately?: boolean;
|
|
617
|
+
},
|
|
618
|
+
) => {
|
|
619
|
+
const subscription = args.subscriptionId
|
|
620
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
621
|
+
id: args.subscriptionId,
|
|
622
|
+
})
|
|
623
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
624
|
+
entityId: args.entityId,
|
|
625
|
+
});
|
|
626
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
627
|
+
if (
|
|
628
|
+
subscription.status !== "active" &&
|
|
629
|
+
subscription.status !== "trialing"
|
|
630
|
+
) {
|
|
631
|
+
throw new ConvexError("Subscription is not active");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Resolve cancel mode: explicit arg > config default > omit (Creem decides)
|
|
635
|
+
const immediate =
|
|
636
|
+
args.revokeImmediately ??
|
|
637
|
+
(this.config.cancelMode === "immediate" ? true : undefined);
|
|
638
|
+
const isImmediate = immediate === true;
|
|
639
|
+
|
|
640
|
+
// Write optimistic state
|
|
641
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
642
|
+
subscriptionId: subscription.id,
|
|
643
|
+
...(isImmediate
|
|
644
|
+
? { status: "canceled", cancelAtPeriodEnd: false }
|
|
645
|
+
: { cancelAtPeriodEnd: true }),
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// Resolve cancel mode string for the action
|
|
649
|
+
const cancelMode = isImmediate
|
|
650
|
+
? "immediate"
|
|
651
|
+
: immediate === false || this.config.cancelMode === "scheduled"
|
|
652
|
+
? "scheduled"
|
|
653
|
+
: undefined;
|
|
654
|
+
|
|
655
|
+
// Schedule the Creem API call
|
|
656
|
+
await ctx.scheduler.runAfter(
|
|
657
|
+
0,
|
|
658
|
+
this.component.lib.executeSubscriptionLifecycle,
|
|
659
|
+
{
|
|
660
|
+
apiKey: this.apiKey,
|
|
661
|
+
serverIdx: this.serverIdx,
|
|
662
|
+
serverURL: this.serverURL,
|
|
663
|
+
subscriptionId: subscription.id,
|
|
664
|
+
operation: "cancel",
|
|
665
|
+
cancelMode,
|
|
666
|
+
previousStatus: subscription.status,
|
|
667
|
+
previousCancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
668
|
+
},
|
|
669
|
+
);
|
|
670
|
+
},
|
|
671
|
+
pause: async (
|
|
672
|
+
ctx: RunSchedulerMutationCtx,
|
|
673
|
+
args: { entityId: string; subscriptionId?: string },
|
|
674
|
+
) => {
|
|
675
|
+
const subscription = args.subscriptionId
|
|
676
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
677
|
+
id: args.subscriptionId,
|
|
678
|
+
})
|
|
679
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
680
|
+
entityId: args.entityId,
|
|
681
|
+
});
|
|
682
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
683
|
+
if (
|
|
684
|
+
subscription.status !== "active" &&
|
|
685
|
+
subscription.status !== "trialing"
|
|
686
|
+
) {
|
|
687
|
+
throw new ConvexError("Subscription is not active");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Write optimistic state
|
|
691
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
692
|
+
subscriptionId: subscription.id,
|
|
693
|
+
status: "paused",
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Schedule the Creem API call
|
|
697
|
+
await ctx.scheduler.runAfter(
|
|
698
|
+
0,
|
|
699
|
+
this.component.lib.executeSubscriptionLifecycle,
|
|
700
|
+
{
|
|
701
|
+
apiKey: this.apiKey,
|
|
702
|
+
serverIdx: this.serverIdx,
|
|
703
|
+
serverURL: this.serverURL,
|
|
704
|
+
subscriptionId: subscription.id,
|
|
705
|
+
operation: "pause",
|
|
706
|
+
previousStatus: subscription.status,
|
|
707
|
+
},
|
|
708
|
+
);
|
|
709
|
+
},
|
|
710
|
+
resume: async (
|
|
711
|
+
ctx: RunSchedulerMutationCtx,
|
|
712
|
+
args: { entityId: string; subscriptionId?: string },
|
|
713
|
+
) => {
|
|
714
|
+
const subscription = args.subscriptionId
|
|
715
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
716
|
+
id: args.subscriptionId,
|
|
717
|
+
})
|
|
718
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
719
|
+
entityId: args.entityId,
|
|
720
|
+
});
|
|
721
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
722
|
+
if (
|
|
723
|
+
subscription.status !== "scheduled_cancel" &&
|
|
724
|
+
subscription.status !== "paused"
|
|
725
|
+
) {
|
|
726
|
+
throw new ConvexError("Subscription is not in a resumable state");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Write optimistic state
|
|
730
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
731
|
+
subscriptionId: subscription.id,
|
|
732
|
+
status: "active",
|
|
733
|
+
cancelAtPeriodEnd: false,
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// Schedule the Creem API call
|
|
737
|
+
await ctx.scheduler.runAfter(
|
|
738
|
+
0,
|
|
739
|
+
this.component.lib.executeSubscriptionLifecycle,
|
|
740
|
+
{
|
|
741
|
+
apiKey: this.apiKey,
|
|
742
|
+
serverIdx: this.serverIdx,
|
|
743
|
+
serverURL: this.serverURL,
|
|
744
|
+
subscriptionId: subscription.id,
|
|
745
|
+
operation: "resume",
|
|
746
|
+
previousStatus: subscription.status,
|
|
747
|
+
previousCancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
748
|
+
},
|
|
749
|
+
);
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Checkout namespace.
|
|
756
|
+
*
|
|
757
|
+
* - `.create()` — create a checkout URL with 3-tier `successUrl` resolution and optional `theme` (Creem API)
|
|
758
|
+
*/
|
|
759
|
+
get checkouts() {
|
|
760
|
+
return {
|
|
761
|
+
create: async (
|
|
762
|
+
ctx: RunActionCtx,
|
|
763
|
+
args: {
|
|
764
|
+
entityId: string;
|
|
765
|
+
userId: string;
|
|
766
|
+
email: string;
|
|
767
|
+
productId: string;
|
|
768
|
+
successUrl?: string;
|
|
769
|
+
fallbackSuccessUrl?: string;
|
|
770
|
+
units?: number;
|
|
771
|
+
metadata?: Record<string, string>;
|
|
772
|
+
discountCode?: string;
|
|
773
|
+
theme?: "light" | "dark";
|
|
774
|
+
},
|
|
775
|
+
): Promise<{ url: string }> => {
|
|
776
|
+
// 3-tier successUrl resolution
|
|
777
|
+
let resolvedSuccessUrl = args.successUrl;
|
|
778
|
+
if (!resolvedSuccessUrl) {
|
|
779
|
+
const product = await ctx.runQuery(this.component.lib.getProduct, {
|
|
780
|
+
id: args.productId,
|
|
781
|
+
});
|
|
782
|
+
resolvedSuccessUrl = product?.defaultSuccessUrl ?? undefined;
|
|
783
|
+
}
|
|
784
|
+
if (!resolvedSuccessUrl) {
|
|
785
|
+
resolvedSuccessUrl = args.fallbackSuccessUrl;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const checkout = await this.createCheckoutSession(ctx, {
|
|
789
|
+
productId: args.productId,
|
|
790
|
+
entityId: args.entityId,
|
|
791
|
+
userId: args.userId,
|
|
792
|
+
email: args.email,
|
|
793
|
+
...(resolvedSuccessUrl ? { successUrl: resolvedSuccessUrl } : {}),
|
|
794
|
+
units: args.units,
|
|
795
|
+
metadata: args.metadata,
|
|
796
|
+
});
|
|
797
|
+
let checkoutUrl = checkout.checkoutUrl;
|
|
798
|
+
if (!checkoutUrl)
|
|
799
|
+
throw new ConvexError("Checkout URL missing from Creem response");
|
|
800
|
+
if (args.theme) {
|
|
801
|
+
const separator = checkoutUrl.includes("?") ? "&" : "?";
|
|
802
|
+
checkoutUrl = `${checkoutUrl}${separator}theme=${args.theme}`;
|
|
803
|
+
}
|
|
804
|
+
return { url: checkoutUrl };
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Product namespace. All reads come from the local Convex DB (synced via webhooks).
|
|
811
|
+
*
|
|
812
|
+
* - `.list()` — all synced products (public, no `entityId` needed)
|
|
813
|
+
* - `.get()` — single product by Creem product ID
|
|
814
|
+
*/
|
|
815
|
+
get products() {
|
|
816
|
+
return {
|
|
817
|
+
list: (ctx: RunQueryCtx, options?: { includeArchived?: boolean }) =>
|
|
818
|
+
this.listProducts(ctx, options),
|
|
819
|
+
get: (ctx: RunQueryCtx, { productId }: { productId: string }) =>
|
|
820
|
+
this.getProduct(ctx, { productId }),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Customer namespace.
|
|
826
|
+
*
|
|
827
|
+
* - `.retrieve()` — customer record by billing entity (Convex DB)
|
|
828
|
+
* - `.portalUrl()` — generate a Creem customer billing portal URL (Creem API)
|
|
829
|
+
*/
|
|
830
|
+
get customers() {
|
|
831
|
+
return {
|
|
832
|
+
retrieve: (ctx: RunQueryCtx, { entityId }: { entityId: string }) =>
|
|
833
|
+
this.getCustomerByEntityId(ctx, entityId),
|
|
834
|
+
portalUrl: (ctx: RunActionCtx, { entityId }: { entityId: string }) =>
|
|
835
|
+
this.createCustomerPortalSession(ctx, { entityId }),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Order namespace.
|
|
841
|
+
*
|
|
842
|
+
* - `.list()` — paid one-time orders for a billing entity (Convex DB)
|
|
843
|
+
*/
|
|
844
|
+
get orders() {
|
|
845
|
+
return {
|
|
846
|
+
list: (ctx: RunQueryCtx, { entityId }: { entityId: string }) =>
|
|
847
|
+
this.listUserOrders(ctx, { entityId }),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ── Component helpers (public, flat) ──────────────────────
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Composite billing model for connected widgets.
|
|
855
|
+
*
|
|
856
|
+
* Aggregates snapshot + products + subscriptions + orders into a single
|
|
857
|
+
* object that `<Subscription.Root>` and `<Product.Root>` widgets consume.
|
|
858
|
+
*
|
|
859
|
+
* Graceful when `entityId` is `null` — returns public product catalog only
|
|
860
|
+
* (useful for unauthenticated pricing pages).
|
|
861
|
+
*
|
|
862
|
+
* @param ctx - Convex query context
|
|
863
|
+
* @param options.entityId - Billing entity ID, or `null` for public-only data
|
|
864
|
+
* @param options.user - User info for the UI (widgets display email, etc.)
|
|
865
|
+
*/
|
|
866
|
+
async getBillingModel(
|
|
867
|
+
ctx: RunQueryCtx,
|
|
868
|
+
{
|
|
869
|
+
entityId,
|
|
870
|
+
user,
|
|
871
|
+
}: {
|
|
872
|
+
entityId: string | null;
|
|
873
|
+
user?: { _id: string; email: string } | null;
|
|
874
|
+
},
|
|
875
|
+
) {
|
|
876
|
+
const products = await this.listProducts(ctx);
|
|
877
|
+
if (!entityId) {
|
|
878
|
+
return {
|
|
879
|
+
user: user ?? null,
|
|
880
|
+
billingSnapshot: null as BillingSnapshot | null,
|
|
881
|
+
allProducts: products,
|
|
882
|
+
ownedProductIds: [] as string[],
|
|
883
|
+
subscriptionProductId: null as string | null,
|
|
884
|
+
activeSubscriptions: [] as Array<{
|
|
885
|
+
id: string;
|
|
886
|
+
productId: string;
|
|
887
|
+
status: string;
|
|
888
|
+
cancelAtPeriodEnd: boolean;
|
|
889
|
+
currentPeriodEnd: string | null;
|
|
890
|
+
currentPeriodStart: string;
|
|
891
|
+
seats: number | null;
|
|
892
|
+
recurringInterval: string | null;
|
|
893
|
+
trialEnd: string | null;
|
|
894
|
+
}>,
|
|
895
|
+
hasCreemCustomer: false,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
const [
|
|
899
|
+
billingSnapshot,
|
|
900
|
+
subscription,
|
|
901
|
+
activeSubscriptions,
|
|
902
|
+
customer,
|
|
903
|
+
orders,
|
|
904
|
+
] = await Promise.all([
|
|
905
|
+
this.getBillingSnapshot(ctx, { entityId }),
|
|
906
|
+
this.getCurrentSubscription(ctx, { entityId }),
|
|
907
|
+
this.listUserSubscriptions(ctx, { entityId }),
|
|
908
|
+
this.getCustomerByEntityId(ctx, entityId),
|
|
909
|
+
this.listUserOrders(ctx, { entityId }),
|
|
910
|
+
]);
|
|
911
|
+
const ownedProductIds = [...new Set(orders.map((o) => o.productId))];
|
|
912
|
+
return {
|
|
913
|
+
user: user ?? null,
|
|
914
|
+
billingSnapshot,
|
|
915
|
+
allProducts: products,
|
|
916
|
+
ownedProductIds,
|
|
917
|
+
subscriptionProductId: subscription?.productId ?? null,
|
|
918
|
+
activeSubscriptions: activeSubscriptions.map((s) => ({
|
|
919
|
+
id: s.id,
|
|
920
|
+
productId: s.productId,
|
|
921
|
+
status: s.status,
|
|
922
|
+
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
|
923
|
+
currentPeriodEnd: s.currentPeriodEnd,
|
|
924
|
+
currentPeriodStart: s.currentPeriodStart,
|
|
925
|
+
seats: s.seats,
|
|
926
|
+
recurringInterval: s.recurringInterval,
|
|
927
|
+
trialEnd: s.trialEnd ?? null,
|
|
928
|
+
})),
|
|
929
|
+
hasCreemCustomer: customer != null,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── api({ resolve }) convenience ──────────────────────────
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Generate ready-to-export Convex function definitions.
|
|
937
|
+
*
|
|
938
|
+
* Each function calls your `resolve` callback to authenticate the user
|
|
939
|
+
* and determine the billing entity, then delegates to the corresponding
|
|
940
|
+
* namespace method. Destructure and re-export in your `convex/billing.ts`.
|
|
941
|
+
*
|
|
942
|
+
* For full control, use the namespace getters directly instead
|
|
943
|
+
* (e.g. `creem.subscriptions.cancel(ctx, { entityId })`).
|
|
944
|
+
*
|
|
945
|
+
* @param options.resolve - Auth callback that returns `{ userId, email, entityId }`
|
|
946
|
+
* @returns Object with `uiModel`, `snapshot`, `checkouts`, `subscriptions`, `products`, `customers`, `orders`
|
|
947
|
+
*
|
|
948
|
+
* @example
|
|
949
|
+
* ```ts
|
|
950
|
+
* const { uiModel, checkouts, subscriptions } = creem.api({ resolve });
|
|
951
|
+
* export { uiModel };
|
|
952
|
+
* export const checkoutsCreate = checkouts.create;
|
|
953
|
+
* ```
|
|
954
|
+
*/
|
|
955
|
+
api({ resolve }: { resolve: ApiResolver }) {
|
|
956
|
+
return {
|
|
957
|
+
uiModel: queryGeneric({
|
|
958
|
+
args: {},
|
|
959
|
+
returns: v.any(),
|
|
960
|
+
handler: async (ctx) => {
|
|
961
|
+
let resolved: {
|
|
962
|
+
userId: string;
|
|
963
|
+
email: string;
|
|
964
|
+
entityId: string;
|
|
965
|
+
} | null = null;
|
|
966
|
+
try {
|
|
967
|
+
resolved = await resolve(ctx);
|
|
968
|
+
} catch {
|
|
969
|
+
// No authenticated user — return unauthenticated model
|
|
970
|
+
}
|
|
971
|
+
return await this.getBillingModel(ctx, {
|
|
972
|
+
entityId: resolved?.entityId ?? null,
|
|
973
|
+
user: resolved
|
|
974
|
+
? { _id: resolved.userId, email: resolved.email }
|
|
975
|
+
: null,
|
|
976
|
+
});
|
|
977
|
+
},
|
|
978
|
+
}),
|
|
979
|
+
snapshot: queryGeneric({
|
|
980
|
+
args: {},
|
|
981
|
+
returns: v.any(),
|
|
982
|
+
handler: async (ctx) => {
|
|
983
|
+
let resolved: { entityId: string } | null = null;
|
|
984
|
+
try {
|
|
985
|
+
resolved = await resolve(ctx);
|
|
986
|
+
} catch {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
if (!resolved) return null;
|
|
990
|
+
return await this.getBillingSnapshot(ctx, {
|
|
991
|
+
entityId: resolved.entityId,
|
|
992
|
+
});
|
|
993
|
+
},
|
|
994
|
+
}),
|
|
995
|
+
checkouts: {
|
|
996
|
+
create: actionGeneric({
|
|
997
|
+
args: checkoutCreateArgs,
|
|
998
|
+
returns: v.object({ url: v.string() }),
|
|
999
|
+
handler: async (ctx, args) => {
|
|
1000
|
+
const { entityId, userId, email } = await resolve(ctx);
|
|
1001
|
+
return await this.checkouts.create(ctx, {
|
|
1002
|
+
entityId,
|
|
1003
|
+
userId,
|
|
1004
|
+
email,
|
|
1005
|
+
...args,
|
|
1006
|
+
});
|
|
1007
|
+
},
|
|
1008
|
+
}),
|
|
1009
|
+
},
|
|
1010
|
+
subscriptions: {
|
|
1011
|
+
update: mutationGeneric({
|
|
1012
|
+
args: subscriptionUpdateArgs,
|
|
1013
|
+
handler: async (ctx, args) => {
|
|
1014
|
+
const { entityId } = await resolve(ctx);
|
|
1015
|
+
if (args.productId && args.units)
|
|
1016
|
+
throw new ConvexError("Provide productId OR units, not both");
|
|
1017
|
+
if (!args.productId && !args.units)
|
|
1018
|
+
throw new ConvexError("Provide productId or units");
|
|
1019
|
+
|
|
1020
|
+
// Resolve current subscription
|
|
1021
|
+
const subscription = args.subscriptionId
|
|
1022
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
1023
|
+
id: args.subscriptionId,
|
|
1024
|
+
})
|
|
1025
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
1026
|
+
entityId,
|
|
1027
|
+
});
|
|
1028
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
1029
|
+
|
|
1030
|
+
// Write optimistic state
|
|
1031
|
+
// For plan switches, also protect current seats from stale webhook data
|
|
1032
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
1033
|
+
subscriptionId: subscription.id,
|
|
1034
|
+
...(args.units != null ? { seats: args.units } : {}),
|
|
1035
|
+
...(args.productId ? { productId: args.productId } : {}),
|
|
1036
|
+
...(args.productId && args.units == null
|
|
1037
|
+
? { seats: subscription.seats ?? null }
|
|
1038
|
+
: {}),
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// Schedule the Creem API call (runs async, reverts on error)
|
|
1042
|
+
await ctx.scheduler.runAfter(
|
|
1043
|
+
0,
|
|
1044
|
+
this.component.lib.executeSubscriptionUpdate,
|
|
1045
|
+
{
|
|
1046
|
+
apiKey: this.apiKey,
|
|
1047
|
+
serverIdx: this.serverIdx,
|
|
1048
|
+
serverURL: this.serverURL,
|
|
1049
|
+
subscriptionId: subscription.id,
|
|
1050
|
+
productId: args.productId,
|
|
1051
|
+
units: args.units,
|
|
1052
|
+
updateBehavior: args.updateBehavior,
|
|
1053
|
+
previousSeats: subscription.seats ?? undefined,
|
|
1054
|
+
previousProductId: subscription.productId,
|
|
1055
|
+
},
|
|
1056
|
+
);
|
|
1057
|
+
},
|
|
1058
|
+
}),
|
|
1059
|
+
cancel: mutationGeneric({
|
|
1060
|
+
args: subscriptionCancelArgs,
|
|
1061
|
+
handler: async (ctx, args) => {
|
|
1062
|
+
const { entityId } = await resolve(ctx);
|
|
1063
|
+
const subscription = args.subscriptionId
|
|
1064
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
1065
|
+
id: args.subscriptionId,
|
|
1066
|
+
})
|
|
1067
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
1068
|
+
entityId,
|
|
1069
|
+
});
|
|
1070
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
1071
|
+
if (
|
|
1072
|
+
subscription.status !== "active" &&
|
|
1073
|
+
subscription.status !== "trialing"
|
|
1074
|
+
) {
|
|
1075
|
+
throw new ConvexError("Subscription is not active");
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Resolve cancel mode: explicit arg > config default > omit (Creem decides)
|
|
1079
|
+
const immediate =
|
|
1080
|
+
args.revokeImmediately ??
|
|
1081
|
+
(this.config.cancelMode === "immediate" ? true : undefined);
|
|
1082
|
+
const isImmediate = immediate === true;
|
|
1083
|
+
|
|
1084
|
+
// Write optimistic state
|
|
1085
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
1086
|
+
subscriptionId: subscription.id,
|
|
1087
|
+
...(isImmediate
|
|
1088
|
+
? { status: "canceled", cancelAtPeriodEnd: false }
|
|
1089
|
+
: { cancelAtPeriodEnd: true }),
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
// Resolve cancel mode string for the action
|
|
1093
|
+
const cancelMode = isImmediate
|
|
1094
|
+
? "immediate"
|
|
1095
|
+
: immediate === false || this.config.cancelMode === "scheduled"
|
|
1096
|
+
? "scheduled"
|
|
1097
|
+
: undefined;
|
|
1098
|
+
|
|
1099
|
+
// Schedule the Creem API call
|
|
1100
|
+
await ctx.scheduler.runAfter(
|
|
1101
|
+
0,
|
|
1102
|
+
this.component.lib.executeSubscriptionLifecycle,
|
|
1103
|
+
{
|
|
1104
|
+
apiKey: this.apiKey,
|
|
1105
|
+
serverIdx: this.serverIdx,
|
|
1106
|
+
serverURL: this.serverURL,
|
|
1107
|
+
subscriptionId: subscription.id,
|
|
1108
|
+
operation: "cancel",
|
|
1109
|
+
cancelMode,
|
|
1110
|
+
previousStatus: subscription.status,
|
|
1111
|
+
previousCancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
1112
|
+
},
|
|
1113
|
+
);
|
|
1114
|
+
},
|
|
1115
|
+
}),
|
|
1116
|
+
resume: mutationGeneric({
|
|
1117
|
+
args: subscriptionResumeArgs,
|
|
1118
|
+
handler: async (ctx, args) => {
|
|
1119
|
+
const { entityId } = await resolve(ctx);
|
|
1120
|
+
const subscription = args.subscriptionId
|
|
1121
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
1122
|
+
id: args.subscriptionId,
|
|
1123
|
+
})
|
|
1124
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
1125
|
+
entityId,
|
|
1126
|
+
});
|
|
1127
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
1128
|
+
if (
|
|
1129
|
+
subscription.status !== "scheduled_cancel" &&
|
|
1130
|
+
subscription.status !== "paused"
|
|
1131
|
+
) {
|
|
1132
|
+
throw new ConvexError("Subscription is not in a resumable state");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Write optimistic state
|
|
1136
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
1137
|
+
subscriptionId: subscription.id,
|
|
1138
|
+
status: "active",
|
|
1139
|
+
cancelAtPeriodEnd: false,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Schedule the Creem API call
|
|
1143
|
+
await ctx.scheduler.runAfter(
|
|
1144
|
+
0,
|
|
1145
|
+
this.component.lib.executeSubscriptionLifecycle,
|
|
1146
|
+
{
|
|
1147
|
+
apiKey: this.apiKey,
|
|
1148
|
+
serverIdx: this.serverIdx,
|
|
1149
|
+
serverURL: this.serverURL,
|
|
1150
|
+
subscriptionId: subscription.id,
|
|
1151
|
+
operation: "resume",
|
|
1152
|
+
previousStatus: subscription.status,
|
|
1153
|
+
previousCancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
1154
|
+
},
|
|
1155
|
+
);
|
|
1156
|
+
},
|
|
1157
|
+
}),
|
|
1158
|
+
pause: mutationGeneric({
|
|
1159
|
+
args: subscriptionPauseArgs,
|
|
1160
|
+
handler: async (ctx, args) => {
|
|
1161
|
+
const { entityId } = await resolve(ctx);
|
|
1162
|
+
const subscription = args.subscriptionId
|
|
1163
|
+
? await ctx.runQuery(this.component.lib.getSubscription, {
|
|
1164
|
+
id: args.subscriptionId,
|
|
1165
|
+
})
|
|
1166
|
+
: await ctx.runQuery(this.component.lib.getCurrentSubscription, {
|
|
1167
|
+
entityId,
|
|
1168
|
+
});
|
|
1169
|
+
if (!subscription) throw new ConvexError("Subscription not found");
|
|
1170
|
+
if (
|
|
1171
|
+
subscription.status !== "active" &&
|
|
1172
|
+
subscription.status !== "trialing"
|
|
1173
|
+
) {
|
|
1174
|
+
throw new ConvexError("Subscription is not active");
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Write optimistic state
|
|
1178
|
+
await ctx.runMutation(this.component.lib.patchSubscription, {
|
|
1179
|
+
subscriptionId: subscription.id,
|
|
1180
|
+
status: "paused",
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
// Schedule the Creem API call
|
|
1184
|
+
await ctx.scheduler.runAfter(
|
|
1185
|
+
0,
|
|
1186
|
+
this.component.lib.executeSubscriptionLifecycle,
|
|
1187
|
+
{
|
|
1188
|
+
apiKey: this.apiKey,
|
|
1189
|
+
serverIdx: this.serverIdx,
|
|
1190
|
+
serverURL: this.serverURL,
|
|
1191
|
+
subscriptionId: subscription.id,
|
|
1192
|
+
operation: "pause",
|
|
1193
|
+
previousStatus: subscription.status,
|
|
1194
|
+
},
|
|
1195
|
+
);
|
|
1196
|
+
},
|
|
1197
|
+
}),
|
|
1198
|
+
list: queryGeneric({
|
|
1199
|
+
args: {},
|
|
1200
|
+
returns: v.any(),
|
|
1201
|
+
handler: async (ctx) => {
|
|
1202
|
+
const { entityId } = await resolve(ctx);
|
|
1203
|
+
return await this.subscriptions.list(ctx, { entityId });
|
|
1204
|
+
},
|
|
1205
|
+
}),
|
|
1206
|
+
listAll: queryGeneric({
|
|
1207
|
+
args: {},
|
|
1208
|
+
returns: v.array(
|
|
1209
|
+
v.object({
|
|
1210
|
+
...schema.tables.subscriptions.validator.fields,
|
|
1211
|
+
product: v.union(schema.tables.products.validator, v.null()),
|
|
1212
|
+
}),
|
|
1213
|
+
),
|
|
1214
|
+
handler: async (ctx) => {
|
|
1215
|
+
const { entityId } = await resolve(ctx);
|
|
1216
|
+
return await this.subscriptions.listAll(ctx, { entityId });
|
|
1217
|
+
},
|
|
1218
|
+
}),
|
|
1219
|
+
},
|
|
1220
|
+
products: {
|
|
1221
|
+
list: queryGeneric({
|
|
1222
|
+
args: {},
|
|
1223
|
+
handler: async (ctx) => {
|
|
1224
|
+
return await this.products.list(ctx);
|
|
1225
|
+
},
|
|
1226
|
+
}),
|
|
1227
|
+
get: queryGeneric({
|
|
1228
|
+
args: { productId: v.string() },
|
|
1229
|
+
returns: v.union(schema.tables.products.validator, v.null()),
|
|
1230
|
+
handler: async (ctx, args) => {
|
|
1231
|
+
return await this.products.get(ctx, { productId: args.productId });
|
|
1232
|
+
},
|
|
1233
|
+
}),
|
|
1234
|
+
},
|
|
1235
|
+
customers: {
|
|
1236
|
+
retrieve: queryGeneric({
|
|
1237
|
+
args: {},
|
|
1238
|
+
returns: v.union(schema.tables.customers.validator, v.null()),
|
|
1239
|
+
handler: async (ctx) => {
|
|
1240
|
+
const { entityId } = await resolve(ctx);
|
|
1241
|
+
return await this.customers.retrieve(ctx, { entityId });
|
|
1242
|
+
},
|
|
1243
|
+
}),
|
|
1244
|
+
portalUrl: actionGeneric({
|
|
1245
|
+
args: {},
|
|
1246
|
+
returns: v.object({ url: v.string() }),
|
|
1247
|
+
handler: async (ctx) => {
|
|
1248
|
+
const { entityId } = await resolve(ctx);
|
|
1249
|
+
return await this.customers.portalUrl(ctx, { entityId });
|
|
1250
|
+
},
|
|
1251
|
+
}),
|
|
1252
|
+
},
|
|
1253
|
+
orders: {
|
|
1254
|
+
list: queryGeneric({
|
|
1255
|
+
args: {},
|
|
1256
|
+
returns: v.array(schema.tables.orders.validator),
|
|
1257
|
+
handler: async (ctx) => {
|
|
1258
|
+
const { entityId } = await resolve(ctx);
|
|
1259
|
+
return await this.orders.list(ctx, { entityId });
|
|
1260
|
+
},
|
|
1261
|
+
}),
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Register the Creem webhook HTTP route on your Convex `httpRouter`.
|
|
1268
|
+
*
|
|
1269
|
+
* Automatically handles `checkout.completed`, `subscription.*`, and `product.*`
|
|
1270
|
+
* events — upserts customers, subscriptions, orders, and products in the Convex DB.
|
|
1271
|
+
*
|
|
1272
|
+
* @param http - Your Convex HTTP router (from `httpRouter()`)
|
|
1273
|
+
* @param options.path - Webhook endpoint path (default: `"/creem/events"`)
|
|
1274
|
+
* @param options.events - Optional custom handlers that run **after** built-in processing
|
|
1275
|
+
*
|
|
1276
|
+
* @example
|
|
1277
|
+
* ```ts
|
|
1278
|
+
* const http = httpRouter();
|
|
1279
|
+
* creem.registerRoutes(http, {
|
|
1280
|
+
* events: {
|
|
1281
|
+
* "checkout.completed": async (ctx, event) => { ... },
|
|
1282
|
+
* },
|
|
1283
|
+
* });
|
|
1284
|
+
* ```
|
|
1285
|
+
*/
|
|
1286
|
+
registerRoutes(
|
|
1287
|
+
http: HttpRouter,
|
|
1288
|
+
{
|
|
1289
|
+
path = "/creem/events",
|
|
1290
|
+
events,
|
|
1291
|
+
}: {
|
|
1292
|
+
path?: string;
|
|
1293
|
+
events?: WebhookEventHandlers;
|
|
1294
|
+
} = {},
|
|
1295
|
+
) {
|
|
1296
|
+
const mergedEvents: WebhookEventHandlers = { ...events };
|
|
1297
|
+
|
|
1298
|
+
http.route({
|
|
1299
|
+
path,
|
|
1300
|
+
method: "POST",
|
|
1301
|
+
handler: httpActionGeneric(async (ctx, request) => {
|
|
1302
|
+
if (!request.body) {
|
|
1303
|
+
throw new ConvexError("No body");
|
|
1304
|
+
}
|
|
1305
|
+
const body = await request.text();
|
|
1306
|
+
const headers: Record<string, string> = {};
|
|
1307
|
+
request.headers.forEach((value, key) => {
|
|
1308
|
+
headers[key] = value;
|
|
1309
|
+
});
|
|
1310
|
+
try {
|
|
1311
|
+
await this.verifyWebhook(body, headers);
|
|
1312
|
+
const event = JSON.parse(body) as CreemWebhookEvent;
|
|
1313
|
+
const eventType = getEventType(event);
|
|
1314
|
+
const eventData = getEventData(event);
|
|
1315
|
+
|
|
1316
|
+
console.log(
|
|
1317
|
+
`[creem-webhook] eventType=${eventType}`,
|
|
1318
|
+
`body=${JSON.stringify(event)}`,
|
|
1319
|
+
);
|
|
1320
|
+
|
|
1321
|
+
if (
|
|
1322
|
+
eventData &&
|
|
1323
|
+
typeof eventData === "object" &&
|
|
1324
|
+
eventType.startsWith("checkout.")
|
|
1325
|
+
) {
|
|
1326
|
+
const raw = eventData as Record<string, unknown>;
|
|
1327
|
+
const checkout = parseCheckout(raw);
|
|
1328
|
+
if (checkout && eventType === "checkout.completed") {
|
|
1329
|
+
// Auto-create customer record from checkout metadata
|
|
1330
|
+
const customerObj =
|
|
1331
|
+
typeof checkout.customer === "object"
|
|
1332
|
+
? checkout.customer
|
|
1333
|
+
: undefined;
|
|
1334
|
+
const customerId = getCustomerId(customerObj);
|
|
1335
|
+
const entityId = getConvexEntityId(checkout.metadata);
|
|
1336
|
+
await this.upsertCustomerFromWebhook(
|
|
1337
|
+
ctx,
|
|
1338
|
+
customerId,
|
|
1339
|
+
entityId,
|
|
1340
|
+
customerObj as CustomerEntity | undefined,
|
|
1341
|
+
);
|
|
1342
|
+
|
|
1343
|
+
// Process embedded subscription if present (recurring checkout).
|
|
1344
|
+
// checkoutEntityFromJSON already parsed it into a typed SubscriptionEntity,
|
|
1345
|
+
// so use it directly — do NOT re-parse through subscriptionEntityFromJSON.
|
|
1346
|
+
if (
|
|
1347
|
+
checkout.subscription &&
|
|
1348
|
+
typeof checkout.subscription === "object"
|
|
1349
|
+
) {
|
|
1350
|
+
const embeddedSub = checkout.subscription as SubscriptionEntity;
|
|
1351
|
+
// Recover metadata: SDK strips it from SubscriptionEntity.
|
|
1352
|
+
// Use checkout-level metadata as fallback (same convexUserId).
|
|
1353
|
+
const embeddedRaw = (raw.subscription ?? {}) as Record<
|
|
1354
|
+
string,
|
|
1355
|
+
unknown
|
|
1356
|
+
>;
|
|
1357
|
+
const rawMeta = (embeddedRaw.metadata ??
|
|
1358
|
+
checkout.metadata ??
|
|
1359
|
+
{}) as Record<string, unknown>;
|
|
1360
|
+
const subscription = convertToDatabaseSubscription(
|
|
1361
|
+
embeddedSub,
|
|
1362
|
+
{ rawMetadata: rawMeta },
|
|
1363
|
+
);
|
|
1364
|
+
await ctx.runMutation(this.component.lib.createSubscription, {
|
|
1365
|
+
subscription,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Store the order (present for both one-time and subscription checkouts)
|
|
1370
|
+
if (checkout.order && typeof checkout.order === "object") {
|
|
1371
|
+
const o = checkout.order as Record<string, unknown>;
|
|
1372
|
+
const order = convertToOrder(
|
|
1373
|
+
{
|
|
1374
|
+
id: o.id as string,
|
|
1375
|
+
customer: (o.customer as string) ?? null,
|
|
1376
|
+
product: o.product as string,
|
|
1377
|
+
amount: o.amount as number,
|
|
1378
|
+
currency: o.currency as string,
|
|
1379
|
+
status: o.status as string,
|
|
1380
|
+
type: o.type as string,
|
|
1381
|
+
transaction: (o.transaction as string) ?? null,
|
|
1382
|
+
subTotal: o.subTotal as number | undefined,
|
|
1383
|
+
sub_total: o.sub_total as number | undefined,
|
|
1384
|
+
taxAmount: o.taxAmount as number | undefined,
|
|
1385
|
+
tax_amount: o.tax_amount as number | undefined,
|
|
1386
|
+
discountAmount: o.discountAmount as number | undefined,
|
|
1387
|
+
discount_amount: o.discount_amount as number | undefined,
|
|
1388
|
+
amountDue: o.amountDue as number | undefined,
|
|
1389
|
+
amount_due: o.amount_due as number | undefined,
|
|
1390
|
+
amountPaid: o.amountPaid as number | undefined,
|
|
1391
|
+
amount_paid: o.amount_paid as number | undefined,
|
|
1392
|
+
discount: (o.discount as string) ?? null,
|
|
1393
|
+
affiliate: (o.affiliate as string) ?? null,
|
|
1394
|
+
mode: o.mode as string | undefined,
|
|
1395
|
+
createdAt: o.createdAt as Date | string | undefined,
|
|
1396
|
+
created_at: o.created_at as string | undefined,
|
|
1397
|
+
updatedAt: o.updatedAt as Date | string | undefined,
|
|
1398
|
+
updated_at: o.updated_at as string | undefined,
|
|
1399
|
+
},
|
|
1400
|
+
{
|
|
1401
|
+
checkoutId: checkout.id,
|
|
1402
|
+
metadata: checkout.metadata as
|
|
1403
|
+
| Record<string, unknown>
|
|
1404
|
+
| undefined,
|
|
1405
|
+
},
|
|
1406
|
+
);
|
|
1407
|
+
await ctx.runMutation(this.component.lib.createOrder, {
|
|
1408
|
+
order,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (
|
|
1415
|
+
eventData &&
|
|
1416
|
+
typeof eventData === "object" &&
|
|
1417
|
+
eventType.startsWith("subscription.")
|
|
1418
|
+
) {
|
|
1419
|
+
const raw = eventData as Record<string, unknown>;
|
|
1420
|
+
const parsed = parseSubscription(raw);
|
|
1421
|
+
if (parsed) {
|
|
1422
|
+
// Pass raw metadata since SDK's SubscriptionEntity type strips it
|
|
1423
|
+
const rawMeta = (raw.metadata ?? {}) as Record<string, unknown>;
|
|
1424
|
+
const subscription = convertToDatabaseSubscription(parsed, {
|
|
1425
|
+
rawMetadata: rawMeta,
|
|
1426
|
+
});
|
|
1427
|
+
if (eventType === "subscription.created") {
|
|
1428
|
+
await ctx.runMutation(this.component.lib.createSubscription, {
|
|
1429
|
+
subscription,
|
|
1430
|
+
});
|
|
1431
|
+
} else {
|
|
1432
|
+
await ctx.runMutation(this.component.lib.updateSubscription, {
|
|
1433
|
+
subscription,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Auto-create customer record from subscription metadata
|
|
1438
|
+
const customerEntity =
|
|
1439
|
+
typeof parsed.customer === "object"
|
|
1440
|
+
? (parsed.customer as CustomerEntity)
|
|
1441
|
+
: undefined;
|
|
1442
|
+
const customerId = getCustomerId(parsed.customer);
|
|
1443
|
+
const entityId = getConvexEntityId(
|
|
1444
|
+
raw.metadata ??
|
|
1445
|
+
(parsed as unknown as Record<string, unknown>).metadata,
|
|
1446
|
+
);
|
|
1447
|
+
await this.upsertCustomerFromWebhook(
|
|
1448
|
+
ctx,
|
|
1449
|
+
customerId,
|
|
1450
|
+
entityId,
|
|
1451
|
+
customerEntity,
|
|
1452
|
+
);
|
|
1453
|
+
} else {
|
|
1454
|
+
// Fallback: SDK parsing failed (e.g., unknown status)
|
|
1455
|
+
// Still try to extract subscription ID for update events
|
|
1456
|
+
const subId = typeof raw.id === "string" ? raw.id : null;
|
|
1457
|
+
if (subId) {
|
|
1458
|
+
console.warn(
|
|
1459
|
+
`Could not parse subscription for ${eventType}, id: ${subId}`,
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (
|
|
1466
|
+
eventData &&
|
|
1467
|
+
typeof eventData === "object" &&
|
|
1468
|
+
eventType.startsWith("product.")
|
|
1469
|
+
) {
|
|
1470
|
+
const raw = eventData as Record<string, unknown>;
|
|
1471
|
+
const parsed = parseProduct(raw);
|
|
1472
|
+
if (parsed) {
|
|
1473
|
+
const product = convertToDatabaseProduct(parsed);
|
|
1474
|
+
if (eventType === "product.created") {
|
|
1475
|
+
await ctx.runMutation(this.component.lib.createProduct, {
|
|
1476
|
+
product,
|
|
1477
|
+
});
|
|
1478
|
+
} else {
|
|
1479
|
+
await ctx.runMutation(this.component.lib.updateProduct, {
|
|
1480
|
+
product,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
} else {
|
|
1484
|
+
console.warn(`Could not parse product for ${eventType}`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const handler = mergedEvents[eventType];
|
|
1489
|
+
if (handler) {
|
|
1490
|
+
await handler(ctx, event);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
return new Response("Accepted", { status: 202 });
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
if (error instanceof WebhookVerificationError) {
|
|
1496
|
+
console.error(error);
|
|
1497
|
+
return new Response("Forbidden", { status: 403 });
|
|
1498
|
+
}
|
|
1499
|
+
throw error;
|
|
1500
|
+
}
|
|
1501
|
+
}),
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|