@liiift-studio/sales-portal 3.1.1 → 3.1.3
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/api/getSales.js +1 -1
- package/api/utils/stripeFetcher.js +128 -75
- package/components/Insights.js +1 -1
- package/components/LoginForm.js +18 -3
- package/package.json +1 -1
package/api/getSales.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { authenticateDesigner, processSalesData } from './utils/salesDataProcessor';
|
|
3
3
|
import { sendError, requirePost } from './utils/apiResponse';
|
|
4
4
|
|
|
5
|
-
export const config = { maxDuration:
|
|
5
|
+
export const config = { maxDuration: 800 };
|
|
6
6
|
|
|
7
7
|
export default async function handler(req, res) {
|
|
8
8
|
if (!requirePost(req, res)) return;
|
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Utility functions for fetching data from Stripe API
|
|
2
|
+
* Utility functions for fetching data from Stripe API with batched secondary lookups
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { stripe } from './clients';
|
|
6
6
|
|
|
7
|
+
/** Maximum number of concurrent secondary API calls (disputes, refunds) */
|
|
8
|
+
const CONCURRENCY_LIMIT = 10;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Runs an array of async tasks with a concurrency limit
|
|
12
|
+
* @param {Array} items - Items to process
|
|
13
|
+
* @param {Function} fn - Async function to call for each item
|
|
14
|
+
* @param {number} limit - Max concurrent tasks
|
|
15
|
+
* @returns {Promise<Array>} Results in original order
|
|
16
|
+
*/
|
|
17
|
+
async function mapWithConcurrency(items, fn, limit = CONCURRENCY_LIMIT) {
|
|
18
|
+
const results = [];
|
|
19
|
+
let index = 0;
|
|
20
|
+
|
|
21
|
+
async function next() {
|
|
22
|
+
const i = index++;
|
|
23
|
+
if (i >= items.length) return;
|
|
24
|
+
results[i] = await fn(items[i], i);
|
|
25
|
+
await next();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => next()));
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
/**
|
|
8
33
|
* Fetches dispute details including all balance transactions
|
|
9
34
|
* @param {Object} dispute - Stripe dispute object
|
|
@@ -12,28 +37,24 @@ import { stripe } from './clients';
|
|
|
12
37
|
async function fetchDisputeDetails(dispute) {
|
|
13
38
|
if (!dispute) return null;
|
|
14
39
|
|
|
15
|
-
// Handle dispute as either a string ID or an object with .id
|
|
16
40
|
const disputeId = typeof dispute === 'string' ? dispute : dispute.id;
|
|
17
41
|
if (!disputeId) return null;
|
|
18
42
|
|
|
19
|
-
// Get dispute with balance transactions
|
|
20
43
|
const fullDispute = await stripe.disputes.retrieve(disputeId, {
|
|
21
44
|
expand: ['balance_transactions']
|
|
22
45
|
});
|
|
23
46
|
|
|
24
|
-
// If there's a dispute charge, get its balance transaction
|
|
25
47
|
if (fullDispute.charge) {
|
|
26
|
-
const disputeCharge = await
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
const [disputeCharge, balanceTransactions] = await Promise.all([
|
|
49
|
+
stripe.charges.retrieve(fullDispute.charge, {
|
|
50
|
+
expand: ['balance_transaction']
|
|
51
|
+
}),
|
|
52
|
+
stripe.balanceTransactions.list({
|
|
53
|
+
type: 'dispute_fee',
|
|
54
|
+
source: fullDispute.charge
|
|
55
|
+
})
|
|
56
|
+
]);
|
|
35
57
|
|
|
36
|
-
// Add balance transactions to the dispute object
|
|
37
58
|
fullDispute.charge = disputeCharge;
|
|
38
59
|
fullDispute.balance_transactions = [
|
|
39
60
|
...(fullDispute.balance_transactions || []),
|
|
@@ -44,6 +65,90 @@ async function fetchDisputeDetails(dispute) {
|
|
|
44
65
|
return fullDispute;
|
|
45
66
|
}
|
|
46
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Enriches a batch of invoices with dispute and refund balance transaction details.
|
|
70
|
+
* Uses concurrency-limited parallel calls instead of sequential per-invoice awaits.
|
|
71
|
+
* @param {Array} invoices - Raw invoices from Stripe list
|
|
72
|
+
* @returns {Promise<Array>} Enriched invoices
|
|
73
|
+
*/
|
|
74
|
+
async function enrichInvoices(invoices) {
|
|
75
|
+
return mapWithConcurrency(invoices, async (invoice) => {
|
|
76
|
+
const tasks = [];
|
|
77
|
+
|
|
78
|
+
// Dispute details
|
|
79
|
+
if (invoice.charge?.disputed) {
|
|
80
|
+
tasks.push(
|
|
81
|
+
fetchDisputeDetails(invoice.charge.dispute).then(d => {
|
|
82
|
+
invoice.charge.dispute = d;
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Refund balance transactions — batch all refunds for this invoice into one Promise.all
|
|
88
|
+
if (invoice.charge?.refunds?.data?.length) {
|
|
89
|
+
tasks.push(
|
|
90
|
+
Promise.all(
|
|
91
|
+
invoice.charge.refunds.data.map(refund =>
|
|
92
|
+
stripe.refunds.retrieve(refund.id, { expand: ['balance_transaction'] })
|
|
93
|
+
)
|
|
94
|
+
).then(fullRefunds => {
|
|
95
|
+
invoice.charge.refunds.data = fullRefunds;
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Only re-fetch payment_intent if Stripe returned a bare string ID (not expanded)
|
|
101
|
+
if (typeof invoice.payment_intent === 'string') {
|
|
102
|
+
tasks.push(
|
|
103
|
+
stripe.paymentIntents.retrieve(invoice.payment_intent, {
|
|
104
|
+
expand: ['payment_method']
|
|
105
|
+
}).then(pi => {
|
|
106
|
+
invoice.payment_intent = pi;
|
|
107
|
+
}).catch(err => {
|
|
108
|
+
console.warn(`Could not expand payment intent for invoice ${invoice.id}:`, err.message);
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (tasks.length) await Promise.all(tasks);
|
|
114
|
+
return invoice;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Enriches a batch of payment intents with dispute and refund balance transaction details.
|
|
120
|
+
* @param {Array} payments - Raw payment intents from Stripe list
|
|
121
|
+
* @returns {Promise<Array>} Enriched payment intents
|
|
122
|
+
*/
|
|
123
|
+
async function enrichPaymentIntents(payments) {
|
|
124
|
+
return mapWithConcurrency(payments, async (payment) => {
|
|
125
|
+
const tasks = [];
|
|
126
|
+
|
|
127
|
+
if (payment.latest_charge?.disputed) {
|
|
128
|
+
tasks.push(
|
|
129
|
+
fetchDisputeDetails(payment.latest_charge.dispute).then(d => {
|
|
130
|
+
payment.latest_charge.dispute = d;
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (payment.latest_charge?.refunds?.data?.length) {
|
|
136
|
+
tasks.push(
|
|
137
|
+
Promise.all(
|
|
138
|
+
payment.latest_charge.refunds.data.map(refund =>
|
|
139
|
+
stripe.refunds.retrieve(refund.id, { expand: ['balance_transaction'] })
|
|
140
|
+
)
|
|
141
|
+
).then(fullRefunds => {
|
|
142
|
+
payment.latest_charge.refunds.data = fullRefunds;
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (tasks.length) await Promise.all(tasks);
|
|
148
|
+
return payment;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
47
152
|
/**
|
|
48
153
|
* Fetches all pages of Stripe invoices for a given date range
|
|
49
154
|
* @param {Object} timeRange - Stripe-compatible date range
|
|
@@ -88,45 +193,13 @@ export async function fetchAllInvoices(timeRange, options = {}) {
|
|
|
88
193
|
? response.data.filter(invoice => invoice?.metadata?.authors?.includes(options.designerId))
|
|
89
194
|
: response.data;
|
|
90
195
|
|
|
91
|
-
//
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// For each refund, fetch its balance transaction
|
|
98
|
-
if (invoice.charge?.refunds?.data?.length) {
|
|
99
|
-
const refundsWithTransactions = await Promise.all(
|
|
100
|
-
invoice.charge.refunds.data.map(async refund => {
|
|
101
|
-
const fullRefund = await stripe.refunds.retrieve(refund.id, {
|
|
102
|
-
expand: ['balance_transaction']
|
|
103
|
-
});
|
|
104
|
-
return fullRefund;
|
|
105
|
-
})
|
|
106
|
-
);
|
|
107
|
-
invoice.charge.refunds.data = refundsWithTransactions;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// If payment_intent is a string, try to fetch the full object
|
|
111
|
-
if (invoice.payment_intent && typeof invoice.payment_intent === 'string') {
|
|
112
|
-
try {
|
|
113
|
-
const paymentIntent = await stripe.paymentIntents.retrieve(invoice.payment_intent, {
|
|
114
|
-
expand: ['payment_method']
|
|
115
|
-
});
|
|
116
|
-
invoice.payment_intent = paymentIntent;
|
|
117
|
-
} catch (error) {
|
|
118
|
-
console.warn(`Could not expand payment intent for invoice ${invoice.id}:`, error.message);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return invoice;
|
|
123
|
-
}));
|
|
124
|
-
|
|
125
|
-
allInvoices = allInvoices.concat(processedInvoices);
|
|
196
|
+
// Enrich invoices with dispute/refund details (batched, concurrency-limited)
|
|
197
|
+
const enriched = await enrichInvoices(invoices);
|
|
198
|
+
allInvoices = allInvoices.concat(enriched);
|
|
199
|
+
|
|
126
200
|
lastId = response.data[response.data.length - 1].id;
|
|
127
201
|
hasMore = response.has_more;
|
|
128
202
|
pageCount++;
|
|
129
|
-
|
|
130
203
|
} else {
|
|
131
204
|
hasMore = false;
|
|
132
205
|
}
|
|
@@ -179,36 +252,16 @@ export async function fetchAllPaymentIntents(timeRange, options = {}) {
|
|
|
179
252
|
// Filter succeeded payments and by designer if needed
|
|
180
253
|
const payments = response.data
|
|
181
254
|
.filter(pi => pi.status === 'succeeded')
|
|
182
|
-
.filter(pi => !options.filterByDesigner || !options.designerId ||
|
|
255
|
+
.filter(pi => !options.filterByDesigner || !options.designerId ||
|
|
183
256
|
pi?.metadata?.authors?.includes(options.designerId));
|
|
184
257
|
|
|
185
|
-
//
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// For each refund, fetch its balance transaction
|
|
192
|
-
if (payment.latest_charge?.refunds?.data?.length) {
|
|
193
|
-
const refundsWithTransactions = await Promise.all(
|
|
194
|
-
payment.latest_charge.refunds.data.map(async refund => {
|
|
195
|
-
const fullRefund = await stripe.refunds.retrieve(refund.id, {
|
|
196
|
-
expand: ['balance_transaction']
|
|
197
|
-
});
|
|
198
|
-
return fullRefund;
|
|
199
|
-
})
|
|
200
|
-
);
|
|
201
|
-
payment.latest_charge.refunds.data = refundsWithTransactions;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return payment;
|
|
205
|
-
}));
|
|
206
|
-
|
|
207
|
-
allPayments = allPayments.concat(processedPayments);
|
|
258
|
+
// Enrich payments with dispute/refund details (batched, concurrency-limited)
|
|
259
|
+
const enriched = await enrichPaymentIntents(payments);
|
|
260
|
+
allPayments = allPayments.concat(enriched);
|
|
261
|
+
|
|
208
262
|
lastId = response.data[response.data.length - 1].id;
|
|
209
263
|
hasMore = response.has_more;
|
|
210
264
|
pageCount++;
|
|
211
|
-
|
|
212
265
|
} else {
|
|
213
266
|
hasMore = false;
|
|
214
267
|
}
|
package/components/Insights.js
CHANGED
|
@@ -259,7 +259,7 @@ export default function Insights({
|
|
|
259
259
|
|
|
260
260
|
{/* Metric cards */}
|
|
261
261
|
{metricCards.map((card, index) => (
|
|
262
|
-
<Box key={`metric-${index}`} sx={{
|
|
262
|
+
<Box key={`metric-${index}`} sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)', md: '1 1 calc(33.33% - 11px)' } }}>
|
|
263
263
|
<Tooltip
|
|
264
264
|
title={card.tooltip}
|
|
265
265
|
placement="top"
|
package/components/LoginForm.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Login form component for sales portal authentication
|
|
2
2
|
import React, { useEffect, useState } from 'react';
|
|
3
|
-
import { Typography, Input, Button, Box } from '@mui/material';
|
|
3
|
+
import { Typography, Input, Button, Box, IconButton, InputAdornment } from '@mui/material';
|
|
4
|
+
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
5
|
+
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
|
4
6
|
import packageJson from '../package.json';
|
|
5
7
|
|
|
6
8
|
const { version } = packageJson;
|
|
@@ -27,6 +29,7 @@ export default function LoginForm({
|
|
|
27
29
|
const [password, setPassword] = useState('');
|
|
28
30
|
const [message, setMessage] = useState('');
|
|
29
31
|
const [loading, setLoading] = useState(false);
|
|
32
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
30
33
|
|
|
31
34
|
const {
|
|
32
35
|
title = 'Sales Portal',
|
|
@@ -128,7 +131,7 @@ export default function LoginForm({
|
|
|
128
131
|
{title}
|
|
129
132
|
</Typography>
|
|
130
133
|
|
|
131
|
-
<Box sx={{ px: { xs:
|
|
134
|
+
<Box sx={{ px: { xs: 0, sm: 6 } }}>
|
|
132
135
|
<Input
|
|
133
136
|
placeholder="Email"
|
|
134
137
|
onChange={(e) => setUser(e.target.value)}
|
|
@@ -160,9 +163,21 @@ export default function LoginForm({
|
|
|
160
163
|
placeholder="Password"
|
|
161
164
|
onChange={(e) => setPassword(e.target.value)}
|
|
162
165
|
value={password}
|
|
163
|
-
type=
|
|
166
|
+
type={showPassword ? 'text' : 'password'}
|
|
164
167
|
autoComplete="current-password"
|
|
165
168
|
onKeyPress={handleKeyPress}
|
|
169
|
+
endAdornment={
|
|
170
|
+
<InputAdornment position="end">
|
|
171
|
+
<IconButton
|
|
172
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
173
|
+
edge="end"
|
|
174
|
+
sx={{ color: 'var(--grey800, #666)', mr: 0.5 }}
|
|
175
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
176
|
+
>
|
|
177
|
+
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
|
178
|
+
</IconButton>
|
|
179
|
+
</InputAdornment>
|
|
180
|
+
}
|
|
166
181
|
sx={{
|
|
167
182
|
m: 0,
|
|
168
183
|
border: 'none',
|