backend-manager 5.0.105 → 5.0.107

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 CHANGED
@@ -14,6 +14,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.0.106] - 2026-03-04
18
+ ### Added
19
+ - `GET /payments/trial-eligibility`: returns whether the authenticated user is eligible for a free trial (checks for any previous subscription orders in `payments-orders`).
20
+
21
+ ### Fixed
22
+ - Payment frequency mapping now supports `daily` and `weekly` in addition to `monthly` and `annually` across Stripe (`resolvePriceId`), PayPal (`resolvePlanId`), and test processor (`createSubscriptionIntent`). Previously, these frequencies silently fell back to `monthly`.
23
+ - Updated docs (CLAUDE.md, README.md) to list all four supported frequency values.
24
+
17
25
  # [5.0.104] - 2026-03-02
18
26
  ### Added
19
27
  - `POST /payments/cancel`: cancels subscription at period end via processor abstraction (Stripe sets `cancel_at_period_end=true`; test processor writes webhook directly into the Firestore pipeline).
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
@@ -705,7 +705,7 @@ subscription: {
705
705
  processor: null, // 'stripe' | 'paypal' | etc.
706
706
  orderId: null, // BEM order ID (e.g., '1234-5678-9012')
707
707
  resourceId: null, // provider subscription ID (e.g., 'sub_xxx')
708
- frequency: null, // 'monthly' | 'annually'
708
+ frequency: null, // 'monthly' | 'annually' | 'weekly' | 'daily'
709
709
  price: 0, // resolved from config (number, e.g., 4.99)
710
710
  startDate: { timestamp, timestampUNIX },
711
711
  updatedBy: {
@@ -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
  };
@@ -910,7 +911,7 @@ payment: {
910
911
  type: 'subscription',
911
912
  limits: { requests: 1000 },
912
913
  trial: { days: 14 },
913
- prices: { monthly: 4.99, annually: 49.99 }, // Flat numbers only
914
+ prices: { monthly: 4.99, annually: 49.99 }, // Flat numbers; also supports 'weekly' and 'daily'
914
915
  stripe: { productId: 'prod_xxx', legacyProductIds: ['prod_OLD'] },
915
916
  paypal: { productId: 'PROD-abc123' },
916
917
  },
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 }, // also supports 'weekly' and 'daily'
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`:
@@ -925,7 +958,7 @@ subscription: {
925
958
  payment: {
926
959
  processor: null, // 'stripe' | 'paypal' | etc.
927
960
  resourceId: null, // provider subscription ID (e.g., 'sub_xxx')
928
- frequency: null, // 'monthly' | 'annually'
961
+ frequency: null, // 'monthly' | 'annually' | 'weekly' | 'daily'
929
962
  startDate: { timestamp, timestampUNIX },
930
963
  updatedBy: {
931
964
  event: { name: null, id: null },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.105",
3
+ "version": "5.0.107",
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
+ };