backend-manager 5.0.175 → 5.0.176
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 +7 -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/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,13 @@ 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.176] - 2026-03-30
|
|
18
|
+
### Fixed
|
|
19
|
+
- Chargeblast `alert.created` events use `alertId` instead of `id` — normalizer now accepts either field
|
|
20
|
+
- 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
|
|
21
|
+
### Changed
|
|
22
|
+
- 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
|
|
23
|
+
|
|
17
24
|
# [5.0.174] - 2026-03-27
|
|
18
25
|
### Fixed
|
|
19
26
|
- 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',
|
package/package.json
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
const
|
|
1
|
+
const path = require('path');
|
|
2
2
|
const powertools = require('node-powertools');
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Firestore trigger: payments-disputes/{alertId} onWrite
|
|
6
6
|
*
|
|
7
7
|
* Processes pending dispute alerts:
|
|
8
|
-
* 1.
|
|
9
|
-
* 2.
|
|
10
|
-
* 3. Issues
|
|
11
|
-
* 4.
|
|
12
|
-
* 5.
|
|
13
|
-
* 6. Updates dispute document with results
|
|
8
|
+
* 1. Loads the processor module for the alert's payment processor
|
|
9
|
+
* 2. Searches for the matching charge via processor.searchAndMatch()
|
|
10
|
+
* 3. Issues refund + cancels subscription via processor.processDispute()
|
|
11
|
+
* 4. Sends email alert to brand contact
|
|
12
|
+
* 5. Updates dispute document with results
|
|
14
13
|
*/
|
|
15
14
|
module.exports = async ({ assistant, change, context }) => {
|
|
16
15
|
const Manager = assistant.Manager;
|
|
@@ -35,13 +34,16 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
35
34
|
|
|
36
35
|
assistant.log(`Processing dispute ${alertId}: processor=${processor}, amount=${alert.amount}, card=****${alert.card.last4}, date=${alert.transactionDate}, chargeId=${alert.chargeId || 'none'}, paymentIntentId=${alert.paymentIntentId || 'none'}`);
|
|
37
36
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
// Load the processor module
|
|
38
|
+
let processorModule;
|
|
39
|
+
try {
|
|
40
|
+
processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw new Error(`Unsupported dispute processor: ${processor}`);
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
// Search for the matching charge
|
|
44
|
-
const match = await searchAndMatch(
|
|
46
|
+
const match = await processorModule.searchAndMatch(alert, assistant);
|
|
45
47
|
|
|
46
48
|
// Build timestamps
|
|
47
49
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
@@ -80,13 +82,12 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
// Process refund and cancel
|
|
83
|
-
const result = await processDispute(
|
|
85
|
+
const result = await processorModule.processDispute(match, alert, assistant);
|
|
84
86
|
|
|
85
87
|
// Update dispute document with results
|
|
86
88
|
await disputeRef.set({
|
|
87
89
|
status: 'resolved',
|
|
88
90
|
match: {
|
|
89
|
-
method: match.method,
|
|
90
91
|
invoiceId: match.invoiceId || null,
|
|
91
92
|
subscriptionId: match.subscriptionId || null,
|
|
92
93
|
customerId: match.customerId,
|
|
@@ -131,253 +132,6 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
131
132
|
}
|
|
132
133
|
};
|
|
133
134
|
|
|
134
|
-
/**
|
|
135
|
-
* Try to match the dispute alert to a Stripe charge.
|
|
136
|
-
*
|
|
137
|
-
* Strategy (in order):
|
|
138
|
-
* 1. Direct lookup via charge ID (externalOrder from Chargeblast)
|
|
139
|
-
* 2. Direct lookup via payment intent ID (metadata from Chargeblast)
|
|
140
|
-
* 3. Fallback: search invoices by date range + amount + card last4
|
|
141
|
-
*
|
|
142
|
-
* @param {object} options
|
|
143
|
-
* @param {object} options.alert - Normalized alert data
|
|
144
|
-
* @param {object} options.assistant - Assistant instance
|
|
145
|
-
* @returns {object|null} Match details or null
|
|
146
|
-
*/
|
|
147
|
-
async function searchAndMatch({ alert, assistant }) {
|
|
148
|
-
const StripeLib = require('../../../libraries/payment/processors/stripe.js');
|
|
149
|
-
const stripe = StripeLib.init();
|
|
150
|
-
|
|
151
|
-
// Strategy 1: Direct charge lookup
|
|
152
|
-
if (alert.chargeId && alert.chargeId.startsWith('ch_')) {
|
|
153
|
-
assistant.log(`Trying direct charge lookup: ${alert.chargeId}`);
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
const charge = await stripe.charges.retrieve(alert.chargeId, {
|
|
157
|
-
expand: ['invoice', 'customer'],
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const match = await resolveMatchFromCharge({ charge, stripe, assistant, method: 'charge-id' });
|
|
161
|
-
|
|
162
|
-
if (match) {
|
|
163
|
-
return match;
|
|
164
|
-
}
|
|
165
|
-
} catch (e) {
|
|
166
|
-
assistant.log(`Direct charge lookup failed for ${alert.chargeId}: ${e.message}`);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Strategy 2: Direct payment intent lookup
|
|
171
|
-
if (alert.paymentIntentId && alert.paymentIntentId.startsWith('pi_')) {
|
|
172
|
-
assistant.log(`Trying direct payment intent lookup: ${alert.paymentIntentId}`);
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
const pi = await stripe.paymentIntents.retrieve(alert.paymentIntentId, {
|
|
176
|
-
expand: ['latest_charge', 'latest_charge.invoice', 'latest_charge.customer'],
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
const charge = pi.latest_charge;
|
|
180
|
-
if (charge) {
|
|
181
|
-
const match = await resolveMatchFromCharge({ charge, stripe, assistant, method: 'payment-intent' });
|
|
182
|
-
|
|
183
|
-
if (match) {
|
|
184
|
-
return match;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
} catch (e) {
|
|
188
|
-
assistant.log(`Direct payment intent lookup failed for ${alert.paymentIntentId}: ${e.message}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Strategy 3: Fallback — search invoices by date range + amount + card last4
|
|
193
|
-
return searchInvoicesFallback({ alert, stripe, assistant });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Build a match object from a Stripe charge
|
|
198
|
-
*/
|
|
199
|
-
async function resolveMatchFromCharge({ charge, stripe, assistant, method }) {
|
|
200
|
-
if (!charge || charge.status !== 'succeeded') {
|
|
201
|
-
assistant.log(`Charge ${charge?.id} status=${charge?.status}, skipping`);
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Resolve UID from customer metadata
|
|
206
|
-
let uid = null;
|
|
207
|
-
let email = null;
|
|
208
|
-
const customerId = typeof charge.customer === 'string' ? charge.customer : charge.customer?.id;
|
|
209
|
-
|
|
210
|
-
if (charge.customer && typeof charge.customer === 'object') {
|
|
211
|
-
uid = charge.customer.metadata?.uid || null;
|
|
212
|
-
email = charge.customer.email || null;
|
|
213
|
-
} else if (customerId) {
|
|
214
|
-
try {
|
|
215
|
-
const customer = await stripe.customers.retrieve(customerId);
|
|
216
|
-
uid = customer.metadata?.uid || null;
|
|
217
|
-
email = customer.email || null;
|
|
218
|
-
} catch (e) {
|
|
219
|
-
assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Resolve invoice/subscription
|
|
224
|
-
const invoiceId = typeof charge.invoice === 'string'
|
|
225
|
-
? charge.invoice
|
|
226
|
-
: charge.invoice?.id || null;
|
|
227
|
-
const subscriptionId = typeof charge.invoice === 'object'
|
|
228
|
-
? charge.invoice?.subscription || null
|
|
229
|
-
: null;
|
|
230
|
-
|
|
231
|
-
assistant.log(`Matched via ${method}: charge=${charge.id}, customer=${customerId}, uid=${uid}, invoice=${invoiceId}`);
|
|
232
|
-
|
|
233
|
-
return {
|
|
234
|
-
method: method,
|
|
235
|
-
invoiceId: invoiceId,
|
|
236
|
-
subscriptionId: subscriptionId,
|
|
237
|
-
customerId: customerId,
|
|
238
|
-
uid: uid,
|
|
239
|
-
email: email,
|
|
240
|
-
chargeId: charge.id,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Fallback: search Stripe invoices by date range + amount and match card last4
|
|
246
|
-
*/
|
|
247
|
-
async function searchInvoicesFallback({ alert, stripe, assistant }) {
|
|
248
|
-
const amountCents = Math.round(alert.amount * 100);
|
|
249
|
-
const alertDate = moment(alert.transactionDate);
|
|
250
|
-
|
|
251
|
-
if (!alertDate.isValid()) {
|
|
252
|
-
throw new Error(`Invalid transactionDate: ${alert.transactionDate}`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const start = alertDate.clone().subtract(2, 'days').unix();
|
|
256
|
-
const end = alertDate.clone().add(2, 'days').unix();
|
|
257
|
-
|
|
258
|
-
assistant.log(`Fallback: searching Stripe invoices: amount=${amountCents} cents, range=${moment.unix(start).format('YYYY-MM-DD')} to ${moment.unix(end).format('YYYY-MM-DD')}`);
|
|
259
|
-
|
|
260
|
-
// Search invoices by date range and amount
|
|
261
|
-
const invoices = await stripe.invoices.search({
|
|
262
|
-
limit: 100,
|
|
263
|
-
query: `created>${start} AND created<${end} AND total:${amountCents}`,
|
|
264
|
-
expand: ['data.payment_intent.payment_method'],
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
if (!invoices.data.length) {
|
|
268
|
-
assistant.log(`No invoices found for amount=${amountCents} in date range`);
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (invoices.data.length >= 100) {
|
|
273
|
-
assistant.log(`Warning: 100+ invoices found, results may be truncated`);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
assistant.log(`Found ${invoices.data.length} invoice(s), matching card last4=${alert.card.last4}`);
|
|
277
|
-
|
|
278
|
-
// Loop through invoices and match card last4
|
|
279
|
-
for (const invoice of invoices.data) {
|
|
280
|
-
const invoiceLast4 = invoice?.payment_intent?.payment_method?.card?.last4;
|
|
281
|
-
|
|
282
|
-
assistant.log(`Checking invoice ${invoice.id}: card last4=${invoiceLast4 || 'unknown'}`);
|
|
283
|
-
|
|
284
|
-
if (!invoiceLast4 || invoiceLast4 !== alert.card.last4) {
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
assistant.log(`Matched invoice ${invoice.id}: card last4=${invoiceLast4}`);
|
|
289
|
-
|
|
290
|
-
// Resolve UID from customer metadata
|
|
291
|
-
let uid = null;
|
|
292
|
-
let email = null;
|
|
293
|
-
const customerId = invoice.customer;
|
|
294
|
-
if (customerId) {
|
|
295
|
-
try {
|
|
296
|
-
const customer = await stripe.customers.retrieve(customerId);
|
|
297
|
-
uid = customer.metadata?.uid || null;
|
|
298
|
-
email = customer.email || null;
|
|
299
|
-
} catch (e) {
|
|
300
|
-
assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return {
|
|
305
|
-
method: 'invoice-search',
|
|
306
|
-
invoiceId: invoice.id,
|
|
307
|
-
subscriptionId: invoice.subscription || null,
|
|
308
|
-
customerId: customerId,
|
|
309
|
-
uid: uid,
|
|
310
|
-
email: email,
|
|
311
|
-
chargeId: invoice.charge || null,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
assistant.log(`No invoice matched card last4=${alert.card.last4}`);
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Process refund + cancellation for a matched dispute
|
|
321
|
-
*
|
|
322
|
-
* @param {object} options
|
|
323
|
-
* @param {object} options.match - Match details from searchAndMatch
|
|
324
|
-
* @param {object} options.alert - Normalized alert data
|
|
325
|
-
* @param {object} options.assistant - Assistant instance
|
|
326
|
-
* @returns {object} Result with statuses
|
|
327
|
-
*/
|
|
328
|
-
async function processDispute({ match, alert, assistant }) {
|
|
329
|
-
const StripeLib = require('../../../libraries/payment/processors/stripe.js');
|
|
330
|
-
const stripe = StripeLib.init();
|
|
331
|
-
|
|
332
|
-
const amountCents = Math.round(alert.amount * 100);
|
|
333
|
-
const result = {
|
|
334
|
-
refundId: null,
|
|
335
|
-
amountRefunded: null,
|
|
336
|
-
currency: null,
|
|
337
|
-
refundStatus: 'skipped',
|
|
338
|
-
cancelStatus: 'skipped',
|
|
339
|
-
errors: [],
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
// Issue full refund
|
|
343
|
-
if (match.chargeId) {
|
|
344
|
-
try {
|
|
345
|
-
const refund = await stripe.refunds.create({
|
|
346
|
-
charge: match.chargeId,
|
|
347
|
-
amount: amountCents,
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
result.refundId = refund.id;
|
|
351
|
-
result.amountRefunded = amountCents;
|
|
352
|
-
result.currency = refund.currency;
|
|
353
|
-
result.refundStatus = 'success';
|
|
354
|
-
|
|
355
|
-
assistant.log(`Refund success: refundId=${refund.id}, amount=${amountCents}, charge=${match.chargeId}`);
|
|
356
|
-
} catch (e) {
|
|
357
|
-
result.refundStatus = 'failed';
|
|
358
|
-
result.errors.push(`Refund failed: ${e.message}`);
|
|
359
|
-
assistant.error(`Refund failed for charge ${match.chargeId}: ${e.message}`);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Cancel subscription immediately
|
|
364
|
-
// Stripe fires customer.subscription.deleted webhook → existing pipeline handles user doc update
|
|
365
|
-
if (match.subscriptionId) {
|
|
366
|
-
try {
|
|
367
|
-
await stripe.subscriptions.cancel(match.subscriptionId);
|
|
368
|
-
result.cancelStatus = 'success';
|
|
369
|
-
|
|
370
|
-
assistant.log(`Subscription cancelled: sub=${match.subscriptionId}`);
|
|
371
|
-
} catch (e) {
|
|
372
|
-
result.cancelStatus = 'failed';
|
|
373
|
-
result.errors.push(`Cancel failed: ${e.message}`);
|
|
374
|
-
assistant.error(`Cancel failed for sub ${match.subscriptionId}: ${e.message}`);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
return result;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
135
|
/**
|
|
382
136
|
* Send dispute alert email to brand contact (fire-and-forget)
|
|
383
137
|
*
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const moment = require('moment');
|
|
2
|
+
|
|
3
|
+
const StripeLib = require('../../../../libraries/payment/processors/stripe.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stripe dispute processor
|
|
7
|
+
*
|
|
8
|
+
* Implements the dispute processor interface for Stripe:
|
|
9
|
+
* - searchAndMatch(alert, assistant) → match | null
|
|
10
|
+
* - processDispute(match, alert, assistant) → result
|
|
11
|
+
*
|
|
12
|
+
* Match strategy: search charges by amount + date range, then confirm card last4.
|
|
13
|
+
* If alert.chargeId is provided (alert.updated events), verify it directly — otherwise
|
|
14
|
+
* fall back to the charge search. Both paths go through the same resolveMatchFromCharge()
|
|
15
|
+
* so the returned match shape is always identical.
|
|
16
|
+
*
|
|
17
|
+
* We use charges.search() rather than invoices.search() because Stripe invoices can have
|
|
18
|
+
* a null charge field even when paid (payment applied via credit/balance), making invoice
|
|
19
|
+
* search unreliable. Charges always have payment_method_details.card.last4.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find the Stripe charge matching this dispute alert.
|
|
24
|
+
*
|
|
25
|
+
* If alert.chargeId is present (Chargeblast alert.updated), verify it directly.
|
|
26
|
+
* Otherwise search charges by amount + ±2 day window and match card last4.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} alert - Normalized alert data
|
|
29
|
+
* @param {object} assistant - Assistant instance
|
|
30
|
+
* @returns {object|null} Match details or null
|
|
31
|
+
*/
|
|
32
|
+
async function searchAndMatch(alert, assistant) {
|
|
33
|
+
const stripe = StripeLib.init();
|
|
34
|
+
|
|
35
|
+
// If Chargeblast already gave us the charge ID, verify it directly
|
|
36
|
+
if (alert.chargeId && alert.chargeId.startsWith('ch_')) {
|
|
37
|
+
assistant.log(`Direct charge lookup: ${alert.chargeId}`);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const charge = await stripe.charges.retrieve(alert.chargeId, {
|
|
41
|
+
expand: ['invoice', 'invoice.subscription', 'customer'],
|
|
42
|
+
});
|
|
43
|
+
return resolveMatchFromCharge({ charge, stripe, assistant });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
assistant.log(`Direct charge lookup failed for ${alert.chargeId}: ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Search charges by amount + date range, match card last4
|
|
50
|
+
const amountCents = Math.round(alert.amount * 100);
|
|
51
|
+
const alertDate = moment(alert.transactionDate);
|
|
52
|
+
|
|
53
|
+
if (!alertDate.isValid()) {
|
|
54
|
+
throw new Error(`Invalid transactionDate: ${alert.transactionDate}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const start = alertDate.clone().subtract(2, 'days').unix();
|
|
58
|
+
const end = alertDate.clone().add(2, 'days').unix();
|
|
59
|
+
|
|
60
|
+
assistant.log(`Searching charges: amount=${amountCents} cents, range=${moment.unix(start).format('YYYY-MM-DD')} to ${moment.unix(end).format('YYYY-MM-DD')}, last4=${alert.card.last4}`);
|
|
61
|
+
|
|
62
|
+
const charges = await stripe.charges.search({
|
|
63
|
+
limit: 100,
|
|
64
|
+
query: `amount:${amountCents} AND created>${start} AND created<${end}`,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!charges.data.length) {
|
|
68
|
+
assistant.log(`No charges found for amount=${amountCents} in date range`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (charges.data.length >= 100) {
|
|
73
|
+
assistant.log(`Warning: 100+ charges found, results may be truncated`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
assistant.log(`Found ${charges.data.length} charge(s), matching last4=${alert.card.last4}`);
|
|
77
|
+
|
|
78
|
+
for (const charge of charges.data) {
|
|
79
|
+
const chargeLast4 = charge.payment_method_details?.card?.last4;
|
|
80
|
+
|
|
81
|
+
if (!chargeLast4 || chargeLast4 !== alert.card.last4) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fetch full charge with invoice + subscription expanded
|
|
86
|
+
try {
|
|
87
|
+
const fullCharge = await stripe.charges.retrieve(charge.id, {
|
|
88
|
+
expand: ['invoice', 'invoice.subscription', 'customer'],
|
|
89
|
+
});
|
|
90
|
+
return resolveMatchFromCharge({ charge: fullCharge, stripe, assistant });
|
|
91
|
+
} catch (e) {
|
|
92
|
+
assistant.log(`Failed to expand charge ${charge.id}: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
assistant.log(`No charge matched last4=${alert.card.last4}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Issue a refund and cancel the subscription for a matched dispute.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} match - Match details from searchAndMatch
|
|
104
|
+
* @param {object} alert - Normalized alert data
|
|
105
|
+
* @param {object} assistant - Assistant instance
|
|
106
|
+
* @returns {object} Result with statuses
|
|
107
|
+
*/
|
|
108
|
+
async function processDispute(match, alert, assistant) {
|
|
109
|
+
const stripe = StripeLib.init();
|
|
110
|
+
|
|
111
|
+
const amountCents = Math.round(alert.amount * 100);
|
|
112
|
+
const result = {
|
|
113
|
+
refundId: null,
|
|
114
|
+
amountRefunded: null,
|
|
115
|
+
currency: null,
|
|
116
|
+
refundStatus: 'skipped',
|
|
117
|
+
cancelStatus: 'skipped',
|
|
118
|
+
errors: [],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Issue full refund
|
|
122
|
+
if (match.chargeId) {
|
|
123
|
+
try {
|
|
124
|
+
const refund = await stripe.refunds.create({
|
|
125
|
+
charge: match.chargeId,
|
|
126
|
+
amount: amountCents,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
result.refundId = refund.id;
|
|
130
|
+
result.amountRefunded = amountCents;
|
|
131
|
+
result.currency = refund.currency;
|
|
132
|
+
result.refundStatus = 'success';
|
|
133
|
+
|
|
134
|
+
assistant.log(`Refund success: refundId=${refund.id}, amount=${amountCents}, charge=${match.chargeId}`);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
result.refundStatus = 'failed';
|
|
137
|
+
result.errors.push(`Refund failed: ${e.message}`);
|
|
138
|
+
assistant.error(`Refund failed for charge ${match.chargeId}: ${e.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Cancel subscription immediately
|
|
143
|
+
// Stripe fires customer.subscription.deleted webhook → existing pipeline handles user doc update
|
|
144
|
+
if (match.subscriptionId) {
|
|
145
|
+
try {
|
|
146
|
+
await stripe.subscriptions.cancel(match.subscriptionId);
|
|
147
|
+
result.cancelStatus = 'success';
|
|
148
|
+
|
|
149
|
+
assistant.log(`Subscription cancelled: sub=${match.subscriptionId}`);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
result.cancelStatus = 'failed';
|
|
152
|
+
result.errors.push(`Cancel failed: ${e.message}`);
|
|
153
|
+
assistant.error(`Cancel failed for sub ${match.subscriptionId}: ${e.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { searchAndMatch, processDispute };
|
|
161
|
+
|
|
162
|
+
// ---
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build a match object from a Stripe charge (with invoice + subscription already expanded).
|
|
166
|
+
*
|
|
167
|
+
* @param {object} options.charge - Stripe charge with invoice + customer expanded
|
|
168
|
+
* @param {object} options.stripe - Stripe SDK instance
|
|
169
|
+
* @param {object} options.assistant - Assistant instance
|
|
170
|
+
* @returns {object|null}
|
|
171
|
+
*/
|
|
172
|
+
async function resolveMatchFromCharge({ charge, stripe, assistant }) {
|
|
173
|
+
if (!charge || charge.status !== 'succeeded') {
|
|
174
|
+
assistant.log(`Charge ${charge?.id} status=${charge?.status}, skipping`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Resolve UID + email from customer
|
|
179
|
+
let uid = null;
|
|
180
|
+
let email = null;
|
|
181
|
+
const customerId = typeof charge.customer === 'string' ? charge.customer : charge.customer?.id;
|
|
182
|
+
|
|
183
|
+
if (charge.customer && typeof charge.customer === 'object') {
|
|
184
|
+
uid = charge.customer.metadata?.uid || null;
|
|
185
|
+
email = charge.customer.email || null;
|
|
186
|
+
} else if (customerId) {
|
|
187
|
+
try {
|
|
188
|
+
const customer = await stripe.customers.retrieve(customerId);
|
|
189
|
+
uid = customer.metadata?.uid || null;
|
|
190
|
+
email = customer.email || null;
|
|
191
|
+
} catch (e) {
|
|
192
|
+
assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Resolve invoice + subscription from expanded invoice
|
|
197
|
+
const invoice = typeof charge.invoice === 'object' ? charge.invoice : null;
|
|
198
|
+
const invoiceId = invoice?.id || (typeof charge.invoice === 'string' ? charge.invoice : null);
|
|
199
|
+
const subscriptionId = invoice?.subscription
|
|
200
|
+
? (typeof invoice.subscription === 'object' ? invoice.subscription.id : invoice.subscription)
|
|
201
|
+
: null;
|
|
202
|
+
|
|
203
|
+
assistant.log(`Matched charge=${charge.id}, customer=${customerId}, uid=${uid}, invoice=${invoiceId}, subscription=${subscriptionId}`);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
chargeId: charge.id,
|
|
207
|
+
invoiceId: invoiceId,
|
|
208
|
+
subscriptionId: subscriptionId,
|
|
209
|
+
customerId: customerId,
|
|
210
|
+
uid: uid,
|
|
211
|
+
email: email,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -14,7 +14,7 @@ module.exports = {
|
|
|
14
14
|
* @returns {object} Normalized dispute alert
|
|
15
15
|
*/
|
|
16
16
|
normalize(body) {
|
|
17
|
-
if (!body.id) {
|
|
17
|
+
if (!body.id && !body.alertId) {
|
|
18
18
|
throw new Error('Missing required field: id');
|
|
19
19
|
}
|
|
20
20
|
if (!body.card) {
|
|
@@ -30,7 +30,7 @@ module.exports = {
|
|
|
30
30
|
const cardStr = String(body.card);
|
|
31
31
|
|
|
32
32
|
return {
|
|
33
|
-
id: String(body.id),
|
|
33
|
+
id: String(body.id || body.alertId),
|
|
34
34
|
card: {
|
|
35
35
|
last4: cardStr.slice(-4),
|
|
36
36
|
brand: body.cardBrand ? String(body.cardBrand).toLowerCase() : null,
|
|
@@ -115,6 +115,14 @@ module.exports = {
|
|
|
115
115
|
amount: 29.99,
|
|
116
116
|
transactionDate: '2026-03-07 14:30:00',
|
|
117
117
|
processor: 'stripe',
|
|
118
|
+
alertType: 'FRAUD',
|
|
119
|
+
customerEmail: 'test@example.com',
|
|
120
|
+
externalOrder: 'ch_test123',
|
|
121
|
+
metadata: 'pi_test456',
|
|
122
|
+
externalUrl: 'https://dashboard.stripe.com/charges/ch_test123',
|
|
123
|
+
reasonCode: 'WIP',
|
|
124
|
+
subprovider: 'Ethoca',
|
|
125
|
+
isRefunded: false,
|
|
118
126
|
});
|
|
119
127
|
|
|
120
128
|
assert.isSuccess(response, 'Should accept valid Chargeblast alert');
|
|
@@ -129,13 +137,23 @@ module.exports = {
|
|
|
129
137
|
'Status should be pending or processing',
|
|
130
138
|
);
|
|
131
139
|
|
|
132
|
-
// Verify normalized alert data
|
|
140
|
+
// Verify core normalized alert data
|
|
133
141
|
assert.equal(doc.alert.card.last4, '4242', 'Should extract last4 from full card number');
|
|
134
142
|
assert.equal(doc.alert.card.brand, 'visa', 'Should lowercase card brand');
|
|
135
143
|
assert.equal(doc.alert.amount, 29.99, 'Amount should be preserved');
|
|
136
144
|
assert.equal(doc.alert.transactionDate, '2026-03-07', 'Should extract date without time');
|
|
137
145
|
assert.equal(doc.alert.processor, 'stripe', 'Processor should be stripe');
|
|
138
146
|
|
|
147
|
+
// Verify new normalized fields
|
|
148
|
+
assert.equal(doc.alert.alertType, 'FRAUD', 'Alert type should be preserved');
|
|
149
|
+
assert.equal(doc.alert.customerEmail, 'test@example.com', 'Customer email should be preserved');
|
|
150
|
+
assert.equal(doc.alert.chargeId, 'ch_test123', 'Charge ID should be extracted from externalOrder');
|
|
151
|
+
assert.equal(doc.alert.paymentIntentId, 'pi_test456', 'Payment intent ID should be extracted from metadata');
|
|
152
|
+
assert.equal(doc.alert.stripeUrl, 'https://dashboard.stripe.com/charges/ch_test123', 'Stripe URL should be preserved');
|
|
153
|
+
assert.equal(doc.alert.reasonCode, 'WIP', 'Reason code should be preserved');
|
|
154
|
+
assert.equal(doc.alert.subprovider, 'Ethoca', 'Subprovider should be preserved');
|
|
155
|
+
assert.equal(doc.alert.isRefunded, false, 'isRefunded should be preserved');
|
|
156
|
+
|
|
139
157
|
// Verify raw payload is preserved
|
|
140
158
|
assert.ok(doc.raw, 'Raw payload should be preserved');
|
|
141
159
|
assert.equal(doc.raw.id, alertId, 'Raw id should match');
|
|
@@ -145,6 +163,73 @@ module.exports = {
|
|
|
145
163
|
},
|
|
146
164
|
},
|
|
147
165
|
|
|
166
|
+
{
|
|
167
|
+
name: 'accepts-alert-with-alertId-field',
|
|
168
|
+
auth: 'none',
|
|
169
|
+
async run({ http, assert, firestore }) {
|
|
170
|
+
const alertId = '_test-dispute-alertid-field';
|
|
171
|
+
|
|
172
|
+
// Clean up any existing doc
|
|
173
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
174
|
+
|
|
175
|
+
// Chargeblast alert.created events use alertId instead of id
|
|
176
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
177
|
+
alertId: alertId,
|
|
178
|
+
card: '546616******5805',
|
|
179
|
+
cardBrand: 'Mastercard',
|
|
180
|
+
amount: 8,
|
|
181
|
+
transactionDate: '2026-03-19 00:00:00.000000Z',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
assert.isSuccess(response, 'Should accept alert using alertId field');
|
|
185
|
+
|
|
186
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
187
|
+
assert.ok(doc, 'Dispute doc should exist in Firestore');
|
|
188
|
+
assert.equal(doc.id, alertId, 'Doc ID should match alertId');
|
|
189
|
+
assert.equal(doc.alert.id, alertId, 'Alert id should be set from alertId');
|
|
190
|
+
assert.equal(doc.alert.card.last4, '5805', 'Should extract last4 from masked card');
|
|
191
|
+
|
|
192
|
+
// Clean up
|
|
193
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
name: 'accepts-alert-without-optional-fields',
|
|
199
|
+
auth: 'none',
|
|
200
|
+
async run({ http, assert, firestore }) {
|
|
201
|
+
const alertId = '_test-dispute-minimal';
|
|
202
|
+
|
|
203
|
+
// Clean up any existing doc
|
|
204
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
205
|
+
|
|
206
|
+
// Send minimal alert (alert.created shape — no externalOrder, metadata, etc.)
|
|
207
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
208
|
+
id: alertId,
|
|
209
|
+
card: '9124',
|
|
210
|
+
amount: 10,
|
|
211
|
+
transactionDate: '2026-03-21 00:01:02',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
assert.isSuccess(response, 'Should accept minimal alert');
|
|
215
|
+
|
|
216
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
217
|
+
assert.equal(doc.alert.card.last4, '9124', 'Should use card as last4');
|
|
218
|
+
assert.equal(doc.alert.processor, 'stripe', 'Processor should default to stripe');
|
|
219
|
+
assert.equal(doc.alert.chargeId, null, 'Charge ID should be null when not provided');
|
|
220
|
+
assert.equal(doc.alert.paymentIntentId, null, 'Payment intent should be null when not provided');
|
|
221
|
+
assert.equal(doc.alert.customerEmail, null, 'Customer email should be null when not provided');
|
|
222
|
+
assert.equal(doc.alert.alertType, null, 'Alert type should be null when not provided');
|
|
223
|
+
assert.equal(doc.alert.stripeUrl, null, 'Stripe URL should be null when not provided');
|
|
224
|
+
assert.equal(doc.alert.reasonCode, null, 'Reason code should be null when not provided');
|
|
225
|
+
assert.equal(doc.alert.subprovider, null, 'Subprovider should be null when not provided');
|
|
226
|
+
assert.equal(doc.alert.isRefunded, false, 'isRefunded should default to false');
|
|
227
|
+
|
|
228
|
+
// Clean up
|
|
229
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
|
|
148
233
|
{
|
|
149
234
|
name: 'accepts-alert-with-last4-only',
|
|
150
235
|
auth: 'none',
|
|
@@ -242,7 +327,7 @@ module.exports = {
|
|
|
242
327
|
},
|
|
243
328
|
|
|
244
329
|
{
|
|
245
|
-
name: 'defaults-
|
|
330
|
+
name: 'defaults-provider-to-chargeblast',
|
|
246
331
|
auth: 'none',
|
|
247
332
|
async run({ http, assert, firestore }) {
|
|
248
333
|
const alertId = '_test-dispute-default-provider';
|
|
@@ -250,7 +335,7 @@ module.exports = {
|
|
|
250
335
|
// Clean up any existing doc
|
|
251
336
|
await firestore.delete(`payments-disputes/${alertId}`);
|
|
252
337
|
|
|
253
|
-
// Send without
|
|
338
|
+
// Send without provider query param
|
|
254
339
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
255
340
|
id: alertId,
|
|
256
341
|
card: '4242',
|
|
@@ -258,7 +343,7 @@ module.exports = {
|
|
|
258
343
|
transactionDate: '2026-01-15',
|
|
259
344
|
});
|
|
260
345
|
|
|
261
|
-
assert.isSuccess(response, 'Should accept without explicit
|
|
346
|
+
assert.isSuccess(response, 'Should accept without explicit provider param');
|
|
262
347
|
|
|
263
348
|
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
264
349
|
assert.equal(doc.provider, 'chargeblast', 'Provider should default to chargeblast');
|