backend-manager 5.0.88 → 5.0.91
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/CLAUDE.md +133 -2
- package/README.md +1 -1
- package/package.json +5 -3
- package/src/cli/index.js +11 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +75 -315
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
- package/src/manager/helpers/user.js +2 -0
- package/src/manager/libraries/payment-processors/order-id.js +18 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +88 -47
- package/src/manager/libraries/payment-processors/test.js +14 -11
- package/src/manager/routes/payments/intent/post.js +61 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
- package/src/manager/routes/payments/intent/processors/test.js +18 -22
- package/src/manager/routes/payments/webhook/post.js +1 -1
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +20 -2
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-cancel.js +2 -0
- package/test/events/payments/journey-payments-failure.js +2 -0
- package/test/events/payments/journey-payments-one-time-failure.js +105 -0
- package/test/events/payments/journey-payments-one-time.js +128 -0
- package/test/events/payments/journey-payments-plan-change.js +126 -0
- package/test/events/payments/journey-payments-suspend.js +2 -0
- package/test/events/payments/journey-payments-trial.js +4 -0
- package/test/events/payments/journey-payments-upgrade.js +20 -10
- package/test/helpers/stripe-to-unified.js +17 -0
- package/test/helpers/user.js +1 -0
- package/test/routes/payments/intent.js +10 -7
- /package/bin/{bem → backend-manager} +0 -0
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transition: payment-failed
|
|
3
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
4
|
*/
|
|
10
|
-
|
|
5
|
+
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
|
+
|
|
7
|
+
module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
|
|
11
8
|
assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product.id}, previousStatus=${before?.status}`);
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
sendOrderEmail({
|
|
11
|
+
template: 'd-e56af0ac62364bfb9e50af02854e2cd3',
|
|
12
|
+
subject: 'Your payment failed',
|
|
13
|
+
categories: ['order/payment-failed'],
|
|
14
|
+
uid,
|
|
15
|
+
assistant,
|
|
16
|
+
Manager,
|
|
17
|
+
data: {
|
|
18
|
+
order: {
|
|
19
|
+
...order,
|
|
20
|
+
_computed: {
|
|
21
|
+
date: formatDate(new Date().toISOString()),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
15
26
|
};
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transition: payment-recovered
|
|
3
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
4
|
*/
|
|
9
|
-
|
|
5
|
+
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
|
+
|
|
7
|
+
module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
|
|
10
8
|
assistant.log(`Transition [subscription/payment-recovered]: uid=${uid}, product=${after.product.id}`);
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
sendOrderEmail({
|
|
11
|
+
template: 'd-d6dbd17a260a4755b34a852ba09c2454',
|
|
12
|
+
subject: 'Your payment was successful',
|
|
13
|
+
categories: ['order/payment-recovered'],
|
|
14
|
+
uid,
|
|
15
|
+
assistant,
|
|
16
|
+
Manager,
|
|
17
|
+
data: {
|
|
18
|
+
order: {
|
|
19
|
+
...order,
|
|
20
|
+
_computed: {
|
|
21
|
+
date: formatDate(new Date().toISOString()),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
14
26
|
};
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transition: plan-changed
|
|
3
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
4
|
*/
|
|
10
|
-
|
|
5
|
+
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
|
+
|
|
7
|
+
module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
|
|
11
8
|
const direction = after.product.id > before.product.id ? 'upgrade' : 'downgrade';
|
|
12
9
|
assistant.log(`Transition [subscription/plan-changed]: uid=${uid}, ${before.product.id} → ${after.product.id} (${direction})`);
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
sendOrderEmail({
|
|
12
|
+
template: 'd-399086311bbb48b4b77bc90b20fb9d0a',
|
|
13
|
+
subject: 'Your subscription plan has been updated',
|
|
14
|
+
categories: ['order/plan-changed'],
|
|
15
|
+
uid,
|
|
16
|
+
assistant,
|
|
17
|
+
Manager,
|
|
18
|
+
data: {
|
|
19
|
+
order: {
|
|
20
|
+
...order,
|
|
21
|
+
// Inject previous plan info into the unified object for the template
|
|
22
|
+
unified: {
|
|
23
|
+
...order.unified,
|
|
24
|
+
previous: {
|
|
25
|
+
product: before.product,
|
|
26
|
+
price: before.payment?.price || 0,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
_computed: {
|
|
30
|
+
date: formatDate(new Date().toISOString()),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
16
35
|
};
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transition: subscription-cancelled
|
|
3
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
4
|
*/
|
|
11
|
-
|
|
5
|
+
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
|
+
|
|
7
|
+
module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
|
|
12
8
|
assistant.log(`Transition [subscription/subscription-cancelled]: uid=${uid}, previousProduct=${before?.product?.id}, previousStatus=${before?.status}`);
|
|
13
9
|
|
|
14
|
-
//
|
|
15
|
-
|
|
10
|
+
// Check if subscription has a future expiry (e.g., cancelled at period end)
|
|
11
|
+
const hasFutureExpiry = after.expires?.timestamp && new Date(after.expires.timestamp) > new Date();
|
|
12
|
+
|
|
13
|
+
sendOrderEmail({
|
|
14
|
+
template: 'd-39041132e6b24e5ebf0e95bce2d94dba',
|
|
15
|
+
subject: 'Your subscription has been cancelled',
|
|
16
|
+
categories: ['order/cancelled'],
|
|
17
|
+
uid,
|
|
18
|
+
assistant,
|
|
19
|
+
Manager,
|
|
20
|
+
data: {
|
|
21
|
+
order: {
|
|
22
|
+
...order,
|
|
23
|
+
_computed: {
|
|
24
|
+
date: formatDate(new Date().toISOString()),
|
|
25
|
+
...(hasFutureExpiry && {
|
|
26
|
+
expiresDate: formatDate(after.expires.timestamp),
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
16
32
|
};
|
|
@@ -38,8 +38,10 @@ const SCHEMA = {
|
|
|
38
38
|
},
|
|
39
39
|
payment: {
|
|
40
40
|
processor: { type: 'string', default: null, nullable: true },
|
|
41
|
+
orderId: { type: 'string', default: null, nullable: true },
|
|
41
42
|
resourceId: { type: 'string', default: null, nullable: true },
|
|
42
43
|
frequency: { type: 'string', default: null, nullable: true },
|
|
44
|
+
price: { type: 'number', default: 0 },
|
|
43
45
|
startDate: '$timestamp',
|
|
44
46
|
updatedBy: {
|
|
45
47
|
event: {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a unique order ID in the format XXXX-XXXX-XXXX
|
|
5
|
+
* 12 random digits, grouped in 3 segments of 4
|
|
6
|
+
*
|
|
7
|
+
* @returns {string} e.g. '4637-8821-0473'
|
|
8
|
+
*/
|
|
9
|
+
function generate() {
|
|
10
|
+
const bytes = crypto.randomBytes(6);
|
|
11
|
+
const digits = Array.from(bytes)
|
|
12
|
+
.map(b => (b % 100).toString().padStart(2, '0'))
|
|
13
|
+
.join('');
|
|
14
|
+
|
|
15
|
+
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 12)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { generate };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the Stripe price ID from a product config object
|
|
3
|
+
*
|
|
4
|
+
* @param {object} product - Product object from config (must have .prices)
|
|
5
|
+
* @param {string} productType - 'subscription' or 'one-time'
|
|
6
|
+
* @param {string} frequency - 'monthly', 'annually', etc. (subscriptions) — ignored for one-time
|
|
7
|
+
* @returns {string} Stripe price ID
|
|
8
|
+
* @throws {Error} If no price ID found
|
|
9
|
+
*/
|
|
10
|
+
module.exports = function resolvePriceId(product, productType, frequency) {
|
|
11
|
+
const key = productType === 'subscription' ? frequency : 'once';
|
|
12
|
+
const priceId = product.prices?.[key]?.stripe;
|
|
13
|
+
|
|
14
|
+
if (!priceId) {
|
|
15
|
+
throw new Error(`No Stripe price found for ${product.id}/${key}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return priceId;
|
|
19
|
+
};
|
|
@@ -3,6 +3,13 @@ const powertools = require('node-powertools');
|
|
|
3
3
|
// Lazy singleton Stripe SDK instance
|
|
4
4
|
let stripeInstance = null;
|
|
5
5
|
|
|
6
|
+
// Epoch zero timestamps (used as default/empty dates)
|
|
7
|
+
const EPOCH_ZERO = powertools.timestamp(new Date(0), { output: 'string' });
|
|
8
|
+
const EPOCH_ZERO_UNIX = powertools.timestamp(EPOCH_ZERO, { output: 'unix' });
|
|
9
|
+
|
|
10
|
+
// Stripe interval → unified frequency map
|
|
11
|
+
const INTERVAL_TO_FREQUENCY = { year: 'annually', month: 'monthly', week: 'weekly', day: 'daily' };
|
|
12
|
+
|
|
6
13
|
/**
|
|
7
14
|
* Stripe shared library
|
|
8
15
|
* Provides SDK initialization, resource fetching, and unified transformations
|
|
@@ -79,9 +86,6 @@ const Stripe = {
|
|
|
79
86
|
options = options || {};
|
|
80
87
|
const config = options.config || {};
|
|
81
88
|
|
|
82
|
-
const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
|
|
83
|
-
const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
|
|
84
|
-
|
|
85
89
|
// Resolve status
|
|
86
90
|
const status = resolveStatus(rawSubscription);
|
|
87
91
|
|
|
@@ -98,10 +102,13 @@ const Stripe = {
|
|
|
98
102
|
const product = resolveProduct(rawSubscription, config);
|
|
99
103
|
|
|
100
104
|
// Resolve expiration
|
|
101
|
-
const expires = resolveExpires(rawSubscription
|
|
105
|
+
const expires = resolveExpires(rawSubscription);
|
|
102
106
|
|
|
103
107
|
// Resolve start date
|
|
104
|
-
const startDate = resolveStartDate(rawSubscription
|
|
108
|
+
const startDate = resolveStartDate(rawSubscription);
|
|
109
|
+
|
|
110
|
+
// Resolve price from config
|
|
111
|
+
const price = resolvePrice(product.id, frequency, config);
|
|
105
112
|
|
|
106
113
|
// Build the unified subscription object
|
|
107
114
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
@@ -115,8 +122,10 @@ const Stripe = {
|
|
|
115
122
|
cancellation: cancellation,
|
|
116
123
|
payment: {
|
|
117
124
|
processor: 'stripe',
|
|
125
|
+
orderId: rawSubscription.metadata?.orderId || null,
|
|
118
126
|
resourceId: rawSubscription.id || null,
|
|
119
127
|
frequency: frequency,
|
|
128
|
+
price: price,
|
|
120
129
|
startDate: startDate,
|
|
121
130
|
updatedBy: {
|
|
122
131
|
event: {
|
|
@@ -134,7 +143,7 @@ const Stripe = {
|
|
|
134
143
|
|
|
135
144
|
/**
|
|
136
145
|
* Transform a raw Stripe one-time payment resource into a unified shape
|
|
137
|
-
*
|
|
146
|
+
* Mirrors subscription structure: { product, status, payment: { ... } }
|
|
138
147
|
*
|
|
139
148
|
* @param {object} rawResource - Raw Stripe resource (session, invoice, etc.)
|
|
140
149
|
* @param {object} options
|
|
@@ -142,19 +151,33 @@ const Stripe = {
|
|
|
142
151
|
*/
|
|
143
152
|
toUnifiedOneTime(rawResource, options) {
|
|
144
153
|
options = options || {};
|
|
154
|
+
const config = options.config || {};
|
|
145
155
|
|
|
146
156
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
147
157
|
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
148
158
|
|
|
159
|
+
// Resolve product + price from config
|
|
160
|
+
const productId = rawResource.metadata?.productId;
|
|
161
|
+
const product = resolveProductOneTime(productId, config);
|
|
162
|
+
const price = resolvePrice(productId, 'once', config);
|
|
163
|
+
|
|
149
164
|
return {
|
|
150
|
-
|
|
151
|
-
processor: 'stripe',
|
|
165
|
+
product: product,
|
|
152
166
|
status: rawResource.status || 'unknown',
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
payment: {
|
|
168
|
+
processor: 'stripe',
|
|
169
|
+
orderId: rawResource.metadata?.orderId || null,
|
|
170
|
+
resourceId: rawResource.id || null,
|
|
171
|
+
price: price,
|
|
172
|
+
updatedBy: {
|
|
173
|
+
event: {
|
|
174
|
+
name: options.eventName || null,
|
|
175
|
+
id: options.eventId || null,
|
|
176
|
+
},
|
|
177
|
+
date: {
|
|
178
|
+
timestamp: now,
|
|
179
|
+
timestampUNIX: nowUNIX,
|
|
180
|
+
},
|
|
158
181
|
},
|
|
159
182
|
},
|
|
160
183
|
};
|
|
@@ -194,9 +217,6 @@ function resolveStatus(raw) {
|
|
|
194
217
|
* Handles cancel_at_period_end for pending cancellations
|
|
195
218
|
*/
|
|
196
219
|
function resolveCancellation(raw) {
|
|
197
|
-
const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
|
|
198
|
-
const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
|
|
199
|
-
|
|
200
220
|
// Pending cancellation: active but set to cancel at period end
|
|
201
221
|
if (raw.cancel_at_period_end) {
|
|
202
222
|
const periodEnd = raw.current_period_end || raw.items?.data?.[0]?.current_period_end || 0;
|
|
@@ -230,8 +250,8 @@ function resolveCancellation(raw) {
|
|
|
230
250
|
return {
|
|
231
251
|
pending: false,
|
|
232
252
|
date: {
|
|
233
|
-
timestamp:
|
|
234
|
-
timestampUNIX:
|
|
253
|
+
timestamp: EPOCH_ZERO,
|
|
254
|
+
timestampUNIX: EPOCH_ZERO_UNIX,
|
|
235
255
|
},
|
|
236
256
|
};
|
|
237
257
|
}
|
|
@@ -240,15 +260,12 @@ function resolveCancellation(raw) {
|
|
|
240
260
|
* Resolve trial state from Stripe subscription
|
|
241
261
|
*/
|
|
242
262
|
function resolveTrial(raw) {
|
|
243
|
-
const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
|
|
244
|
-
const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
|
|
245
|
-
|
|
246
263
|
const trialStart = raw.trial_start ? raw.trial_start * 1000 : 0;
|
|
247
264
|
const trialEnd = raw.trial_end ? raw.trial_end * 1000 : 0;
|
|
248
265
|
const activated = !!(trialStart && trialEnd);
|
|
249
266
|
|
|
250
267
|
// Build trial expiration
|
|
251
|
-
let trialExpires = { timestamp:
|
|
268
|
+
let trialExpires = { timestamp: EPOCH_ZERO, timestampUNIX: EPOCH_ZERO_UNIX };
|
|
252
269
|
if (trialEnd) {
|
|
253
270
|
const trialEndDate = powertools.timestamp(new Date(trialEnd), { output: 'string' });
|
|
254
271
|
trialExpires = {
|
|
@@ -272,23 +289,7 @@ function resolveFrequency(raw) {
|
|
|
272
289
|
|| raw.items?.data?.[0]?.price?.recurring?.interval
|
|
273
290
|
|| null;
|
|
274
291
|
|
|
275
|
-
|
|
276
|
-
return 'annually';
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (interval === 'month') {
|
|
280
|
-
return 'monthly';
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (interval === 'week') {
|
|
284
|
-
return 'weekly';
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (interval === 'day') {
|
|
288
|
-
return 'daily';
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return null;
|
|
292
|
+
return INTERVAL_TO_FREQUENCY[interval] || null;
|
|
292
293
|
}
|
|
293
294
|
|
|
294
295
|
/**
|
|
@@ -322,10 +323,28 @@ function resolveProduct(raw, config) {
|
|
|
322
323
|
return { id: 'basic', name: 'Basic' };
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Resolve product for one-time payments by matching productId from metadata
|
|
328
|
+
* Returns { id, name } — falls back to 'unknown' if no match is found
|
|
329
|
+
*/
|
|
330
|
+
function resolveProductOneTime(productId, config) {
|
|
331
|
+
if (!productId || !config.payment?.products) {
|
|
332
|
+
return { id: productId || 'unknown', name: 'Unknown' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const product = config.payment.products.find(p => p.id === productId);
|
|
336
|
+
|
|
337
|
+
if (!product) {
|
|
338
|
+
return { id: productId, name: productId };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { id: product.id, name: product.name || product.id };
|
|
342
|
+
}
|
|
343
|
+
|
|
325
344
|
/**
|
|
326
345
|
* Resolve subscription expiration from Stripe data
|
|
327
346
|
*/
|
|
328
|
-
function resolveExpires(raw
|
|
347
|
+
function resolveExpires(raw) {
|
|
329
348
|
// Stripe API 2025+ moves period dates to items.data[0]
|
|
330
349
|
const periodEndRaw = raw.current_period_end
|
|
331
350
|
|| raw.items?.data?.[0]?.current_period_end
|
|
@@ -333,30 +352,52 @@ function resolveExpires(raw, oldDate, oldDateUNIX) {
|
|
|
333
352
|
|
|
334
353
|
const periodEnd = periodEndRaw
|
|
335
354
|
? powertools.timestamp(new Date(periodEndRaw * 1000), { output: 'string' })
|
|
336
|
-
:
|
|
355
|
+
: EPOCH_ZERO;
|
|
337
356
|
|
|
338
357
|
return {
|
|
339
358
|
timestamp: periodEnd,
|
|
340
|
-
timestampUNIX: periodEnd !==
|
|
359
|
+
timestampUNIX: periodEnd !== EPOCH_ZERO
|
|
341
360
|
? powertools.timestamp(periodEnd, { output: 'unix' })
|
|
342
|
-
:
|
|
361
|
+
: EPOCH_ZERO_UNIX,
|
|
343
362
|
};
|
|
344
363
|
}
|
|
345
364
|
|
|
346
365
|
/**
|
|
347
366
|
* Resolve subscription start date from Stripe data
|
|
348
367
|
*/
|
|
349
|
-
function resolveStartDate(raw
|
|
368
|
+
function resolveStartDate(raw) {
|
|
350
369
|
const startDate = raw.start_date
|
|
351
370
|
? powertools.timestamp(new Date(raw.start_date * 1000), { output: 'string' })
|
|
352
|
-
:
|
|
371
|
+
: EPOCH_ZERO;
|
|
353
372
|
|
|
354
373
|
return {
|
|
355
374
|
timestamp: startDate,
|
|
356
|
-
timestampUNIX: startDate !==
|
|
375
|
+
timestampUNIX: startDate !== EPOCH_ZERO
|
|
357
376
|
? powertools.timestamp(startDate, { output: 'unix' })
|
|
358
|
-
:
|
|
377
|
+
: EPOCH_ZERO_UNIX,
|
|
359
378
|
};
|
|
360
379
|
}
|
|
361
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Resolve the display price for a product/frequency from config
|
|
383
|
+
*
|
|
384
|
+
* @param {string} productId - Product ID (e.g., 'premium')
|
|
385
|
+
* @param {string} frequency - 'monthly', 'annually', or 'once'
|
|
386
|
+
* @param {object} config - App config
|
|
387
|
+
* @returns {number} Price amount (e.g., 4.99) or 0
|
|
388
|
+
*/
|
|
389
|
+
function resolvePrice(productId, frequency, config) {
|
|
390
|
+
const product = config.payment?.products?.find(p => p.id === productId);
|
|
391
|
+
|
|
392
|
+
if (!product) {
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (frequency === 'once') {
|
|
397
|
+
return product.prices?.once?.amount || 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return product.prices?.[frequency]?.amount || 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
362
403
|
module.exports = Stripe;
|
|
@@ -26,18 +26,21 @@ const Test = {
|
|
|
26
26
|
return rawFallback;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Fallback doesn't match — try to look up the resource from
|
|
29
|
+
// Fallback doesn't match — try to look up the resource from payments-orders
|
|
30
30
|
const admin = context?.admin;
|
|
31
31
|
if (admin && resourceId) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
const snapshot = await admin.firestore()
|
|
33
|
+
.collection('payments-orders')
|
|
34
|
+
.where('resourceId', '==', resourceId)
|
|
35
|
+
.limit(1)
|
|
36
|
+
.get();
|
|
37
|
+
|
|
38
|
+
if (!snapshot.empty) {
|
|
39
|
+
const data = snapshot.docs[0].data();
|
|
40
|
+
// payments-orders stores the unified subscription inside .unified
|
|
38
41
|
// Reconstruct a Stripe-shaped object from the unified data for toUnifiedSubscription()
|
|
39
|
-
if (resourceType === 'subscription' && data.
|
|
40
|
-
return buildStripeSubscriptionFromUnified(data.
|
|
42
|
+
if (resourceType === 'subscription' && data.unified) {
|
|
43
|
+
return buildStripeSubscriptionFromUnified(data.unified, resourceId, context?.eventType, context?.config);
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
}
|
|
@@ -62,7 +65,7 @@ const Test = {
|
|
|
62
65
|
*/
|
|
63
66
|
toUnifiedOneTime(rawResource, options) {
|
|
64
67
|
const unified = Stripe.toUnifiedOneTime(rawResource, options);
|
|
65
|
-
unified.processor = 'test';
|
|
68
|
+
unified.payment.processor = 'test';
|
|
66
69
|
return unified;
|
|
67
70
|
},
|
|
68
71
|
};
|
|
@@ -110,7 +113,7 @@ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, conf
|
|
|
110
113
|
id: resourceId,
|
|
111
114
|
object: 'subscription',
|
|
112
115
|
status: status,
|
|
113
|
-
metadata: {},
|
|
116
|
+
metadata: { orderId: unified.payment?.orderId || null },
|
|
114
117
|
plan: {
|
|
115
118
|
id: priceId,
|
|
116
119
|
interval: INTERVAL_MAP[frequency] || 'month',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const powertools = require('node-powertools');
|
|
3
|
+
const OrderId = require('../../../libraries/payment-processors/order-id.js');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* POST /payments/intent
|
|
@@ -49,8 +50,9 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
49
50
|
// Resolve trial eligibility: if requested but user has subscription history, silently downgrade
|
|
50
51
|
if (trial) {
|
|
51
52
|
const historySnapshot = await admin.firestore()
|
|
52
|
-
.collection('payments-
|
|
53
|
-
.where('
|
|
53
|
+
.collection('payments-orders')
|
|
54
|
+
.where('owner', '==', uid)
|
|
55
|
+
.where('type', '==', 'subscription')
|
|
54
56
|
.limit(1)
|
|
55
57
|
.get();
|
|
56
58
|
|
|
@@ -64,6 +66,15 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
64
66
|
trial = false;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Generate order ID
|
|
70
|
+
const orderId = OrderId.generate();
|
|
71
|
+
|
|
72
|
+
assistant.log(`Generated orderId=${orderId}`);
|
|
73
|
+
|
|
74
|
+
// Build redirect URLs
|
|
75
|
+
const confirmationUrl = buildConfirmationUrl(Manager.project.websiteUrl, { product, productId, productType, frequency, processor, trial, orderId });
|
|
76
|
+
const cancelUrl = buildCancelUrl(Manager.project.websiteUrl, { productId, frequency });
|
|
77
|
+
|
|
67
78
|
// Load the processor module
|
|
68
79
|
let processorModule;
|
|
69
80
|
try {
|
|
@@ -77,10 +88,13 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
77
88
|
try {
|
|
78
89
|
result = await processorModule.createIntent({
|
|
79
90
|
uid,
|
|
91
|
+
orderId,
|
|
80
92
|
product,
|
|
81
93
|
productId,
|
|
82
94
|
frequency,
|
|
83
95
|
trial,
|
|
96
|
+
confirmationUrl,
|
|
97
|
+
cancelUrl,
|
|
84
98
|
config: Manager.config,
|
|
85
99
|
Manager,
|
|
86
100
|
assistant,
|
|
@@ -96,11 +110,12 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
96
110
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
97
111
|
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
98
112
|
|
|
99
|
-
// Save to payments-intents collection
|
|
100
|
-
await admin.firestore().doc(`payments-intents/${
|
|
101
|
-
id:
|
|
113
|
+
// Save to payments-intents collection (keyed by orderId for consistent lookup with payments-orders)
|
|
114
|
+
await admin.firestore().doc(`payments-intents/${orderId}`).set({
|
|
115
|
+
id: orderId,
|
|
116
|
+
intentId: result.id,
|
|
102
117
|
processor: processor,
|
|
103
|
-
|
|
118
|
+
owner: uid,
|
|
104
119
|
status: 'pending',
|
|
105
120
|
productId: productId,
|
|
106
121
|
productType: productType,
|
|
@@ -115,10 +130,49 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
115
130
|
},
|
|
116
131
|
});
|
|
117
132
|
|
|
118
|
-
assistant.log(`Saved payments-intents/${
|
|
133
|
+
assistant.log(`Saved payments-intents/${orderId}: uid=${uid}, product=${productId}, type=${productType}, frequency=${frequency}, trial=${trial}`);
|
|
119
134
|
|
|
120
135
|
return assistant.respond({
|
|
121
136
|
id: result.id,
|
|
137
|
+
orderId: orderId,
|
|
122
138
|
url: result.url,
|
|
123
139
|
});
|
|
124
140
|
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build the confirmation/success redirect URL
|
|
144
|
+
*/
|
|
145
|
+
function buildConfirmationUrl(baseUrl, { product, productId, productType, frequency, processor, trial, orderId }) {
|
|
146
|
+
const amount = productType === 'subscription'
|
|
147
|
+
? (product.prices?.[frequency]?.amount || 0)
|
|
148
|
+
: (product.prices?.once?.amount || 0);
|
|
149
|
+
|
|
150
|
+
const url = new URL('/payment/confirmation', baseUrl);
|
|
151
|
+
url.searchParams.set('productId', productId);
|
|
152
|
+
url.searchParams.set('productName', product.name || productId);
|
|
153
|
+
url.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
|
|
154
|
+
url.searchParams.set('currency', 'USD');
|
|
155
|
+
url.searchParams.set('frequency', frequency || 'once');
|
|
156
|
+
url.searchParams.set('paymentMethod', processor);
|
|
157
|
+
url.searchParams.set('trial', String(!!trial && !!product.trial?.days));
|
|
158
|
+
url.searchParams.set('orderId', orderId);
|
|
159
|
+
url.searchParams.set('track', 'true');
|
|
160
|
+
|
|
161
|
+
return url.toString();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the cancel/back redirect URL
|
|
166
|
+
*/
|
|
167
|
+
function buildCancelUrl(baseUrl, { productId, frequency }) {
|
|
168
|
+
const url = new URL('/payment/checkout', baseUrl);
|
|
169
|
+
url.searchParams.set('product', productId);
|
|
170
|
+
|
|
171
|
+
if (frequency) {
|
|
172
|
+
url.searchParams.set('frequency', frequency);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
url.searchParams.set('payment', 'cancelled');
|
|
176
|
+
|
|
177
|
+
return url.toString();
|
|
178
|
+
}
|