backend-manager 5.0.105 → 5.0.106

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 CHANGED
@@ -89,7 +89,7 @@ src/
89
89
  post.js # Intent creation orchestrator
90
90
  processors/ # Per-processor intent creators
91
91
  stripe.js # Stripe Checkout Session creation
92
- paypal.js # PayPal subscription creation
92
+ paypal.js # PayPal subscription + one-time order creation
93
93
  test.js # Test processor (auto-fires webhooks)
94
94
  webhook/ # POST /payments/webhook
95
95
  post.js # Webhook ingestion + Firestore write
@@ -804,7 +804,7 @@ The payment system is cleanly separated into three independent layers:
804
804
 
805
805
  | Layer | Purpose | Tests |
806
806
  |-------|---------|-------|
807
- | **Processor input** (Stripe, PayPal, Test) | Parse raw webhooks + transform to unified shape | Helper tests per processor (`stripe-to-unified.js`, `paypal-to-unified.js`, etc.) |
807
+ | **Processor input** (Stripe, PayPal, Test) | Parse raw webhooks + transform to unified shape | Helper tests per processor (`payment/stripe/to-unified-subscription.js`, `payment/paypal/to-unified-one-time.js`, etc.) |
808
808
  | **Unified pipeline** (processor-agnostic) | Transition detection, Firestore writes, analytics | Journey tests (`journey-payments-*.js`) |
809
809
  | **Transition handlers** (fire-and-forget) | Emails, notifications, side effects | Skipped during tests unless `TEST_EXTENDED_MODE` |
810
810
 
