backend-manager 5.0.144 → 5.0.146

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
@@ -809,6 +809,38 @@ user.subscription.cancellation.pending === true
809
809
  user.subscription.status === 'suspended'
810
810
  ```
811
811
 
812
+ ### resolveSubscription(account)
813
+
814
+ `User.resolveSubscription(account)` is a static method on the User helper that derives calculated subscription fields from raw account data. It returns only fields that require derivation logic — raw data (product.id, status, trial, cancellation) lives on the account object directly.
815
+
816
+ ```javascript
817
+ const User = require('backend-manager/src/manager/helpers/user');
818
+
819
+ const resolved = User.resolveSubscription(account);
820
+ // Returns: { plan, active, trialing, cancelling }
821
+ ```
822
+
823
+ | Field | Type | Description |
824
+ |-------|------|-------------|
825
+ | `plan` | `string` | Effective plan ID the user has access to RIGHT NOW (`'basic'` if cancelled/suspended) |
826
+ | `active` | `boolean` | User has active access (active, trialing, or cancelling) |
827
+ | `trialing` | `boolean` | In an active trial (status `'active'` + `trial.claimed` + unexpired `trial.expires`) |
828
+ | `cancelling` | `boolean` | Cancellation pending (status `'active'` + `cancellation.pending` + NOT trialing) |
829
+
830
+ Accepts either a raw Firestore account object or a resolved `User` instance (checks both `account.subscription` and `account.properties.subscription`).
831
+
832
+ **Unified with web-manager**: The same function exists as `auth.resolveSubscription(account)` in web-manager (`modules/auth.js`) with identical logic and return shape.
833
+
834
+ **Use this instead of manual access checks** — it centralizes all the derivation logic in one place:
835
+ ```javascript
836
+ // ✅ PREFERRED — use resolveSubscription
837
+ const resolved = User.resolveSubscription(user);
838
+ if (resolved.active) { /* has access */ }
839
+
840
+ // ❌ AVOID — manual checks that duplicate logic
841
+ if (user.subscription.status === 'active' && user.subscription.product.id !== 'basic') { /* ... */ }
842
+ ```
843
+
812
844
  ## Payment Transition Handlers
813
845
 
814
846
  ### Overview
package/README.md CHANGED
@@ -471,7 +471,8 @@ const userProps = Manager.User(existingData, { defaults: true }).properties;
471
471
  },
472
472
  roles: { admin, betaTester, developer },
473
473
  affiliate: { code, referrals, referrer },
474
- activity: { lastActivity, created, geolocation, client },
474
+ metadata: { created, updated },
475
+ activity: { geolocation, client },
475
476
  api: { clientId, privateKey },
476
477
  usage: { requests: { monthly, daily, total, last } },
477
478
  personal: { birthday, gender, location, name, company, telephone },
@@ -608,7 +609,7 @@ const results = await utilities.iterateCollection(
608
609
  batchSize: 1000,
609
610
  maxBatches: 10,
610
611
  where: [{ field: 'subscription.product.id', operator: '==', value: 'premium' }],
611
- orderBy: { field: 'activity.created.timestamp', direction: 'desc' },
612
+ orderBy: { field: 'metadata.created.timestamp', direction: 'desc' },
612
613
  startAfter: 'lastDocId',
613
614
  log: true,
614
615
  }
@@ -994,6 +995,26 @@ user.subscription.cancellation.pending === true
994
995
  user.subscription.status === 'suspended'
995
996
  ```
996
997
 
998
+ ### resolveSubscription(account)
999
+
1000
+ Static method on the `User` helper that derives calculated subscription fields. Returns only fields that require derivation logic — raw data lives on the account object directly.
1001
+
1002
+ ```javascript
1003
+ const User = require('backend-manager/src/manager/helpers/user');
1004
+
1005
+ const resolved = User.resolveSubscription(account);
1006
+ // Returns: { plan, active, trialing, cancelling }
1007
+ ```
1008
+
1009
+ | Field | Type | Description |
1010
+ |-------|------|-------------|
1011
+ | `plan` | `string` | Effective plan ID right now (`'basic'` if cancelled/suspended) |
1012
+ | `active` | `boolean` | Has paid access (product is not `'basic'` and status is `'active'`) |
1013
+ | `trialing` | `boolean` | In active trial (status `'active'` + claimed + unexpired) |
1014
+ | `cancelling` | `boolean` | Cancellation pending (status `'active'` + `cancellation.pending`) |
1015
+
1016
+ The same function exists as `auth.resolveSubscription(account)` in [web-manager](https://github.com/itw-creative-works/web-manager) with identical logic and return shape.
1017
+
997
1018
  ## Final Words
998
1019
 
999
1020
  If you are still having difficulty, we would love for you to post a question to [the Backend Manager issues page](https://github.com/itw-creative-works/backend-manager/issues). It is much easier to answer questions that include your code and relevant files! So if you can provide them, we'd be extremely grateful (and more likely to help you find the answer!)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.144",
3
+ "version": "5.0.146",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -50,8 +50,9 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
50
50
  },
51
51
  }).properties;
52
52
 
53
- // Add metadata
54
- userRecord.metadata = Manager.Metadata().set({ tag: 'auth:on-create' });
53
+ // Add metadata tag (merge into existing metadata to preserve metadata.created from User schema)
54
+ const meta = Manager.Metadata().set({ tag: 'auth:on-create' });
55
+ userRecord.metadata = { ...userRecord.metadata, ...meta };
55
56
 
56
57
  assistant.log(`onCreate: Creating user doc for ${user.uid}`, userRecord);
