@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.
@@ -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
- // Calculate balance change from charges and refunds only (what sales data tracks)
25
+ // Build detailed reconciliation data
26
26
  const RECONCILIATION_TYPES = ['charge', 'refund'];
27
- let totalBalanceChange = 0;
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.status === 'pending') return;
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
- totalBalanceChange += transaction.amount;
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: transaction.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
- sx={{
510
- display: 'inline-block',
511
- gap: 2,
512
- p: 2,
513
- ml: -2,
514
- borderRadius: '4px',
515
- border: reconciliationData.isReconciled
516
- ? '2px solid var(--green, green)'
517
- : '2px solid var(--red, red)',
518
- color: reconciliationData.isReconciled
519
- ? 'var(--green, green)'
520
- : 'var(--red, red)',
521
- backgroundColor: 'rgba(255, 255, 255, 0.9)',
522
- filter: 'invert(0) !important',
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
- <Box sx={{ display: 'flex', alignItems: 'center' }}>
540
- <Tooltip title="The difference between Stripe balance change and gross sales">
541
- <Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
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 (charges + refunds):</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
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
- {reconciliationData.flaggedTransactions?.length > 0 && (
577
- <Box sx={{ mt: 1 }}>
578
- <Tooltip title="View disputes, adjustments, and other flagged transactions">
579
- <Button
580
- size="small"
581
- startIcon={<InfoIcon />}
582
- onClick={() => setFlaggedOpen(true)}
583
- sx={{ color: 'inherit', textTransform: 'none' }}
584
- >
585
- {reconciliationData.flaggedTransactions.length} flagged transaction{reconciliationData.flaggedTransactions.length !== 1 ? 's' : ''}
586
- </Button>
587
- </Tooltip>
588
- </Box>
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
  )}
@@ -1,23 +1,23 @@
1
- // Login form modal component for sales portal authentication
1
+ // Login form component for sales portal authentication
2
2
  import React, { useEffect, useState } from 'react';
3
- import { Typography, Input, Button, Box, Dialog, DialogContent } from '@mui/material';
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 modal component for designer authentication
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 modal is open
12
- * @param {Function} props.onClose Callback when the modal is closed
13
- * @param {Function} props.onLoginSuccess Callback when login succeeds, receives { designers, admin, user, password }
14
- * @param {Function} props.renderLoader Optional render prop for custom loader component
15
- * @param {string} props.apiEndpoint Optional custom API endpoint (default: '/api/sales-portal/getDesignerInfo')
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 modal reopens
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
- <Dialog
93
- open={open}
94
- onClose={onClose}
95
- maxWidth="md"
96
- fullWidth
97
- PaperProps={{
98
- sx: {
99
- borderRadius: '12px',
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
- <DialogContent sx={{ p: 0 }}>
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
- </DialogContent>
260
- </Dialog>
254
+ </Box>
255
+ </Box>
261
256
  );
262
257
  }
@@ -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" }}>USD</Box>$ </Box> : ``}
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}
@@ -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
- sx={{
169
- display: 'inline-block',
170
- gap: 2,
171
- p: 2,
172
- mt: "20px",
173
- borderRadius: '4px',
174
- border: reconciliationData.isReconciled
175
- ? '2px solid var(--green, green)'
176
- : '2px solid var(--red, red)',
177
- color: reconciliationData.isReconciled
178
- ? 'var(--green, green)'
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
- <Box sx={{ display: 'flex', alignItems: 'center' }}>
190
- <Tooltip title="The difference between Stripe balance change and gross sales">
191
- <Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
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 (charges + refunds):</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
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
- {reconciliationData.flaggedTransactions?.length > 0 && (
228
- <Box sx={{ mt: 1 }}>
229
- <Tooltip title="View disputes, adjustments, and other flagged transactions">
230
- <Button
231
- size="small"
232
- startIcon={<InfoIcon />}
233
- onClick={() => setFlaggedOpen(true)}
234
- sx={{ color: 'inherit', textTransform: 'none' }}
235
- >
236
- {reconciliationData.flaggedTransactions.length} flagged transaction{reconciliationData.flaggedTransactions.length !== 1 ? 's' : ''}
237
- </Button>
238
- </Tooltip>
239
- </Box>
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 totalWithTax', () => {
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
- // totalWithTax (10700) - refund.total (10700) = 0
32
+ // chargeAmount (10700) - refund.total (10700) = 0
32
33
  expect(grossSales).toBe(0);
33
34
  });
34
35
 
35
- test('multi-line invoice refund is deduplicated by ID', () => {
36
+ test('multi-line invoice: charge counted once, refund counted once', () => {
36
37
  const { grossSales } = calculateGrossSales([multiLineItem1, multiLineItem2]);
37
38
 
38
- // Both items have totalWithTax 5350 each = 10700
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 still counted (fallback)', () => {
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
- // totalWithTax is 21400, disputeDeduction is stored separately
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 works the same as invoice', () => {
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 refund = 0
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 from sales data, matching Stripe's charge+refund total
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
- // Use totalWithTax for all items — this is what Stripe actually charged
14
- grossSales += sale?.totalWithTax || 0;
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: totalBalanceChange - grossSales,
69
- isReconciled: Math.abs(totalBalanceChange - grossSales) < 1,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sales-portal",
3
- "version": "2.0.0",
3
+ "version": "2.3.0",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -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
- * Format currency value in USD
8
- * @param {number} value - Value in dollars to format as currency
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
- value,
16
- { minimumFractionDigits = 2, maximumFractionDigits = 2 } = {}
40
+ value,
41
+ { currency = 'usd', minimumFractionDigits = 2, maximumFractionDigits = 2 } = {}
17
42
  ) => {
18
- return new Intl.NumberFormat('en-US', {
19
- style: 'currency',
20
- currency: 'USD',
21
- minimumFractionDigits,
22
- maximumFractionDigits,
23
- }).format(value);
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 dollars
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
- if (typeof cents !== 'number') {
33
- return 0;
34
- }
35
- return cents / 100;
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
- return formatCurrency(centsToDollars(cents), options);
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
- if (!previous || previous === 0) {
56
- return null;
57
- }
58
- return ((current - previous) / previous) * 100;
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
- value,
71
- { decimals = 1, showPlusSign = true } = {}
95
+ value,
96
+ { decimals = 1, showPlusSign = true } = {}
72
97
  ) => {
73
- if (value === null || value === undefined) {
74
- return '';
75
- }
76
-
77
- const sign = value > 0 && showPlusSign ? '+' : '';
78
- return `${sign}${value.toFixed(decimals)}%`;
98
+ if (value === null || value === undefined) {
99
+ return '';
100
+ }
101
+
102
+ const sign = value > 0 && showPlusSign ? '+' : '';
103
+ return `${sign}${value.toFixed(decimals)}%`;
79
104
  };