@@ -864,6 +864,7 @@ module.exports = {
864
864
  module.exports = {
865
865
  init() { /* return SDK instance */ },
866
866
  async fetchResource(resourceType, resourceId, rawFallback, context) { /* return resource */ },
867
+ getOrderId(resource) { /* return orderId string or null */ },
867
868
  toUnifiedSubscription(rawSubscription, options) { /* return unified object */ },
868
869
  toUnifiedOneTime(rawResource, options) { /* return unified object */ },
869
870
  };
package/README.md CHANGED
@@ -879,7 +879,7 @@ See `CLAUDE.md` for complete test API documentation.
879
879
 
880
880
  ## Subscription System
881
881
 
882
- BEM includes a built-in payment/subscription system with Stripe integration (extensible to other providers).
882
+ BEM includes a built-in payment/subscription system with Stripe and PayPal integration.
883
883
 
884
884
  ### Subscription Statuses
885
885
 
@@ -902,6 +902,39 @@ BEM includes a built-in payment/subscription system with Stripe integration (ext
902
902
  | `incomplete_expired` | `cancelled` | Expired before completion |
903
903
  | `active` + `cancel_at_period_end` | `active` | `cancellation.pending = true` |
904
904
 
905
+ ### PayPal Status Mapping
906
+
907
+ | PayPal Status | `subscription.status` | Notes |
908
+ |---|---|---|
909
+ | `ACTIVE` | `active` | Normal active subscription |
910
+ | `SUSPENDED` | `suspended` | Payment failed or manually suspended |
911
+ | `CANCELLED` | `cancelled` | Subscription terminated |
912
+ | `EXPIRED` | `cancelled` | Billing cycles completed |
913
+
914
+ ### Product Configuration
915
+
916
+ Products are defined in `config.payment.products` with flat prices and per-processor IDs:
917
+
918
+ ```javascript
919
+ payment: {
920
+ products: [
921
+ { id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 10 } },
922
+ {
923
+ id: 'plus', name: 'Plus', type: 'subscription',
924
+ limits: { requests: 100 }, trial: { days: 14 },
925
+ prices: { monthly: 28, annually: 276 },
926
+ stripe: { productId: 'prod_xxx' },
927
+ paypal: { productId: 'PROD-abc123' },
928
+ },
929
+ {
930
+ id: 'boost', name: 'Boost Pack', type: 'one-time',
931
+ prices: { once: 9.99 },
932
+ stripe: { productId: 'prod_yyy' },
933
+ },
934
+ ],
935
+ }
936
+ ```
937
+
905
938
  ### Unified Subscription Object
906
939
 
907
940
  The same subscription shape is stored in `users/{uid}.subscription` and `payments-orders/{orderId}.subscription`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.105",
3
+ "version": "5.0.106",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  // Libraries
2
2
  const path = require('path');
3
- const { merge } = require('lodash');
3
+ const { mergeWith, isArray } = require('lodash');
4
4
  const jetpack = require('fs-jetpack');
5
5
  const JSON5 = require('json5');
6
6
  const EventEmitter = require('events');
@@ -129,10 +129,13 @@ Manager.prototype.init = function (exporter, options) {
129
129
  }
130
130
 
131
131
  // Load config
132
- self.config = merge(
132
+ // Use mergeWith to replace arrays instead of merging by index
133
+ // (lodash merge merges arrays positionally, causing template defaults to bleed into project values)
134
+ self.config = mergeWith(
133
135
  {},
134
136
  requireJSON5(BEM_CONFIG_TEMPLATE_PATH, true),
135
137
  requireJSON5(self.project.backendManagerConfigPath, true),
138
+ (_objValue, srcValue) => isArray(srcValue) ? srcValue : undefined,
136
139
  );
137
140
 
138
141
  // Resolve legacy paths
@@ -6,6 +6,7 @@ const EPOCH_ZERO_UNIX = powertools.timestamp(EPOCH_ZERO, { output: 'unix' });
6
6
 
7
7
  // PayPal interval → unified frequency map
8
8
  const INTERVAL_TO_FREQUENCY = { YEAR: 'annually', MONTH: 'monthly', WEEK: 'weekly', DAY: 'daily' };
9
+ const FREQUENCY_TO_INTERVAL = { annually: 'YEAR', monthly: 'MONTH', weekly: 'WEEK', daily: 'DAY' };
9
10
 
10
11
  // PayPal API base URL
11
12
  const PAYPAL_API_BASE = 'https://api-m.paypal.com';
@@ -271,7 +272,7 @@ const PayPal = {
271
272
  const plans = response.plans || [];
272
273
 
273
274
  // Map frequency to PayPal interval unit
274
- const intervalUnit = frequency === 'annually' ? 'YEAR' : 'MONTH';
275
+ const intervalUnit = FREQUENCY_TO_INTERVAL[frequency] || 'MONTH';
275
276
 
276
277
  // Find matching active plan by interval + amount
277
278
  for (const plan of plans) {
@@ -9,6 +9,7 @@ const EPOCH_ZERO_UNIX = powertools.timestamp(EPOCH_ZERO, { output: 'unix' });
9
9
 
10
10
  // Stripe interval → unified frequency map
11
11
  const INTERVAL_TO_FREQUENCY = { year: 'annually', month: 'monthly', week: 'weekly', day: 'daily' };
12
+ const FREQUENCY_TO_INTERVAL = { annually: 'year', monthly: 'month', weekly: 'week', daily: 'day' };
12
13
 
13
14
  /**
14
15
  * Stripe shared library
@@ -228,7 +229,7 @@ const Stripe = {
228
229
 
229
230
  // Match by interval + amount
230
231
  if (productType === 'subscription') {
231
- const interval = frequency === 'annually' ? 'year' : 'month';
232
+ const interval = FREQUENCY_TO_INTERVAL[frequency] || 'month';
232
233
  const match = prices.find(p =>
233
234
  p.recurring?.interval === interval
234
235
  && p.unit_amount === amountCents
@@ -14,7 +14,7 @@ module.exports = {
14
14
  * @param {string} options.uid - User's UID
15
15
  * @param {object} options.product - Full product object from config
16
16
  * @param {string} options.productId - Product ID from config
17
- * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
17
+ * @param {string} options.frequency - 'monthly', 'annually', 'weekly', or 'daily' (subscriptions only)
18
18
  * @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
19
19
  * @param {string} options.confirmationUrl - Success redirect URL
20
20
  * @param {string} options.cancelUrl - Cancel redirect URL
@@ -49,13 +49,13 @@ async function createSubscriptionIntent({ uid, orderId, product, frequency, tria
49
49
  const eventId = `_test-evt-${timestamp}`;
50
50
 
51
51
  // Map frequency to Stripe interval
52
- const interval = frequency === 'annually' ? 'year' : 'month';
52
+ const FREQUENCY_TO_INTERVAL = { annually: 'year', monthly: 'month', weekly: 'week', daily: 'day' };
53
+ const FREQUENCY_TO_PERIOD = { annually: 365 * 86400, monthly: 30 * 86400, weekly: 7 * 86400, daily: 1 * 86400 };
54
+ const interval = FREQUENCY_TO_INTERVAL[frequency] || 'month';
53
55
 
54
56
  // Build timestamps
55
57
  const now = Math.floor(timestamp / 1000);
56
- const periodEnd = frequency === 'annually'
57
- ? now + (365 * 86400)
58
- : now + (30 * 86400);
58
+ const periodEnd = now + (FREQUENCY_TO_PERIOD[frequency] || 30 * 86400);
59
59
 
60
60
  // Build Stripe-shaped subscription object
61
61
  // Uses product's Stripe product ID so resolveProduct() can match it
@@ -0,0 +1,29 @@
1
+ /**
2
+ * GET /payments/trial-eligibility
3
+ * Returns whether the authenticated user is eligible for a free trial
4
+ * Eligible = no previous subscription orders in payments-orders
5
+ */
6
+ module.exports = async ({ assistant, user, libraries }) => {
7
+ const { admin } = libraries;
8
+
9
+ // Require authentication
10
+ if (!user.authenticated) {
11
+ return assistant.respond('Authentication required', { code: 401 });
12
+ }
13
+
14
+ const uid = user.auth.uid;
15
+
16
+ // Check for any previous subscription orders
17
+ const historySnapshot = await admin.firestore()
18
+ .collection('payments-orders')
19
+ .where('owner', '==', uid)
20
+ .where('type', '==', 'subscription')
21
+ .limit(1)
22
+ .get();
23
+
24
+ const eligible = historySnapshot.empty;
25
+
26
+ assistant.log(`Trial eligibility for ${uid}: ${eligible}`);
27
+
28
+ return assistant.respond({ eligible });
29
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Schema: GET /payments/trial-eligibility
3
+ * No parameters required — uses authenticated user's UID
4
+ */
5
+ module.exports = () => ({});
@@ -40,11 +40,9 @@ service cloud.firestore {
40
40
  }
41
41
 
42
42
  function getRoles() {
43
- // return get(/databases/$(database)/documents/users/$(request.auth.token.email)).data.roles;
44
43
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles;
45
44
  }
46
45
  function getVerifications() {
47
- // return get(/databases/$(database)/documents/users/$(request.auth.token.email)).data.roles;
48
46
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.verifications;
49
47
  }
50
48
  function isAdmin() {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Test: GET /payments/trial-eligibility
3
+ * Tests trial eligibility check based on subscription order history
4
+ */
5
+ module.exports = {
6
+ description: 'Trial eligibility check',
7
+ type: 'group',
8
+ timeout: 15000,
9
+
10
+ tests: [
11
+ {
12
+ name: 'rejects-unauthenticated',
13
+ async run({ http, assert }) {
14
+ const response = await http.as('none').get('payments/trial-eligibility');
15
+
16
+ assert.isError(response, 401, 'Should reject unauthenticated request');
17
+ },
18
+ },
19
+
20
+ {
21
+ name: 'eligible-when-no-orders',
22
+ async run({ http, assert }) {
23
+ // Basic user with no subscription history should be eligible
24
+ const response = await http.as('basic').get('payments/trial-eligibility');
25
+
26
+ assert.isSuccess(response, 'Should succeed for authenticated user');
27
+ assert.equal(response.data.eligible, true, 'Should be eligible with no order history');
28
+ },
29
+ },
30
+
31
+ {
32
+ name: 'ineligible-when-has-subscription-history',
33
+ async run({ http, assert, accounts, firestore }) {
34
+ const uid = accounts['basic'].uid;
35
+ const orderDocPath = `payments-orders/_test-trial-eligibility-${uid}`;
36
+
37
+ // Create fake subscription order history
38
+ await firestore.set(orderDocPath, { owner: uid, type: 'subscription', processor: 'test', status: 'cancelled' });
39
+
40
+ try {
41
+ const response = await http.as('basic').get('payments/trial-eligibility');
42
+
43
+ assert.isSuccess(response, 'Should succeed for authenticated user');
44
+ assert.equal(response.data.eligible, false, 'Should be ineligible with subscription history');
45
+ } finally {
46
+ await firestore.delete(orderDocPath);
47
+ }
48
+ },
49
+ },
50
+
51
+ {
52
+ name: 'eligible-when-only-non-subscription-orders',
53
+ async run({ http, assert, accounts, firestore }) {
54
+ const uid = accounts['basic'].uid;
55
+ const orderDocPath = `payments-orders/_test-trial-eligibility-onetime-${uid}`;
56
+
57
+ // Create a non-subscription order (one-time purchase)
58
+ await firestore.set(orderDocPath, { owner: uid, type: 'one-time', processor: 'test', status: 'completed' });
59
+
60
+ try {
61
+ const response = await http.as('basic').get('payments/trial-eligibility');
62
+
63
+ assert.isSuccess(response, 'Should succeed for authenticated user');
64
+ assert.equal(response.data.eligible, true, 'Should be eligible — only non-subscription orders');
65
+ } finally {
66
+ await firestore.delete(orderDocPath);
67
+ }
68
+ },
69
+ },
70
+ ],
71
+ };