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 +22 -0
- package/CLAUDE.md +3 -0
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/firestore-indexes-synced.js +93 -38
- package/src/cli/commands/setup-tests/required-indexes.js +11 -0
- package/src/manager/cron/frequent/abandoned-carts.js +8 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +29 -3
- package/src/manager/libraries/payment/processors/chargebee.js +73 -0
- package/src/manager/routes/payments/intent/processors/chargebee.js +4 -4
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
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
82
|
+
params.subscription = { trial_end: 0 };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Apply discount coupon (first payment only)
|