backend-manager 5.0.138 → 5.0.140

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/CHANGELOG.md CHANGED
@@ -14,6 +14,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.0.139] - 2026-03-12
18
+ ### Fixed
19
+ - Chargebee hosted page checkout failing to resolve UID from webhooks because `subscription[meta_data]` is not supported by Chargebee's hosted page API
20
+ - Webhook pipeline now falls back to resolving UID from hosted page `pass_thru_content` when meta_data is missing
21
+
22
+ ### Added
23
+ - `resolveUidFromHostedPage()` in Chargebee library to search recent hosted pages by subscription ID and extract UID from `pass_thru_content`
24
+ - `setMetaData()` in Chargebee library to backfill meta_data on subscriptions and customers after first UID resolution
25
+ - Automatic meta_data backfill on subscription + customer after resolving UID from pass_thru_content, so future webhooks resolve directly
26
+
27
+ ### Changed
28
+ - Chargebee intent now uses `pass_thru_content` instead of `subscription.meta_data` to carry UID/orderId through checkout
29
+
30
+ # [5.0.132] - 2026-03-13
31
+ ### Fixed
32
+ - Abandoned cart cron crashing with `FAILED_PRECONDITION` due to missing `payments-carts` composite index (status + nextReminderAt)
33
+ - Abandoned cart email subject and template using raw `productId` instead of resolved `productName` and `brandName`
34
+
35
+ ### Changed
36
+ - Index sync (`npx bm setup`) now auto-merges local and live indexes instead of prompting to choose one direction
37
+ - Added `payments-carts` composite index to `required-indexes.js`
38
+
17
39
  # [5.0.131] - 2026-03-11
18
40
  ### Changed
19
41
  - Analytics config restructured: consolidated `googleAnalytics`, `meta`, and `tiktok` under unified `analytics.providers` namespace with `google`, `meta`, and `tiktok` keys
package/CLAUDE.md CHANGED
@@ -1044,6 +1044,8 @@ The `test` processor generates Stripe-shaped data and auto-fires webhooks to the
1044
1044
 
1045
1045
  8. **Increment usage before update** - Call `usage.increment()` then `usage.update()`
1046
1046
 
1047
+ 9. **Add Firestore composite indexes for new compound queries** - Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx bm setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
1048
+
1047
1049
  ## Key Files Reference
1048
1050
 
1049
1051
  | Purpose | File |
@@ -1072,6 +1074,7 @@ The `test` processor generates Stripe-shaped data and auto-fires webhooks to the
1072
1074
  | Stripe library | `src/manager/libraries/payment/processors/stripe.js` |
1073
1075
  | PayPal library | `src/manager/libraries/payment/processors/paypal.js` |
1074
1076
  | Order ID generator | `src/manager/libraries/payment/order-id.js` |
1077
+ | Required Firestore indexes (SSOT) | `src/cli/commands/setup-tests/required-indexes.js` |
1075
1078
  | Test accounts | `src/test/test-accounts.js` |
1076
1079
 
1077
1080
  ## Environment Detection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.138",
3
+ "version": "5.0.140",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -2,7 +2,6 @@ const BaseTest = require('./base-test');
2
2
  const jetpack = require('fs-jetpack');
3
3
  const chalk = require('chalk');
4
4
  const _ = require('lodash');
5
- const inquirer = require('inquirer');
6
5
  const powertools = require('node-powertools');
7
6
 
