backend-manager 5.0.103 → 5.0.105
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 +31 -0
- package/CLAUDE.md +113 -24
- package/README.md +8 -0
- package/TODO-PAYMENT-v2.md +5 -2
- package/package.json +1 -1
- package/src/cli/commands/deploy.js +2 -4
- package/src/cli/commands/emulator.js +30 -1
- package/src/cli/commands/test.js +33 -2
- package/src/manager/events/firestore/payments-webhooks/on-write.js +17 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/index.js +6 -0
- package/src/manager/libraries/payment/processors/paypal.js +587 -0
- package/src/manager/libraries/{payment-processors → payment/processors}/stripe.js +86 -18
- package/src/manager/libraries/{payment-processors → payment/processors}/test.js +15 -8
- package/src/manager/routes/payments/cancel/processors/paypal.js +30 -0
- package/src/manager/routes/payments/cancel/processors/stripe.js +1 -1
- package/src/manager/routes/payments/cancel/processors/test.js +4 -6
- package/src/manager/routes/payments/intent/post.js +3 -3
- package/src/manager/routes/payments/intent/processors/paypal.js +150 -0
- package/src/manager/routes/payments/intent/processors/stripe.js +3 -5
- package/src/manager/routes/payments/intent/processors/test.js +7 -8
- package/src/manager/routes/payments/portal/processors/paypal.js +24 -0
- package/src/manager/routes/payments/portal/processors/stripe.js +1 -1
- package/src/manager/routes/payments/refund/post.js +85 -0
- package/src/manager/routes/payments/refund/processors/paypal.js +117 -0
- package/src/manager/routes/payments/refund/processors/stripe.js +103 -0
- package/src/manager/routes/payments/refund/processors/test.js +98 -0
- package/src/manager/routes/payments/webhook/processors/paypal.js +137 -0
- package/src/manager/schemas/payments/refund/post.js +18 -0
- package/src/test/test-accounts.js +46 -0
- package/templates/backend-manager-config.json +20 -24
- package/test/events/payments/journey-payments-cancel.js +3 -3
- package/test/events/payments/journey-payments-failure.js +1 -1
- package/test/events/payments/journey-payments-one-time.js +1 -1
- package/test/events/payments/journey-payments-plan-change.js +4 -4
- package/test/events/payments/journey-payments-suspend.js +3 -3
- package/test/events/payments/journey-payments-trial.js +2 -2
- package/test/fixtures/paypal/order-approved.json +62 -0
- package/test/fixtures/paypal/order-completed.json +110 -0
- package/test/fixtures/paypal/subscription-active.json +76 -0
- package/test/fixtures/paypal/subscription-cancelled.json +50 -0
- package/test/fixtures/paypal/subscription-suspended.json +65 -0
- package/test/helpers/payment/paypal/parse-webhook.js +539 -0
- package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
- package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
- package/test/helpers/{stripe-parse-webhook.js → payment/stripe/parse-webhook.js} +4 -4
- package/test/helpers/{stripe-to-unified-one-time.js → payment/stripe/to-unified-one-time.js} +8 -6
- package/test/helpers/{stripe-to-unified.js → payment/stripe/to-unified-subscription.js} +40 -33
- package/test/routes/payments/refund.js +174 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +0 -19
- package/src/manager/routes/forms/delete.js +0 -37
- package/src/manager/routes/forms/get.js +0 -46
- package/src/manager/routes/forms/post.js +0 -45
- package/src/manager/routes/forms/public/get.js +0 -37
- package/src/manager/routes/forms/put.js +0 -52
- package/src/manager/schemas/forms/delete.js +0 -6
- package/src/manager/schemas/forms/get.js +0 -6
- package/src/manager/schemas/forms/post.js +0 -9
- package/src/manager/schemas/forms/public/get.js +0 -6
- package/src/manager/schemas/forms/put.js +0 -10
- /package/src/manager/libraries/{payment-processors → payment}/order-id.js +0 -0
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* Verifies that parseWebhook() correctly determines category, resourceType, resourceId,
|
|
6
6
|
* and uid for each supported event type. Uses real Stripe CLI fixtures where available.
|
|
7
7
|
*/
|
|
8
|
-
const stripeProcessor = require('
|
|
8
|
+
const stripeProcessor = require('../../../../src/manager/routes/payments/webhook/processors/stripe.js');
|
|
9
9
|
|
|
10
10
|
// Real Stripe CLI fixtures
|
|
11
|
-
const FIXTURE_INVOICE_MANUAL = require('
|
|
12
|
-
const FIXTURE_CHECKOUT_PAYMENT = require('
|
|
11
|
+
const FIXTURE_INVOICE_MANUAL = require('../../../fixtures/stripe/invoice-payment-failed.json');
|
|
12
|
+
const FIXTURE_CHECKOUT_PAYMENT = require('../../../fixtures/stripe/checkout-session-completed.json');
|
|
13
13
|
|
|
14
14
|
// Hand-crafted fixture (subscription-related invoice failure)
|
|
15
|
-
const FIXTURE_INVOICE_SUB = require('
|
|
15
|
+
const FIXTURE_INVOICE_SUB = require('../../../fixtures/stripe/invoice-subscription-payment-failed.json');
|
|
16
16
|
|
|
17
17
|
function parseWebhook(event) {
|
|
18
18
|
return stripeProcessor.parseWebhook({ body: event });
|
package/test/helpers/{stripe-to-unified-one-time.js → payment/stripe/to-unified-one-time.js}
RENAMED
|
@@ -4,24 +4,26 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Tests the pure function directly — no emulator, no Firestore, no HTTP
|
|
6
6
|
*/
|
|
7
|
-
const Stripe = require('
|
|
7
|
+
const Stripe = require('../../../../src/manager/libraries/payment/processors/stripe.js');
|
|
8
8
|
|
|
9
9
|
// Real Stripe CLI fixtures (generated via `stripe trigger`)
|
|
10
|
-
const FIXTURE_SESSION = require('
|
|
11
|
-
const FIXTURE_INVOICE_FAILED = require('
|
|
10
|
+
const FIXTURE_SESSION = require('../../../fixtures/stripe/checkout-session-completed.json');
|
|
11
|
+
const FIXTURE_INVOICE_FAILED = require('../../../fixtures/stripe/invoice-payment-failed.json');
|
|
12
12
|
|
|
13
|
-
// Mock config matching the BEM template
|
|
13
|
+
// Mock config matching the BEM template (new flat price structure)
|
|
14
14
|
const MOCK_CONFIG = {
|
|
15
15
|
payment: {
|
|
16
16
|
products: [
|
|
17
17
|
{ id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 100 } },
|
|
18
18
|
{
|
|
19
19
|
id: 'credits-100', name: '100 Credits', type: 'one-time',
|
|
20
|
-
prices: { once:
|
|
20
|
+
prices: { once: 9.99 },
|
|
21
|
+
stripe: { productId: 'prod_credits_100' },
|
|
21
22
|
},
|
|
22
23
|
{
|
|
23
24
|
id: 'credits-500', name: '500 Credits', type: 'one-time',
|
|
24
|
-
prices: { once:
|
|
25
|
+
prices: { once: 39.99 },
|
|
26
|
+
stripe: { productId: 'prod_credits_500' },
|
|
25
27
|
},
|
|
26
28
|
],
|
|
27
29
|
},
|
|
@@ -4,31 +4,27 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Tests the pure function directly — no emulator, no Firestore, no HTTP
|
|
6
6
|
*/
|
|
7
|
-
const Stripe = require('
|
|
7
|
+
const Stripe = require('../../../../src/manager/libraries/payment/processors/stripe.js');
|
|
8
8
|
|
|
9
9
|
// Real Stripe CLI fixtures (generated via `stripe trigger`)
|
|
10
|
-
const FIXTURE_ACTIVE = require('
|
|
11
|
-
const FIXTURE_CANCELED = require('
|
|
12
|
-
const FIXTURE_TRIALING = require('
|
|
10
|
+
const FIXTURE_ACTIVE = require('../../../fixtures/stripe/subscription-active.json');
|
|
11
|
+
const FIXTURE_CANCELED = require('../../../fixtures/stripe/subscription-canceled.json');
|
|
12
|
+
const FIXTURE_TRIALING = require('../../../fixtures/stripe/subscription-trialing.json');
|
|
13
13
|
|
|
14
|
-
// Mock config matching the BEM template
|
|
14
|
+
// Mock config matching the BEM template (new flat price structure)
|
|
15
15
|
const MOCK_CONFIG = {
|
|
16
16
|
payment: {
|
|
17
17
|
products: [
|
|
18
18
|
{ id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 100 } },
|
|
19
19
|
{
|
|
20
20
|
id: 'plus', name: 'Plus', type: 'subscription',
|
|
21
|
-
prices: {
|
|
22
|
-
|
|
23
|
-
annually: { amount: 99.99, stripe: 'price_plus_annually' },
|
|
24
|
-
},
|
|
21
|
+
prices: { monthly: 9.99, annually: 99.99 },
|
|
22
|
+
stripe: { productId: 'prod_plus' },
|
|
25
23
|
},
|
|
26
24
|
{
|
|
27
25
|
id: 'pro', name: 'Pro', type: 'subscription',
|
|
28
|
-
prices: {
|
|
29
|
-
|
|
30
|
-
annually: { amount: 299.99, stripe: 'price_pro_annually' },
|
|
31
|
-
},
|
|
26
|
+
prices: { monthly: 29.99, annually: 299.99 },
|
|
27
|
+
stripe: { productId: 'prod_pro', legacyProductIds: ['prod_pro_old'] },
|
|
32
28
|
},
|
|
33
29
|
],
|
|
34
30
|
},
|
|
@@ -112,18 +108,18 @@ module.exports = {
|
|
|
112
108
|
// ─── Product resolution ───
|
|
113
109
|
|
|
114
110
|
{
|
|
115
|
-
name: 'product-resolves-
|
|
111
|
+
name: 'product-resolves-from-plan-product',
|
|
116
112
|
async run({ assert }) {
|
|
117
|
-
const result = toUnifiedSubscription({ plan: {
|
|
113
|
+
const result = toUnifiedSubscription({ plan: { product: 'prod_plus' } });
|
|
118
114
|
assert.equal(result.product.id, 'plus', 'Should resolve to plus');
|
|
119
115
|
assert.equal(result.product.name, 'Plus', 'Should have correct name');
|
|
120
116
|
},
|
|
121
117
|
},
|
|
122
118
|
|
|
123
119
|
{
|
|
124
|
-
name: 'product-resolves-
|
|
120
|
+
name: 'product-resolves-pro-from-plan-product',
|
|
125
121
|
async run({ assert }) {
|
|
126
|
-
const result = toUnifiedSubscription({ plan: {
|
|
122
|
+
const result = toUnifiedSubscription({ plan: { product: 'prod_pro' } });
|
|
127
123
|
assert.equal(result.product.id, 'pro', 'Should resolve to pro');
|
|
128
124
|
assert.equal(result.product.name, 'Pro', 'Should have correct name');
|
|
129
125
|
},
|
|
@@ -133,18 +129,29 @@ module.exports = {
|
|
|
133
129
|
name: 'product-resolves-from-items-array',
|
|
134
130
|
async run({ assert }) {
|
|
135
131
|
const result = toUnifiedSubscription({
|
|
136
|
-
items: { data: [{ price: {
|
|
132
|
+
items: { data: [{ price: { product: 'prod_plus' } }] },
|
|
137
133
|
});
|
|
138
|
-
assert.equal(result.product.id, 'plus', 'Should resolve from items.data[0].price.
|
|
134
|
+
assert.equal(result.product.id, 'plus', 'Should resolve from items.data[0].price.product');
|
|
139
135
|
},
|
|
140
136
|
},
|
|
141
137
|
|
|
142
138
|
{
|
|
143
|
-
name: 'product-
|
|
139
|
+
name: 'product-resolves-legacy-product-id',
|
|
144
140
|
async run({ assert }) {
|
|
145
|
-
const result = toUnifiedSubscription({
|
|
146
|
-
|
|
147
|
-
|
|
141
|
+
const result = toUnifiedSubscription({
|
|
142
|
+
items: { data: [{ price: { product: 'prod_pro_old' } }] },
|
|
143
|
+
});
|
|
144
|
+
assert.equal(result.product.id, 'pro', 'Legacy product ID → pro');
|
|
145
|
+
assert.equal(result.product.name, 'Pro', 'Legacy product ID → Pro name');
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
name: 'product-falls-back-to-basic-on-unknown-product',
|
|
151
|
+
async run({ assert }) {
|
|
152
|
+
const result = toUnifiedSubscription({ plan: { product: 'prod_nonexistent' } });
|
|
153
|
+
assert.equal(result.product.id, 'basic', 'Unknown product → basic');
|
|
154
|
+
assert.equal(result.product.name, 'Basic', 'Unknown product → Basic name');
|
|
148
155
|
},
|
|
149
156
|
},
|
|
150
157
|
|
|
@@ -159,7 +166,7 @@ module.exports = {
|
|
|
159
166
|
{
|
|
160
167
|
name: 'product-falls-back-to-basic-without-config',
|
|
161
168
|
async run({ assert }) {
|
|
162
|
-
const result = Stripe.toUnifiedSubscription({ plan: {
|
|
169
|
+
const result = Stripe.toUnifiedSubscription({ plan: { product: 'prod_plus' } }, {});
|
|
163
170
|
assert.equal(result.product.id, 'basic', 'No config → basic');
|
|
164
171
|
},
|
|
165
172
|
},
|
|
@@ -408,7 +415,7 @@ module.exports = {
|
|
|
408
415
|
const result = toUnifiedSubscription({
|
|
409
416
|
id: 'sub_full_test',
|
|
410
417
|
status: 'active',
|
|
411
|
-
plan: {
|
|
418
|
+
plan: { product: 'prod_pro', interval: 'month' },
|
|
412
419
|
current_period_end: now + 86400 * 30,
|
|
413
420
|
current_period_start: now,
|
|
414
421
|
start_date: now - 86400 * 60,
|
|
@@ -488,9 +495,9 @@ module.exports = {
|
|
|
488
495
|
{
|
|
489
496
|
name: 'fixture-active-product-falls-back',
|
|
490
497
|
async run({ assert }) {
|
|
491
|
-
// Fixture
|
|
498
|
+
// Fixture product IDs won't match our mock config, so it should fall back to basic
|
|
492
499
|
const result = toUnifiedSubscription(FIXTURE_ACTIVE);
|
|
493
|
-
assert.equal(result.product.id, 'basic', 'Unknown
|
|
500
|
+
assert.equal(result.product.id, 'basic', 'Unknown product → basic fallback');
|
|
494
501
|
},
|
|
495
502
|
},
|
|
496
503
|
|
|
@@ -576,7 +583,7 @@ module.exports = {
|
|
|
576
583
|
canceled_at: null,
|
|
577
584
|
current_period_end: now + 86400 * 11,
|
|
578
585
|
start_date: now - 86400 * 3,
|
|
579
|
-
plan: {
|
|
586
|
+
plan: { product: 'prod_plus', interval: 'month' },
|
|
580
587
|
});
|
|
581
588
|
|
|
582
589
|
assert.equal(result.status, 'active', 'Trialing + cancel → still active');
|
|
@@ -599,7 +606,7 @@ module.exports = {
|
|
|
599
606
|
canceled_at: now,
|
|
600
607
|
current_period_end: now - 86400,
|
|
601
608
|
start_date: now - 86400 * 14,
|
|
602
|
-
plan: {
|
|
609
|
+
plan: { product: 'prod_plus', interval: 'month' },
|
|
603
610
|
});
|
|
604
611
|
|
|
605
612
|
assert.equal(result.status, 'cancelled', 'Failed trial → cancelled');
|
|
@@ -622,7 +629,7 @@ module.exports = {
|
|
|
622
629
|
canceled_at: null,
|
|
623
630
|
current_period_end: now + 86400 * 14,
|
|
624
631
|
start_date: now - 86400 * 30,
|
|
625
|
-
plan: {
|
|
632
|
+
plan: { product: 'prod_pro', interval: 'month' },
|
|
626
633
|
});
|
|
627
634
|
|
|
628
635
|
assert.equal(result.status, 'active', 'Active with past trial → active');
|
|
@@ -646,7 +653,7 @@ module.exports = {
|
|
|
646
653
|
start_date: now - 86400 * 40,
|
|
647
654
|
trial_start: null,
|
|
648
655
|
trial_end: null,
|
|
649
|
-
plan: {
|
|
656
|
+
plan: { product: 'prod_pro', interval: 'month' },
|
|
650
657
|
});
|
|
651
658
|
|
|
652
659
|
assert.equal(result.status, 'active', 'Reactivated → active');
|
|
@@ -668,7 +675,7 @@ module.exports = {
|
|
|
668
675
|
start_date: now - 86400 * 60,
|
|
669
676
|
trial_start: null,
|
|
670
677
|
trial_end: null,
|
|
671
|
-
plan: {
|
|
678
|
+
plan: { product: 'prod_plus', interval: 'month' },
|
|
672
679
|
});
|
|
673
680
|
|
|
674
681
|
assert.equal(result.status, 'suspended', 'Past due → suspended');
|
|
@@ -689,7 +696,7 @@ module.exports = {
|
|
|
689
696
|
canceled_at: null,
|
|
690
697
|
current_period_end: now + 86400,
|
|
691
698
|
start_date: now - 86400 * 14,
|
|
692
|
-
plan: {
|
|
699
|
+
plan: { product: 'prod_plus', interval: 'month' },
|
|
693
700
|
});
|
|
694
701
|
|
|
695
702
|
assert.equal(result.status, 'suspended', 'Trial ended + payment failed → suspended');
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /payments/refund
|
|
3
|
+
* Tests rejection cases and a full end-to-end refund flow with the test processor.
|
|
4
|
+
*
|
|
5
|
+
* Refund requires the subscription to be cancelled or pending cancellation.
|
|
6
|
+
* The test processor simulates refund by writing a customer.subscription.deleted
|
|
7
|
+
* webhook which triggers the existing pipeline.
|
|
8
|
+
*/
|
|
9
|
+
module.exports = {
|
|
10
|
+
description: 'Payment refund endpoint',
|
|
11
|
+
type: 'group',
|
|
12
|
+
timeout: 30000,
|
|
13
|
+
|
|
14
|
+
tests: [
|
|
15
|
+
{
|
|
16
|
+
name: 'rejects-unauthenticated',
|
|
17
|
+
async run({ http, assert }) {
|
|
18
|
+
const response = await http.as('none').post('payments/refund', {
|
|
19
|
+
confirmed: true,
|
|
20
|
+
reason: 'Too expensive',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.isError(response, 401, 'Should reject unauthenticated request');
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
name: 'rejects-missing-confirmed',
|
|
29
|
+
async run({ http, assert }) {
|
|
30
|
+
const response = await http.as('basic').post('payments/refund', {
|
|
31
|
+
reason: 'Too expensive',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
assert.isError(response, 400, 'Should reject missing confirmed field');
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
name: 'rejects-missing-reason',
|
|
40
|
+
async run({ http, assert }) {
|
|
41
|
+
const response = await http.as('basic').post('payments/refund', {
|
|
42
|
+
confirmed: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.isError(response, 400, 'Should reject missing reason field');
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
name: 'rejects-basic-user',
|
|
51
|
+
async run({ http, assert }) {
|
|
52
|
+
const response = await http.as('basic').post('payments/refund', {
|
|
53
|
+
confirmed: true,
|
|
54
|
+
reason: 'Too expensive',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.isError(response, 400, 'Should reject basic user with no paid subscription');
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
name: 'rejects-active-subscription-without-cancellation',
|
|
63
|
+
async run({ http, assert }) {
|
|
64
|
+
// refund-active-no-cancel has an active subscription without pending cancellation
|
|
65
|
+
const response = await http.as('refund-active-no-cancel').post('payments/refund', {
|
|
66
|
+
confirmed: true,
|
|
67
|
+
reason: 'Too expensive',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
assert.isError(response, 400, 'Should reject active subscription that is not cancelled or pending cancellation');
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
name: 'rejects-payment-older-than-6-months',
|
|
76
|
+
async run({ http, assert }) {
|
|
77
|
+
// refund-expired-payment has a cancelled subscription with a payment older than 6 months
|
|
78
|
+
const response = await http.as('refund-expired-payment').post('payments/refund', {
|
|
79
|
+
confirmed: true,
|
|
80
|
+
reason: 'Too expensive',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assert.isError(response, 400, 'Should reject payment older than 6 months');
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
name: 'rejects-no-processor-or-resource-id',
|
|
89
|
+
async run({ http, assert }) {
|
|
90
|
+
// refund-no-processor has a cancelled subscription but no processor
|
|
91
|
+
const response = await http.as('refund-no-processor').post('payments/refund', {
|
|
92
|
+
confirmed: true,
|
|
93
|
+
reason: 'Too expensive',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert.isError(response, 400, 'Should reject when no processor or resourceId is set');
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
name: 'rejects-unknown-processor',
|
|
102
|
+
async run({ http, assert }) {
|
|
103
|
+
// refund-unknown-processor has a cancelled subscription with unknown processor
|
|
104
|
+
const response = await http.as('refund-unknown-processor').post('payments/refund', {
|
|
105
|
+
confirmed: true,
|
|
106
|
+
reason: 'Too expensive',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
assert.isError(response, 400, 'Should reject unknown processor');
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
name: 'succeeds-with-test-processor',
|
|
115
|
+
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
116
|
+
const uid = accounts['route-refund-success'].uid;
|
|
117
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
118
|
+
|
|
119
|
+
// Step 1: Create a test subscription intent to set up a proper paid subscription
|
|
120
|
+
const intentResponse = await http.as('route-refund-success').post('payments/intent', {
|
|
121
|
+
processor: 'test',
|
|
122
|
+
productId: paidProduct.id,
|
|
123
|
+
frequency: 'monthly',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.isSuccess(intentResponse, 'Intent should succeed');
|
|
127
|
+
|
|
128
|
+
// Wait for the auto-webhook to activate the subscription
|
|
129
|
+
await waitFor(async () => {
|
|
130
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
131
|
+
return userDoc?.subscription?.payment?.processor === 'test'
|
|
132
|
+
&& userDoc?.subscription?.payment?.resourceId
|
|
133
|
+
&& userDoc?.subscription?.status === 'active';
|
|
134
|
+
}, 15000, 500);
|
|
135
|
+
|
|
136
|
+
// Step 2: Cancel the subscription first (refund requires cancellation)
|
|
137
|
+
const cancelResponse = await http.as('route-refund-success').post('payments/cancel', {
|
|
138
|
+
confirmed: true,
|
|
139
|
+
reason: 'Too expensive',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
assert.isSuccess(cancelResponse, 'Cancel should succeed');
|
|
143
|
+
|
|
144
|
+
// Wait for cancellation.pending to be set via the webhook pipeline
|
|
145
|
+
await waitFor(async () => {
|
|
146
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
147
|
+
return userDoc?.subscription?.cancellation?.pending === true;
|
|
148
|
+
}, 15000, 500);
|
|
149
|
+
|
|
150
|
+
// Step 3: Request a refund
|
|
151
|
+
const refundResponse = await http.as('route-refund-success').post('payments/refund', {
|
|
152
|
+
confirmed: true,
|
|
153
|
+
reason: 'Not satisfied with the service',
|
|
154
|
+
feedback: 'Testing refund flow',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.isSuccess(refundResponse, 'Refund should succeed');
|
|
158
|
+
assert.ok(refundResponse.data.success, 'Should return success: true');
|
|
159
|
+
assert.ok(refundResponse.data.refund, 'Should return refund details');
|
|
160
|
+
assert.isType(refundResponse.data.refund.amount, 'number', 'Refund amount should be a number');
|
|
161
|
+
assert.equal(refundResponse.data.refund.full, true, 'Should be a full refund (test processor)');
|
|
162
|
+
|
|
163
|
+
// Step 4: Verify subscription is cancelled via the webhook pipeline
|
|
164
|
+
await waitFor(async () => {
|
|
165
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
166
|
+
return userDoc?.subscription?.status === 'cancelled';
|
|
167
|
+
}, 15000, 500);
|
|
168
|
+
|
|
169
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
170
|
+
assert.equal(userDoc?.subscription?.status, 'cancelled', 'Subscription should be cancelled after refund');
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve the Stripe price ID from a product config object
|
|
3
|
-
*
|
|
4
|
-
* @param {object} product - Product object from config (must have .prices)
|
|
5
|
-
* @param {string} productType - 'subscription' or 'one-time'
|
|
6
|
-
* @param {string} frequency - 'monthly', 'annually', etc. (subscriptions) — ignored for one-time
|
|
7
|
-
* @returns {string} Stripe price ID
|
|
8
|
-
* @throws {Error} If no price ID found
|
|
9
|
-
*/
|
|
10
|
-
module.exports = function resolvePriceId(product, productType, frequency) {
|
|
11
|
-
const key = productType === 'subscription' ? frequency : 'once';
|
|
12
|
-
const priceId = product.prices?.[key]?.stripe;
|
|
13
|
-
|
|
14
|
-
if (!priceId) {
|
|
15
|
-
throw new Error(`No Stripe price found for ${product.id}/${key}`);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return priceId;
|
|
19
|
-
};
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DELETE /forms - Delete a form
|
|
3
|
-
* Requires authentication and ownership.
|
|
4
|
-
*/
|
|
5
|
-
module.exports = async ({ assistant, user, settings, analytics, libraries }) => {
|
|
6
|
-
const { admin } = libraries;
|
|
7
|
-
|
|
8
|
-
// Require authentication
|
|
9
|
-
if (!user.authenticated) {
|
|
10
|
-
return assistant.respond('Authentication required', { code: 401 });
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
if (!settings.id) {
|
|
14
|
-
return assistant.respond('Missing required parameter: id', { code: 400 });
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const uid = user.auth.uid;
|
|
18
|
-
const formRef = admin.firestore().doc(`forms/${settings.id}`);
|
|
19
|
-
const doc = await formRef.get();
|
|
20
|
-
|
|
21
|
-
if (!doc.exists) {
|
|
22
|
-
return assistant.respond('Form not found', { code: 404 });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Ownership check
|
|
26
|
-
if (doc.data().owner !== uid) {
|
|
27
|
-
return assistant.respond('Not authorized to delete this form', { code: 403 });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
await formRef.delete();
|
|
31
|
-
|
|
32
|
-
assistant.log(`Deleted form ${settings.id} for user ${uid}`);
|
|
33
|
-
|
|
34
|
-
analytics.event('forms', { action: 'delete' });
|
|
35
|
-
|
|
36
|
-
return assistant.respond({ data: { deleted: true } });
|
|
37
|
-
};
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GET /forms - Get a single form or list all forms for the authenticated user
|
|
3
|
-
* Requires authentication.
|
|
4
|
-
* - With ?id=xxx: returns a single form (with ownership check)
|
|
5
|
-
* - Without id: returns all forms owned by the user
|
|
6
|
-
*/
|
|
7
|
-
module.exports = async ({ assistant, user, settings, analytics, libraries }) => {
|
|
8
|
-
const { admin } = libraries;
|
|
9
|
-
|
|
10
|
-
// Require authentication
|
|
11
|
-
if (!user.authenticated) {
|
|
12
|
-
return assistant.respond('Authentication required', { code: 401 });
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const uid = user.auth.uid;
|
|
16
|
-
|
|
17
|
-
// Single form
|
|
18
|
-
if (settings.id) {
|
|
19
|
-
const doc = await admin.firestore().doc(`forms/${settings.id}`).get();
|
|
20
|
-
|
|
21
|
-
if (!doc.exists) {
|
|
22
|
-
return assistant.respond('Form not found', { code: 404 });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (doc.data().owner !== uid) {
|
|
26
|
-
return assistant.respond('Not authorized to view this form', { code: 403 });
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
analytics.event('forms', { action: 'get' });
|
|
30
|
-
|
|
31
|
-
return assistant.respond({ data: { form: doc.data() } });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// List all forms
|
|
35
|
-
const snapshot = await admin.firestore()
|
|
36
|
-
.collection('forms')
|
|
37
|
-
.where('owner', '==', uid)
|
|
38
|
-
.orderBy('created.timestampUNIX', 'desc')
|
|
39
|
-
.get();
|
|
40
|
-
|
|
41
|
-
const forms = snapshot.docs.map(doc => doc.data());
|
|
42
|
-
|
|
43
|
-
analytics.event('forms', { action: 'list' });
|
|
44
|
-
|
|
45
|
-
return assistant.respond({ data: { forms } });
|
|
46
|
-
};
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /forms - Create a new form
|
|
3
|
-
* Requires authentication. Creates a Firestore doc in the `forms` collection.
|
|
4
|
-
*/
|
|
5
|
-
module.exports = async ({ assistant, Manager, user, settings, analytics, libraries }) => {
|
|
6
|
-
const { admin } = libraries;
|
|
7
|
-
|
|
8
|
-
// Require authentication
|
|
9
|
-
if (!user.authenticated) {
|
|
10
|
-
return assistant.respond('Authentication required', { code: 401 });
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const uid = user.auth.uid;
|
|
14
|
-
const now = new Date().toISOString();
|
|
15
|
-
const nowUnix = Date.now();
|
|
16
|
-
|
|
17
|
-
// Generate a new document reference
|
|
18
|
-
const docRef = admin.firestore().collection('forms').doc();
|
|
19
|
-
|
|
20
|
-
const form = {
|
|
21
|
-
id: docRef.id,
|
|
22
|
-
owner: uid,
|
|
23
|
-
name: settings.name || 'Untitled Form',
|
|
24
|
-
description: settings.description || '',
|
|
25
|
-
settings: settings.settings || {},
|
|
26
|
-
pages: settings.pages || [],
|
|
27
|
-
created: {
|
|
28
|
-
timestamp: now,
|
|
29
|
-
timestampUNIX: nowUnix,
|
|
30
|
-
},
|
|
31
|
-
edited: {
|
|
32
|
-
timestamp: now,
|
|
33
|
-
timestampUNIX: nowUnix,
|
|
34
|
-
},
|
|
35
|
-
metadata: Manager.Metadata().set({ tag: 'forms/post' }),
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
await docRef.set(form);
|
|
39
|
-
|
|
40
|
-
assistant.log(`Created form ${docRef.id} for user ${uid}`);
|
|
41
|
-
|
|
42
|
-
analytics.event('forms', { action: 'create' });
|
|
43
|
-
|
|
44
|
-
return assistant.respond({ data: { id: docRef.id, form } });
|
|
45
|
-
};
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GET /forms/public - Get a public form by ID
|
|
3
|
-
* No authentication required. Only returns forms with settings.public = true.
|
|
4
|
-
*/
|
|
5
|
-
module.exports = async ({ assistant, settings, analytics, libraries }) => {
|
|
6
|
-
const { admin } = libraries;
|
|
7
|
-
|
|
8
|
-
if (!settings.id) {
|
|
9
|
-
return assistant.respond('Missing required parameter: id', { code: 400 });
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const doc = await admin.firestore().doc(`forms/${settings.id}`).get();
|
|
13
|
-
|
|
14
|
-
if (!doc.exists) {
|
|
15
|
-
return assistant.respond('Form not found', { code: 404 });
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const form = doc.data();
|
|
19
|
-
|
|
20
|
-
// Only allow access to public forms
|
|
21
|
-
if (!form.settings?.public) {
|
|
22
|
-
return assistant.respond('Form not found', { code: 404 });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Strip sensitive fields
|
|
26
|
-
const publicForm = {
|
|
27
|
-
id: form.id,
|
|
28
|
-
name: form.name,
|
|
29
|
-
description: form.description,
|
|
30
|
-
settings: form.settings,
|
|
31
|
-
pages: form.pages,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
analytics.event('forms/public', { action: 'get' });
|
|
35
|
-
|
|
36
|
-
return assistant.respond({ payload: publicForm });
|
|
37
|
-
};
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PUT /forms - Update an existing form
|
|
3
|
-
* Requires authentication and ownership.
|
|
4
|
-
*/
|
|
5
|
-
module.exports = async ({ assistant, Manager, user, settings, analytics, libraries }) => {
|
|
6
|
-
const { admin } = libraries;
|
|
7
|
-
|
|
8
|
-
// Require authentication
|
|
9
|
-
if (!user.authenticated) {
|
|
10
|
-
return assistant.respond('Authentication required', { code: 401 });
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
if (!settings.id) {
|
|
14
|
-
return assistant.respond('Missing required parameter: id', { code: 400 });
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const uid = user.auth.uid;
|
|
18
|
-
const formRef = admin.firestore().doc(`forms/${settings.id}`);
|
|
19
|
-
const doc = await formRef.get();
|
|
20
|
-
|
|
21
|
-
if (!doc.exists) {
|
|
22
|
-
return assistant.respond('Form not found', { code: 404 });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Ownership check
|
|
26
|
-
if (doc.data().owner !== uid) {
|
|
27
|
-
return assistant.respond('Not authorized to edit this form', { code: 403 });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const now = new Date().toISOString();
|
|
31
|
-
const nowUnix = Date.now();
|
|
32
|
-
|
|
33
|
-
const updates = {
|
|
34
|
-
name: settings.name,
|
|
35
|
-
description: settings.description,
|
|
36
|
-
settings: settings.settings,
|
|
37
|
-
pages: settings.pages,
|
|
38
|
-
edited: {
|
|
39
|
-
timestamp: now,
|
|
40
|
-
timestampUNIX: nowUnix,
|
|
41
|
-
},
|
|
42
|
-
metadata: Manager.Metadata().set({ tag: 'forms/put' }),
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
await formRef.update(updates);
|
|
46
|
-
|
|
47
|
-
assistant.log(`Updated form ${settings.id} for user ${uid}`);
|
|
48
|
-
|
|
49
|
-
analytics.event('forms', { action: 'update' });
|
|
50
|
-
|
|
51
|
-
return assistant.respond({ data: { id: settings.id } });
|
|
52
|
-
};
|