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 +3 -2
- package/README.md +34 -1
- package/package.json +1 -1
- package/src/manager/index.js +5 -2
- package/src/manager/libraries/payment/processors/paypal.js +2 -1
- package/src/manager/libraries/payment/processors/stripe.js +2 -1
- package/src/manager/routes/payments/intent/processors/test.js +5 -5
- package/src/manager/routes/payments/trial-eligibility/get.js +29 -0
- package/src/manager/schemas/payments/trial-eligibility/get.js +5 -0
- package/templates/firestore.rules +0 -2
- package/test/routes/payments/trial-eligibility.js +71 -0
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
|
|
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
|
|
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
package/src/manager/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Libraries
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 '
|
|
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
|
|
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
|
|
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
|
+
};
|
|
@@ -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
|
+
};
|