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.
Files changed (40) hide show
  1. package/CLAUDE.md +53 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/base-command.js +5 -1
  4. package/src/cli/commands/serve.js +1 -2
  5. package/src/manager/cron/daily/ghostii-auto-publisher.js +10 -19
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +351 -56
  7. package/src/manager/events/firestore/payments-webhooks/transitions/index.js +148 -0
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +16 -0
  9. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +15 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +15 -0
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +18 -0
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +15 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +14 -0
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +16 -0
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +16 -0
  16. package/src/manager/index.js +26 -36
  17. package/src/manager/libraries/{stripe.js → payment-processors/stripe.js} +57 -2
  18. package/src/manager/libraries/payment-processors/test.js +141 -0
  19. package/src/manager/routes/app/get.js +5 -22
  20. package/src/manager/routes/payments/intent/post.js +38 -23
  21. package/src/manager/routes/payments/intent/processors/stripe.js +112 -44
  22. package/src/manager/routes/payments/intent/processors/test.js +139 -76
  23. package/src/manager/routes/payments/webhook/post.js +14 -5
  24. package/src/manager/routes/payments/webhook/processors/stripe.js +75 -9
  25. package/src/manager/schemas/payments/intent/post.js +1 -1
  26. package/src/test/test-accounts.js +10 -1
  27. package/templates/backend-manager-config.json +16 -4
  28. package/test/events/payments/journey-payments-cancel.js +6 -0
  29. package/test/events/payments/journey-payments-failure.js +114 -0
  30. package/test/events/payments/journey-payments-suspend.js +6 -0
  31. package/test/events/payments/journey-payments-trial.js +12 -0
  32. package/test/events/payments/journey-payments-upgrade.js +17 -0
  33. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  34. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  35. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  36. package/test/helpers/stripe-parse-webhook.js +447 -0
  37. package/test/helpers/stripe-to-unified.js +59 -59
  38. package/test/routes/payments/intent.js +3 -3
  39. package/test/routes/payments/webhook.js +2 -2
  40. 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
- id: config.app?.id,
18
- name: config.brand?.name,
19
- description: config.brand?.description,
20
- url: config.brand?.url,
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
- payment: {
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
- assistant.log(`Product resolved: id=${product.id}, name=${product.name}, trialDays=${product.trial?.days || 'none'}`);
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 for a subscription
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 (must contain products array)
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
- // Find the product in config
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
- // Get the Stripe price ID for the requested frequency
30
- const priceId = product.prices?.[frequency]?.stripe;
31
- if (!priceId) {
32
- throw new Error(`No Stripe price found for ${productId}/${frequency}`);
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 session params
42
- const sessionParams = {
43
- mode: 'subscription',
44
- customer: customer.id,
45
- line_items: [{
46
- price: priceId,
47
- quantity: 1,
48
- }],
49
- subscription_data: {
50
- metadata: {
51
- uid: uid,
52
- },
53
- },
54
- success_url: `${config.brand?.url || 'https://example.com'}/account/?payment=success`,
55
- cancel_url: `${config.brand?.url || 'https://example.com'}/account/?payment=cancelled`,
56
- metadata: {
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
- // Add trial period if requested
64
- if (trial && product.trial?.days) {
65
- sessionParams.subscription_data.trial_period_days = product.trial.days;
66
- assistant?.log(`Trial period added: ${product.trial.days} days`);
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
  */