57
58
 
@@ -10,7 +10,12 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
10
10
  const brandName = assistant.Manager.config.brand?.name || '';
11
11
  const planName = after.product?.name || '';
12
12
 
13
- assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product?.id}, frequency=${after.payment?.frequency}, trial=${isTrial}`);
13
+ // Pre-compute discount values for the email template
14
+ const price = parseFloat(order.unified?.payment?.price || 0);
15
+ const discount = order.discount;
16
+ const hasPromoDiscount = discount?.valid === true && discount?.percent > 0;
17
+
18
+ assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product?.id}, frequency=${after.payment?.frequency}, trial=${isTrial}, discount=${hasPromoDiscount ? discount.code : 'none'}`);
14
19
 
15
20
  sendOrderEmail({
16
21
  template: 'main/order/confirmation',
@@ -26,6 +31,16 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
26
31
  ...(isTrial && after.trial?.expires?.timestamp && {
27
32
  trialExpires: formatDate(after.trial.expires.timestamp),
28
33
  }),
34
+ ...(hasPromoDiscount && {
35
+ promoCode: discount.code,
36
+ promoPercent: discount.percent,
37
+ promoSavings: (price * discount.percent / 100).toFixed(2),
38
+ }),
39
+ totalToday: isTrial
40
+ ? '0.00'
41
+ : hasPromoDiscount
42
+ ? (price - (price * discount.percent / 100)).toFixed(2)
43
+ : price.toFixed(2),
29
44
  },
30
45
  },
31
46
  },
@@ -117,8 +117,8 @@ function Analytics(Manager, options) {
117
117
  value: authUser?.subscription?.trial?.claimed || false,
118
118
  },
119
119
  activity_created: {
120
- value: moment(authUser?.activity?.created?.timestampUNIX
121
- ? authUser?.activity?.created?.timestamp
120
+ value: moment(authUser?.metadata?.created?.timestampUNIX
121
+ ? authUser?.metadata?.created?.timestamp
122
122
  : self.assistant.meta.startTime.timestamp).format('YYYY-MM-DD'),
123
123
  },
124
124
 
@@ -91,7 +91,9 @@ const Chargebee = {
91
91
 
92
92
  if (!response.ok) {
93
93
  const msg = data.message || data.error_description || JSON.stringify(data);
94
- throw new Error(`Chargebee API ${response.status}: ${msg}`);
94
+ const err = new Error(`Chargebee API ${response.status}: ${msg}`);
95
+ err.statusCode = response.status;
96
+ throw err;
95
97
  }
96
98
 
97
99
  return data;
@@ -44,9 +44,9 @@ module.exports = {
44
44
  // Validate affiliate code (generated by on-create via User helper)
45
45
  assert.hasProperty(userDoc, 'affiliate.code', `Account '${accountId}' should have affiliate.code`);
46
46
 
47
- // Validate activity.created (set by on-create)
48
- assert.hasProperty(userDoc, 'activity.created.timestamp', `Account '${accountId}' should have activity.created.timestamp`);
49
- assert.hasProperty(userDoc, 'activity.created.timestampUNIX', `Account '${accountId}' should have activity.created.timestampUNIX`);
47
+ // Validate metadata.created (set by on-create via User helper)
48
+ assert.hasProperty(userDoc, 'metadata.created.timestamp', `Account '${accountId}' should have metadata.created.timestamp`);
49
+ assert.hasProperty(userDoc, 'metadata.created.timestampUNIX', `Account '${accountId}' should have metadata.created.timestampUNIX`);
50
50
 
51
51
  // Validate subscription fields (merged from test account properties)
52
52
  assert.hasProperty(userDoc, 'subscription.product.id', `Account '${accountId}' should have subscription.product.id`);
@@ -58,9 +58,9 @@ module.exports = {
58
58
  assert.ok(user.affiliate.code.length > 0, 'affiliate.code should not be empty');
59
59
  assert.ok(Array.isArray(user.affiliate.referrals), 'affiliate.referrals should be array');
60
60
 
61
- // Activity
62
- assert.ok(user.activity.lastActivity.timestamp, 'activity.lastActivity.timestamp should exist');
63
- assert.ok(user.activity.created.timestamp, 'activity.created.timestamp should exist');
61
+ // Metadata
62
+ assert.ok(user.metadata.updated.timestamp, 'metadata.updated.timestamp should exist');
63
+ assert.ok(user.metadata.created.timestamp, 'metadata.created.timestamp should exist');
64
64
  assert.equal(user.activity.geolocation.latitude, 0, 'geolocation.latitude should be 0');
65
65
  assert.equal(user.activity.geolocation.longitude, 0, 'geolocation.longitude should be 0');
66
66
  assert.equal(user.activity.client.mobile, false, 'client.mobile should be false');
@@ -379,8 +379,8 @@ module.exports = {
379
379
  assert.equal(user.activity.geolocation.country, 'US', 'provided country preserved');
380
380
  assert.equal(user.activity.geolocation.ip, null, 'missing ip defaults to null');
381
381
  assert.equal(user.activity.geolocation.latitude, 0, 'missing latitude defaults to 0');
382
- assert.ok(user.activity.lastActivity.timestamp, 'missing lastActivity gets default');
383
- assert.ok(user.activity.created.timestamp, 'missing created gets default');
382
+ assert.ok(user.metadata.updated.timestamp, 'missing updated gets default');
383
+ assert.ok(user.metadata.created.timestamp, 'missing created gets default');
384
384
  },
385
385
  },
386
386