8
7
  class FirestoreIndexesSyncedTest extends BaseTest {
@@ -87,45 +86,101 @@ class FirestoreIndexesSyncedTest extends BaseTest {
87
86
 
88
87
  async fix() {
89
88
  const self = this.self;
89
+ const filePath = `${self.firebaseProjectPath}/firestore.indexes.json`;
90
90
 
91
- const answer = await inquirer.prompt([
92
- {
93
- type: 'list',
94
- name: 'direction',
95
- message: 'Firestore indexes are out of sync. Which direction?',
96
- choices: [
97
- {
98
- name: `Local → Live (replace ${chalk.bold('live')} indexes with ${chalk.bold('local')})`,
99
- value: 'local-to-live',
100
- },
101
- {
102
- name: `Live → Local (replace ${chalk.bold('local')} indexes with ${chalk.bold('live')})`,
103
- value: 'live-to-local',
104
- },
105
- {
106
- name: 'Skip',
107
- value: 'skip',
108
- },
109
- ],
110
- },
111
- ]);
112
-
113
- if (answer.direction === 'live-to-local') {
114
- const commands = require('../index');
115
- const IndexesCommand = commands.IndexesCommand;
116
- const indexesCmd = new IndexesCommand(self);
117
-
118
- await indexesCmd.get(undefined, true);
119
- } else if (answer.direction === 'local-to-live') {
120
- console.log(chalk.yellow(` Deploying local indexes to live...`));
121
-
122
- await powertools.execute('firebase deploy --only firestore:indexes', {
123
- log: true,
124
- cwd: self.firebaseProjectPath,
125
- });
126
-
127
- console.log(chalk.green(` ✓ Live indexes updated from local`));
91
+ // Fetch live indexes
92
+ const commands = require('../index');
93
+ const IndexesCommand = commands.IndexesCommand;
94
+ const indexesCmd = new IndexesCommand(self);
95
+ const tempPath = '_firestore.indexes.json';
96
+ const liveIndexes = await indexesCmd.get(tempPath, false);
97
+ jetpack.remove(`${self.firebaseProjectPath}/${tempPath}`);
98
+
99
+ // Read local indexes
100
+ let localIndexes = { indexes: [], fieldOverrides: [] };
101
+ if (jetpack.exists(filePath)) {
102
+ localIndexes = JSON.parse(jetpack.read(filePath));
103
+ }
104
+
105
+ // Merge: start with local, add any live indexes that don't already exist locally
106
+ const merged = [...(localIndexes.indexes || [])];
107
+ const liveList = (liveIndexes?.indexes || []);
108
+
109
+ for (const liveIdx of liveList) {
110
+ const alreadyExists = merged.some(localIdx => this._normalizedMatch(localIdx, liveIdx));
111
+
112
+ if (!alreadyExists) {
113
+ merged.push(this._stripImplicitFields(liveIdx));
114
+ }
115
+ }
116
+
117
+ // Merge fieldOverrides the same way
118
+ const mergedOverrides = [...(localIndexes.fieldOverrides || [])];
119
+ for (const liveOverride of (liveIndexes?.fieldOverrides || [])) {
120
+ const alreadyExists = mergedOverrides.some(lo => _.isEqual(lo, liveOverride));
121
+
122
+ if (!alreadyExists) {
123
+ mergedOverrides.push(liveOverride);
124
+ }
125
+ }
126
+
127
+ // Write merged result locally
128
+ const result = { indexes: merged, fieldOverrides: mergedOverrides };
129
+ jetpack.write(filePath, JSON.stringify(result, null, 2));
130
+
131
+ const addedCount = merged.length - (localIndexes.indexes || []).length;
132
+ console.log(chalk.green(` ✓ Merged indexes (${merged.length} total, ${addedCount} added from live)`));
133
+
134
+ // Deploy merged indexes to live
135
+ console.log(chalk.yellow(` Deploying merged indexes to live...`));
136
+ await powertools.execute('firebase deploy --only firestore:indexes', {
137
+ log: true,
138
+ cwd: self.firebaseProjectPath,
139
+ });
140
+
141
+ console.log(chalk.green(` ✓ Live indexes synced`));
142
+ }
143
+
144
+ /**
145
+ * Check if two indexes match (ignoring implicit __name__ fields and density)
146
+ */
147
+ _normalizedMatch(a, b) {
148
+ if (!a || !b) {
149
+ return false;
150
+ }
151
+
152
+ if (a.collectionGroup !== b.collectionGroup) {
153
+ return false;
128
154
  }
155
+
156
+ if ((a.queryScope || 'COLLECTION') !== (b.queryScope || 'COLLECTION')) {
157
+ return false;
158
+ }
159
+
160
+ const aFields = (a.fields || []).filter(f => f.fieldPath !== '__name__');
161
+ const bFields = (b.fields || []).filter(f => f.fieldPath !== '__name__');
162
+
163
+ if (aFields.length !== bFields.length) {
164
+ return false;
165
+ }
166
+
167
+ return aFields.every((af, i) => {
168
+ const bf = bFields[i];
169
+ return af.fieldPath === bf.fieldPath
170
+ && (af.order || null) === (bf.order || null)
171
+ && (af.arrayConfig || null) === (bf.arrayConfig || null);
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Strip implicit fields Firebase adds to live indexes (density, __name__)
177
+ */
178
+ _stripImplicitFields(idx) {
179
+ const { density, ...rest } = idx;
180
+ return {
181
+ ...rest,
182
+ fields: (rest.fields || []).filter(f => f.fieldPath !== '__name__'),
183
+ };
129
184
  }
130
185
  }
131
186
 
@@ -35,4 +35,15 @@ module.exports = [
35
35
  { fieldPath: 'owner', order: 'ASCENDING' },
36
36
  ],
37
37
  },
38
+
39
+ // Abandoned cart cron — find pending carts due for reminders
40
+ // Query: .where('status', '==', 'pending').where('nextReminderAt', '<=', nowUNIX)
41
+ {
42
+ collectionGroup: 'payments-carts',
43
+ queryScope: 'COLLECTION',
44
+ fields: [
45
+ { fieldPath: 'status', order: 'ASCENDING' },
46
+ { fieldPath: 'nextReminderAt', order: 'ASCENDING' },
47
+ ],
48
+ },
38
49
  ];
@@ -60,18 +60,25 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
60
60
  // Build checkout URL from cart data
61
61
  const checkoutUrl = buildCheckoutUrl(Manager.project.websiteUrl, data);
62
62
 
63
+ // Resolve product name from config
64
+ const product = (Manager.config.payment?.products || []).find(p => p.id === data.productId);
65
+ const productName = product?.name || data.productId;
66
+ const brandName = Manager.config.brand?.name || '';
67
+
63
68
  // Send reminder email
64
69
  assistant.log(`Sending abandoned cart reminder #${reminderIndex + 1} to uid=${uid}, product=${data.productId}`);
65
70
 
66
71
  sendOrderEmail({
67
72
  template: 'main/order/abandoned-cart',
68
- subject: `Complete your ${data.productId} checkout`,
73
+ subject: `Complete your ${brandName} ${productName} checkout`,
69
74
  categories: ['order/abandoned-cart', `order/abandoned-cart/reminder-${reminderIndex + 1}`],
70
75
  userDoc,
71
76
  assistant,
72
77
  data: {
73
78
  abandonedCart: {
74
79
  productId: data.productId,
80
+ productName: productName,
81
+ brandName: brandName,
75
82
  type: data.type,
76
83
  frequency: data.frequency,
77
84
  reminderNumber: reminderIndex + 1,
@@ -78,9 +78,34 @@ module.exports = async ({ assistant, change, context }) => {
78
78
  }
79
79
  }
80
80
 
81
- // Validate UID — must have one by now (either from webhook parse or fetched resource)
81
+ // Fallback: resolve UID from the hosted page's pass_thru_content
82
+ // Chargebee hosted page checkouts don't forward subscription[meta_data] to the subscription,
83
+ // but pass_thru_content is stored on the hosted page and contains our UID + orderId
84
+ let resolvedFromPassThru = false;
85
+ let passThruOrderId = null;
86
+ if (!uid && library.resolveUidFromHostedPage) {
87
+ const passThruResult = await library.resolveUidFromHostedPage(resourceId, assistant);
88
+ if (passThruResult) {
89
+ uid = passThruResult.uid;
90
+ passThruOrderId = passThruResult.orderId || null;
91
+ resolvedFromPassThru = true;
92
+ assistant.log(`UID resolved from hosted page pass_thru_content: uid=${uid}, orderId=${passThruOrderId}, resourceId=${resourceId}`);
93
+
94
+ await webhookRef.set({ owner: uid }, { merge: true });
95
+ }
96
+ }
97
+
98
+ // Validate UID — must have one by now
82
99
  if (!uid) {
83
- throw new Error(`Webhook event has no UID — could not extract from webhook parse or fetched ${resourceType} resource`);
100
+ throw new Error(`Webhook event has no UID — could not extract from webhook parse, fetched ${resourceType} resource, or hosted page pass_thru_content`);
101
+ }
102
+
103
+ // Backfill: if UID was resolved from pass_thru_content, set meta_data on the subscription
104
+ // so future webhooks (renewals, cancellations) can resolve the UID directly
105
+ if (resolvedFromPassThru && resourceType === 'subscription' && library.setMetaData) {
106
+ library.setMetaData(resource, { uid, orderId: passThruOrderId })
107
+ .then(() => assistant.log(`Backfilled meta_data on subscription ${resourceId} + customer: uid=${uid}, orderId=${passThruOrderId}`))
108
+ .catch((e) => assistant.error(`Failed to backfill meta_data on ${resourceType} ${resourceId}: ${e.message}`));
84
109
  }
85
110
 
86
111
  // Build timestamps
@@ -89,7 +114,8 @@ module.exports = async ({ assistant, change, context }) => {
89
114
  const webhookReceivedUNIX = dataAfter.metadata?.created?.timestampUNIX || nowUNIX;
90
115
 
91
116
  // Extract orderId from resource (processor-agnostic)
92
- orderId = library.getOrderId(resource);
117
+ // Falls back to pass_thru_content orderId when meta_data wasn't available on the resource
118
+ orderId = library.getOrderId(resource) || passThruOrderId;
93
119
 
94
120
  // Process the payment event (subscription or one-time)
95
121
  if (category !== 'subscription' && category !== 'one-time') {
@@ -130,6 +130,79 @@ const Chargebee = {
130
130
  }
131
131
  },
132
132
 
133
+ /**
134
+ * Resolve UID from a Chargebee hosted page's pass_thru_content
135
+ * Searches recent hosted pages for one whose subscription matches the given resourceId
136
+ *
137
+ * @param {string} resourceId - Chargebee subscription ID to match
138
+ * @param {object} assistant - Assistant instance for logging
139
+ * @returns {Promise<{ uid: string, orderId: string }|null>}
140
+ */
141
+ async resolveUidFromHostedPage(resourceId, assistant) {
142
+ try {
143
+ this.init();
144
+ const result = await this.request('/hosted_pages?limit=25&sort_by[desc]=created_at');
145
+ const pages = result?.list || [];
146
+
147
+ for (const entry of pages) {
148
+ const hp = entry.hosted_page;
149
+
150
+ // Match by subscription ID in the hosted page content
151
+ if (hp.content?.subscription?.id !== resourceId) {
152
+ continue;
153
+ }
154
+
155
+ if (!hp.pass_thru_content) {
156
+ continue;
157
+ }
158
+
159
+ try {
160
+ const parsed = typeof hp.pass_thru_content === 'string'
161
+ ? JSON.parse(hp.pass_thru_content)
162
+ : hp.pass_thru_content;
163
+
164
+ if (parsed.uid) {
165
+ return { uid: parsed.uid, orderId: parsed.orderId || null };
166
+ }
167
+ } catch (e) {
168
+ // Invalid JSON in pass_thru_content — skip
169
+ }
170
+ }
171
+
172
+ return null;
173
+ } catch (e) {
174
+ assistant.log(`resolveUidFromHostedPage failed: ${e.message}`);
175
+ return null;
176
+ }
177
+ },
178
+
179
+ /**
180
+ * Set meta_data on a Chargebee subscription and its customer via direct API calls
181
+ * Used to backfill meta_data after resolving UID from pass_thru_content,
182
+ * so future webhooks (renewals, cancellations) can resolve UID directly
183
+ *
184
+ * @param {object} resource - Fetched subscription resource (has .id and .customer_id)
185
+ * @param {object} meta - { uid, orderId } to backfill on the subscription + customer
186
+ */
187
+ async setMetaData(resource, meta = {}) {
188
+ this.init();
189
+ const metaBody = { meta_data: meta };
190
+
191
+ // Backfill subscription
192
+ await this.request(`/subscriptions/${resource.id}`, {
193
+ method: 'POST',
194
+ body: metaBody,
195
+ });
196
+
197
+ // Backfill customer
198
+ if (resource.customer_id) {
199
+ await this.request(`/customers/${resource.customer_id}`, {
200
+ method: 'POST',
201
+ body: metaBody,
202
+ });
203
+ }
204
+ },
205
+
133
206
  /**
134
207
  * Extract the internal orderId from a Chargebee resource
135
208
  * Checks meta_data JSON first (new), then cf_clientorderid (legacy)
@@ -62,14 +62,14 @@ async function createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product,
62
62
  // Deterministic item price ID: {itemId}-{frequency}
63
63
  const itemPriceId = `${chargebeeItemId}-${frequency}`;
64
64
 
65
+ // NOTE: subscription[meta_data] is NOT supported by Chargebee's hosted page checkout.
66
+ // We use pass_thru_content to carry our UID/orderId through the checkout flow,
67
+ // then backfill meta_data on the subscription after the webhook resolves the UID.
65
68
  const params = {
66
69
  subscription_items: {
67
70
  item_price_id: [itemPriceId],
68
71
  quantity: [1],
69
72
  },
70
- subscription: {
71
- meta_data: metaData,
72
- },
73
73
  redirect_url: confirmationUrl,
74
74
  cancel_url: cancelUrl,
75
75
  pass_thru_content: metaData,
@@ -79,7 +79,7 @@ async function createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product,
79
79
  // set trial_end explicitly. Otherwise let the item price's trial config handle it.
80
80
  if (trial === false && product.trial?.days) {
81
81
  // Explicitly skip trial by setting trial_end to 0
82
- params.subscription.trial_end = 0;
82
+ params.subscription = { trial_end: 0 };
83
83
  }
84
84
 
85
85
  // Apply discount coupon (first payment only)