backend-manager 5.0.85 → 5.0.87
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 +53 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +5 -1
- package/src/cli/commands/serve.js +1 -2
- package/src/manager/cron/daily/ghostii-auto-publisher.js +10 -19
- package/src/manager/events/firestore/payments-webhooks/on-write.js +351 -56
- package/src/manager/events/firestore/payments-webhooks/transitions/index.js +148 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +16 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +18 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +14 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +16 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +16 -0
- package/src/manager/index.js +26 -36
- package/src/manager/libraries/{stripe.js → payment-processors/stripe.js} +57 -2
- package/src/manager/libraries/payment-processors/test.js +141 -0
- package/src/manager/routes/app/get.js +5 -22
- package/src/manager/routes/payments/intent/post.js +38 -23
- package/src/manager/routes/payments/intent/processors/stripe.js +112 -44
- package/src/manager/routes/payments/intent/processors/test.js +139 -76
- package/src/manager/routes/payments/webhook/post.js +14 -5
- package/src/manager/routes/payments/webhook/processors/stripe.js +75 -9
- package/src/manager/schemas/payments/intent/post.js +1 -1
- package/src/test/test-accounts.js +10 -1
- package/templates/backend-manager-config.json +16 -4
- package/test/events/payments/journey-payments-cancel.js +6 -0
- package/test/events/payments/journey-payments-failure.js +114 -0
- package/test/events/payments/journey-payments-suspend.js +6 -0
- package/test/events/payments/journey-payments-trial.js +12 -0
- package/test/events/payments/journey-payments-upgrade.js +17 -0
- package/test/fixtures/stripe/checkout-session-completed.json +130 -0
- package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
- package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
- package/test/helpers/stripe-parse-webhook.js +447 -0
- package/test/helpers/stripe-to-unified.js +59 -59
- package/test/routes/payments/intent.js +3 -3
- package/test/routes/payments/webhook.js +2 -2
- package/src/manager/libraries/test.js +0 -27
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const Stripe = require('./stripe.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test processor library
|
|
5
|
+
* Delegates to Stripe's transformers since test processor generates Stripe-shaped data
|
|
6
|
+
* Stamps processor as 'test' to distinguish from real Stripe data
|
|
7
|
+
*/
|
|
8
|
+
const Test = {
|
|
9
|
+
/**
|
|
10
|
+
* No-op init — test processor doesn't need an external SDK
|
|
11
|
+
*/
|
|
12
|
+
init() {
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch resource — test processor has no real API
|
|
18
|
+
*
|
|
19
|
+
* When the requested resourceType doesn't match the fallback (e.g., requesting a subscription
|
|
20
|
+
* but the fallback is an invoice from invoice.payment_failed), look up the existing resource
|
|
21
|
+
* from Firestore instead of returning mismatched data.
|
|
22
|
+
*/
|
|
23
|
+
async fetchResource(resourceType, resourceId, rawFallback, context) {
|
|
24
|
+
// If the fallback matches the requested type, return it directly
|
|
25
|
+
if (rawFallback?.object === resourceType) {
|
|
26
|
+
return rawFallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fallback doesn't match — try to look up the resource from Firestore
|
|
30
|
+
const admin = context?.admin;
|
|
31
|
+
if (admin && resourceId) {
|
|
32
|
+
const collection = resourceType === 'subscription' ? 'payments-subscriptions' : 'payments-one-time';
|
|
33
|
+
const doc = await admin.firestore().doc(`${collection}/${resourceId}`).get();
|
|
34
|
+
|
|
35
|
+
if (doc.exists) {
|
|
36
|
+
const data = doc.data();
|
|
37
|
+
// payments-subscriptions stores the unified subscription inside .subscription
|
|
38
|
+
// Reconstruct a Stripe-shaped object from the unified data for toUnifiedSubscription()
|
|
39
|
+
if (resourceType === 'subscription' && data.subscription) {
|
|
40
|
+
return buildStripeSubscriptionFromUnified(data.subscription, resourceId, context?.eventType, context?.config);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Last resort: return the raw fallback
|
|
46
|
+
return rawFallback;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Transform raw subscription into unified shape
|
|
51
|
+
* Delegates to Stripe's toUnifiedSubscription (same data shape), stamps processor as 'test'
|
|
52
|
+
*/
|
|
53
|
+
toUnifiedSubscription(rawSubscription, options) {
|
|
54
|
+
const unified = Stripe.toUnifiedSubscription(rawSubscription, options);
|
|
55
|
+
unified.payment.processor = 'test';
|
|
56
|
+
return unified;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Transform raw one-time payment into unified shape
|
|
61
|
+
* Delegates to Stripe's toUnifiedOneTime, stamps processor as 'test'
|
|
62
|
+
*/
|
|
63
|
+
toUnifiedOneTime(rawResource, options) {
|
|
64
|
+
const unified = Stripe.toUnifiedOneTime(rawResource, options);
|
|
65
|
+
unified.processor = 'test';
|
|
66
|
+
return unified;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports = Test;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reconstruct a Stripe-shaped subscription from the unified subscription stored in Firestore
|
|
74
|
+
* This is only needed for the test processor when the webhook fallback doesn't match the resource type
|
|
75
|
+
* (e.g., invoice.payment_failed sends invoice data but we need the subscription)
|
|
76
|
+
*
|
|
77
|
+
* The unified → Stripe mapping must produce data that toUnifiedSubscription() can process correctly.
|
|
78
|
+
* For payment failure events, we override the status to past_due so it maps to 'suspended'.
|
|
79
|
+
*/
|
|
80
|
+
function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, config) {
|
|
81
|
+
// Map unified status back to a Stripe status
|
|
82
|
+
const STATUS_MAP = {
|
|
83
|
+
active: 'active',
|
|
84
|
+
suspended: 'past_due',
|
|
85
|
+
cancelled: 'canceled',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Map unified frequency back to Stripe interval
|
|
89
|
+
const INTERVAL_MAP = {
|
|
90
|
+
monthly: 'month',
|
|
91
|
+
annually: 'year',
|
|
92
|
+
weekly: 'week',
|
|
93
|
+
daily: 'day',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Determine status: for payment failure events, force past_due regardless of current state
|
|
97
|
+
// In production, Stripe would have already updated the subscription status
|
|
98
|
+
let status = STATUS_MAP[unified.status] || 'active';
|
|
99
|
+
if (eventType === 'invoice.payment_failed') {
|
|
100
|
+
status = 'past_due';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Resolve the Stripe price ID from product + frequency via config
|
|
104
|
+
// This is needed for resolveProduct() in toUnifiedSubscription() to match the correct product
|
|
105
|
+
const frequency = unified.payment?.frequency;
|
|
106
|
+
const productId = unified.product?.id;
|
|
107
|
+
const priceId = resolvePriceId(productId, frequency, config);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id: resourceId,
|
|
111
|
+
object: 'subscription',
|
|
112
|
+
status: status,
|
|
113
|
+
metadata: {},
|
|
114
|
+
plan: {
|
|
115
|
+
id: priceId,
|
|
116
|
+
interval: INTERVAL_MAP[frequency] || 'month',
|
|
117
|
+
},
|
|
118
|
+
current_period_end: unified.expires?.timestampUNIX || 0,
|
|
119
|
+
current_period_start: unified.payment?.startDate?.timestampUNIX || 0,
|
|
120
|
+
start_date: unified.payment?.startDate?.timestampUNIX || 0,
|
|
121
|
+
cancel_at_period_end: unified.cancellation?.pending || false,
|
|
122
|
+
cancel_at: unified.cancellation?.pending ? unified.cancellation?.date?.timestampUNIX : null,
|
|
123
|
+
canceled_at: null,
|
|
124
|
+
trial_start: unified.trial?.claimed ? (unified.payment?.startDate?.timestampUNIX || 0) : null,
|
|
125
|
+
trial_end: unified.trial?.claimed ? (unified.trial?.expires?.timestampUNIX || 0) : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Look up the Stripe price ID from config given a product ID and frequency
|
|
131
|
+
* e.g., ('plus', 'monthly') → 'price_plus_monthly'
|
|
132
|
+
*/
|
|
133
|
+
function resolvePriceId(productId, frequency, config) {
|
|
134
|
+
if (!productId || !frequency || !config?.payment?.products) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const product = config.payment.products.find(p => p.id === productId);
|
|
139
|
+
|
|
140
|
+
return product?.prices?.[frequency]?.stripe || null;
|
|
141
|
+
}
|
|
@@ -14,29 +14,12 @@ module.exports = async ({ assistant, Manager }) => {
|
|
|
14
14
|
*/
|
|
15
15
|
function buildPublicConfig(config) {
|
|
16
16
|
return {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
email: config.brand?.contact?.email,
|
|
22
|
-
images: config.brand?.images || {},
|
|
23
|
-
github: {
|
|
24
|
-
user: config.github?.user,
|
|
25
|
-
repo: (config.github?.repo_website || '').split('/').pop(),
|
|
26
|
-
},
|
|
27
|
-
reviews: config.reviews || {},
|
|
17
|
+
brand: config.brand || {},
|
|
18
|
+
github: config.github || {},
|
|
19
|
+
oauth2: config.oauth2 || {},
|
|
20
|
+
payment: config.payment || {},
|
|
28
21
|
firebaseConfig: config.firebaseConfig || {},
|
|
29
|
-
|
|
30
|
-
processors: config.payment?.processors || {},
|
|
31
|
-
products: (config.payment?.products || []).map(p => ({
|
|
32
|
-
id: p.id,
|
|
33
|
-
name: p.name,
|
|
34
|
-
type: p.type,
|
|
35
|
-
limits: p.limits || {},
|
|
36
|
-
trial: p.trial || {},
|
|
37
|
-
prices: p.prices || {},
|
|
38
|
-
})),
|
|
39
|
-
},
|
|
22
|
+
reviews: config.reviews || {},
|
|
40
23
|
};
|
|
41
24
|
}
|
|
42
25
|
|
|
@@ -3,7 +3,7 @@ const powertools = require('node-powertools');
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* POST /payments/intent
|
|
6
|
-
* Creates a payment intent (e.g., Stripe Checkout Session) for subscription purchase
|
|
6
|
+
* Creates a payment intent (e.g., Stripe Checkout Session) for subscription or one-time purchase
|
|
7
7
|
* Requires authentication
|
|
8
8
|
*/
|
|
9
9
|
module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
@@ -22,26 +22,6 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
22
22
|
|
|
23
23
|
assistant.log(`Intent request: uid=${uid}, processor=${processor}, product=${productId}, frequency=${frequency}, trial=${trial}`);
|
|
24
24
|
|
|
25
|
-
// Check if user already has an active non-basic subscription
|
|
26
|
-
if (user.subscription?.status === 'active' && user.subscription?.product?.id !== 'basic') {
|
|
27
|
-
assistant.log(`User ${uid} already has active subscription: product=${user.subscription.product.id}, status=${user.subscription.status}, resourceId=${user.subscription.payment?.resourceId}`);
|
|
28
|
-
return assistant.respond('User already has an active subscription', { code: 400 });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Resolve trial eligibility: if requested but user has subscription history, silently downgrade
|
|
32
|
-
if (trial) {
|
|
33
|
-
const historySnapshot = await admin.firestore()
|
|
34
|
-
.collection('payments-subscriptions')
|
|
35
|
-
.where('uid', '==', uid)
|
|
36
|
-
.limit(1)
|
|
37
|
-
.get();
|
|
38
|
-
|
|
39
|
-
if (!historySnapshot.empty) {
|
|
40
|
-
assistant.log(`User ${uid} not eligible for trial (has subscription history), continuing without trial`);
|
|
41
|
-
trial = false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
25
|
// Validate product exists in config
|
|
46
26
|
const product = (Manager.config.payment?.products || []).find(p => p.id === productId);
|
|
47
27
|
if (!product) {
|
|
@@ -49,7 +29,40 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
49
29
|
return assistant.respond(`Product '${productId}' not found`, { code: 400 });
|
|
50
30
|
}
|
|
51
31
|
|
|
52
|
-
|
|
32
|
+
const productType = product.type || 'subscription';
|
|
33
|
+
|
|
34
|
+
assistant.log(`Product resolved: id=${product.id}, name=${product.name}, type=${productType}, trialDays=${product.trial?.days || 'none'}`);
|
|
35
|
+
|
|
36
|
+
// Subscription-specific guards
|
|
37
|
+
if (productType === 'subscription') {
|
|
38
|
+
// Require frequency for subscriptions
|
|
39
|
+
if (!frequency) {
|
|
40
|
+
return assistant.respond('Frequency is required for subscription products', { code: 400 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check if user already has an active non-basic subscription
|
|
44
|
+
if (user.subscription?.status === 'active' && user.subscription?.product?.id !== 'basic') {
|
|
45
|
+
assistant.log(`User ${uid} already has active subscription: product=${user.subscription.product.id}, status=${user.subscription.status}, resourceId=${user.subscription.payment?.resourceId}`);
|
|
46
|
+
return assistant.respond('User already has an active subscription', { code: 400 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve trial eligibility: if requested but user has subscription history, silently downgrade
|
|
50
|
+
if (trial) {
|
|
51
|
+
const historySnapshot = await admin.firestore()
|
|
52
|
+
.collection('payments-subscriptions')
|
|
53
|
+
.where('uid', '==', uid)
|
|
54
|
+
.limit(1)
|
|
55
|
+
.get();
|
|
56
|
+
|
|
57
|
+
if (!historySnapshot.empty) {
|
|
58
|
+
assistant.log(`User ${uid} not eligible for trial (has subscription history), continuing without trial`);
|
|
59
|
+
trial = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
// One-time purchases don't use trial or frequency
|
|
64
|
+
trial = false;
|
|
65
|
+
}
|
|
53
66
|
|
|
54
67
|
// Load the processor module
|
|
55
68
|
let processorModule;
|
|
@@ -64,6 +77,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
64
77
|
try {
|
|
65
78
|
result = await processorModule.createIntent({
|
|
66
79
|
uid,
|
|
80
|
+
product,
|
|
67
81
|
productId,
|
|
68
82
|
frequency,
|
|
69
83
|
trial,
|
|
@@ -89,6 +103,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
89
103
|
uid: uid,
|
|
90
104
|
status: 'pending',
|
|
91
105
|
productId: productId,
|
|
106
|
+
productType: productType,
|
|
92
107
|
frequency: frequency,
|
|
93
108
|
trial: trial,
|
|
94
109
|
raw: result.raw,
|
|
@@ -100,7 +115,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
100
115
|
},
|
|
101
116
|
});
|
|
102
117
|
|
|
103
|
-
assistant.log(`Saved payments-intents/${result.id}: uid=${uid}, product=${productId}, frequency=${frequency}, trial=${trial}`);
|
|
118
|
+
assistant.log(`Saved payments-intents/${result.id}: uid=${uid}, product=${productId}, type=${productType}, frequency=${frequency}, trial=${trial}`);
|
|
104
119
|
|
|
105
120
|
return assistant.respond({
|
|
106
121
|
id: result.id,
|
|
@@ -1,75 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stripe intent processor
|
|
3
|
-
* Creates Stripe Checkout Sessions for subscription purchases
|
|
3
|
+
* Creates Stripe Checkout Sessions for subscription and one-time purchases
|
|
4
4
|
*/
|
|
5
5
|
module.exports = {
|
|
6
6
|
/**
|
|
7
|
-
* Create a Stripe Checkout Session
|
|
7
|
+
* Create a Stripe Checkout Session
|
|
8
8
|
*
|
|
9
9
|
* @param {object} options
|
|
10
10
|
* @param {string} options.uid - User's UID
|
|
11
|
+
* @param {object} options.product - Full product object from config
|
|
11
12
|
* @param {string} options.productId - Product ID from config (e.g., 'premium')
|
|
12
|
-
* @param {string} options.frequency - 'monthly' or 'annually'
|
|
13
|
-
* @param {boolean} options.trial - Whether to include a trial period
|
|
14
|
-
* @param {object} options.config - BEM config
|
|
13
|
+
* @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
|
|
14
|
+
* @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
|
|
15
|
+
* @param {object} options.config - BEM config
|
|
15
16
|
* @param {object} options.Manager - Manager instance
|
|
16
17
|
* @returns {object} { id, url, raw }
|
|
17
18
|
*/
|
|
18
|
-
async createIntent({ uid, productId, frequency, trial, config, Manager, assistant }) {
|
|
19
|
+
async createIntent({ uid, product, productId, frequency, trial, config, Manager, assistant }) {
|
|
19
20
|
// Initialize Stripe SDK
|
|
20
|
-
const StripeLib = require('../../../../libraries/stripe.js');
|
|
21
|
+
const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
|
|
21
22
|
const stripe = StripeLib.init();
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
const product = (config.payment?.products || []).find(p => p.id === productId);
|
|
25
|
-
if (!product) {
|
|
26
|
-
throw new Error(`Product '${productId}' not found in config`);
|
|
27
|
-
}
|
|
24
|
+
const productType = product.type || 'subscription';
|
|
28
25
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
26
|
+
// Resolve the Stripe price ID based on product type
|
|
27
|
+
let priceId;
|
|
28
|
+
if (productType === 'subscription') {
|
|
29
|
+
priceId = product.prices?.[frequency]?.stripe;
|
|
30
|
+
if (!priceId) {
|
|
31
|
+
throw new Error(`No Stripe price found for ${productId}/${frequency}`);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
priceId = product.prices?.once?.stripe;
|
|
35
|
+
if (!priceId) {
|
|
36
|
+
throw new Error(`No Stripe price found for ${productId}/once`);
|
|
37
|
+
}
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
// Resolve or create Stripe customer (keyed by uid in metadata)
|
|
36
41
|
const email = assistant?.getUser()?.auth?.email || null;
|
|
37
42
|
const customer = await resolveCustomer(stripe, uid, email, assistant);
|
|
38
43
|
|
|
39
|
-
assistant?.log(`Stripe checkout: priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
|
|
40
|
-
|
|
41
|
-
// Build
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
uid: uid,
|
|
58
|
-
productId: productId,
|
|
59
|
-
frequency: frequency,
|
|
60
|
-
},
|
|
61
|
-
};
|
|
44
|
+
assistant?.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
|
|
45
|
+
|
|
46
|
+
// Build confirmation redirect URL
|
|
47
|
+
const baseUrl = config.brand?.url;
|
|
48
|
+
const amount = productType === 'subscription'
|
|
49
|
+
? (product.prices?.[frequency]?.amount || 0)
|
|
50
|
+
: (product.prices?.once?.amount || 0);
|
|
51
|
+
|
|
52
|
+
const confirmationUrl = new URL('/payment/confirmation', baseUrl);
|
|
53
|
+
confirmationUrl.searchParams.set('orderId', '{CHECKOUT_SESSION_ID}');
|
|
54
|
+
confirmationUrl.searchParams.set('productId', productId);
|
|
55
|
+
confirmationUrl.searchParams.set('productName', product.name || productId);
|
|
56
|
+
confirmationUrl.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
|
|
57
|
+
confirmationUrl.searchParams.set('currency', 'USD');
|
|
58
|
+
confirmationUrl.searchParams.set('frequency', frequency || 'once');
|
|
59
|
+
confirmationUrl.searchParams.set('paymentMethod', 'stripe');
|
|
60
|
+
confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
|
|
61
|
+
confirmationUrl.searchParams.set('track', 'true');
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
const cancelUrl = new URL('/payment/checkout', baseUrl);
|
|
64
|
+
cancelUrl.searchParams.set('product', productId);
|
|
65
|
+
if (frequency) {
|
|
66
|
+
cancelUrl.searchParams.set('frequency', frequency);
|
|
67
|
+
}
|
|
68
|
+
cancelUrl.searchParams.set('payment', 'cancelled');
|
|
69
|
+
|
|
70
|
+
// Build session params based on product type
|
|
71
|
+
let sessionParams;
|
|
72
|
+
|
|
73
|
+
if (productType === 'subscription') {
|
|
74
|
+
sessionParams = buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl });
|
|
75
|
+
} else {
|
|
76
|
+
sessionParams = buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl });
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
// Create the checkout session
|
|
70
80
|
const session = await stripe.checkout.sessions.create(sessionParams);
|
|
71
81
|
|
|
72
|
-
assistant?.log(`Stripe session created: sessionId=${session.id}, url=${session.url}`);
|
|
82
|
+
assistant?.log(`Stripe session created: sessionId=${session.id}, mode=${sessionParams.mode}, url=${session.url}`);
|
|
73
83
|
|
|
74
84
|
return {
|
|
75
85
|
id: session.id,
|
|
@@ -79,6 +89,64 @@ module.exports = {
|
|
|
79
89
|
},
|
|
80
90
|
};
|
|
81
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Build Stripe Checkout Session params for a subscription
|
|
94
|
+
*/
|
|
95
|
+
function buildSubscriptionSession({ priceId, customer, uid, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
|
|
96
|
+
const sessionParams = {
|
|
97
|
+
mode: 'subscription',
|
|
98
|
+
customer: customer.id,
|
|
99
|
+
line_items: [{
|
|
100
|
+
price: priceId,
|
|
101
|
+
quantity: 1,
|
|
102
|
+
}],
|
|
103
|
+
subscription_data: {
|
|
104
|
+
metadata: {
|
|
105
|
+
uid: uid,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
success_url: confirmationUrl.toString(),
|
|
109
|
+
cancel_url: cancelUrl.toString(),
|
|
110
|
+
metadata: {
|
|
111
|
+
uid: uid,
|
|
112
|
+
productId: productId,
|
|
113
|
+
frequency: frequency,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Add trial period if requested
|
|
118
|
+
if (trial && product.trial?.days) {
|
|
119
|
+
sessionParams.subscription_data.trial_period_days = product.trial.days;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return sessionParams;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build Stripe Checkout Session params for a one-time payment
|
|
127
|
+
*/
|
|
128
|
+
function buildOneTimeSession({ priceId, customer, uid, productId, product, confirmationUrl, cancelUrl }) {
|
|
129
|
+
return {
|
|
130
|
+
mode: 'payment',
|
|
131
|
+
customer: customer.id,
|
|
132
|
+
line_items: [{
|
|
133
|
+
price: priceId,
|
|
134
|
+
quantity: 1,
|
|
135
|
+
}],
|
|
136
|
+
payment_intent_data: {
|
|
137
|
+
metadata: {
|
|
138
|
+
uid: uid,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
success_url: confirmationUrl.toString(),
|
|
142
|
+
cancel_url: cancelUrl.toString(),
|
|
143
|
+
metadata: {
|
|
144
|
+
uid: uid,
|
|
145
|
+
productId: productId,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
82
150
|
/**
|
|
83
151
|
* Find an existing Stripe customer by uid metadata, or create one
|
|
84
152
|
*/
|