backend-manager 5.0.175 → 5.0.177
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 +11 -0
- package/_zombie-sub-guard-wip/journey-payments-zombie-sub.js +159 -0
- package/_zombie-sub-guard-wip/on-write.js +442 -0
- package/_zombie-sub-guard-wip/test-accounts.diff +21 -0
- package/package.json +1 -1
- package/src/manager/events/firestore/payments-disputes/on-write.js +14 -260
- package/src/manager/events/firestore/payments-disputes/processors/stripe.js +213 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +9 -4
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +1 -0
- package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +2 -2
- package/test/routes/payments/dispute-alert.js +89 -4
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,17 @@ 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.177] - 2026-03-29
|
|
18
|
+
### Changed
|
|
19
|
+
- `payment-recovered` transition now sends email to internal team only — customer no longer receives a "Payment received" notification
|
|
20
|
+
|
|
21
|
+
# [5.0.176] - 2026-03-30
|
|
22
|
+
### Fixed
|
|
23
|
+
- Chargeblast `alert.created` events use `alertId` instead of `id` — normalizer now accepts either field
|
|
24
|
+
- Dispute charge matching now uses `charges.search()` instead of invoice search, fixing cases where Stripe invoices had `charge: null` even when paid (via balance/credit). Single reliable strategy: amount + ±2 day date window + card last4
|
|
25
|
+
### Changed
|
|
26
|
+
- Dispute `on-write` trigger is now processor-agnostic — Stripe-specific match/refund logic extracted to `processors/stripe.js`, matching the pattern used by payments-webhooks
|
|
27
|
+
|
|
17
28
|
# [5.0.174] - 2026-03-27
|
|
18
29
|
### Fixed
|
|
19
30
|
- Payments-orders `metadata.created` timestamp no longer overwritten on subsequent webhook events (renewals, cancellations, payment failures)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Zombie/Stale Subscription Guard
|
|
3
|
+
* Simulates: user has active sub A → webhook arrives for old sub B (cancellation)
|
|
4
|
+
*
|
|
5
|
+
* Verifies that on-write.js does NOT overwrite the user doc when a webhook
|
|
6
|
+
* arrives for a subscription that doesn't match the user's current resourceId.
|
|
7
|
+
* The order doc should still be updated, but user.subscription must remain unchanged.
|
|
8
|
+
*/
|
|
9
|
+
module.exports = {
|
|
10
|
+
description: 'Payment journey: zombie subscription webhook does not overwrite current subscription',
|
|
11
|
+
type: 'suite',
|
|
12
|
+
timeout: 30000,
|
|
13
|
+
|
|
14
|
+
tests: [
|
|
15
|
+
{
|
|
16
|
+
name: 'setup-active-subscription',
|
|
17
|
+
async run({ accounts, firestore, assert, state, config, http, waitFor }) {
|
|
18
|
+
const uid = accounts['journey-payments-zombie-sub'].uid;
|
|
19
|
+
|
|
20
|
+
// Resolve first paid product from config
|
|
21
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
22
|
+
assert.ok(paidProduct, 'Config should have at least one paid product');
|
|
23
|
+
|
|
24
|
+
state.uid = uid;
|
|
25
|
+
state.paidProductId = paidProduct.id;
|
|
26
|
+
state.paidProductName = paidProduct.name;
|
|
27
|
+
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
28
|
+
|
|
29
|
+
// Create subscription via test intent
|
|
30
|
+
const response = await http.as('journey-payments-zombie-sub').post('payments/intent', {
|
|
31
|
+
processor: 'test',
|
|
32
|
+
productId: paidProduct.id,
|
|
33
|
+
frequency: 'monthly',
|
|
34
|
+
});
|
|
35
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
36
|
+
state.orderId = response.data.orderId;
|
|
37
|
+
|
|
38
|
+
// Wait for subscription to activate
|
|
39
|
+
await waitFor(async () => {
|
|
40
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
41
|
+
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
42
|
+
}, 15000, 500);
|
|
43
|
+
|
|
44
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
45
|
+
assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should be ${paidProduct.id}`);
|
|
46
|
+
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
47
|
+
|
|
48
|
+
// Save the current (real) subscription resourceId
|
|
49
|
+
state.currentResourceId = userDoc.subscription.payment.resourceId;
|
|
50
|
+
assert.ok(state.currentResourceId, 'Should have a resourceId');
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
name: 'send-zombie-cancellation-webhook',
|
|
56
|
+
async run({ http, assert, state, config }) {
|
|
57
|
+
// Send a cancellation webhook for a DIFFERENT subscription (the "zombie")
|
|
58
|
+
state.zombieResourceId = 'sub_zombie_old_dead_' + Date.now();
|
|
59
|
+
state.zombieEventId = `_test-evt-zombie-cancel-${Date.now()}`;
|
|
60
|
+
|
|
61
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
62
|
+
id: state.zombieEventId,
|
|
63
|
+
type: 'customer.subscription.deleted',
|
|
64
|
+
data: {
|
|
65
|
+
object: {
|
|
66
|
+
id: state.zombieResourceId,
|
|
67
|
+
object: 'subscription',
|
|
68
|
+
status: 'canceled',
|
|
69
|
+
metadata: { uid: state.uid },
|
|
70
|
+
cancel_at_period_end: false,
|
|
71
|
+
canceled_at: Math.floor(Date.now() / 1000),
|
|
72
|
+
current_period_end: Math.floor(Date.now() / 1000),
|
|
73
|
+
current_period_start: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
74
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
75
|
+
trial_start: null,
|
|
76
|
+
trial_end: null,
|
|
77
|
+
plan: { product: state.paidStripeProductId, interval: 'month' },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
name: 'user-doc-unchanged',
|
|
88
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
89
|
+
// Wait for the zombie webhook to complete processing
|
|
90
|
+
await waitFor(async () => {
|
|
91
|
+
const doc = await firestore.get(`payments-webhooks/${state.zombieEventId}`);
|
|
92
|
+
return doc?.status === 'completed';
|
|
93
|
+
}, 15000, 500);
|
|
94
|
+
|
|
95
|
+
// Verify the webhook completed but did NOT trigger a transition
|
|
96
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.zombieEventId}`);
|
|
97
|
+
assert.equal(webhookDoc.status, 'completed', 'Webhook should complete successfully');
|
|
98
|
+
assert.equal(webhookDoc.transition, null, 'Transition should be null (suppressed for zombie sub)');
|
|
99
|
+
|
|
100
|
+
// Verify user doc was NOT overwritten — subscription should still be active with original resourceId
|
|
101
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
102
|
+
assert.equal(userDoc.subscription.status, 'active', 'User subscription should still be active');
|
|
103
|
+
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
|
|
104
|
+
assert.equal(userDoc.subscription.payment.resourceId, state.currentResourceId, 'ResourceId should still be the current subscription, not the zombie');
|
|
105
|
+
assert.notEqual(userDoc.subscription.payment.resourceId, state.zombieResourceId, 'ResourceId should NOT be the zombie subscription');
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
name: 'send-zombie-suspension-webhook',
|
|
111
|
+
async run({ http, assert, state, config }) {
|
|
112
|
+
// Also test that a payment failure for a zombie sub doesn't suspend the user
|
|
113
|
+
state.zombieSuspendEventId = `_test-evt-zombie-suspend-${Date.now()}`;
|
|
114
|
+
|
|
115
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
116
|
+
id: state.zombieSuspendEventId,
|
|
117
|
+
type: 'invoice.payment_failed',
|
|
118
|
+
data: {
|
|
119
|
+
object: {
|
|
120
|
+
id: state.zombieResourceId,
|
|
121
|
+
object: 'subscription',
|
|
122
|
+
status: 'active',
|
|
123
|
+
metadata: { uid: state.uid },
|
|
124
|
+
cancel_at_period_end: false,
|
|
125
|
+
canceled_at: null,
|
|
126
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30,
|
|
127
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
128
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
129
|
+
trial_start: null,
|
|
130
|
+
trial_end: null,
|
|
131
|
+
plan: { product: state.paidStripeProductId, interval: 'month' },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
name: 'user-still-active-after-zombie-suspension',
|
|
142
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
143
|
+
await waitFor(async () => {
|
|
144
|
+
const doc = await firestore.get(`payments-webhooks/${state.zombieSuspendEventId}`);
|
|
145
|
+
return doc?.status === 'completed';
|
|
146
|
+
}, 15000, 500);
|
|
147
|
+
|
|
148
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.zombieSuspendEventId}`);
|
|
149
|
+
assert.equal(webhookDoc.status, 'completed', 'Webhook should complete');
|
|
150
|
+
assert.equal(webhookDoc.transition, null, 'Transition should be null (suppressed for zombie sub)');
|
|
151
|
+
|
|
152
|
+
// User should STILL be active — zombie payment failure must not suspend them
|
|
153
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
154
|
+
assert.equal(userDoc.subscription.status, 'active', 'User should still be active after zombie payment failure');
|
|
155
|
+
assert.equal(userDoc.subscription.payment.resourceId, state.currentResourceId, 'ResourceId should still be the current subscription');
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
const powertools = require('node-powertools');
|
|
2
|
+
const transitions = require('./transitions/index.js');
|
|
3
|
+
const { trackPayment } = require('./analytics.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Firestore trigger: payments-webhooks/{eventId} onWrite
|
|
7
|
+
*
|
|
8
|
+
* Processes pending webhook events:
|
|
9
|
+
* 1. Loads the processor library
|
|
10
|
+
* 2. Fetches the latest resource from the processor API (not the stale webhook payload)
|
|
11
|
+
* 3. Branches on event.category to transform + write:
|
|
12
|
+
* - subscription → toUnifiedSubscription → users/{uid}.subscription + payments-orders/{orderId}
|
|
13
|
+
* - one-time → toUnifiedOneTime → payments-orders/{orderId}
|
|
14
|
+
* 4. Detects state transitions and dispatches handler files (non-blocking)
|
|
15
|
+
* 5. Marks the webhook as completed
|
|
16
|
+
*/
|
|
17
|
+
module.exports = async ({ assistant, change, context }) => {
|
|
18
|
+
const Manager = assistant.Manager;
|
|
19
|
+
const admin = Manager.libraries.admin;
|
|
20
|
+
|
|
21
|
+
const dataAfter = change.after.data();
|
|
22
|
+
|
|
23
|
+
// Short-circuit: deleted doc or non-pending status
|
|
24
|
+
if (!dataAfter || dataAfter.status !== 'pending') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const eventId = context.params.eventId;
|
|
29
|
+
const webhookRef = admin.firestore().doc(`payments-webhooks/${eventId}`);
|
|
30
|
+
|
|
31
|
+
// Set status to processing
|
|
32
|
+
await webhookRef.set({ status: 'processing' }, { merge: true });
|
|
33
|
+
|
|
34
|
+
// Hoisted so orderId is available in catch block for audit trail
|
|
35
|
+
let orderId = null;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const processor = dataAfter.processor;
|
|
39
|
+
let uid = dataAfter.owner;
|
|
40
|
+
const raw = dataAfter.raw;
|
|
41
|
+
const eventType = dataAfter.event?.type;
|
|
42
|
+
const category = dataAfter.event?.category;
|
|
43
|
+
const resourceType = dataAfter.event?.resourceType;
|
|
44
|
+
const resourceId = dataAfter.event?.resourceId;
|
|
45
|
+
|
|
46
|
+
assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, category=${category}, resourceType=${resourceType}, resourceId=${resourceId}, uid=${uid || 'null'}`);
|
|
47
|
+
|
|
48
|
+
// Validate category
|
|
49
|
+
if (!category) {
|
|
50
|
+
throw new Error(`Webhook event has no category — cannot process`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Load the shared library for this processor
|
|
54
|
+
let library;
|
|
55
|
+
try {
|
|
56
|
+
library = require(`../../../libraries/payment/processors/${processor}.js`);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new Error(`Unknown processor library: ${processor}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fetch the latest resource from the processor API
|
|
62
|
+
// This ensures we always work with the most current state, not stale webhook data
|
|
63
|
+
const rawFallback = raw.data?.object || {};
|
|
64
|
+
const resource = await library.fetchResource(resourceType, resourceId, rawFallback, { admin, eventType, config: Manager.config });
|
|
65
|
+
|
|
66
|
+
assistant.log(`Fetched resource: type=${resourceType}, id=${resourceId}, status=${resource.status || 'unknown'}`);
|
|
67
|
+
|
|
68
|
+
// Resolve UID from the fetched resource if not available from webhook parse
|
|
69
|
+
// This handles events like PAYMENT.SALE where the Sale object doesn't carry custom_id
|
|
70
|
+
// but the parent subscription (fetched via fetchResource) does
|
|
71
|
+
if (!uid && library.getUid) {
|
|
72
|
+
uid = library.getUid(resource);
|
|
73
|
+
assistant.log(`UID resolved from fetched resource: uid=${uid || 'null'}, processor=${processor}, resourceType=${resourceType}`);
|
|
74
|
+
|
|
75
|
+
// Update the webhook doc with the resolved UID so it's persisted for debugging
|
|
76
|
+
if (uid) {
|
|
77
|
+
await webhookRef.set({ owner: uid }, { merge: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback: resolve UID from the hosted page's pass_thru_content
|
|
82
|
+
// Chargebee hosted page checkouts don't forward subscription[meta_data] to the subscription,
|
|
83
|
+
// but pass_thru_content is stored on the hosted page and contains our UID + orderId
|
|
84
|
+
let resolvedFromPassThru = false;
|
|
85
|
+
let passThruOrderId = null;
|
|
86
|
+
if (!uid && library.resolveUidFromHostedPage) {
|
|
87
|
+
const passThruResult = await library.resolveUidFromHostedPage(resourceId, assistant);
|
|
88
|
+
if (passThruResult) {
|
|
89
|
+
uid = passThruResult.uid;
|
|
90
|
+
passThruOrderId = passThruResult.orderId || null;
|
|
91
|
+
resolvedFromPassThru = true;
|
|
92
|
+
assistant.log(`UID resolved from hosted page pass_thru_content: uid=${uid}, orderId=${passThruOrderId}, resourceId=${resourceId}`);
|
|
93
|
+
|
|
94
|
+
await webhookRef.set({ owner: uid }, { merge: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate UID — must have one by now
|
|
99
|
+
if (!uid) {
|
|
100
|
+
throw new Error(`Webhook event has no UID — could not extract from webhook parse, fetched ${resourceType} resource, or hosted page pass_thru_content`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Backfill: if UID was resolved from pass_thru_content, set meta_data on the subscription
|
|
104
|
+
// so future webhooks (renewals, cancellations) can resolve the UID directly
|
|
105
|
+
if (resolvedFromPassThru && resourceType === 'subscription' && library.setMetaData) {
|
|
106
|
+
library.setMetaData(resource, { uid, orderId: passThruOrderId })
|
|
107
|
+
.then(() => assistant.log(`Backfilled meta_data on subscription ${resourceId} + customer: uid=${uid}, orderId=${passThruOrderId}`))
|
|
108
|
+
.catch((e) => assistant.error(`Failed to backfill meta_data on ${resourceType} ${resourceId}: ${e.message}`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Build timestamps
|
|
112
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
113
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
114
|
+
const webhookReceivedUNIX = dataAfter.metadata?.created?.timestampUNIX || nowUNIX;
|
|
115
|
+
|
|
116
|
+
// Extract orderId from resource (processor-agnostic)
|
|
117
|
+
// Falls back to pass_thru_content orderId when meta_data wasn't available on the resource
|
|
118
|
+
orderId = library.getOrderId(resource) || passThruOrderId;
|
|
119
|
+
|
|
120
|
+
// Process the payment event (subscription or one-time)
|
|
121
|
+
if (category !== 'subscription' && category !== 'one-time') {
|
|
122
|
+
throw new Error(`Unknown event category: ${category}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw });
|
|
126
|
+
|
|
127
|
+
// Mark webhook as completed (include transition name for auditing/testing)
|
|
128
|
+
await webhookRef.set({
|
|
129
|
+
status: 'completed',
|
|
130
|
+
owner: uid,
|
|
131
|
+
orderId: orderId,
|
|
132
|
+
transition: transitionName,
|
|
133
|
+
metadata: {
|
|
134
|
+
completed: {
|
|
135
|
+
timestamp: now,
|
|
136
|
+
timestampUNIX: nowUNIX,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
}, { merge: true });
|
|
140
|
+
|
|
141
|
+
assistant.log(`Webhook ${eventId} completed`);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
assistant.error(`Webhook ${eventId} failed: ${e.message}`, e);
|
|
144
|
+
|
|
145
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
146
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
147
|
+
|
|
148
|
+
// Mark as failed with error message
|
|
149
|
+
await webhookRef.set({
|
|
150
|
+
status: 'failed',
|
|
151
|
+
error: e.message || String(e),
|
|
152
|
+
}, { merge: true });
|
|
153
|
+
|
|
154
|
+
// Mark intent as failed if we resolved the orderId before the error
|
|
155
|
+
if (orderId) {
|
|
156
|
+
await admin.firestore().doc(`payments-intents/${orderId}`).set({
|
|
157
|
+
status: 'failed',
|
|
158
|
+
error: e.message || String(e),
|
|
159
|
+
metadata: {
|
|
160
|
+
completed: {
|
|
161
|
+
timestamp: now,
|
|
162
|
+
timestampUNIX: nowUNIX,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
}, { merge: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Process a payment event (subscription or one-time)
|
|
172
|
+
* 1. Staleness check
|
|
173
|
+
* 2. Read user doc (for transition detection)
|
|
174
|
+
* 3. Transform raw resource → unified object
|
|
175
|
+
* 4. Build order object
|
|
176
|
+
* 5. Detect and dispatch transition handlers (non-blocking)
|
|
177
|
+
* 6. Track analytics (non-blocking)
|
|
178
|
+
* 7. Write to Firestore (user doc for subscriptions + payments-orders)
|
|
179
|
+
*/
|
|
180
|
+
async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw }) {
|
|
181
|
+
const Manager = assistant.Manager;
|
|
182
|
+
const admin = Manager.libraries.admin;
|
|
183
|
+
const isSubscription = category === 'subscription';
|
|
184
|
+
|
|
185
|
+
// Staleness check: skip if a newer webhook already wrote to this order
|
|
186
|
+
if (orderId) {
|
|
187
|
+
const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
|
|
188
|
+
if (existingDoc.exists) {
|
|
189
|
+
const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
|
|
190
|
+
if (webhookReceivedUNIX < existingUpdatedUNIX) {
|
|
191
|
+
assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Read current user doc (needed for transition detection + handler context)
|
|
198
|
+
const userDoc = await admin.firestore().doc(`users/${uid}`).get();
|
|
199
|
+
const userData = userDoc.exists ? userDoc.data() : {};
|
|
200
|
+
const before = isSubscription ? (userData.subscription || null) : null;
|
|
201
|
+
|
|
202
|
+
assistant.log(`User doc for ${uid}: exists=${userDoc.exists}, email=${userData?.auth?.email || 'null'}, name=${userData?.personal?.name?.first || 'null'}, subscription=${userData?.subscription?.product?.id || 'null'}`);
|
|
203
|
+
|
|
204
|
+
// Auto-fill user name from payment processor if not already set
|
|
205
|
+
if (!userData?.personal?.name?.first) {
|
|
206
|
+
const customerName = extractCustomerName(resource, resourceType);
|
|
207
|
+
if (customerName?.first) {
|
|
208
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
209
|
+
personal: { name: customerName },
|
|
210
|
+
}, { merge: true });
|
|
211
|
+
assistant.log(`Auto-filled user name from ${resourceType}: ${customerName.first} ${customerName.last || ''}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Transform raw resource → unified object
|
|
216
|
+
const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
|
|
217
|
+
const unified = isSubscription
|
|
218
|
+
? library.toUnifiedSubscription(resource, transformOptions)
|
|
219
|
+
: library.toUnifiedOneTime(resource, transformOptions);
|
|
220
|
+
|
|
221
|
+
// Override: immediately suspend on payment denial
|
|
222
|
+
// Processors keep the sub active while retrying, but we revoke access right away.
|
|
223
|
+
// If the retry succeeds (e.g. PAYMENT.SALE.COMPLETED), it will restore active status.
|
|
224
|
+
// PayPal: PAYMENT.SALE.DENIED, Stripe: invoice.payment_failed, Chargebee: payment_failed
|
|
225
|
+
const PAYMENT_DENIED_EVENTS = ['PAYMENT.SALE.DENIED', 'invoice.payment_failed', 'payment_failed'];
|
|
226
|
+
if (isSubscription && PAYMENT_DENIED_EVENTS.includes(eventType) && unified.status === 'active') {
|
|
227
|
+
assistant.log(`Overriding status to suspended: ${eventType} received but provider still says active`);
|
|
228
|
+
unified.status = 'suspended';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
assistant.log(`Unified ${category}: product=${unified.product.id}, status=${unified.status}`, unified);
|
|
232
|
+
|
|
233
|
+
// Read checkout context from payments-intents (attribution, discount, supplemental)
|
|
234
|
+
let intentData = {};
|
|
235
|
+
if (orderId) {
|
|
236
|
+
const intentDoc = await admin.firestore().doc(`payments-intents/${orderId}`).get();
|
|
237
|
+
intentData = intentDoc.exists ? intentDoc.data() : {};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Build the order object (single source of truth for handlers + Firestore)
|
|
241
|
+
const order = {
|
|
242
|
+
id: orderId,
|
|
243
|
+
type: category,
|
|
244
|
+
owner: uid,
|
|
245
|
+
productId: unified.product.id,
|
|
246
|
+
processor: processor,
|
|
247
|
+
resourceId: resourceId,
|
|
248
|
+
unified: unified,
|
|
249
|
+
attribution: intentData.attribution || {},
|
|
250
|
+
discount: intentData.discount || null,
|
|
251
|
+
supplemental: intentData.supplemental || {},
|
|
252
|
+
metadata: {
|
|
253
|
+
created: {
|
|
254
|
+
timestamp: now,
|
|
255
|
+
timestampUNIX: nowUNIX,
|
|
256
|
+
},
|
|
257
|
+
updated: {
|
|
258
|
+
timestamp: now,
|
|
259
|
+
timestampUNIX: nowUNIX,
|
|
260
|
+
},
|
|
261
|
+
updatedBy: {
|
|
262
|
+
event: {
|
|
263
|
+
name: eventType,
|
|
264
|
+
id: eventId,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Guard: check if this webhook is for the user's current subscription
|
|
271
|
+
const currentResourceId = userData?.subscription?.payment?.resourceId;
|
|
272
|
+
const isCurrentSub = !currentResourceId || currentResourceId === resourceId;
|
|
273
|
+
|
|
274
|
+
// Detect and dispatch transition (non-blocking)
|
|
275
|
+
// Only run transitions for the user's current subscription — zombie webhooks should not trigger emails
|
|
276
|
+
const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
277
|
+
const transitionName = isCurrentSub
|
|
278
|
+
? transitions.detectTransition(category, before, unified, eventType)
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
if (!isCurrentSub && isSubscription) {
|
|
282
|
+
const wouldBeTransition = transitions.detectTransition(category, before, unified, eventType);
|
|
283
|
+
if (wouldBeTransition) {
|
|
284
|
+
assistant.log(`Transition suppressed for stale sub: ${category}/${wouldBeTransition} (webhook resourceId=${resourceId} != current resourceId=${currentResourceId})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (transitionName) {
|
|
289
|
+
assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
|
|
290
|
+
|
|
291
|
+
if (shouldRunHandlers) {
|
|
292
|
+
// Extract unified refund details from the processor library (keeps handlers processor-agnostic)
|
|
293
|
+
const refundDetails = (transitionName === 'payment-refunded' && library.getRefundDetails)
|
|
294
|
+
? library.getRefundDetails(raw)
|
|
295
|
+
: null;
|
|
296
|
+
|
|
297
|
+
transitions.dispatch(transitionName, category, {
|
|
298
|
+
before, after: unified, order, uid, userDoc: userData, assistant, refundDetails,
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Track payment analytics (non-blocking)
|
|
306
|
+
// Fires independently of transitions — renewals have no transition but still need tracking
|
|
307
|
+
if (shouldRunHandlers) {
|
|
308
|
+
trackPayment({ category, transitionName, eventType, unified, order, uid, processor, assistant });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Write unified subscription to user doc (subscriptions only)
|
|
312
|
+
if (isSubscription) {
|
|
313
|
+
if (isCurrentSub) {
|
|
314
|
+
await admin.firestore().doc(`users/${uid}`).set({ subscription: unified }, { merge: true });
|
|
315
|
+
assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
|
|
316
|
+
|
|
317
|
+
// Sync marketing contact with updated subscription data (non-blocking)
|
|
318
|
+
if (shouldRunHandlers) {
|
|
319
|
+
const email = Manager.Email(assistant);
|
|
320
|
+
const updatedUserDoc = { ...userData, subscription: unified };
|
|
321
|
+
email.sync(updatedUserDoc)
|
|
322
|
+
.then((r) => assistant.log('Marketing sync after payment:', r))
|
|
323
|
+
.catch((e) => assistant.error('Marketing sync after payment failed:', e));
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
assistant.log(`Skipping user doc update: webhook resourceId=${resourceId} does not match current subscription resourceId=${currentResourceId}. This is a stale/zombie subscription webhook — only the order doc will be updated.`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Write to payments-orders/{orderId}
|
|
331
|
+
if (orderId) {
|
|
332
|
+
const orderRef = admin.firestore().doc(`payments-orders/${orderId}`);
|
|
333
|
+
const orderSnap = await orderRef.get();
|
|
334
|
+
|
|
335
|
+
if (!orderSnap.exists) {
|
|
336
|
+
// Initialize requests on first creation only (avoid overwriting cancel/refund data set by endpoints)
|
|
337
|
+
order.requests = {
|
|
338
|
+
cancellation: null,
|
|
339
|
+
refund: null,
|
|
340
|
+
};
|
|
341
|
+
} else {
|
|
342
|
+
// Preserve original created timestamp on subsequent webhook events
|
|
343
|
+
order.metadata.created = orderSnap.data().metadata?.created || order.metadata.created;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await orderRef.set(order, { merge: true });
|
|
347
|
+
assistant.log(`Updated payments-orders/${orderId}: type=${category}, uid=${uid}, eventType=${eventType}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Update payments-intents/{orderId} status to match webhook outcome
|
|
351
|
+
if (orderId) {
|
|
352
|
+
await admin.firestore().doc(`payments-intents/${orderId}`).set({
|
|
353
|
+
status: 'completed',
|
|
354
|
+
metadata: {
|
|
355
|
+
completed: {
|
|
356
|
+
timestamp: now,
|
|
357
|
+
timestampUNIX: nowUNIX,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
}, { merge: true });
|
|
361
|
+
assistant.log(`Updated payments-intents/${orderId}: status=completed`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Mark abandoned cart as completed (non-blocking, fire-and-forget)
|
|
365
|
+
const { COLLECTION } = require('../../../libraries/abandoned-cart-config.js');
|
|
366
|
+
admin.firestore().doc(`${COLLECTION}/${uid}`).set({
|
|
367
|
+
status: 'completed',
|
|
368
|
+
metadata: {
|
|
369
|
+
updated: {
|
|
370
|
+
timestamp: now,
|
|
371
|
+
timestampUNIX: nowUNIX,
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
}, { merge: true })
|
|
375
|
+
.then(() => assistant.log(`Updated ${COLLECTION}/${uid}: status=completed`))
|
|
376
|
+
.catch((e) => {
|
|
377
|
+
// Ignore not-found — cart may not exist for this user
|
|
378
|
+
if (e.code !== 5) {
|
|
379
|
+
assistant.error(`Failed to update ${COLLECTION}/${uid}: ${e.message}`);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return transitionName;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Extract customer name from a raw payment processor resource
|
|
388
|
+
*
|
|
389
|
+
* @param {object} resource - Raw processor resource (Stripe subscription, session, invoice)
|
|
390
|
+
* @param {string} resourceType - 'subscription' | 'session' | 'invoice'
|
|
391
|
+
* @returns {{ first: string, last: string }|null}
|
|
392
|
+
*/
|
|
393
|
+
function extractCustomerName(resource, resourceType) {
|
|
394
|
+
let fullName = null;
|
|
395
|
+
|
|
396
|
+
// Checkout sessions have customer_details.name
|
|
397
|
+
if (resourceType === 'session') {
|
|
398
|
+
fullName = resource.customer_details?.name;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Invoices have customer_name
|
|
402
|
+
if (resourceType === 'invoice') {
|
|
403
|
+
fullName = resource.customer_name;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// PayPal orders have payer.name
|
|
407
|
+
if (resourceType === 'order') {
|
|
408
|
+
const givenName = resource.payer?.name?.given_name;
|
|
409
|
+
const surname = resource.payer?.name?.surname;
|
|
410
|
+
|
|
411
|
+
if (givenName) {
|
|
412
|
+
const { capitalize } = require('../../../libraries/infer-contact.js');
|
|
413
|
+
return {
|
|
414
|
+
first: capitalize(givenName) || null,
|
|
415
|
+
last: capitalize(surname) || null,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Chargebee subscriptions carry shipping_address / billing_address with first_name + last_name
|
|
421
|
+
if (resourceType === 'subscription') {
|
|
422
|
+
const addr = resource.shipping_address || resource.billing_address;
|
|
423
|
+
if (addr?.first_name) {
|
|
424
|
+
const { capitalize } = require('../../../libraries/infer-contact.js');
|
|
425
|
+
return {
|
|
426
|
+
first: capitalize(addr.first_name) || null,
|
|
427
|
+
last: capitalize(addr.last_name) || null,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!fullName) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const { capitalize } = require('../../../libraries/infer-contact.js');
|
|
437
|
+
const parts = fullName.trim().split(/\s+/);
|
|
438
|
+
return {
|
|
439
|
+
first: capitalize(parts[0]) || null,
|
|
440
|
+
last: capitalize(parts.slice(1).join(' ')) || null,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
diff --git a/src/test/test-accounts.js b/src/test/test-accounts.js
|
|
2
|
+
index 796f4c3..5b5efc4 100644
|
|
3
|
+
--- a/src/test/test-accounts.js
|
|
4
|
+
+++ b/src/test/test-accounts.js
|
|
5
|
+
@@ -417,6 +417,16 @@ const JOURNEY_ACCOUNTS = {
|
|
6
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
7
|
+
},
|
|
8
|
+
},
|
|
9
|
+
+ // Journey: zombie/stale subscription guard (webhook for old sub should not overwrite current sub)
|
|
10
|
+
+ 'journey-payments-zombie-sub': {
|
|
11
|
+
+ id: 'journey-payments-zombie-sub',
|
|
12
|
+
+ uid: '_test-journey-payments-zombie-sub',
|
|
13
|
+
+ email: '_test.journey-payments-zombie-sub@{domain}',
|
|
14
|
+
+ properties: {
|
|
15
|
+
+ roles: {},
|
|
16
|
+
+ subscription: { product: { id: 'basic' }, status: 'active' },
|
|
17
|
+
+ },
|
|
18
|
+
+ },
|
|
19
|
+
// Journey: legacy product ID resolution (webhook with legacy product ID maps to correct product)
|
|
20
|
+
'journey-payments-legacy-product': {
|
|
21
|
+
id: 'journey-payments-legacy-product',
|