backend-manager 5.0.86 → 5.0.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CLAUDE.md +53 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/base-command.js +5 -1
  4. package/src/cli/commands/serve.js +1 -2
  5. package/src/manager/cron/daily/ghostii-auto-publisher.js +10 -19
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +376 -56
  7. package/src/manager/events/firestore/payments-webhooks/transitions/index.js +148 -0
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +16 -0
  9. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +15 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +15 -0
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +18 -0
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +15 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +14 -0
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +16 -0
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +16 -0
  16. package/src/manager/functions/core/actions/api/user/oauth2.js +2 -6
  17. package/src/manager/index.js +34 -36
  18. package/src/manager/libraries/{stripe.js → payment-processors/stripe.js} +67 -2
  19. package/src/manager/libraries/payment-processors/test.js +141 -0
  20. package/src/manager/routes/app/get.js +5 -22
  21. package/src/manager/routes/handler/post/post.js +1 -1
  22. package/src/manager/routes/payments/intent/post.js +38 -23
  23. package/src/manager/routes/payments/intent/processors/stripe.js +103 -52
  24. package/src/manager/routes/payments/intent/processors/test.js +139 -76
  25. package/src/manager/routes/payments/webhook/post.js +14 -5
  26. package/src/manager/routes/payments/webhook/processors/stripe.js +75 -9
  27. package/src/manager/routes/user/oauth2/_helpers.js +1 -3
  28. package/src/manager/routes/user/oauth2/post.js +1 -3
  29. package/src/manager/schemas/payments/intent/post.js +1 -1
  30. package/src/test/test-accounts.js +10 -1
  31. package/templates/backend-manager-config.json +16 -4
  32. package/test/events/payments/journey-payments-cancel.js +6 -0
  33. package/test/events/payments/journey-payments-failure.js +114 -0
  34. package/test/events/payments/journey-payments-suspend.js +6 -0
  35. package/test/events/payments/journey-payments-trial.js +12 -0
  36. package/test/events/payments/journey-payments-upgrade.js +17 -0
  37. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  38. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  39. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  40. package/test/helpers/stripe-parse-webhook.js +447 -0
  41. package/test/helpers/stripe-to-unified.js +59 -59
  42. package/test/routes/payments/intent.js +3 -3
  43. package/test/routes/payments/webhook.js +2 -2
  44. package/src/manager/libraries/test.js +0 -27
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Payment transition detection and dispatch
3
+ *
4
+ * Compares subscription state before and after a webhook to detect meaningful
5
+ * transitions (e.g., new subscription, payment failed, cancellation).
6
+ * Dispatches to individual handler files for each transition type.
7
+ */
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Detect what transition occurred based on category and before/after state
12
+ *
13
+ * @param {string} category - 'subscription' or 'one-time'
14
+ * @param {object|null} before - Previous state (null for new users / one-time)
15
+ * @param {object} after - New unified state about to be written
16
+ * @param {string} eventType - Original webhook event type (used for one-time detection)
17
+ * @returns {string|null} Transition name or null if no meaningful change
18
+ */
19
+ function detectTransition(category, before, after, eventType) {
20
+ if (category === 'subscription') {
21
+ return detectSubscriptionTransition(before, after);
22
+ }
23
+
24
+ if (category === 'one-time') {
25
+ return detectOneTimeTransition(eventType);
26
+ }
27
+
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Detect subscription state transitions by comparing before and after
33
+ *
34
+ * Checks are ordered by specificity — most specific first to avoid misclassification.
35
+ *
36
+ * @param {object|null} before - Previous users/{uid}.subscription (null/undefined for new users)
37
+ * @param {object} after - New unified subscription
38
+ * @returns {string|null} Transition name
39
+ */
40
+ function detectSubscriptionTransition(before, after) {
41
+ if (!after) {
42
+ return null;
43
+ }
44
+
45
+ const beforeStatus = before?.status;
46
+ const afterStatus = after.status;
47
+
48
+ // 1. new-subscription: basic/null → active paid (handler checks after.trial.claimed for trial info)
49
+ if (isBasicOrNull(before) && afterStatus === 'active' && isPaid(after)) {
50
+ return 'new-subscription';
51
+ }
52
+
53
+ // 2. payment-failed: active → suspended
54
+ if (beforeStatus === 'active' && afterStatus === 'suspended') {
55
+ return 'payment-failed';
56
+ }
57
+
58
+ // 3. payment-recovered: suspended → active
59
+ if (beforeStatus === 'suspended' && afterStatus === 'active') {
60
+ return 'payment-recovered';
61
+ }
62
+
63
+ // 4. cancellation-requested: pending flips from false → true while still active
64
+ if (afterStatus === 'active' && !before?.cancellation?.pending && after.cancellation?.pending) {
65
+ return 'cancellation-requested';
66
+ }
67
+
68
+ // 5. subscription-cancelled: any non-cancelled → cancelled
69
+ if (beforeStatus !== 'cancelled' && afterStatus === 'cancelled') {
70
+ return 'subscription-cancelled';
71
+ }
72
+
73
+ // 6. plan-changed: both active, both paid, different product
74
+ if (
75
+ beforeStatus === 'active'
76
+ && afterStatus === 'active'
77
+ && isPaid(before)
78
+ && isPaid(after)
79
+ && before.product.id !== after.product.id
80
+ ) {
81
+ return 'plan-changed';
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Detect one-time payment transitions from event type
89
+ * Simpler than subscriptions — no before/after comparison needed
90
+ *
91
+ * @param {string} eventType - Webhook event type
92
+ * @returns {string|null} Transition name
93
+ */
94
+ function detectOneTimeTransition(eventType) {
95
+ if (eventType === 'checkout.session.completed') {
96
+ return 'purchase-completed';
97
+ }
98
+
99
+ if (eventType === 'invoice.payment_failed') {
100
+ return 'purchase-failed';
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Dispatch a transition handler (fire-and-forget)
108
+ *
109
+ * @param {string} transitionName - e.g., 'new-subscription', 'payment-failed'
110
+ * @param {string} category - 'subscription' or 'one-time'
111
+ * @param {object} context - Full context passed to the handler
112
+ */
113
+ function dispatch(transitionName, category, context) {
114
+ const { assistant } = context;
115
+
116
+ try {
117
+ const handlerPath = path.join(__dirname, category, `${transitionName}.js`);
118
+ const handler = require(handlerPath);
119
+
120
+ // Fire-and-forget — don't block the main webhook processing
121
+ Promise.resolve(handler(context)).catch((e) => {
122
+ assistant.error(`Transition handler [${category}/${transitionName}] failed: ${e.message}`, e);
123
+ });
124
+ } catch (e) {
125
+ // Handler file doesn't exist or can't be loaded — log but don't fail
126
+ assistant.error(`Transition handler [${category}/${transitionName}] not found: ${e.message}`);
127
+ }
128
+ }
129
+
130
+ // ─── Helpers ───
131
+
132
+ function isBasicOrNull(sub) {
133
+ return !sub || !sub.product || sub.product.id === 'basic';
134
+ }
135
+
136
+ function isPaid(sub) {
137
+ return sub && sub.product && sub.product.id !== 'basic';
138
+ }
139
+
140
+ module.exports = {
141
+ detectTransition,
142
+ detectSubscriptionTransition,
143
+ detectOneTimeTransition,
144
+ dispatch,
145
+ // Exported for testing
146
+ isBasicOrNull,
147
+ isPaid,
148
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Transition: purchase-completed
3
+ * Triggered when a one-time payment checkout completes (checkout.session.completed with mode=payment)
4
+ *
5
+ * Use cases:
6
+ * - Send purchase receipt/confirmation email
7
+ * - Deliver digital goods or credits
8
+ * - Fire analytics event for purchase
9
+ */
10
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
+ assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.id}`);
12
+
13
+ // TODO: Send purchase confirmation email
14
+ // TODO: Deliver digital goods
15
+ // TODO: Fire analytics event
16
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Transition: purchase-failed
3
+ * Triggered when a one-time payment fails (invoice.payment_failed with billing_reason=manual)
4
+ *
5
+ * Use cases:
6
+ * - Send payment failure notification
7
+ * - Include retry link or alternative payment method
8
+ * - Fire analytics event
9
+ */
10
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
+ assistant.log(`Transition [one-time/purchase-failed]: uid=${uid}, resourceId=${after.id}`);
12
+
13
+ // TODO: Send payment failure email with retry link
14
+ // TODO: Fire analytics event
15
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Transition: cancellation-requested
3
+ * Triggered when a user requests cancellation at period end (cancellation.pending flips to true)
4
+ *
5
+ * Use cases:
6
+ * - Send cancellation confirmation email with period end date
7
+ * - Include win-back offer or feedback survey link
8
+ * - Fire analytics event for churn intent
9
+ */
10
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
+ assistant.log(`Transition [subscription/cancellation-requested]: uid=${uid}, product=${after.product.id}, cancelDate=${after.cancellation.date.timestamp}`);
12
+
13
+ // TODO: Send cancellation confirmation email with end date
14
+ // TODO: Fire analytics event
15
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Transition: new-subscription
3
+ * Triggered when a user subscribes for the first time (basic/null → active paid)
4
+ * Check after.trial.claimed to determine if this is a trial subscription
5
+ *
6
+ * Use cases:
7
+ * - Send order confirmation email with plan details (include trial info if applicable)
8
+ * - Fire analytics event for new subscriber
9
+ * - Update marketing lists
10
+ */
11
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
12
+ const isTrial = after.trial?.claimed === true;
13
+
14
+ assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product.id}, frequency=${after.payment.frequency}, trial=${isTrial}`);
15
+
16
+ // TODO: Send order confirmation email (modify content if isTrial)
17
+ // TODO: Fire analytics event
18
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Transition: payment-failed
3
+ * Triggered when a subscription payment fails (active → suspended)
4
+ *
5
+ * Use cases:
6
+ * - Send payment failure notification email
7
+ * - Include link to update payment method
8
+ * - Fire analytics event for churn risk
9
+ */
10
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
+ assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product.id}, previousStatus=${before?.status}`);
12
+
13
+ // TODO: Send payment failure email with update-payment link
14
+ // TODO: Fire analytics event
15
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Transition: payment-recovered
3
+ * Triggered when a suspended subscription is recovered (suspended → active)
4
+ *
5
+ * Use cases:
6
+ * - Send payment recovered confirmation email
7
+ * - Fire analytics event for recovered subscriber
8
+ */
9
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
10
+ assistant.log(`Transition [subscription/payment-recovered]: uid=${uid}, product=${after.product.id}`);
11
+
12
+ // TODO: Send payment recovered email
13
+ // TODO: Fire analytics event
14
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Transition: plan-changed
3
+ * Triggered when a user upgrades or downgrades their plan (product A → product B, both active + paid)
4
+ *
5
+ * Use cases:
6
+ * - Send plan change confirmation email
7
+ * - Include new plan details and what changed
8
+ * - Fire analytics event for upgrade/downgrade
9
+ */
10
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
+ const direction = after.product.id > before.product.id ? 'upgrade' : 'downgrade';
12
+ assistant.log(`Transition [subscription/plan-changed]: uid=${uid}, ${before.product.id} → ${after.product.id} (${direction})`);
13
+
14
+ // TODO: Send plan change email with new plan details
15
+ // TODO: Fire analytics event
16
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Transition: subscription-cancelled
3
+ * Triggered when a subscription is fully cancelled (any non-cancelled → cancelled)
4
+ *
5
+ * Use cases:
6
+ * - Send final cancellation email
7
+ * - Include reactivation link or win-back offer
8
+ * - Fire analytics event for churned subscriber
9
+ * - Clean up any subscription-specific resources
10
+ */
11
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
12
+ assistant.log(`Transition [subscription/subscription-cancelled]: uid=${uid}, previousProduct=${before?.product?.id}, previousStatus=${before?.status}`);
13
+
14
+ // TODO: Send cancellation email with reactivation link
15
+ // TODO: Fire analytics event
16
+ };
@@ -25,14 +25,10 @@ Module.prototype.main = function () {
25
25
  Api.resolveUser({adminRequired: true})
26
26
  .then(async (user) => {
27
27
 
28
- self.ultimateJekyllOAuth2Url = assistant.isDevelopment()
29
- ? `https://localhost:4000/oauth2`
30
- : `${Manager.config.brand.url}/oauth2`
28
+ self.ultimateJekyllOAuth2Url = `${Manager.project.websiteUrl}/oauth2`
31
29
  self.oauth2 = null;
32
30
  self.omittedPayloadFields = ['redirect', 'referrer', 'provider', 'state'];
33
31
 
34
- // self.ultimateJekyllOAuth2Url = `${Manager.config.brand.url}/oauth2`;
35
-
36
32
  // Options
37
33
  // payload.data.payload.uid = payload.data.payload.uid;
38
34
  payload.data.payload.redirect = typeof payload.data.payload.redirect === 'undefined'
@@ -40,7 +36,7 @@ Module.prototype.main = function () {
40
36
  : payload.data.payload.redirect
41
37
 
42
38
  payload.data.payload.referrer = typeof payload.data.payload.referrer === 'undefined'
43
- ? (assistant.isDevelopment() ? `https://localhost:4000/account` : `${Manager.config.brand.url}/account`)
39
+ ? `${Manager.project.websiteUrl}/account`
44
40
  : payload.data.payload.referrer
45
41
 
46
42
  payload.data.payload.serverUrl = typeof payload.data.payload.serverUrl === 'undefined'
@@ -178,12 +178,20 @@ Manager.prototype.init = function (exporter, options) {
178
178
  ? 'http://localhost:5002'
179
179
  : `https://api.${(self.config.brand?.url || '').replace(/^https?:\/\//, '')}`;
180
180
 
181
+ // Set website URL
182
+ // Development: https://localhost:4000 (local hosting)
183
+ // Production: https://{domain} (from brand.url)
184
+ self.project.websiteUrl = self.assistant.isDevelopment()
185
+ ? 'https://localhost:4000'
186
+ : self.config.brand?.url || '';
187
+
181
188
  // Set environment
182
189
  process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.assistant.meta.environment;
183
190
 
184
191
  // Set BEM env variables
185
192
  process.env.BEM_FUNCTIONS_URL = self.project.functionsUrl;
186
193
  process.env.BEM_API_URL = self.project.apiUrl;
194
+ process.env.BEM_WEBSITE_URL = self.project.websiteUrl;
187
195
 
188
196
  // Use the working Firebase logger that they disabled for whatever reason
189
197
  if (
@@ -709,6 +717,14 @@ Manager.prototype.debug = function () {
709
717
  // Setup functions
710
718
  Manager.prototype.setupFunctions = function (exporter, options) {
711
719
  const self = this;
720
+ const resourceZone = options.resourceZone;
721
+
722
+ // Helper to create a function builder with region + runtime options
723
+ function fn(runtimeOptions) {
724
+ return self.libraries.functions
725
+ .runWith(runtimeOptions)
726
+ .region(resourceZone);
727
+ }
712
728
 
713
729
  // Log
714
730
  if (options.log) {
@@ -717,8 +733,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
717
733
 
718
734
  // Setup functions
719
735
  exporter.bm_api =
720
- self.libraries.functions
721
- .runWith({memory: '256MB', timeoutSeconds: 60 * 5})
736
+ fn({memory: '256MB', timeoutSeconds: 60 * 5})
722
737
  .https.onRequest(async (req, res) => {
723
738
  const route = self.BemRouter(req, res).resolve();
724
739
 
@@ -734,8 +749,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
734
749
  // Setup legacy functions
735
750
  if (options.setupFunctionsLegacy) {
736
751
  exporter.bm_signUpHandler =
737
- self.libraries.functions
738
- .runWith({memory: '256MB', timeoutSeconds: 60})
752
+ fn({memory: '256MB', timeoutSeconds: 60})
739
753
  .https.onRequest(async (req, res) => {
740
754
  const Module = require(`${_legacy}/actions/sign-up-handler.js`);
741
755
  Module.init(self, { req: req, res: res, });
@@ -750,8 +764,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
750
764
 
751
765
  // Admin
752
766
  exporter.bm_createPost =
753
- self.libraries.functions
754
- .runWith({memory: '256MB', timeoutSeconds: 60})
767
+ fn({memory: '256MB', timeoutSeconds: 60})
755
768
  .https.onRequest(async (req, res) => {
756
769
  const Module = require(`${_legacy}/admin/create-post.js`);
757
770
  Module.init(self, { req: req, res: res, });
@@ -765,8 +778,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
765
778
  });
766
779
 
767
780
  exporter.bm_firestoreWrite =
768
- self.libraries.functions
769
- .runWith({memory: '256MB', timeoutSeconds: 60})
781
+ fn({memory: '256MB', timeoutSeconds: 60})
770
782
  .https.onRequest(async (req, res) => {
771
783
  const Module = require(`${_legacy}/admin/firestore-write.js`);
772
784
  Module.init(self, { req: req, res: res, });
@@ -780,8 +792,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
780
792
  });
781
793
 
782
794
  exporter.bm_getStats =
783
- self.libraries.functions
784
- .runWith({memory: '256MB', timeoutSeconds: 420})
795
+ fn({memory: '256MB', timeoutSeconds: 420})
785
796
  .https.onRequest(async (req, res) => {
786
797
  const Module = require(`${_legacy}/admin/get-stats.js`);
787
798
  Module.init(self, { req: req, res: res, });
@@ -795,8 +806,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
795
806
  });
796
807
 
797
808
  exporter.bm_sendNotification =
798
- self.libraries.functions
799
- .runWith({memory: '1GB', timeoutSeconds: 420})
809
+ fn({memory: '1GB', timeoutSeconds: 420})
800
810
  .https.onRequest(async (req, res) => {
801
811
  const Module = require(`${_legacy}/admin/send-notification.js`);
802
812
  Module.init(self, { req: req, res: res, });
@@ -810,8 +820,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
810
820
  });
811
821
 
812
822
  exporter.bm_query =
813
- self.libraries.functions
814
- .runWith({memory: '256MB', timeoutSeconds: 60})
823
+ fn({memory: '256MB', timeoutSeconds: 60})
815
824
  .https.onRequest(async (req, res) => {
816
825
  const Module = require(`${_legacy}/admin/query.js`);
817
826
  Module.init(self, { req: req, res: res, });
@@ -825,8 +834,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
825
834
  });
826
835
 
827
836
  exporter.bm_createPostHandler =
828
- self.libraries.functions
829
- .runWith({memory: '256MB', timeoutSeconds: 60})
837
+ fn({memory: '256MB', timeoutSeconds: 60})
830
838
  .https.onRequest(async (req, res) => {
831
839
  const Module = require(`${_legacy}/actions/create-post-handler.js`);
832
840
  Module.init(self, { req: req, res: res, });
@@ -840,8 +848,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
840
848
  });
841
849
 
842
850
  exporter.bm_generateUuid =
843
- self.libraries.functions
844
- .runWith({memory: '256MB', timeoutSeconds: 60})
851
+ fn({memory: '256MB', timeoutSeconds: 60})
845
852
  .https.onRequest(async (req, res) => {
846
853
  const Module = require(`${_legacy}/actions/generate-uuid.js`);
847
854
  Module.init(self, { req: req, res: res, });
@@ -856,8 +863,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
856
863
 
857
864
  // Test
858
865
  exporter.bm_test_authenticate =
859
- self.libraries.functions
860
- .runWith({memory: '256MB', timeoutSeconds: 60})
866
+ fn({memory: '256MB', timeoutSeconds: 60})
861
867
  .https.onRequest(async (req, res) => {
862
868
  const Module = require(`${_legacy}/test/authenticate.js`);
863
869
  Module.init(self, { req: req, res: res, });
@@ -871,8 +877,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
871
877
  });
872
878
 
873
879
  exporter.bm_test_webhook =
874
- self.libraries.functions
875
- .runWith({memory: '256MB', timeoutSeconds: 60})
880
+ fn({memory: '256MB', timeoutSeconds: 60})
876
881
  .https.onRequest(async (req, res) => {
877
882
  const Module = require(`${_legacy}/test/webhook.js`);
878
883
  Module.init(self, { req: req, res: res, });
@@ -889,47 +894,40 @@ Manager.prototype.setupFunctions = function (exporter, options) {
889
894
  // Setup identity functions
890
895
  if (options.setupFunctionsIdentity) {
891
896
  exporter.bm_authBeforeCreate =
892
- self.libraries.functions
893
- .runWith({memory: '256MB', timeoutSeconds: 60})
897
+ fn({memory: '256MB', timeoutSeconds: 60})
894
898
  .auth.user()
895
899
  .beforeCreate((user, context) => self.EventMiddleware({ user, context }).run(`${events}/auth/before-create.js`));
896
900
 
897
901
  exporter.bm_authBeforeSignIn =
898
- self.libraries.functions
899
- .runWith({memory: '256MB', timeoutSeconds: 60})
902
+ fn({memory: '256MB', timeoutSeconds: 60})
900
903
  .auth.user()
901
904
  .beforeSignIn((user, context) => self.EventMiddleware({ user, context }).run(`${events}/auth/before-signin.js`));
902
905
  }
903
906
 
904
907
  // Setup events
905
908
  exporter.bm_authOnCreate =
906
- self.libraries.functions
907
- .runWith({memory: '256MB', timeoutSeconds: 60})
909
+ fn({memory: '256MB', timeoutSeconds: 60})
908
910
  .auth.user()
909
911
  .onCreate((user, context) => self.EventMiddleware({ user, context }).run(`${events}/auth/on-create.js`));
910
912
 
911
913
  exporter.bm_authOnDelete =
912
- self.libraries.functions
913
- .runWith({memory: '256MB', timeoutSeconds: 60})
914
+ fn({memory: '256MB', timeoutSeconds: 60})
914
915
  .auth.user()
915
916
  .onDelete((user, context) => self.EventMiddleware({ user, context }).run(`${events}/auth/on-delete.js`));
916
917
 
917
918
  exporter.bm_notificationsOnWrite =
918
- self.libraries.functions
919
- .runWith({memory: '256MB', timeoutSeconds: 60})
919
+ fn({memory: '256MB', timeoutSeconds: 60})
920
920
  .firestore.document('notifications/{token}')
921
921
  .onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/notifications/on-write.js`));
922
922
 
923
923
  exporter.bm_paymentsWebhookOnWrite =
924
- self.libraries.functions
925
- .runWith({memory: '256MB', timeoutSeconds: 60})
924
+ fn({memory: '256MB', timeoutSeconds: 60})
926
925
  .firestore.document('payments-webhooks/{eventId}')
927
926
  .onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/payments-webhooks/on-write.js`));
928
927
 
929
928
  // Setup cron jobs
930
929
  exporter.bm_cronDaily =
931
- self.libraries.functions
932
- .runWith({ memory: '256MB', timeoutSeconds: 60 * 5})
930
+ fn({memory: '256MB', timeoutSeconds: 60 * 5})
933
931
  .pubsub.schedule('0 0 * * *')
934
932
  .onRun((context) => self.EventMiddleware({ context }).run(`${cron}/daily.js`));
935
933
  };
@@ -5,7 +5,7 @@ let stripeInstance = null;
5
5
 
6
6
  /**
7
7
  * Stripe shared library
8
- * Provides SDK initialization and unified subscription transformation
8
+ * Provides SDK initialization, resource fetching, and unified transformations
9
9
  */
10
10
  const Stripe = {
11
11
  /**
@@ -27,6 +27,43 @@ const Stripe = {
27
27
  return stripeInstance;
28
28
  },
29
29
 
30
+ /**
31
+ * Fetch the latest resource from Stripe's API
32
+ * Falls back to the raw webhook payload if the API call fails
33
+ *
34
+ * @param {string} resourceType - 'subscription' | 'invoice' | 'session'
35
+ * @param {string} resourceId - Stripe resource ID
36
+ * @param {object} rawFallback - Fallback data from webhook payload
37
+ * @param {object} context - Additional context (e.g., { admin })
38
+ * @returns {object} Full Stripe resource object
39
+ */
40
+ async fetchResource(resourceType, resourceId, rawFallback, context) {
41
+ const stripe = this.init();
42
+
43
+ try {
44
+ if (resourceType === 'subscription') {
45
+ return await stripe.subscriptions.retrieve(resourceId);
46
+ }
47
+
48
+ if (resourceType === 'invoice') {
49
+ return await stripe.invoices.retrieve(resourceId);
50
+ }
51
+
52
+ if (resourceType === 'session') {
53
+ return await stripe.checkout.sessions.retrieve(resourceId);
54
+ }
55
+
56
+ throw new Error(`Unknown resource type: ${resourceType}`);
57
+ } catch (e) {
58
+ // If the API call fails but we have raw webhook data, use it
59
+ if (rawFallback && Object.keys(rawFallback).length > 0) {
60
+ return rawFallback;
61
+ }
62
+
63
+ throw e;
64
+ }
65
+ },
66
+
30
67
  /**
31
68
  * Transform a raw Stripe subscription object into the unified subscription shape
32
69
  * This produces the exact same object stored in users/{uid}.subscription
@@ -38,7 +75,7 @@ const Stripe = {
38
75
  * @param {string} options.eventId - ID of the webhook event (e.g., 'evt_xxx')
39
76
  * @returns {object} Unified subscription object
40
77
  */
41
- toUnified(rawSubscription, options) {
78
+ toUnifiedSubscription(rawSubscription, options) {
42
79
  options = options || {};
43
80
  const config = options.config || {};
44
81
 
@@ -94,6 +131,34 @@ const Stripe = {
94
131
  },
95
132
  };
96
133
  },
134
+
135
+ /**
136
+ * Transform a raw Stripe one-time payment resource into a unified shape
137
+ * Stub for now — will be fully implemented when one-time purchases are built out
138
+ *
139
+ * @param {object} rawResource - Raw Stripe resource (session, invoice, etc.)
140
+ * @param {object} options
141
+ * @returns {object} Unified one-time payment object
142
+ */
143
+ toUnifiedOneTime(rawResource, options) {
144
+ options = options || {};
145
+
146
+ const now = powertools.timestamp(new Date(), { output: 'string' });
147
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
148
+
149
+ return {
150
+ id: rawResource.id || null,
151
+ processor: 'stripe',
152
+ status: rawResource.status || 'unknown',
153
+ raw: rawResource,
154
+ metadata: {
155
+ created: {
156
+ timestamp: now,
157
+ timestampUNIX: nowUNIX,
158
+ },
159
+ },
160
+ };
161
+ },
97
162
  };
98
163
 
99
164
  /**