backend-manager 5.0.122 → 5.0.124
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 +48 -0
- package/CLAUDE.md +18 -19
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/manager/cron/daily/reset-usage.js +79 -73
- package/src/manager/cron/daily.js +2 -53
- package/src/manager/cron/frequent/abandoned-carts.js +148 -0
- package/src/manager/cron/frequent.js +3 -0
- package/src/manager/cron/runner.js +60 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +358 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +245 -121
- package/src/manager/events/firestore/payments-webhooks/on-write.js +32 -2
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +11 -34
- package/src/manager/helpers/analytics.js +2 -2
- package/src/manager/helpers/usage.js +44 -20
- package/src/manager/helpers/user.js +2 -1
- package/src/manager/index.js +10 -0
- package/src/manager/libraries/abandoned-cart-config.js +12 -0
- package/src/manager/libraries/email.js +5 -5
- package/src/manager/libraries/openai.js +76 -7
- package/src/manager/libraries/payment/discount-codes.js +40 -0
- package/src/manager/libraries/recaptcha.js +36 -0
- package/src/manager/routes/app/get.js +1 -1
- package/src/manager/routes/marketing/contact/post.js +11 -29
- package/src/manager/routes/payments/discount/get.js +22 -0
- package/src/manager/routes/payments/dispute-alert/post.js +93 -0
- package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +43 -0
- package/src/manager/routes/payments/intent/post.js +29 -0
- package/src/manager/routes/payments/intent/processors/chargebee.js +59 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +55 -7
- package/src/manager/routes/test/usage/post.js +10 -6
- package/src/manager/schemas/payments/discount/get.js +9 -0
- package/src/manager/schemas/payments/dispute-alert/post.js +6 -0
- package/src/manager/schemas/payments/intent/post.js +16 -0
- package/src/test/runner.js +14 -4
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +7 -1
- package/templates/firestore.rules +9 -1
- package/test/_legacy/usage.js +5 -5
- package/test/routes/marketing/contact.js +3 -2
- package/test/routes/payments/discount.js +80 -0
- package/test/routes/payments/dispute-alert.js +271 -0
- package/test/routes/payments/intent.js +60 -0
- package/test/routes/test/usage.js +134 -30
- package/test/rules/payments-carts.js +371 -0
|
@@ -1,171 +1,295 @@
|
|
|
1
|
+
const fetch = require('wonderful-fetch');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Payment analytics tracking
|
|
3
|
-
* Fires server-side events
|
|
5
|
+
* Fires server-side events independently to GA4, Meta Conversions API, and TikTok Events API
|
|
6
|
+
*
|
|
7
|
+
* Two independent concerns:
|
|
8
|
+
* 1. Transition events (mutually exclusive, one per webhook):
|
|
9
|
+
* new-subscription (no trial) → purchase
|
|
10
|
+
* new-subscription (trial) → start_trial
|
|
11
|
+
* payment-recovered → purchase (recurring)
|
|
12
|
+
* purchase-completed → purchase (one-time)
|
|
4
13
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* new-subscription (trial) → start_trial / StartTrial / Subscribe
|
|
8
|
-
* payment-recovered → purchase / Subscribe / Subscribe (recurring)
|
|
9
|
-
* purchase-completed → purchase / Purchase / CompletePayment
|
|
14
|
+
* 2. Payment events (fire whenever money changes hands, including renewals):
|
|
15
|
+
* subscription renewal → purchase (recurring)
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Track payment events across analytics platforms (non-blocking)
|
|
14
|
-
*
|
|
15
|
-
* @param {object} options
|
|
16
|
-
* @param {string} options.category - 'subscription' or 'one-time'
|
|
17
|
-
* @param {string} options.transitionName - Detected transition (e.g., 'new-subscription', 'purchase-completed')
|
|
18
|
-
* @param {object} options.unified - Unified subscription or one-time object
|
|
19
|
-
* @param {string} options.uid - User ID
|
|
20
|
-
* @param {string} options.processor - Payment processor (e.g., 'stripe', 'paypal')
|
|
21
|
-
* @param {object} options.assistant - Assistant instance (Manager derived via assistant.Manager)
|
|
20
|
+
* Fires GA4, Meta, and TikTok independently with per-platform payloads
|
|
22
21
|
*/
|
|
23
|
-
function trackPayment({ category, transitionName, unified, uid, processor, assistant }) {
|
|
22
|
+
function trackPayment({ category, transitionName, eventType, unified, uid, processor, assistant }) {
|
|
24
23
|
const Manager = assistant.Manager;
|
|
24
|
+
const config = Manager.config;
|
|
25
25
|
|
|
26
26
|
try {
|
|
27
|
-
// Resolve
|
|
28
|
-
const
|
|
27
|
+
// Resolve what kind of payment event this is
|
|
28
|
+
const resolved = resolvePaymentEvent(category, transitionName, eventType, unified);
|
|
29
29
|
|
|
30
|
-
if (!
|
|
30
|
+
if (!resolved) {
|
|
31
|
+
assistant.log(`trackPayment: skipped — no trackable event (category=${category}, transition=${transitionName || 'null'}, eventType=${eventType})`);
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// GA4 via Measurement Protocol
|
|
37
|
-
Manager.Analytics({ assistant, uuid: uid }).event(event.ga4, {
|
|
38
|
-
transaction_id: event.transactionId,
|
|
39
|
-
value: event.value,
|
|
40
|
-
currency: event.currency,
|
|
41
|
-
items: [{
|
|
42
|
-
item_id: event.productId,
|
|
43
|
-
item_name: event.productName,
|
|
44
|
-
price: event.value,
|
|
45
|
-
quantity: 1,
|
|
46
|
-
}],
|
|
47
|
-
payment_processor: processor,
|
|
48
|
-
payment_frequency: event.frequency,
|
|
49
|
-
is_trial: event.isTrial,
|
|
50
|
-
is_recurring: event.isRecurring,
|
|
51
|
-
});
|
|
35
|
+
const currency = config.payment?.currency || 'USD';
|
|
52
36
|
|
|
53
|
-
|
|
54
|
-
// Event name: event.meta (e.g., 'Purchase', 'StartTrial', 'Subscribe')
|
|
55
|
-
// https://developers.facebook.com/docs/marketing-api/conversions-api
|
|
37
|
+
assistant.log(`trackPayment: reason=${resolved.reason}, value=${resolved.value}, currency=${currency}, product=${resolved.productId}, uid=${uid}, processor=${processor}`);
|
|
56
38
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
39
|
+
// Fire each platform independently (non-blocking, errors isolated)
|
|
40
|
+
fireGA4({ resolved, currency, uid, processor, assistant, Manager });
|
|
41
|
+
fireMeta({ resolved, currency, uid, processor, assistant, config });
|
|
42
|
+
fireTikTok({ resolved, currency, uid, processor, assistant, config });
|
|
60
43
|
} catch (e) {
|
|
61
44
|
assistant.error(`trackPayment failed: ${e.message}`, e);
|
|
62
45
|
}
|
|
63
46
|
}
|
|
64
47
|
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Event resolution
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
65
52
|
/**
|
|
66
|
-
*
|
|
67
|
-
* Returns null if
|
|
53
|
+
* Determine what kind of payment event occurred and extract common fields
|
|
54
|
+
* Returns null if nothing should be tracked
|
|
68
55
|
*/
|
|
69
|
-
function resolvePaymentEvent(category, transitionName,
|
|
56
|
+
function resolvePaymentEvent(category, transitionName, eventType, unified) {
|
|
57
|
+
const productId = unified.product?.id;
|
|
58
|
+
const productName = unified.product?.name;
|
|
59
|
+
const frequency = unified.payment?.frequency || null;
|
|
60
|
+
const isTrial = unified.trial?.claimed === true;
|
|
61
|
+
const resourceId = unified.payment?.resourceId;
|
|
62
|
+
const price = unified.payment?.price || 0;
|
|
63
|
+
|
|
64
|
+
const base = { productId, productName, frequency, resourceId, isTrial };
|
|
65
|
+
|
|
66
|
+
// --- Subscription transitions ---
|
|
70
67
|
if (category === 'subscription') {
|
|
71
|
-
|
|
68
|
+
if (transitionName === 'new-subscription' && isTrial) {
|
|
69
|
+
return { ...base, reason: 'trial-started', value: 0, isRecurring: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (transitionName === 'new-subscription') {
|
|
73
|
+
return { ...base, reason: 'first-purchase', value: price, isRecurring: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (transitionName === 'payment-recovered') {
|
|
77
|
+
return { ...base, reason: 'payment-recovered', value: price, isRecurring: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// No transition but a payment event fired (renewal)
|
|
81
|
+
if (!transitionName && isPaymentEvent(eventType) && price > 0) {
|
|
82
|
+
return { ...base, reason: 'renewal', value: price, isRecurring: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
72
86
|
}
|
|
73
87
|
|
|
88
|
+
// --- One-time transitions ---
|
|
74
89
|
if (category === 'one-time') {
|
|
75
|
-
|
|
90
|
+
if (transitionName === 'purchase-completed') {
|
|
91
|
+
return { ...base, reason: 'one-time-purchase', value: price, isRecurring: false, productId: productId || 'unknown', productName: productName || 'Unknown' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
return null;
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
/**
|
|
82
|
-
*
|
|
101
|
+
* Check if a webhook event type represents a payment being made
|
|
83
102
|
*/
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const frequency = unified.payment?.frequency;
|
|
88
|
-
const isTrial = unified.trial?.claimed === true;
|
|
89
|
-
const resourceId = unified.payment?.resourceId;
|
|
90
|
-
const price = unified.payment?.price || 0;
|
|
91
|
-
|
|
92
|
-
if (transitionName === 'new-subscription' && isTrial) {
|
|
93
|
-
return {
|
|
94
|
-
ga4: 'start_trial',
|
|
95
|
-
meta: 'StartTrial',
|
|
96
|
-
tiktok: 'Subscribe',
|
|
97
|
-
value: 0,
|
|
98
|
-
currency: config.payment?.currency || 'USD',
|
|
99
|
-
productId,
|
|
100
|
-
productName,
|
|
101
|
-
frequency,
|
|
102
|
-
isTrial: true,
|
|
103
|
-
isRecurring: false,
|
|
104
|
-
transactionId: resourceId,
|
|
105
|
-
};
|
|
103
|
+
function isPaymentEvent(eventType) {
|
|
104
|
+
if (!eventType) {
|
|
105
|
+
return false;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
108
|
+
return [
|
|
109
|
+
// PayPal
|
|
110
|
+
'PAYMENT.SALE.COMPLETED',
|
|
111
|
+
// Stripe
|
|
112
|
+
'invoice.payment_succeeded',
|
|
113
|
+
'invoice.paid',
|
|
114
|
+
].includes(eventType);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// GA4 — Measurement Protocol
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fire GA4 event via Manager.Analytics (Measurement Protocol)
|
|
123
|
+
* https://developers.google.com/analytics/devguides/collection/protocol/ga4
|
|
124
|
+
*/
|
|
125
|
+
function fireGA4({ resolved, currency, uid, processor, assistant, Manager }) {
|
|
126
|
+
try {
|
|
127
|
+
// Map reason → GA4 event name
|
|
128
|
+
const eventName = resolved.reason === 'trial-started' ? 'start_trial' : 'purchase';
|
|
129
|
+
|
|
130
|
+
Manager.Analytics({ assistant, uuid: uid }).event(eventName, {
|
|
131
|
+
transaction_id: resolved.resourceId,
|
|
132
|
+
value: resolved.value,
|
|
133
|
+
currency: currency,
|
|
134
|
+
items: [{
|
|
135
|
+
item_id: resolved.productId,
|
|
136
|
+
item_name: resolved.productName,
|
|
137
|
+
price: resolved.value,
|
|
138
|
+
quantity: 1,
|
|
139
|
+
}],
|
|
140
|
+
payment_processor: processor,
|
|
141
|
+
payment_frequency: resolved.frequency,
|
|
142
|
+
is_trial: resolved.isTrial,
|
|
143
|
+
is_recurring: resolved.isRecurring,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
assistant.log(`trackPayment [GA4]: event=${eventName}, value=${resolved.value}, product=${resolved.productId}, uid=${uid}`);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
assistant.error(`trackPayment [GA4] failed: ${e.message}`, e);
|
|
122
149
|
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Meta — Conversions API
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
// Meta event name mapping
|
|
157
|
+
const META_EVENTS = {
|
|
158
|
+
'trial-started': 'StartTrial',
|
|
159
|
+
'first-purchase': 'Purchase',
|
|
160
|
+
'payment-recovered': 'Subscribe',
|
|
161
|
+
'renewal': 'Subscribe',
|
|
162
|
+
'one-time-purchase': 'Purchase',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fire Meta Conversions API event
|
|
167
|
+
* https://developers.facebook.com/docs/marketing-api/conversions-api
|
|
168
|
+
*/
|
|
169
|
+
function fireMeta({ resolved, currency, uid, processor, assistant, config }) {
|
|
170
|
+
try {
|
|
171
|
+
const pixelId = config.meta?.pixelId;
|
|
172
|
+
const accessToken = process.env.META_ACCESS_TOKEN;
|
|
123
173
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
174
|
+
if (!pixelId || !accessToken) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const eventName = META_EVENTS[resolved.reason];
|
|
179
|
+
|
|
180
|
+
if (!eventName) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const payload = {
|
|
185
|
+
data: [{
|
|
186
|
+
event_name: eventName,
|
|
187
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
188
|
+
action_source: 'website',
|
|
189
|
+
user_data: {
|
|
190
|
+
external_id: uid,
|
|
191
|
+
},
|
|
192
|
+
custom_data: {
|
|
193
|
+
value: resolved.value,
|
|
194
|
+
currency: currency,
|
|
195
|
+
content_ids: [resolved.productId],
|
|
196
|
+
content_name: resolved.productName,
|
|
197
|
+
content_type: 'product',
|
|
198
|
+
payment_processor: processor,
|
|
199
|
+
is_recurring: resolved.isRecurring,
|
|
200
|
+
},
|
|
201
|
+
}],
|
|
137
202
|
};
|
|
138
|
-
}
|
|
139
203
|
|
|
140
|
-
|
|
204
|
+
fetch(`https://graph.facebook.com/v21.0/${pixelId}/events?access_token=${accessToken}`, {
|
|
205
|
+
method: 'post',
|
|
206
|
+
response: 'json',
|
|
207
|
+
body: payload,
|
|
208
|
+
timeout: 30000,
|
|
209
|
+
tries: 2,
|
|
210
|
+
})
|
|
211
|
+
.then(() => {
|
|
212
|
+
assistant.log(`trackPayment [Meta]: event=${eventName}, value=${resolved.value}, product=${resolved.productId}, uid=${uid}`);
|
|
213
|
+
})
|
|
214
|
+
.catch((e) => {
|
|
215
|
+
assistant.error(`trackPayment [Meta] failed: ${e.message}`, e);
|
|
216
|
+
});
|
|
217
|
+
} catch (e) {
|
|
218
|
+
assistant.error(`trackPayment [Meta] failed: ${e.message}`, e);
|
|
219
|
+
}
|
|
141
220
|
}
|
|
142
221
|
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// TikTok — Events API
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
// TikTok event name mapping
|
|
227
|
+
const TIKTOK_EVENTS = {
|
|
228
|
+
'trial-started': 'Subscribe',
|
|
229
|
+
'first-purchase': 'CompletePayment',
|
|
230
|
+
'payment-recovered': 'Subscribe',
|
|
231
|
+
'renewal': 'Subscribe',
|
|
232
|
+
'one-time-purchase': 'CompletePayment',
|
|
233
|
+
};
|
|
234
|
+
|
|
143
235
|
/**
|
|
144
|
-
*
|
|
236
|
+
* Fire TikTok Events API event
|
|
237
|
+
* https://business-api.tiktok.com/portal/docs?id=1771100865818625
|
|
145
238
|
*/
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
239
|
+
function fireTikTok({ resolved, currency, uid, processor, assistant, config }) {
|
|
240
|
+
try {
|
|
241
|
+
const pixelCode = config.tiktok?.pixelCode;
|
|
242
|
+
const accessToken = process.env.TIKTOK_ACCESS_TOKEN;
|
|
150
243
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
244
|
+
if (!pixelCode || !accessToken) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const eventName = TIKTOK_EVENTS[resolved.reason];
|
|
249
|
+
|
|
250
|
+
if (!eventName) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
155
253
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
254
|
+
const payload = {
|
|
255
|
+
pixel_code: pixelCode,
|
|
256
|
+
event: eventName,
|
|
257
|
+
event_id: `${uid}-${Date.now()}`,
|
|
258
|
+
timestamp: new Date().toISOString(),
|
|
259
|
+
context: {
|
|
260
|
+
user: {
|
|
261
|
+
external_id: uid,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
properties: {
|
|
265
|
+
value: resolved.value,
|
|
266
|
+
currency: currency,
|
|
267
|
+
content_id: resolved.productId,
|
|
268
|
+
content_name: resolved.productName,
|
|
269
|
+
content_type: 'product',
|
|
270
|
+
description: `${resolved.reason} via ${processor}`,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
fetch('https://business-api.tiktok.com/open_api/v1.3/event/track/', {
|
|
275
|
+
method: 'post',
|
|
276
|
+
response: 'json',
|
|
277
|
+
headers: {
|
|
278
|
+
'Access-Token': accessToken,
|
|
279
|
+
},
|
|
280
|
+
body: { data: [payload] },
|
|
281
|
+
timeout: 30000,
|
|
282
|
+
tries: 2,
|
|
283
|
+
})
|
|
284
|
+
.then(() => {
|
|
285
|
+
assistant.log(`trackPayment [TikTok]: event=${eventName}, value=${resolved.value}, product=${resolved.productId}, uid=${uid}`);
|
|
286
|
+
})
|
|
287
|
+
.catch((e) => {
|
|
288
|
+
assistant.error(`trackPayment [TikTok] failed: ${e.message}`, e);
|
|
289
|
+
});
|
|
290
|
+
} catch (e) {
|
|
291
|
+
assistant.error(`trackPayment [TikTok] failed: ${e.message}`, e);
|
|
292
|
+
}
|
|
169
293
|
}
|
|
170
294
|
|
|
171
295
|
module.exports = { trackPayment };
|
|
@@ -194,6 +194,13 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
194
194
|
|
|
195
195
|
assistant.log(`Unified ${category}: product=${unified.product.id}, status=${unified.status}`, unified);
|
|
196
196
|
|
|
197
|
+
// Read checkout context from payments-intents (attribution, discount, supplemental)
|
|
198
|
+
let intentData = {};
|
|
199
|
+
if (orderId) {
|
|
200
|
+
const intentDoc = await admin.firestore().doc(`payments-intents/${orderId}`).get();
|
|
201
|
+
intentData = intentDoc.exists ? intentDoc.data() : {};
|
|
202
|
+
}
|
|
203
|
+
|
|
197
204
|
// Build the order object (single source of truth for handlers + Firestore)
|
|
198
205
|
const order = {
|
|
199
206
|
id: orderId,
|
|
@@ -203,6 +210,9 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
203
210
|
processor: processor,
|
|
204
211
|
resourceId: resourceId,
|
|
205
212
|
unified: unified,
|
|
213
|
+
attribution: intentData.attribution || {},
|
|
214
|
+
discount: intentData.discount || null,
|
|
215
|
+
supplemental: intentData.supplemental || {},
|
|
206
216
|
metadata: {
|
|
207
217
|
created: {
|
|
208
218
|
timestamp: now,
|
|
@@ -243,8 +253,9 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
// Track payment analytics (non-blocking)
|
|
246
|
-
|
|
247
|
-
|
|
256
|
+
// Fires independently of transitions — renewals have no transition but still need tracking
|
|
257
|
+
if (shouldRunHandlers) {
|
|
258
|
+
trackPayment({ category, transitionName, eventType, unified, uid, processor, assistant });
|
|
248
259
|
}
|
|
249
260
|
|
|
250
261
|
// Write unified subscription to user doc (subscriptions only)
|
|
@@ -284,6 +295,25 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
284
295
|
assistant.log(`Updated payments-intents/${orderId}: status=completed`);
|
|
285
296
|
}
|
|
286
297
|
|
|
298
|
+
// Mark abandoned cart as completed (non-blocking, fire-and-forget)
|
|
299
|
+
const { COLLECTION } = require('../../../libraries/abandoned-cart-config.js');
|
|
300
|
+
admin.firestore().doc(`${COLLECTION}/${uid}`).set({
|
|
301
|
+
status: 'completed',
|
|
302
|
+
metadata: {
|
|
303
|
+
updated: {
|
|
304
|
+
timestamp: now,
|
|
305
|
+
timestampUNIX: nowUNIX,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
}, { merge: true })
|
|
309
|
+
.then(() => assistant.log(`Updated ${COLLECTION}/${uid}: status=completed`))
|
|
310
|
+
.catch((e) => {
|
|
311
|
+
// Ignore not-found — cart may not exist for this user
|
|
312
|
+
if (e.code !== 5) {
|
|
313
|
+
assistant.error(`Failed to update ${COLLECTION}/${uid}: ${e.message}`);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
287
317
|
return transitionName;
|
|
288
318
|
}
|
|
289
319
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fetch = require('wonderful-fetch');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const dns = require('dns').promises;
|
|
4
|
+
const recaptcha = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'recaptcha.js'));
|
|
4
5
|
|
|
5
6
|
// Load disposable domains list
|
|
6
7
|
const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'disposable-domains.json'));
|
|
@@ -49,15 +50,17 @@ Module.prototype.main = function () {
|
|
|
49
50
|
|
|
50
51
|
// Public access protection
|
|
51
52
|
if (!isAdmin) {
|
|
52
|
-
// Verify reCAPTCHA
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
// Verify reCAPTCHA (skip during automated tests)
|
|
54
|
+
if (!assistant.isTesting()) {
|
|
55
|
+
const recaptchaToken = requestPayload['g-recaptcha-response'];
|
|
56
|
+
if (!recaptchaToken) {
|
|
57
|
+
return reject(assistant.errorify('Request could not be verified', { code: 403 }));
|
|
58
|
+
}
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
const recaptchaValid = await recaptcha.verify(recaptchaToken);
|
|
61
|
+
if (!recaptchaValid) {
|
|
62
|
+
return reject(assistant.errorify('Request could not be verified', { code: 403 }));
|
|
63
|
+
}
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
// Check rate limit via Usage API
|
|
@@ -174,32 +177,6 @@ Module.prototype.main = function () {
|
|
|
174
177
|
});
|
|
175
178
|
};
|
|
176
179
|
|
|
177
|
-
/**
|
|
178
|
-
* Verify Google reCAPTCHA (invisible) token
|
|
179
|
-
*/
|
|
180
|
-
async function verifyRecaptcha(token) {
|
|
181
|
-
if (!process.env.RECAPTCHA_SECRET_KEY) {
|
|
182
|
-
// Skip verification if no secret configured
|
|
183
|
-
return true;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
try {
|
|
187
|
-
// reCAPTCHA requires form-urlencoded, not JSON
|
|
188
|
-
const data = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
|
189
|
-
method: 'post',
|
|
190
|
-
response: 'json',
|
|
191
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
192
|
-
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// For v3 invisible reCAPTCHA, check score (0.5+ is typically human)
|
|
196
|
-
return data.success && (data.score === undefined || data.score >= 0.5);
|
|
197
|
-
} catch (e) {
|
|
198
|
-
console.error('reCAPTCHA verification error:', e);
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
180
|
/**
|
|
204
181
|
* Validate email with ZeroBounce API
|
|
205
182
|
*/
|
|
@@ -161,8 +161,8 @@ function Analytics(Manager, options) {
|
|
|
161
161
|
};
|
|
162
162
|
|
|
163
163
|
// Set id and secret
|
|
164
|
-
self.analyticsId = self?.Manager?.config?.
|
|
165
|
-
self.analyticsSecret = self?.Manager?.config?.
|
|
164
|
+
self.analyticsId = self?.Manager?.config?.googleAnalytics?.id;
|
|
165
|
+
self.analyticsSecret = self?.Manager?.config?.googleAnalytics?.secret;
|
|
166
166
|
|
|
167
167
|
// Check if we have the required properties
|
|
168
168
|
if (!self.analyticsId) {
|