backend-manager 5.0.87 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.87",
3
+ "version": "5.0.88",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -68,14 +68,15 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
68
68
  // Build timestamps
69
69
  const now = powertools.timestamp(new Date(), { output: 'string' });
70
70
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
71
+ const webhookReceivedUNIX = dataAfter.metadata?.received?.timestampUNIX || nowUNIX;
71
72
 
72
73
  // Branch on category
73
74
  let transitionName = null;
74
75
 
75
76
  if (category === 'subscription') {
76
- transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager });
77
+ transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
77
78
  } else if (category === 'one-time') {
78
- transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager });
79
+ transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
79
80
  } else {
80
81
  throw new Error(`Unknown event category: ${category}`);
81
82
  }
@@ -112,7 +113,19 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
112
113
  * 3. Detect and dispatch transition handlers (non-blocking)
113
114
  * 4. Write to user doc + payments-subscriptions
114
115
  */
115
- async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager }) {
116
+ async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
117
+ // Staleness check: skip if a newer webhook already wrote to this resource
118
+ if (resourceId) {
119
+ const existingDoc = await admin.firestore().doc(`payments-subscriptions/${resourceId}`).get();
120
+ if (existingDoc.exists) {
121
+ const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
122
+ if (webhookReceivedUNIX < existingUpdatedUNIX) {
123
+ assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
124
+ return null;
125
+ }
126
+ }
127
+ }
128
+
116
129
  // Read current user doc BEFORE writing (for transition detection)
117
130
  const userDoc = await admin.firestore().doc(`users/${uid}`).get();
118
131
  const userData = userDoc.exists ? userDoc.data() : {};
@@ -191,7 +204,19 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
191
204
  * 2. Detect and dispatch transition handlers (non-blocking)
192
205
  * 3. Write to payments-one-time
193
206
  */
194
- async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager }) {
207
+ async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
208
+ // Staleness check: skip if a newer webhook already wrote to this resource
209
+ if (resourceId) {
210
+ const existingDoc = await admin.firestore().doc(`payments-one-time/${resourceId}`).get();
211
+ if (existingDoc.exists) {
212
+ const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
213
+ if (webhookReceivedUNIX < existingUpdatedUNIX) {
214
+ assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
215
+ return null;
216
+ }
217
+ }
218
+ }
219
+
195
220
  const unified = library.toUnifiedOneTime(resource, {
196
221
  config: Manager.config,
197
222
  eventName: eventType,
@@ -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 (
@@ -29,29 +29,39 @@ const Stripe = {
29
29
 
30
30
  /**
31
31
  * Fetch the latest resource from Stripe's API
32
+ * Falls back to the raw webhook payload if the API call fails
32
33
  *
33
34
  * @param {string} resourceType - 'subscription' | 'invoice' | 'session'
34
35
  * @param {string} resourceId - Stripe resource ID
35
- * @param {object} rawFallback - Fallback data from webhook payload (unused for Stripe — fetches live)
36
- * @param {object} context - Additional context (e.g., { admin }) — unused for Stripe
36
+ * @param {object} rawFallback - Fallback data from webhook payload
37
+ * @param {object} context - Additional context (e.g., { admin })
37
38
  * @returns {object} Full Stripe resource object
38
39
  */
39
40
  async fetchResource(resourceType, resourceId, rawFallback, context) {
40
41
  const stripe = this.init();
41
42
 
42
- if (resourceType === 'subscription') {
43
- return stripe.subscriptions.retrieve(resourceId);
44
- }
43
+ try {
44
+ if (resourceType === 'subscription') {
45
+ return await stripe.subscriptions.retrieve(resourceId);
46
+ }
45
47
 
46
- if (resourceType === 'invoice') {
47
- return stripe.invoices.retrieve(resourceId);
48
- }
48
+ if (resourceType === 'invoice') {
49
+ return await stripe.invoices.retrieve(resourceId);
50
+ }
49
51
 
50
- if (resourceType === 'session') {
51
- return stripe.checkout.sessions.retrieve(resourceId);
52
- }
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
+ }
53
62
 
54
- throw new Error(`Unknown resource type: ${resourceType}`);
63
+ throw e;
64
+ }
55
65
  },
56
66
 
57
67
  /**
@@ -110,7 +110,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
110
110
  notification: {
111
111
  title: settings.title,
112
112
  body: `"${settings.title}" was just published on our blog. It's a great read and we think you'll enjoy the content!`,
113
- click_action: `${Manager.config.brand.url}/blog`,
113
+ click_action: `${Manager.project.websiteUrl}/blog`,
114
114
  icon: Manager.config.brand.images.brandmark,
115
115
  }
116
116
  },
@@ -44,13 +44,12 @@ module.exports = {
44
44
  assistant?.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
45
45
 
46
46
  // Build confirmation redirect URL
47
- const baseUrl = config.brand?.url;
47
+ const baseUrl = Manager.project.websiteUrl;
48
48
  const amount = productType === 'subscription'
49
49
  ? (product.prices?.[frequency]?.amount || 0)
50
50
  : (product.prices?.once?.amount || 0);
51
51
 
52
- const confirmationUrl = new URL('/payment/confirmation', baseUrl);
53
- confirmationUrl.searchParams.set('orderId', '{CHECKOUT_SESSION_ID}');
52
+ let confirmationUrl = new URL('/payment/confirmation', baseUrl);
54
53
  confirmationUrl.searchParams.set('productId', productId);
55
54
  confirmationUrl.searchParams.set('productName', product.name || productId);
56
55
  confirmationUrl.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
@@ -59,13 +58,17 @@ module.exports = {
59
58
  confirmationUrl.searchParams.set('paymentMethod', 'stripe');
60
59
  confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
61
60
  confirmationUrl.searchParams.set('track', 'true');
61
+ // Append orderId as raw string — Stripe replaces {CHECKOUT_SESSION_ID} at redirect
62
+ // time, but only if the braces are NOT URL-encoded
63
+ confirmationUrl = `${confirmationUrl.toString()}&orderId={CHECKOUT_SESSION_ID}`;
62
64
 
63
- const cancelUrl = new URL('/payment/checkout', baseUrl);
65
+ let cancelUrl = new URL('/payment/checkout', baseUrl);
64
66
  cancelUrl.searchParams.set('product', productId);
65
67
  if (frequency) {
66
68
  cancelUrl.searchParams.set('frequency', frequency);
67
69
  }
68
70
  cancelUrl.searchParams.set('payment', 'cancelled');
71
+ cancelUrl = cancelUrl.toString();
69
72
 
70
73
  // Build session params based on product type
71
74
  let sessionParams;
@@ -105,8 +108,8 @@ function buildSubscriptionSession({ priceId, customer, uid, productId, frequency
105
108
  uid: uid,
106
109
  },
107
110
  },
108
- success_url: confirmationUrl.toString(),
109
- cancel_url: cancelUrl.toString(),
111
+ success_url: confirmationUrl,
112
+ cancel_url: cancelUrl,
110
113
  metadata: {
111
114
  uid: uid,
112
115
  productId: productId,
@@ -138,8 +141,8 @@ function buildOneTimeSession({ priceId, customer, uid, productId, product, confi
138
141
  uid: uid,
139
142
  },
140
143
  },
141
- success_url: confirmationUrl.toString(),
142
- cancel_url: cancelUrl.toString(),
144
+ success_url: confirmationUrl,
145
+ cancel_url: cancelUrl,
143
146
  metadata: {
144
147
  uid: uid,
145
148
  productId: productId,
@@ -43,9 +43,7 @@ async function buildContext({ assistant, Manager, user, settings, libraries, req
43
43
  }
44
44
 
45
45
  // Build redirect URI
46
- const redirectUri = assistant.isDevelopment()
47
- ? 'https://localhost:4000/oauth2'
48
- : `${Manager.config.brand.url}/oauth2`;
46
+ const redirectUri = `${Manager.project.websiteUrl}/oauth2`;
49
47
 
50
48
  // If provider not required (e.g., tokenize gets it from encrypted state), skip loading
51
49
  if (!requireProvider) {
@@ -52,9 +52,7 @@ async function processTokenize({ assistant, Manager, admin, settings }) {
52
52
  }
53
53
 
54
54
  // Build redirect URI
55
- const redirectUri = assistant.isDevelopment()
56
- ? 'https://localhost:4000/oauth2'
57
- : `${Manager.config.brand.url}/oauth2`;
55
+ const redirectUri = `${Manager.project.websiteUrl}/oauth2`;
58
56
 
59
57
  // Decrypt and validate state
60
58
  let stateData;