@liiift-studio/sales-portal 2.0.0 → 2.3.0
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/getBalanceTransactions.js +42 -6
- package/api/utils/processors/invoiceProcessor.js +6 -2
- package/api/utils/processors/paymentProcessor.js +6 -2
- package/components/DateRangeSalesTable.js +58 -60
- package/components/LoginForm.js +31 -36
- package/components/Sales.js +2 -1
- package/components/SalesPortalPage.js +1 -1
- package/components/SalesTable.js +56 -57
- package/hooks/__tests__/useReconciliation.test.js +21 -16
- package/hooks/calculateGrossSales.js +12 -4
- package/hooks/useReconciliation.js +106 -10
- package/package.json +1 -1
- package/utils/currencyUtils.js +57 -32
|
@@ -22,22 +22,46 @@ export default async function handler(req, res) {
|
|
|
22
22
|
// Fetch all balance transactions for the period
|
|
23
23
|
const balanceTransactions = await fetchAllBalanceTransactions(timeRange);
|
|
24
24
|
|
|
25
|
-
//
|
|
25
|
+
// Build detailed reconciliation data
|
|
26
26
|
const RECONCILIATION_TYPES = ['charge', 'refund'];
|
|
27
|
-
let
|
|
27
|
+
let rawTotal = 0; // Accumulate as float, round once at the end
|
|
28
|
+
let chargeCount = 0;
|
|
29
|
+
let refundCount = 0;
|
|
30
|
+
let chargeTotal = 0;
|
|
31
|
+
let refundTotal = 0;
|
|
32
|
+
let settlementCurrency = null;
|
|
33
|
+
let hasCurrencyConversion = false;
|
|
34
|
+
let pendingCount = 0;
|
|
28
35
|
const flaggedTransactions = [];
|
|
29
36
|
|
|
30
37
|
balanceTransactions.forEach(transaction => {
|
|
31
|
-
if (transaction.
|
|
38
|
+
if (!settlementCurrency && transaction.currency) {
|
|
39
|
+
settlementCurrency = transaction.currency;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (transaction.status === 'pending') {
|
|
43
|
+
pendingCount++;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rate = transaction.exchange_rate || 1;
|
|
48
|
+
if (rate !== 1) hasCurrencyConversion = true;
|
|
49
|
+
const originalAmount = transaction.amount / rate;
|
|
32
50
|
|
|
33
51
|
if (RECONCILIATION_TYPES.includes(transaction.type)) {
|
|
34
|
-
|
|
52
|
+
rawTotal += originalAmount;
|
|
53
|
+
if (transaction.type === 'charge') {
|
|
54
|
+
chargeCount++;
|
|
55
|
+
chargeTotal += originalAmount;
|
|
56
|
+
} else {
|
|
57
|
+
refundCount++;
|
|
58
|
+
refundTotal += originalAmount;
|
|
59
|
+
}
|
|
35
60
|
} else if (transaction.type !== 'payout' && transaction.type !== 'stripe_fee') {
|
|
36
|
-
// Collect disputes, adjustments, connect transfers for review
|
|
37
61
|
flaggedTransactions.push({
|
|
38
62
|
id: transaction.id,
|
|
39
63
|
type: transaction.type,
|
|
40
|
-
amount:
|
|
64
|
+
amount: Math.round(originalAmount),
|
|
41
65
|
created: transaction.created,
|
|
42
66
|
description: transaction.description,
|
|
43
67
|
source: transaction.source,
|
|
@@ -45,11 +69,23 @@ export default async function handler(req, res) {
|
|
|
45
69
|
}
|
|
46
70
|
});
|
|
47
71
|
|
|
72
|
+
// Round the accumulated total once to minimize rounding error
|
|
73
|
+
const totalBalanceChange = Math.round(rawTotal);
|
|
74
|
+
|
|
48
75
|
return res.status(200).json({
|
|
49
76
|
success: true,
|
|
50
77
|
data: {
|
|
51
78
|
totalBalanceChange,
|
|
52
79
|
flaggedTransactions,
|
|
80
|
+
diagnostics: {
|
|
81
|
+
chargeCount,
|
|
82
|
+
refundCount,
|
|
83
|
+
chargeTotal: Math.round(chargeTotal),
|
|
84
|
+
refundTotal: Math.round(refundTotal),
|
|
85
|
+
pendingCount,
|
|
86
|
+
settlementCurrency,
|
|
87
|
+
hasCurrencyConversion,
|
|
88
|
+
},
|
|
53
89
|
transactions: balanceTransactions
|
|
54
90
|
}
|
|
55
91
|
});
|
|
@@ -94,6 +94,7 @@ function processLineItem({ line, invoice, associatedOrder, associatedTypeface, i
|
|
|
94
94
|
disputeDeduction: disputeAmount || 0,
|
|
95
95
|
invoiceTotal: invoice.total_excluding_tax,
|
|
96
96
|
invoiceTotalWithTax: invoice.total,
|
|
97
|
+
chargeAmount: invoice.charge?.amount || invoice.total,
|
|
97
98
|
discountAmounts: line.discount_amounts,
|
|
98
99
|
taxAmounts: line.tax_amounts,
|
|
99
100
|
stripeFees: totalFees,
|
|
@@ -140,7 +141,8 @@ function processLineItem({ line, invoice, associatedOrder, associatedTypeface, i
|
|
|
140
141
|
orderNumber: associatedOrder?.orderNumber || null,
|
|
141
142
|
author: associatedTypeface?.author || null,
|
|
142
143
|
typeface: associatedTypeface || null,
|
|
143
|
-
saleType: "invoice"
|
|
144
|
+
saleType: "invoice",
|
|
145
|
+
currency: invoice.currency || 'usd'
|
|
144
146
|
};
|
|
145
147
|
}
|
|
146
148
|
|
|
@@ -177,6 +179,7 @@ function processShippingCost({ invoice, associatedOrder, totalFee = null, paymen
|
|
|
177
179
|
disputeDeduction: disputeAmount || 0,
|
|
178
180
|
invoiceTotal: invoice.total_excluding_tax,
|
|
179
181
|
invoiceTotalWithTax: invoice.total,
|
|
182
|
+
chargeAmount: invoice.charge?.amount || invoice.total,
|
|
180
183
|
discountAmounts: null,
|
|
181
184
|
stripeFees: totalFees,
|
|
182
185
|
disputed,
|
|
@@ -230,7 +233,8 @@ function processShippingCost({ invoice, associatedOrder, totalFee = null, paymen
|
|
|
230
233
|
orderNumber: associatedOrder?.orderNumber || null,
|
|
231
234
|
author: null,
|
|
232
235
|
typeface: null,
|
|
233
|
-
saleType: "invoice"
|
|
236
|
+
saleType: "invoice",
|
|
237
|
+
currency: invoice.currency || 'usd'
|
|
234
238
|
};
|
|
235
239
|
}
|
|
236
240
|
|
|
@@ -235,6 +235,7 @@ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTyp
|
|
|
235
235
|
disputeDeduction: disputeAmount || 0,
|
|
236
236
|
invoiceTotal: payment.amount,
|
|
237
237
|
invoiceTotalWithTax: payment.amount,
|
|
238
|
+
chargeAmount: payment.latest_charge?.amount || payment.amount,
|
|
238
239
|
discountAmounts: [],
|
|
239
240
|
stripeFees: totalFees,
|
|
240
241
|
disputed,
|
|
@@ -283,7 +284,8 @@ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTyp
|
|
|
283
284
|
orderNumber: associatedOrder?.orderNumber || null,
|
|
284
285
|
author: associatedTypeface?.author || null,
|
|
285
286
|
typeface: associatedTypeface || null,
|
|
286
|
-
saleType: "paymentIntent"
|
|
287
|
+
saleType: "paymentIntent",
|
|
288
|
+
currency: payment.currency || 'usd'
|
|
287
289
|
};
|
|
288
290
|
}
|
|
289
291
|
|
|
@@ -319,6 +321,7 @@ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
|
|
|
319
321
|
disputeDeduction: disputeAmount || 0,
|
|
320
322
|
invoiceTotal: payment.amount,
|
|
321
323
|
invoiceTotalWithTax: payment.amount,
|
|
324
|
+
chargeAmount: payment.latest_charge?.amount || payment.amount,
|
|
322
325
|
discountAmounts: [],
|
|
323
326
|
stripeFees: totalFees,
|
|
324
327
|
disputed,
|
|
@@ -359,7 +362,8 @@ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
|
|
|
359
362
|
orderNumber: associatedOrder?.orderNumber || null,
|
|
360
363
|
author: null,
|
|
361
364
|
typeface: null,
|
|
362
|
-
saleType: "paymentIntent"
|
|
365
|
+
saleType: "paymentIntent",
|
|
366
|
+
currency: payment.currency || 'usd'
|
|
363
367
|
};
|
|
364
368
|
}
|
|
365
369
|
|
|
@@ -505,25 +505,21 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
|
|
|
505
505
|
{/* Reconciliation Check - Only show for admin users */}
|
|
506
506
|
{admin && dateRangeSales.length > 0 && (
|
|
507
507
|
<Box sx={{ width: '100%', mb: 2 }}>
|
|
508
|
-
<Box
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
'& *': { filter: 'invert(0) !important' },
|
|
524
|
-
}}
|
|
525
|
-
>
|
|
526
|
-
|
|
508
|
+
<Box sx={{
|
|
509
|
+
display: 'inline-block',
|
|
510
|
+
p: 2,
|
|
511
|
+
ml: -2,
|
|
512
|
+
borderRadius: '4px',
|
|
513
|
+
border: reconciliationData.isReconciled
|
|
514
|
+
? '2px solid var(--green, green)'
|
|
515
|
+
: '2px solid var(--red, red)',
|
|
516
|
+
color: reconciliationData.isReconciled
|
|
517
|
+
? 'var(--green, green)'
|
|
518
|
+
: 'var(--red, red)',
|
|
519
|
+
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
|
520
|
+
filter: 'invert(0) !important',
|
|
521
|
+
'& *': { filter: 'invert(0) !important' },
|
|
522
|
+
}}>
|
|
527
523
|
{reconciliationData.error ? (
|
|
528
524
|
<Typography variant="body1" sx={{ color: 'var(--red, red)' }}>
|
|
529
525
|
<WarningIcon fontSize="small" sx={{ mr: 1 }} />
|
|
@@ -532,37 +528,24 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
|
|
|
532
528
|
) : reconciliationData.isLoading ? (
|
|
533
529
|
<Typography variant="body1"><CircularProgress size={24} sx={{ mr: 1 }} /> Checking reconciliation...</Typography>
|
|
534
530
|
) : reconciliationData.isReconciled ? (
|
|
535
|
-
<Typography variant="body1" sx={{ fontWeight: 'bold'}}>
|
|
531
|
+
<Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
536
532
|
<CheckCircleIcon /> Reconciled
|
|
533
|
+
{reconciliationData.percentOff !== 0 && (
|
|
534
|
+
<span style={{ fontWeight: 'normal', opacity: 0.7 }}>
|
|
535
|
+
({Math.abs(reconciliationData.percentOff).toFixed(2)}% variance)
|
|
536
|
+
</span>
|
|
537
|
+
)}
|
|
537
538
|
</Typography>
|
|
538
539
|
) : (
|
|
539
|
-
<
|
|
540
|
-
<
|
|
541
|
-
|
|
542
|
-
<WarningIcon/> Needs Reconciliation (Difference: ${(Math.abs(reconciliationData.difference) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })})
|
|
543
|
-
</Typography>
|
|
544
|
-
</Tooltip>
|
|
545
|
-
<Tooltip
|
|
546
|
-
title={
|
|
547
|
-
<React.Fragment>
|
|
548
|
-
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>Possible causes:</Typography>
|
|
549
|
-
<Typography variant="body2">• This could be due to a sale being refunded outside your selected date range. Check for the same difference in other periods.</Typography>
|
|
550
|
-
</React.Fragment>
|
|
551
|
-
}
|
|
552
|
-
placement="right"
|
|
553
|
-
arrow
|
|
554
|
-
>
|
|
555
|
-
<IconButton size="small" sx={{ ml: 1, color: 'inherit' }}>
|
|
556
|
-
<InfoIcon fontSize="small" />
|
|
557
|
-
</IconButton>
|
|
558
|
-
</Tooltip>
|
|
559
|
-
</Box>
|
|
540
|
+
<Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
541
|
+
<WarningIcon /> Needs Reconciliation — ${(Math.abs(reconciliationData.difference) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} off ({Math.abs(reconciliationData.percentOff).toFixed(2)}%)
|
|
542
|
+
</Typography>
|
|
560
543
|
)}
|
|
561
|
-
|
|
562
|
-
{!reconciliationData.isLoading && (
|
|
544
|
+
|
|
545
|
+
{!reconciliationData.isLoading && !reconciliationData.error && (
|
|
563
546
|
<>
|
|
564
547
|
<Typography variant="body1">
|
|
565
|
-
<strong>Stripe Balance
|
|
548
|
+
<strong>Stripe Balance:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
566
549
|
</Typography>
|
|
567
550
|
<Typography variant="body1">
|
|
568
551
|
<strong>Calculated Gross:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
@@ -570,24 +553,39 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
|
|
|
570
553
|
<span style={{ opacity: 0.6 }}> (incl. ${(reconciliationData.shippingTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)</span>
|
|
571
554
|
)}
|
|
572
555
|
</Typography>
|
|
573
|
-
</>
|
|
574
|
-
)}
|
|
575
556
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
557
|
+
{reconciliationData.checks?.length > 0 && (
|
|
558
|
+
<Box sx={{ mt: 2, borderTop: '1px solid currentColor', pt: 1, opacity: 0.9 }}>
|
|
559
|
+
{reconciliationData.checks.map((check, i) => (
|
|
560
|
+
<Tooltip key={i} title={check.tip || ''} placement="right" arrow>
|
|
561
|
+
<Typography variant="body2" sx={{
|
|
562
|
+
display: 'flex', alignItems: 'center', gap: 0.5, py: 0.25,
|
|
563
|
+
cursor: check.tip ? 'help' : 'default',
|
|
564
|
+
}}>
|
|
565
|
+
<span style={{ fontSize: '0.9em' }}>
|
|
566
|
+
{check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : check.status === 'warn' ? '⚠' : 'ℹ'}
|
|
567
|
+
</span>
|
|
568
|
+
<strong>{check.label}:</strong> {check.detail}
|
|
569
|
+
</Typography>
|
|
570
|
+
</Tooltip>
|
|
571
|
+
))}
|
|
572
|
+
</Box>
|
|
573
|
+
)}
|
|
590
574
|
|
|
575
|
+
{reconciliationData.flaggedTransactions?.length > 0 && (
|
|
576
|
+
<Box sx={{ mt: 1 }}>
|
|
577
|
+
<Button
|
|
578
|
+
size="small"
|
|
579
|
+
startIcon={<InfoIcon />}
|
|
580
|
+
onClick={() => setFlaggedOpen(true)}
|
|
581
|
+
sx={{ color: 'inherit', textTransform: 'none' }}
|
|
582
|
+
>
|
|
583
|
+
View {reconciliationData.flaggedTransactions.length} flagged transaction{reconciliationData.flaggedTransactions.length !== 1 ? 's' : ''}
|
|
584
|
+
</Button>
|
|
585
|
+
</Box>
|
|
586
|
+
)}
|
|
587
|
+
</>
|
|
588
|
+
)}
|
|
591
589
|
</Box>
|
|
592
590
|
</Box>
|
|
593
591
|
)}
|
package/components/LoginForm.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
// Login form
|
|
1
|
+
// Login form component for sales portal authentication
|
|
2
2
|
import React, { useEffect, useState } from 'react';
|
|
3
|
-
import { Typography, Input, Button, Box
|
|
3
|
+
import { Typography, Input, Button, Box } from '@mui/material';
|
|
4
4
|
import packageJson from '../package.json';
|
|
5
5
|
|
|
6
6
|
const { version } = packageJson;
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* LoginForm
|
|
9
|
+
* LoginForm component for designer authentication
|
|
10
|
+
* Renders as an inline centered card (not a modal overlay) so the
|
|
11
|
+
* site header and footer remain accessible.
|
|
10
12
|
* @param {Object} props Component props
|
|
11
|
-
* @param {boolean} props.open Whether the
|
|
12
|
-
* @param {Function} props.
|
|
13
|
-
* @param {Function} props.
|
|
14
|
-
* @param {
|
|
15
|
-
* @param {
|
|
16
|
-
* @param {Object} props.texts Optional text customization { title, description, subtitle, buttonLabel }
|
|
13
|
+
* @param {boolean} props.open Whether the form is visible
|
|
14
|
+
* @param {Function} props.onLoginSuccess Callback when login succeeds
|
|
15
|
+
* @param {Function} props.renderLoader Optional render prop for custom loader
|
|
16
|
+
* @param {string} props.apiEndpoint Optional custom API endpoint
|
|
17
|
+
* @param {Object} props.texts Optional text customization
|
|
17
18
|
*/
|
|
18
19
|
export default function LoginForm({
|
|
19
20
|
open = true,
|
|
20
|
-
onClose,
|
|
21
21
|
onLoginSuccess,
|
|
22
22
|
renderLoader,
|
|
23
23
|
apiEndpoint = '/api/sales-portal/getDesignerInfo',
|
|
@@ -72,7 +72,7 @@ export default function LoginForm({
|
|
|
72
72
|
}
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
// Clear message when inputs change or
|
|
75
|
+
// Clear message when inputs change or form reopens
|
|
76
76
|
useEffect(() => {
|
|
77
77
|
setMessage('');
|
|
78
78
|
}, [password, user]);
|
|
@@ -88,32 +88,27 @@ export default function LoginForm({
|
|
|
88
88
|
}
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
if (!open) return null;
|
|
92
|
+
|
|
91
93
|
return (
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
overflow: 'hidden',
|
|
101
|
-
m: { xs: 2, sm: 4 },
|
|
102
|
-
maxHeight: 'calc(100vh - 64px)',
|
|
103
|
-
}
|
|
104
|
-
}}
|
|
105
|
-
slotProps={{
|
|
106
|
-
backdrop: {
|
|
107
|
-
sx: {
|
|
108
|
-
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
109
|
-
backdropFilter: 'blur(4px)',
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}}
|
|
113
|
-
>
|
|
94
|
+
<Box sx={{
|
|
95
|
+
display: 'flex',
|
|
96
|
+
justifyContent: 'center',
|
|
97
|
+
alignItems: 'center',
|
|
98
|
+
minHeight: 'calc(100vh - var(--headerHeight, 80px))',
|
|
99
|
+
px: { xs: 2, sm: 4 },
|
|
100
|
+
py: { xs: 4, sm: 8 },
|
|
101
|
+
}}>
|
|
114
102
|
{loading && renderLoader && renderLoader({ fill: true, position: 'fixed', height: '100%' })}
|
|
115
103
|
|
|
116
|
-
<
|
|
104
|
+
<Box sx={{
|
|
105
|
+
width: '100%',
|
|
106
|
+
maxWidth: '860px',
|
|
107
|
+
borderRadius: '12px',
|
|
108
|
+
overflow: 'hidden',
|
|
109
|
+
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
|
|
110
|
+
bgcolor: 'var(--white, white)',
|
|
111
|
+
}}>
|
|
117
112
|
<Box sx={{
|
|
118
113
|
display: 'flex',
|
|
119
114
|
flexDirection: { xs: 'column', sm: 'row' },
|
|
@@ -256,7 +251,7 @@ export default function LoginForm({
|
|
|
256
251
|
</Box>
|
|
257
252
|
</Box>
|
|
258
253
|
</Box>
|
|
259
|
-
</
|
|
260
|
-
</
|
|
254
|
+
</Box>
|
|
255
|
+
</Box>
|
|
261
256
|
);
|
|
262
257
|
}
|
package/components/Sales.js
CHANGED
|
@@ -15,6 +15,7 @@ import dayjs from 'dayjs';
|
|
|
15
15
|
import utc from 'dayjs/plugin/utc';
|
|
16
16
|
import { processChartData, processTypefaceData, processDesignersData, generateSeriesData, processLicenseTypeData } from '../utils/salesDataProcessing';
|
|
17
17
|
import countryCodes from '../data/countryCode.json';
|
|
18
|
+
import { getCurrencySymbol, getCurrencyLabel } from '../utils/currencyUtils';
|
|
18
19
|
import { SalesTable } from './SalesTable';
|
|
19
20
|
import { DateRangeSalesTable } from './DateRangeSalesTable';
|
|
20
21
|
import SalesChart from './SalesChart';
|
|
@@ -522,7 +523,7 @@ export default function Sales(props) {
|
|
|
522
523
|
padding: "5px 10px 0 10px",
|
|
523
524
|
}}
|
|
524
525
|
>
|
|
525
|
-
{sales ? <Box component="span" sx={{ fontVariationSettings: '"wght" 300' }}><Box component="span" sx={{ display: { xs: 'none', md: 'inherit' }, opacity: "0.5" }}>
|
|
526
|
+
{sales ? <Box component="span" sx={{ fontVariationSettings: '"wght" 300' }}><Box component="span" sx={{ display: { xs: 'none', md: 'inherit' }, opacity: "0.5" }}>{getCurrencyLabel(sales[0]?.currency)}</Box>{getCurrencySymbol(sales[0]?.currency)} </Box> : ``}
|
|
526
527
|
{sales ? (
|
|
527
528
|
<>
|
|
528
529
|
<span className={`sales-total`} style={{ fontVariationSettings: '"wght" 900' }}>
|
|
@@ -67,7 +67,7 @@ export default function SalesPortalPage({
|
|
|
67
67
|
const isLoggedIn = !!designers;
|
|
68
68
|
|
|
69
69
|
const content = (
|
|
70
|
-
<Box sx={{ minHeight: '100vh' }}>
|
|
70
|
+
<Box sx={{ minHeight: 'calc(100vh - var(--headerHeight, 80px))' }}>
|
|
71
71
|
{/* Login Modal */}
|
|
72
72
|
<LoginForm
|
|
73
73
|
open={!isLoggedIn}
|
package/components/SalesTable.js
CHANGED
|
@@ -162,58 +162,41 @@ export function SalesTable({ sales = [], designer = {}, admin = false, loading =
|
|
|
162
162
|
sx={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}
|
|
163
163
|
>
|
|
164
164
|
{/* Reconciliation Check - Only show for admin users */}
|
|
165
|
-
{admin && !reconciliationData.error && sales.length > 0 &&
|
|
165
|
+
{admin && !reconciliationData.error && sales.length > 0 && (
|
|
166
166
|
<Box sx={{ width: '100%', mb: 2 }}>
|
|
167
|
-
<Box
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: 'var(--red, red)',
|
|
180
|
-
}}
|
|
181
|
-
>
|
|
167
|
+
<Box sx={{
|
|
168
|
+
display: 'inline-block',
|
|
169
|
+
p: 2,
|
|
170
|
+
mt: '20px',
|
|
171
|
+
borderRadius: '4px',
|
|
172
|
+
border: reconciliationData.isReconciled
|
|
173
|
+
? '2px solid var(--green, green)'
|
|
174
|
+
: '2px solid var(--red, red)',
|
|
175
|
+
color: reconciliationData.isReconciled
|
|
176
|
+
? 'var(--green, green)'
|
|
177
|
+
: 'var(--red, red)',
|
|
178
|
+
}}>
|
|
182
179
|
{reconciliationData.isLoading ? (
|
|
183
180
|
<Typography variant="body1"><CircularProgress size={24} /> Checking reconciliation...</Typography>
|
|
184
181
|
) : reconciliationData.isReconciled ? (
|
|
185
|
-
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>
|
|
182
|
+
<Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
186
183
|
<CheckCircleIcon /> Reconciled
|
|
184
|
+
{reconciliationData.percentOff !== 0 && (
|
|
185
|
+
<span style={{ fontWeight: 'normal', opacity: 0.7 }}>
|
|
186
|
+
({Math.abs(reconciliationData.percentOff).toFixed(2)}% variance)
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
187
189
|
</Typography>
|
|
188
190
|
) : (
|
|
189
|
-
<
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
<WarningIcon/> Needs Reconciliation (Difference: ${(Math.abs(reconciliationData.difference) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })})
|
|
193
|
-
</Typography>
|
|
194
|
-
</Tooltip>
|
|
195
|
-
<Tooltip
|
|
196
|
-
title={
|
|
197
|
-
<React.Fragment>
|
|
198
|
-
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>Possible causes:</Typography>
|
|
199
|
-
<Typography variant="body2">• This could be due to a sale being refunded in another month. Check for the same difference either the prior or next month.</Typography>
|
|
200
|
-
<Typography variant="body2">• If a payment was approved after the invoice was finalized, there might be a delayed payment that shows up next month.</Typography>
|
|
201
|
-
</React.Fragment>
|
|
202
|
-
}
|
|
203
|
-
placement="right"
|
|
204
|
-
arrow
|
|
205
|
-
>
|
|
206
|
-
<IconButton size="small" sx={{ ml: 1, color: 'inherit' }}>
|
|
207
|
-
<InfoIcon fontSize="small" />
|
|
208
|
-
</IconButton>
|
|
209
|
-
</Tooltip>
|
|
210
|
-
</Box>
|
|
191
|
+
<Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
192
|
+
<WarningIcon /> Needs Reconciliation — ${(Math.abs(reconciliationData.difference) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} off ({Math.abs(reconciliationData.percentOff).toFixed(2)}%)
|
|
193
|
+
</Typography>
|
|
211
194
|
)}
|
|
212
|
-
|
|
195
|
+
|
|
213
196
|
{!reconciliationData.isLoading && (
|
|
214
197
|
<>
|
|
215
198
|
<Typography variant="body1">
|
|
216
|
-
<strong>Stripe Balance
|
|
199
|
+
<strong>Stripe Balance:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
217
200
|
</Typography>
|
|
218
201
|
<Typography variant="body1">
|
|
219
202
|
<strong>Calculated Gross:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
@@ -221,24 +204,40 @@ export function SalesTable({ sales = [], designer = {}, admin = false, loading =
|
|
|
221
204
|
<span style={{ opacity: 0.6 }}> (incl. ${(reconciliationData.shippingTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)</span>
|
|
222
205
|
)}
|
|
223
206
|
</Typography>
|
|
224
|
-
</>
|
|
225
|
-
)}
|
|
226
207
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
208
|
+
{/* Diagnostic checks */}
|
|
209
|
+
{reconciliationData.checks?.length > 0 && (
|
|
210
|
+
<Box sx={{ mt: 2, borderTop: '1px solid currentColor', pt: 1, opacity: 0.9 }}>
|
|
211
|
+
{reconciliationData.checks.map((check, i) => (
|
|
212
|
+
<Tooltip key={i} title={check.tip || ''} placement="right" arrow>
|
|
213
|
+
<Typography variant="body2" sx={{
|
|
214
|
+
display: 'flex', alignItems: 'center', gap: 0.5, py: 0.25,
|
|
215
|
+
cursor: check.tip ? 'help' : 'default',
|
|
216
|
+
}}>
|
|
217
|
+
<span style={{ fontSize: '0.9em' }}>
|
|
218
|
+
{check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : check.status === 'warn' ? '⚠' : 'ℹ'}
|
|
219
|
+
</span>
|
|
220
|
+
<strong>{check.label}:</strong> {check.detail}
|
|
221
|
+
</Typography>
|
|
222
|
+
</Tooltip>
|
|
223
|
+
))}
|
|
224
|
+
</Box>
|
|
225
|
+
)}
|
|
241
226
|
|
|
227
|
+
{reconciliationData.flaggedTransactions?.length > 0 && (
|
|
228
|
+
<Box sx={{ mt: 1 }}>
|
|
229
|
+
<Button
|
|
230
|
+
size="small"
|
|
231
|
+
startIcon={<InfoIcon />}
|
|
232
|
+
onClick={() => setFlaggedOpen(true)}
|
|
233
|
+
sx={{ color: 'inherit', textTransform: 'none' }}
|
|
234
|
+
>
|
|
235
|
+
View {reconciliationData.flaggedTransactions.length} flagged transaction{reconciliationData.flaggedTransactions.length !== 1 ? 's' : ''}
|
|
236
|
+
</Button>
|
|
237
|
+
</Box>
|
|
238
|
+
)}
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
242
241
|
</Box>
|
|
243
242
|
</Box>
|
|
244
243
|
)}
|
|
@@ -11,16 +11,17 @@ import {
|
|
|
11
11
|
} from '../../__fixtures__/sampleSales.js';
|
|
12
12
|
|
|
13
13
|
describe('calculateGrossSales', () => {
|
|
14
|
-
test('simple sale: grossSales equals
|
|
14
|
+
test('simple sale: grossSales equals chargeAmount', () => {
|
|
15
15
|
const { grossSales, shippingTotal } = calculateGrossSales([simpleSale]);
|
|
16
16
|
|
|
17
|
-
expect(grossSales).toBe(5350);
|
|
17
|
+
expect(grossSales).toBe(5350); // chargeAmount
|
|
18
18
|
expect(shippingTotal).toBe(0);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
test('shipping is included in gross but tracked separately', () => {
|
|
22
22
|
const { grossSales, shippingTotal } = calculateGrossSales([simpleSale, shippingSale]);
|
|
23
23
|
|
|
24
|
+
// Each has unique ID so both chargeAmounts are summed
|
|
24
25
|
expect(grossSales).toBe(5350 + 1605);
|
|
25
26
|
expect(shippingTotal).toBe(1605);
|
|
26
27
|
});
|
|
@@ -28,20 +29,19 @@ describe('calculateGrossSales', () => {
|
|
|
28
29
|
test('refund is subtracted from gross', () => {
|
|
29
30
|
const { grossSales } = calculateGrossSales([saleWithRefund]);
|
|
30
31
|
|
|
31
|
-
//
|
|
32
|
+
// chargeAmount (10700) - refund.total (10700) = 0
|
|
32
33
|
expect(grossSales).toBe(0);
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
test('multi-line invoice
|
|
36
|
+
test('multi-line invoice: charge counted once, refund counted once', () => {
|
|
36
37
|
const { grossSales } = calculateGrossSales([multiLineItem1, multiLineItem2]);
|
|
37
38
|
|
|
38
|
-
// Both items have
|
|
39
|
-
// Both have the SAME refund ref_multi789 with total 5000
|
|
40
|
-
// Should only subtract 5000 once, not twice
|
|
39
|
+
// Both items have the SAME id (in_multi456) — chargeAmount (10700) counted once
|
|
40
|
+
// Both have the SAME refund ref_multi789 with total 5000 — counted once
|
|
41
41
|
expect(grossSales).toBe(10700 - 5000);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
test('refund without ID is
|
|
44
|
+
test('refund without ID is skipped (known limitation)', () => {
|
|
45
45
|
const saleWithUnidentifiedRefund = {
|
|
46
46
|
...simpleSale,
|
|
47
47
|
refunds: [{
|
|
@@ -52,20 +52,17 @@ describe('calculateGrossSales', () => {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
const { grossSales } = calculateGrossSales([saleWithUnidentifiedRefund]);
|
|
55
|
-
// Refund without id is skipped by dedup logic (refund.id is falsy)
|
|
56
|
-
// So it won't be subtracted — this is a known limitation
|
|
57
55
|
expect(grossSales).toBe(5350);
|
|
58
56
|
});
|
|
59
57
|
|
|
60
58
|
test('disputed sale: disputeDeduction is NOT subtracted from grossSales', () => {
|
|
61
59
|
const { grossSales } = calculateGrossSales([disputedSale]);
|
|
62
60
|
|
|
63
|
-
//
|
|
64
|
-
// grossSales should be the full 21400
|
|
61
|
+
// chargeAmount is 21400, disputeDeduction is stored separately
|
|
65
62
|
expect(grossSales).toBe(21400);
|
|
66
63
|
});
|
|
67
64
|
|
|
68
|
-
test('payment intent sale
|
|
65
|
+
test('payment intent sale uses chargeAmount', () => {
|
|
69
66
|
const { grossSales } = calculateGrossSales([paymentIntentSale]);
|
|
70
67
|
|
|
71
68
|
expect(grossSales).toBe(8000);
|
|
@@ -80,12 +77,20 @@ describe('calculateGrossSales', () => {
|
|
|
80
77
|
|
|
81
78
|
test('mixed sales: products + shipping + refund', () => {
|
|
82
79
|
const { grossSales, shippingTotal } = calculateGrossSales([
|
|
83
|
-
simpleSale, // 5350
|
|
84
|
-
shippingSale, // 1605
|
|
85
|
-
saleWithRefund, // 10700 - 10700
|
|
80
|
+
simpleSale, // chargeAmount 5350, unique ID
|
|
81
|
+
shippingSale, // chargeAmount 1605, unique ID
|
|
82
|
+
saleWithRefund, // chargeAmount 10700 - refund 10700 = 0, unique ID
|
|
86
83
|
]);
|
|
87
84
|
|
|
88
85
|
expect(grossSales).toBe(5350 + 1605 + 10700 - 10700);
|
|
89
86
|
expect(shippingTotal).toBe(1605);
|
|
90
87
|
});
|
|
88
|
+
|
|
89
|
+
test('duplicate IDs only counted once for charge', () => {
|
|
90
|
+
const dup1 = { ...simpleSale, id: 'in_dup', chargeAmount: 5000 };
|
|
91
|
+
const dup2 = { ...simpleSale, id: 'in_dup', chargeAmount: 5000 };
|
|
92
|
+
|
|
93
|
+
const { grossSales } = calculateGrossSales([dup1, dup2]);
|
|
94
|
+
expect(grossSales).toBe(5000); // Not 10000
|
|
95
|
+
});
|
|
91
96
|
});
|
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
// Pure function for calculating gross sales — extracted for testability
|
|
2
2
|
/**
|
|
3
|
-
* Calculates gross sales
|
|
3
|
+
* Calculates gross sales matching Stripe's charge+refund balance.
|
|
4
|
+
* Uses chargeAmount (actual amount Stripe charged the customer, including tax)
|
|
5
|
+
* deduplicated by invoice/payment ID so multi-line invoices are counted once.
|
|
4
6
|
* @param {Array} sales - Array of sale objects
|
|
5
7
|
* @returns {Object} grossSales total and shipping breakdown (in cents)
|
|
6
8
|
*/
|
|
7
9
|
export function calculateGrossSales(sales) {
|
|
8
10
|
let grossSales = 0;
|
|
9
11
|
let shippingTotal = 0;
|
|
12
|
+
const processedCharges = new Set();
|
|
10
13
|
const processedRefunds = new Set();
|
|
11
14
|
|
|
12
15
|
sales.forEach(sale => {
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
const chargeId = sale?.id;
|
|
17
|
+
|
|
18
|
+
// Use chargeAmount (actual Stripe charge including tax) per unique charge
|
|
19
|
+
// Fall back to totalWithTax for backward compatibility
|
|
20
|
+
if (chargeId && !processedCharges.has(chargeId)) {
|
|
21
|
+
processedCharges.add(chargeId);
|
|
22
|
+
grossSales += sale?.chargeAmount || sale?.invoiceTotalWithTax || 0;
|
|
23
|
+
}
|
|
15
24
|
|
|
16
25
|
if (sale?.shippingProvision) {
|
|
17
26
|
shippingTotal += sale?.totalWithTax || 0;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
// Deduplicate refunds by ID — multi-line invoices repeat the same refunds
|
|
21
|
-
// Use refund.total (actual Stripe amount) not adjustedTotal (proportional)
|
|
22
30
|
sale?.refunds?.forEach(refund => {
|
|
23
31
|
if (refund?.id && !processedRefunds.has(refund.id)) {
|
|
24
32
|
processedRefunds.add(refund.id);
|
|
@@ -1,19 +1,97 @@
|
|
|
1
|
-
// Shared hook for Stripe balance reconciliation
|
|
1
|
+
// Shared hook for Stripe balance reconciliation with diagnostic checks
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { calculateGrossSales } from './calculateGrossSales.js';
|
|
4
4
|
|
|
5
5
|
// Re-export for backward compatibility
|
|
6
6
|
export { calculateGrossSales };
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Builds diagnostic checks comparing sales data against Stripe balance
|
|
10
|
+
* @param {Object} params - Sales and Stripe data
|
|
11
|
+
* @returns {Array} Array of check results
|
|
12
|
+
*/
|
|
13
|
+
function buildChecks({ sales, diagnostics, grossSales, totalBalanceChange, difference, flaggedTransactions }) {
|
|
14
|
+
const checks = [];
|
|
15
|
+
const uniqueChargeIds = new Set(sales.map(s => s.id)).size;
|
|
16
|
+
const salesRefundIds = new Set();
|
|
17
|
+
sales.forEach(s => s.refunds?.forEach(r => r.id && salesRefundIds.add(r.id)));
|
|
18
|
+
const testSales = sales.filter(s => s.testSale);
|
|
19
|
+
|
|
20
|
+
// 1. Transaction count match
|
|
21
|
+
const countMatch = uniqueChargeIds === diagnostics.chargeCount;
|
|
22
|
+
checks.push({
|
|
23
|
+
label: 'Transaction count',
|
|
24
|
+
status: countMatch ? 'pass' : 'fail',
|
|
25
|
+
detail: `${uniqueChargeIds} sales vs ${diagnostics.chargeCount} Stripe charges`,
|
|
26
|
+
tip: !countMatch ? 'Mismatch may indicate missing orders in Sanity or charges outside this period.' : null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 2. Refund count match
|
|
30
|
+
const refundMatch = salesRefundIds.size === diagnostics.refundCount;
|
|
31
|
+
checks.push({
|
|
32
|
+
label: 'Refund count',
|
|
33
|
+
status: diagnostics.refundCount === 0 && salesRefundIds.size === 0 ? 'pass' : refundMatch ? 'pass' : 'warn',
|
|
34
|
+
detail: `${salesRefundIds.size} in sales vs ${diagnostics.refundCount} in Stripe`,
|
|
35
|
+
tip: !refundMatch ? 'A refund may have been issued in a different month than the original charge.' : null,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 3. Currency conversion
|
|
39
|
+
if (diagnostics.hasCurrencyConversion) {
|
|
40
|
+
const pctOff = totalBalanceChange !== 0 ? Math.abs(difference / totalBalanceChange) * 100 : 0;
|
|
41
|
+
checks.push({
|
|
42
|
+
label: 'Currency conversion',
|
|
43
|
+
status: pctOff < 1 ? 'info' : 'warn',
|
|
44
|
+
detail: `Settlement currency: ${diagnostics.settlementCurrency?.toUpperCase()}. Exchange rate rounding may cause up to ~1% variance.`,
|
|
45
|
+
tip: pctOff >= 1 ? 'Large currency variance — verify exchange rates on specific transactions in Stripe dashboard.' : null,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 4. Amount reconciliation
|
|
50
|
+
const pctDiff = grossSales !== 0 ? (difference / grossSales) * 100 : 0;
|
|
51
|
+
checks.push({
|
|
52
|
+
label: 'Amount reconciliation',
|
|
53
|
+
status: Math.abs(difference) < 1 ? 'pass' : Math.abs(pctDiff) < 1 ? 'warn' : 'fail',
|
|
54
|
+
detail: `Difference: $${(Math.abs(difference) / 100).toFixed(2)} (${Math.abs(pctDiff).toFixed(2)}%)`,
|
|
55
|
+
tip: Math.abs(difference) >= 1 ? 'Small differences are usually exchange rate rounding. Larger gaps may indicate a charge or refund crossing a month boundary.' : null,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 5. Pending transactions
|
|
59
|
+
if (diagnostics.pendingCount > 0) {
|
|
60
|
+
checks.push({
|
|
61
|
+
label: 'Pending transactions',
|
|
62
|
+
status: 'warn',
|
|
63
|
+
detail: `${diagnostics.pendingCount} pending balance transactions excluded`,
|
|
64
|
+
tip: 'Pending transactions haven\'t settled yet. They\'ll appear in reconciliation once settled (usually 2 business days).',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 6. Flagged transactions
|
|
69
|
+
if (flaggedTransactions.length > 0) {
|
|
70
|
+
const totalFlagged = flaggedTransactions.reduce((sum, t) => sum + t.amount, 0);
|
|
71
|
+
checks.push({
|
|
72
|
+
label: 'Non-standard transactions',
|
|
73
|
+
status: 'info',
|
|
74
|
+
detail: `${flaggedTransactions.length} transactions ($${(Math.abs(totalFlagged) / 100).toFixed(2)}) — disputes, adjustments, or transfers`,
|
|
75
|
+
tip: 'These affect your Stripe balance but aren\'t sales or refunds. Click to review details.',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 7. Test sales
|
|
80
|
+
if (testSales.length > 0) {
|
|
81
|
+
const testTotal = testSales.reduce((sum, s) => sum + (s.totalWithTax || 0), 0);
|
|
82
|
+
checks.push({
|
|
83
|
+
label: 'Test sales detected',
|
|
84
|
+
status: 'info',
|
|
85
|
+
detail: `${testSales.length} test transactions ($${(testTotal / 100).toFixed(2)}) from internal accounts`,
|
|
86
|
+
tip: 'Test sales are included in the totals. These come from accounts marked as test/debug.',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return checks;
|
|
91
|
+
}
|
|
92
|
+
|
|
8
93
|
/**
|
|
9
94
|
* Hook for fetching and comparing Stripe balance transactions against sales data
|
|
10
|
-
* @param {Object} options
|
|
11
|
-
* @param {Object} options.designer - Designer data with user/password
|
|
12
|
-
* @param {boolean} options.admin - Whether user is admin
|
|
13
|
-
* @param {Array} options.sales - Sales data to reconcile against
|
|
14
|
-
* @param {Date} [options.date] - Month date (for monthly view)
|
|
15
|
-
* @param {Object} [options.dateRange] - Date range with start/end (for export view)
|
|
16
|
-
* @returns {Object} Reconciliation state
|
|
17
95
|
*/
|
|
18
96
|
export function useReconciliation({ designer, admin, sales, date, dateRange }) {
|
|
19
97
|
const [reconciliationData, setReconciliationData] = useState({
|
|
@@ -23,7 +101,10 @@ export function useReconciliation({ designer, admin, sales, date, dateRange }) {
|
|
|
23
101
|
grossSales: 0,
|
|
24
102
|
shippingTotal: 0,
|
|
25
103
|
difference: 0,
|
|
104
|
+
percentOff: 0,
|
|
26
105
|
flaggedTransactions: [],
|
|
106
|
+
checks: [],
|
|
107
|
+
diagnostics: null,
|
|
27
108
|
error: null
|
|
28
109
|
});
|
|
29
110
|
|
|
@@ -58,16 +139,31 @@ export function useReconciliation({ designer, admin, sales, date, dateRange }) {
|
|
|
58
139
|
if (data.success) {
|
|
59
140
|
const totalBalanceChange = data.data.totalBalanceChange;
|
|
60
141
|
const flaggedTransactions = data.data.flaggedTransactions || [];
|
|
142
|
+
const diagnostics = data.data.diagnostics || {};
|
|
61
143
|
const { grossSales, shippingTotal } = calculateGrossSales(sales);
|
|
144
|
+
const difference = totalBalanceChange - grossSales;
|
|
145
|
+
const percentOff = grossSales !== 0 ? (difference / grossSales) * 100 : 0;
|
|
146
|
+
|
|
147
|
+
const checks = buildChecks({
|
|
148
|
+
sales,
|
|
149
|
+
diagnostics,
|
|
150
|
+
grossSales,
|
|
151
|
+
totalBalanceChange,
|
|
152
|
+
difference,
|
|
153
|
+
flaggedTransactions,
|
|
154
|
+
});
|
|
62
155
|
|
|
63
156
|
setReconciliationData(prev => ({
|
|
64
157
|
...prev,
|
|
65
158
|
totalBalanceChange,
|
|
66
159
|
grossSales,
|
|
67
160
|
shippingTotal,
|
|
68
|
-
difference
|
|
69
|
-
|
|
161
|
+
difference,
|
|
162
|
+
percentOff,
|
|
163
|
+
isReconciled: Math.abs(difference) < 1 || Math.abs(percentOff) < 0.5,
|
|
70
164
|
flaggedTransactions,
|
|
165
|
+
checks,
|
|
166
|
+
diagnostics,
|
|
71
167
|
isLoading: false
|
|
72
168
|
}));
|
|
73
169
|
} else {
|
package/package.json
CHANGED
package/utils/currencyUtils.js
CHANGED
|
@@ -3,46 +3,71 @@
|
|
|
3
3
|
* All sales data from API is in cents, these utilities help with proper formatting
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
// Currency symbol lookup for common currencies
|
|
7
|
+
const CURRENCY_SYMBOLS = {
|
|
8
|
+
usd: '$', aud: 'A$', nzd: 'NZ$', cad: 'CA$', gbp: '£', eur: '€',
|
|
9
|
+
jpy: '¥', chf: 'CHF', sek: 'kr', nok: 'kr', dkk: 'kr',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get the display symbol for a currency code
|
|
14
|
+
* @param {string} currencyCode - ISO 4217 currency code (e.g. 'usd', 'nzd')
|
|
15
|
+
* @returns {string} Currency symbol
|
|
16
|
+
*/
|
|
17
|
+
export const getCurrencySymbol = (currencyCode) => {
|
|
18
|
+
return CURRENCY_SYMBOLS[currencyCode?.toLowerCase()] || currencyCode?.toUpperCase() || '$';
|
|
19
|
+
};
|
|
20
|
+
|
|
6
21
|
/**
|
|
7
|
-
*
|
|
8
|
-
* @param {
|
|
22
|
+
* Get the display label for a currency code (e.g. 'USD', 'NZD')
|
|
23
|
+
* @param {string} currencyCode - ISO 4217 currency code
|
|
24
|
+
* @returns {string} Uppercase currency label
|
|
25
|
+
*/
|
|
26
|
+
export const getCurrencyLabel = (currencyCode) => {
|
|
27
|
+
return currencyCode?.toUpperCase() || 'USD';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format currency value
|
|
32
|
+
* @param {number} value - Value in major units (e.g. dollars) to format
|
|
9
33
|
* @param {Object} options - Formatting options
|
|
34
|
+
* @param {string} options.currency - ISO 4217 currency code (default: 'usd')
|
|
10
35
|
* @param {number} options.minimumFractionDigits - Minimum fraction digits (default: 2)
|
|
11
36
|
* @param {number} options.maximumFractionDigits - Maximum fraction digits (default: 2)
|
|
12
37
|
* @returns {string} Formatted currency string
|
|
13
38
|
*/
|
|
14
39
|
export const formatCurrency = (
|
|
15
|
-
|
|
16
|
-
|
|
40
|
+
value,
|
|
41
|
+
{ currency = 'usd', minimumFractionDigits = 2, maximumFractionDigits = 2 } = {}
|
|
17
42
|
) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
return new Intl.NumberFormat('en-US', {
|
|
44
|
+
style: 'currency',
|
|
45
|
+
currency: currency?.toUpperCase() || 'USD',
|
|
46
|
+
minimumFractionDigits,
|
|
47
|
+
maximumFractionDigits,
|
|
48
|
+
}).format(value);
|
|
24
49
|
};
|
|
25
50
|
|
|
26
51
|
/**
|
|
27
|
-
* Convert cents to
|
|
28
|
-
* @param {number} cents - Value in cents
|
|
29
|
-
* @returns {number} Value in dollars
|
|
52
|
+
* Convert cents to major currency units
|
|
53
|
+
* @param {number} cents - Value in minor units (cents)
|
|
54
|
+
* @returns {number} Value in major units (dollars)
|
|
30
55
|
*/
|
|
31
56
|
export const centsToDollars = (cents) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
if (typeof cents !== 'number') {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
return cents / 100;
|
|
36
61
|
};
|
|
37
62
|
|
|
38
63
|
/**
|
|
39
64
|
* Format cents as currency
|
|
40
|
-
* @param {number} cents - Value in cents
|
|
41
|
-
* @param {Object} options - Formatting options
|
|
65
|
+
* @param {number} cents - Value in minor units (cents)
|
|
66
|
+
* @param {Object} options - Formatting options (passed to formatCurrency)
|
|
42
67
|
* @returns {string} Formatted currency string
|
|
43
68
|
*/
|
|
44
69
|
export const formatCentsAsCurrency = (cents, options = {}) => {
|
|
45
|
-
|
|
70
|
+
return formatCurrency(centsToDollars(cents), options);
|
|
46
71
|
};
|
|
47
72
|
|
|
48
73
|
/**
|
|
@@ -52,10 +77,10 @@ export const formatCentsAsCurrency = (cents, options = {}) => {
|
|
|
52
77
|
* @returns {number|null} Percentage change or null if previous is zero
|
|
53
78
|
*/
|
|
54
79
|
export const calculatePercentageChange = (current, previous) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
80
|
+
if (!previous || previous === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return ((current - previous) / previous) * 100;
|
|
59
84
|
};
|
|
60
85
|
|
|
61
86
|
/**
|
|
@@ -67,13 +92,13 @@ export const calculatePercentageChange = (current, previous) => {
|
|
|
67
92
|
* @returns {string} Formatted percentage string
|
|
68
93
|
*/
|
|
69
94
|
export const formatPercentage = (
|
|
70
|
-
|
|
71
|
-
|
|
95
|
+
value,
|
|
96
|
+
{ decimals = 1, showPlusSign = true } = {}
|
|
72
97
|
) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
if (value === null || value === undefined) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sign = value > 0 && showPlusSign ? '+' : '';
|
|
103
|
+
return `${sign}${value.toFixed(decimals)}%`;
|
|
79
104
|
};
|