@liiift-studio/sales-portal 1.8.1 → 2.0.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,18 +22,34 @@ 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 total balance change (ignoring payouts and pending transactions)
26
- const totalBalanceChange = balanceTransactions.reduce((total, transaction) => {
27
- if (transaction.type === 'payout' || transaction.type === 'stripe_fee' || transaction.status === 'pending') {
28
- return total;
25
+ // Calculate balance change from charges and refunds only (what sales data tracks)
26
+ const RECONCILIATION_TYPES = ['charge', 'refund'];
27
+ let totalBalanceChange = 0;
28
+ const flaggedTransactions = [];
29
+
30
+ balanceTransactions.forEach(transaction => {
31
+ if (transaction.status === 'pending') return;
32
+
33
+ if (RECONCILIATION_TYPES.includes(transaction.type)) {
34
+ totalBalanceChange += transaction.amount;
35
+ } else if (transaction.type !== 'payout' && transaction.type !== 'stripe_fee') {
36
+ // Collect disputes, adjustments, connect transfers for review
37
+ flaggedTransactions.push({
38
+ id: transaction.id,
39
+ type: transaction.type,
40
+ amount: transaction.amount,
41
+ created: transaction.created,
42
+ description: transaction.description,
43
+ source: transaction.source,
44
+ });
29
45
  }
30
- return total + transaction.amount;
31
- }, 0);
46
+ });
32
47
 
33
48
  return res.status(200).json({
34
49
  success: true,
35
50
  data: {
36
51
  totalBalanceChange,
52
+ flaggedTransactions,
37
53
  transactions: balanceTransactions
38
54
  }
39
55
  });
@@ -0,0 +1,111 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { createDateRange, createStripeTimeRange, isInDateRange } from '../dateUtils.js';
3
+
4
+ describe('createDateRange', () => {
5
+ test('creates correct month boundaries for January', () => {
6
+ const { startDate, endDate } = createDateRange({ date: '2024-01-15T00:00:00Z' });
7
+
8
+ expect(startDate.toISOString()).toBe('2024-01-01T00:00:00.000Z');
9
+ expect(endDate.toISOString()).toBe('2024-01-31T23:59:59.999Z');
10
+ });
11
+
12
+ test('creates correct month boundaries for February (leap year)', () => {
13
+ const { startDate, endDate } = createDateRange({ date: '2024-02-10T00:00:00Z' });
14
+
15
+ expect(startDate.toISOString()).toBe('2024-02-01T00:00:00.000Z');
16
+ expect(endDate.toISOString()).toBe('2024-02-29T23:59:59.999Z');
17
+ });
18
+
19
+ test('creates correct month boundaries for February (non-leap year)', () => {
20
+ const { startDate, endDate } = createDateRange({ date: '2025-02-10T00:00:00Z' });
21
+
22
+ expect(endDate.toISOString()).toBe('2025-02-28T23:59:59.999Z');
23
+ });
24
+
25
+ test('creates correct month boundaries for December', () => {
26
+ const { startDate, endDate } = createDateRange({ date: '2024-12-25T00:00:00Z' });
27
+
28
+ expect(startDate.toISOString()).toBe('2024-12-01T00:00:00.000Z');
29
+ expect(endDate.toISOString()).toBe('2024-12-31T23:59:59.999Z');
30
+ });
31
+
32
+ test('handles custom date range', () => {
33
+ const start = new Date('2024-03-15T00:00:00Z').getTime();
34
+ const end = new Date('2024-06-20T23:59:59Z').getTime();
35
+
36
+ const { startDate, endDate } = createDateRange({
37
+ dateRange: { start, end }
38
+ });
39
+
40
+ expect(startDate.getUTCFullYear()).toBe(2024);
41
+ expect(startDate.getUTCMonth()).toBe(2); // March
42
+ expect(startDate.getUTCDate()).toBe(15);
43
+ expect(startDate.getUTCHours()).toBe(0);
44
+
45
+ expect(endDate.getUTCMonth()).toBe(5); // June
46
+ expect(endDate.getUTCDate()).toBe(20);
47
+ expect(endDate.getUTCHours()).toBe(23);
48
+ });
49
+
50
+ test('handles serialized Date from JSON (string)', () => {
51
+ const { startDate, endDate } = createDateRange({
52
+ date: '2024-03-01T00:00:00.000Z'
53
+ });
54
+
55
+ expect(startDate.toISOString()).toBe('2024-03-01T00:00:00.000Z');
56
+ expect(endDate.toISOString()).toBe('2024-03-31T23:59:59.999Z');
57
+ });
58
+ });
59
+
60
+ describe('createStripeTimeRange', () => {
61
+ test('converts dates to Unix seconds', () => {
62
+ const startDate = new Date('2024-03-01T00:00:00.000Z');
63
+ const endDate = new Date('2024-03-31T23:59:59.999Z');
64
+
65
+ const range = createStripeTimeRange(startDate, endDate);
66
+
67
+ expect(range.gte).toBe(Math.floor(startDate.getTime() / 1000));
68
+ expect(range.lte).toBe(Math.floor(endDate.getTime() / 1000));
69
+ });
70
+
71
+ test('does not add padding', () => {
72
+ const startDate = new Date('2024-01-01T00:00:00.000Z');
73
+ const endDate = new Date('2024-01-31T23:59:59.999Z');
74
+
75
+ const range = createStripeTimeRange(startDate, endDate);
76
+
77
+ // Should be exactly the start/end, no ±24hr padding
78
+ expect(range.gte).toBe(1704067200); // Jan 1 2024 00:00:00 UTC
79
+ expect(range.lte).toBe(1706745599); // Jan 31 2024 23:59:59 UTC
80
+ });
81
+ });
82
+
83
+ describe('isInDateRange', () => {
84
+ const startDate = new Date('2024-03-01T00:00:00.000Z');
85
+ const endDate = new Date('2024-03-31T23:59:59.999Z');
86
+
87
+ test('includes first second of month', () => {
88
+ const timestamp = Math.floor(startDate.getTime() / 1000);
89
+ expect(isInDateRange(timestamp, startDate, endDate)).toBe(true);
90
+ });
91
+
92
+ test('includes last second of month', () => {
93
+ const timestamp = Math.floor(endDate.getTime() / 1000);
94
+ expect(isInDateRange(timestamp, startDate, endDate)).toBe(true);
95
+ });
96
+
97
+ test('excludes timestamp before range', () => {
98
+ const beforeStart = Math.floor(startDate.getTime() / 1000) - 1;
99
+ expect(isInDateRange(beforeStart, startDate, endDate)).toBe(false);
100
+ });
101
+
102
+ test('excludes timestamp after range', () => {
103
+ const afterEnd = Math.floor(endDate.getTime() / 1000) + 1;
104
+ expect(isInDateRange(afterEnd, startDate, endDate)).toBe(false);
105
+ });
106
+
107
+ test('includes mid-month timestamp', () => {
108
+ const mid = Math.floor(new Date('2024-03-15T12:00:00Z').getTime() / 1000);
109
+ expect(isInDateRange(mid, startDate, endDate)).toBe(true);
110
+ });
111
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { calculateFees, formatRefunds } from '../feeCalculator.js';
3
+
4
+ describe('calculateFees', () => {
5
+ const mockCharge = {
6
+ balance_transaction: {
7
+ fee_details: [
8
+ { type: 'stripe_fee', amount: 300 }, // $3.00 Stripe fee
9
+ ]
10
+ }
11
+ };
12
+
13
+ test('primary item calculates total fee and proportional share', () => {
14
+ const result = calculateFees({
15
+ charge: mockCharge,
16
+ amount: 5000, // $50 of $100 total
17
+ total: 10000,
18
+ isPrimaryItem: true,
19
+ isRefund: false,
20
+ });
21
+
22
+ expect(result.totalTransactionFee).toBe(300);
23
+ expect(result.stripeFees).toBe(150); // 50% of 300
24
+ });
25
+
26
+ test('non-primary item distributes fee proportionally', () => {
27
+ const result = calculateFees({
28
+ charge: mockCharge,
29
+ amount: 3000, // $30 of $100 total
30
+ total: 10000,
31
+ isPrimaryItem: false,
32
+ isRefund: false,
33
+ totalFee: 300,
34
+ });
35
+
36
+ expect(result.stripeFees).toBe(90); // 30% of 300
37
+ });
38
+
39
+ test('refunds have zero fees', () => {
40
+ const result = calculateFees({
41
+ charge: mockCharge,
42
+ amount: -5000,
43
+ total: 10000,
44
+ isPrimaryItem: false,
45
+ isRefund: true,
46
+ });
47
+
48
+ expect(result.stripeFees).toBe(0);
49
+ });
50
+
51
+ test('zero total produces zero ratio without crash', () => {
52
+ const result = calculateFees({
53
+ charge: mockCharge,
54
+ amount: 0,
55
+ total: 0,
56
+ isPrimaryItem: false,
57
+ isRefund: false,
58
+ totalFee: 300,
59
+ });
60
+
61
+ expect(result.stripeFees).toBe(0);
62
+ expect(result.disputeAmount).toBe(0);
63
+ });
64
+ });
65
+
66
+ describe('formatRefunds', () => {
67
+ const refunds = [
68
+ { amount: 5000, created: 1711929600, id: 'ref_1', reason: 'requested_by_customer', status: 'succeeded' },
69
+ ];
70
+
71
+ test('returns empty array for no refunds', () => {
72
+ expect(formatRefunds(null, 5000, 10000)).toEqual([]);
73
+ expect(formatRefunds([], 5000, 10000)).toEqual([]);
74
+ });
75
+
76
+ test('calculates proportional adjustedTotal', () => {
77
+ const result = formatRefunds(refunds, 5000, 10000);
78
+
79
+ expect(result).toHaveLength(1);
80
+ expect(result[0].total).toBe(5000); // Full refund amount
81
+ expect(result[0].adjustedTotal).toBe(2500); // 50% of 5000
82
+ expect(result[0].percentOf_total).toBe(0.5);
83
+ });
84
+
85
+ test('full-invoice refund has ratio of 1', () => {
86
+ const result = formatRefunds(refunds, 10000, 10000);
87
+
88
+ expect(result[0].adjustedTotal).toBe(5000); // 100% of 5000
89
+ expect(result[0].percentOf_total).toBe(1);
90
+ });
91
+
92
+ test('preserves refund metadata', () => {
93
+ const result = formatRefunds(refunds, 5000, 10000);
94
+
95
+ expect(result[0].id).toBe('ref_1');
96
+ expect(result[0].description).toBe('requested_by_customer');
97
+ expect(result[0].status).toBe('succeeded');
98
+ expect(result[0].created).toBe(1711929600);
99
+ });
100
+ });
@@ -85,12 +85,13 @@ function processLineItem({ line, invoice, associatedOrder, associatedTypeface, i
85
85
 
86
86
  // Calculate final totals
87
87
  const lineTotal = lineAmount; // Pre-tax total (discounts already applied by Stripe)
88
- const lineTotalWithTax = lineAmount + line.tax_amounts.reduce((acc, tax) => acc + tax.amount, 0) - (disputeAmount || 0);
88
+ const lineTotalWithTax = lineAmount + line.tax_amounts.reduce((acc, tax) => acc + tax.amount, 0);
89
89
 
90
90
  return {
91
91
  // Quantities
92
92
  totalWithTax: lineTotalWithTax,
93
93
  total: lineTotal, // Pre-tax total
94
+ disputeDeduction: disputeAmount || 0,
94
95
  invoiceTotal: invoice.total_excluding_tax,
95
96
  invoiceTotalWithTax: invoice.total,
96
97
  discountAmounts: line.discount_amounts,
@@ -171,8 +172,9 @@ function processShippingCost({ invoice, associatedOrder, totalFee = null, paymen
171
172
 
172
173
  return {
173
174
  // Quantities
174
- totalWithTax: invoice.shipping_cost.amount_total - disputeAmount,
175
+ totalWithTax: invoice.shipping_cost.amount_total,
175
176
  total: invoice.shipping_cost.amount_subtotal, // Pre-tax total
177
+ disputeDeduction: disputeAmount || 0,
176
178
  invoiceTotal: invoice.total_excluding_tax,
177
179
  invoiceTotalWithTax: invoice.total,
178
180
  discountAmounts: null,
@@ -221,8 +221,8 @@ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTyp
221
221
  const orderTax = associatedOrder?.tax || 0;
222
222
  const itemRatio = payment.amount > 0 ? (item.amount || 0) / payment.amount : 0;
223
223
  const itemTaxAmount = Math.round(orderTax * itemRatio);
224
- const itemTotal = (item.amount || 0) - itemTaxAmount - disputeAmount;
225
- const itemTotalWithTax = (item.amount || 0) - disputeAmount;
224
+ const itemTotal = (item.amount || 0) - itemTaxAmount;
225
+ const itemTotalWithTax = (item.amount || 0);
226
226
 
227
227
  // Try to find matching product if no description
228
228
  let description = item.description;
@@ -232,6 +232,7 @@ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTyp
232
232
  return {
233
233
  totalWithTax: itemTotalWithTax,
234
234
  total: itemTotal,
235
+ disputeDeduction: disputeAmount || 0,
235
236
  invoiceTotal: payment.amount,
236
237
  invoiceTotalWithTax: payment.amount,
237
238
  discountAmounts: [],
@@ -313,8 +314,9 @@ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
313
314
  });
314
315
 
315
316
  return {
316
- totalWithTax: payment.shipping_cost - disputeAmount,
317
- total: payment.shipping_cost - disputeAmount,
317
+ totalWithTax: payment.shipping_cost,
318
+ total: payment.shipping_cost,
319
+ disputeDeduction: disputeAmount || 0,
318
320
  invoiceTotal: payment.amount,
319
321
  invoiceTotalWithTax: payment.amount,
320
322
  discountAmounts: [],
@@ -10,7 +10,10 @@ import {
10
10
  ListItemText,
11
11
  IconButton,
12
12
  Tooltip,
13
- Box
13
+ Box,
14
+ Dialog,
15
+ DialogTitle,
16
+ DialogContent
14
17
  } from '@mui/material';
15
18
  import { DataGrid } from '@mui/x-data-grid';
16
19
  import DownloadIcon from '@mui/icons-material/Download';
@@ -71,6 +74,7 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
71
74
  const reconciliationData = useReconciliation({
72
75
  designer, admin, sales: dateRangeSales, dateRange
73
76
  });
77
+ const [flaggedOpen, setFlaggedOpen] = useState(false);
74
78
 
75
79
  // Dashboard states for summary data
76
80
  const [chartState, setChartState] = useState({
@@ -558,17 +562,61 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
558
562
  {!reconciliationData.isLoading && (
559
563
  <>
560
564
  <Typography variant="body1">
561
- <strong>Stripe Balance Change:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
565
+ <strong>Stripe Balance (charges + refunds):</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
562
566
  </Typography>
563
567
  <Typography variant="body1">
564
- <strong>Gross Sales:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
568
+ <strong>Calculated Gross:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
569
+ {reconciliationData.shippingTotal > 0 && (
570
+ <span style={{ opacity: 0.6 }}> (incl. ${(reconciliationData.shippingTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)</span>
571
+ )}
565
572
  </Typography>
566
573
  </>
567
574
  )}
575
+
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
+ )}
590
+
568
591
  </Box>
569
592
  </Box>
570
593
  )}
571
594
 
595
+ {/* Flagged Transactions Modal */}
596
+ <Dialog open={flaggedOpen} onClose={() => setFlaggedOpen(false)} maxWidth="sm" fullWidth>
597
+ <DialogTitle>Flagged Transactions</DialogTitle>
598
+ <DialogContent>
599
+ <Typography variant="body2" sx={{ mb: 2, opacity: 0.7 }}>
600
+ These transactions are in Stripe's balance but excluded from the charges+refunds reconciliation.
601
+ </Typography>
602
+ {reconciliationData.flaggedTransactions?.map((t, i) => (
603
+ <Box key={t.id || i} sx={{ display: 'flex', justifyContent: 'space-between', py: 1, borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
604
+ <Box>
605
+ <Typography variant="body2" sx={{ fontWeight: 'bold', textTransform: 'capitalize' }}>
606
+ {t.type?.replace(/_/g, ' ')}
607
+ </Typography>
608
+ <Typography variant="caption" sx={{ opacity: 0.6 }}>
609
+ {t.description || t.id} — {new Date(t.created * 1000).toLocaleDateString()}
610
+ </Typography>
611
+ </Box>
612
+ <Typography variant="body2" sx={{ fontWeight: 'bold', color: t.amount < 0 ? 'var(--red, red)' : 'inherit' }}>
613
+ ${(t.amount / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
614
+ </Typography>
615
+ </Box>
616
+ ))}
617
+ </DialogContent>
618
+ </Dialog>
619
+
572
620
  {/* Summary Dashboard and Analysis Components - only show when data is loaded */}
573
621
  {!!dateRangeSales.length && (
574
622
  <>
@@ -321,10 +321,9 @@ export default function Sales(props) {
321
321
 
322
322
  if (previousSales && previousSales.length > 0) {
323
323
  previousPeriodTotal = previousSales.reduce((sum, sale) => {
324
+ if (sale.shippingProvision) return sum; // Exclude shipping from revenue
324
325
  const saleTotal = sale.total || 0;
325
- const saleTax = sale.taxAmount || 0;
326
- const saleShipping = sale.shippingCost || 0;
327
- return sum + (saleTotal - saleTax - saleShipping) / 100; // Convert cents to dollars
326
+ return sum + saleTotal / 100; // Convert cents to dollars
328
327
  }, 0);
329
328
 
330
329
  if (previousPeriodTotal > 0) {
@@ -379,7 +378,7 @@ export default function Sales(props) {
379
378
  };
380
379
  }
381
380
 
382
- const saleRevenue = (sale.total - (sale.taxAmount || 0) - (sale.shippingCost || 0)) / 100;
381
+ const saleRevenue = sale.shippingProvision ? 0 : (sale.total || 0) / 100;
383
382
  locationSales[location].totalRevenue += saleRevenue;
384
383
  locationSales[location].orders += 1;
385
384
  });
@@ -416,7 +415,7 @@ export default function Sales(props) {
416
415
  };
417
416
  }
418
417
 
419
- const saleRevenue = (sale.total - (sale.taxAmount || 0) - (sale.shippingCost || 0)) / 100;
418
+ const saleRevenue = sale.shippingProvision ? 0 : (sale.total || 0) / 100;
420
419
  prevLocationSales[location].totalRevenue += saleRevenue;
421
420
  prevLocationSales[location].orders += 1;
422
421
  prevTotalRevenue += saleRevenue;
@@ -11,7 +11,10 @@ import {
11
11
  IconButton,
12
12
  Tooltip,
13
13
  Box,
14
- CircularProgress
14
+ CircularProgress,
15
+ Dialog,
16
+ DialogTitle,
17
+ DialogContent
15
18
  } from '@mui/material';
16
19
  import DownloadIcon from '@mui/icons-material/Download';
17
20
  import TuneIcon from '@mui/icons-material/Tune';
@@ -35,6 +38,7 @@ export function SalesTable({ sales = [], designer = {}, admin = false, loading =
35
38
  const [filterModel, setFilterModel] = useState({ items: [], quickFilterValues: [] });
36
39
 
37
40
  const reconciliationData = useReconciliation({ designer, admin, sales, date });
41
+ const [flaggedOpen, setFlaggedOpen] = useState(false);
38
42
 
39
43
  // Early return if no designer data is available
40
44
  if (!designer?.user || !designer?.password) {
@@ -209,18 +213,61 @@ export function SalesTable({ sales = [], designer = {}, admin = false, loading =
209
213
  {!reconciliationData.isLoading && (
210
214
  <>
211
215
  <Typography variant="body1">
212
- <strong>Stripe Balance Change:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
216
+ <strong>Stripe Balance (charges + refunds):</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
213
217
  </Typography>
214
218
  <Typography variant="body1">
215
- <strong>Gross Sales:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
219
+ <strong>Calculated Gross:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
220
+ {reconciliationData.shippingTotal > 0 && (
221
+ <span style={{ opacity: 0.6 }}> (incl. ${(reconciliationData.shippingTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)</span>
222
+ )}
216
223
  </Typography>
217
224
  </>
218
225
  )}
219
-
226
+
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
+ )}
241
+
220
242
  </Box>
221
243
  </Box>
222
244
  )}
223
245
 
246
+ {/* Flagged Transactions Modal */}
247
+ <Dialog open={flaggedOpen} onClose={() => setFlaggedOpen(false)} maxWidth="sm" fullWidth>
248
+ <DialogTitle>Flagged Transactions</DialogTitle>
249
+ <DialogContent>
250
+ <Typography variant="body2" sx={{ mb: 2, opacity: 0.7 }}>
251
+ These transactions are in Stripe's balance but excluded from the charges+refunds reconciliation.
252
+ </Typography>
253
+ {reconciliationData.flaggedTransactions?.map((t, i) => (
254
+ <Box key={t.id || i} sx={{ display: 'flex', justifyContent: 'space-between', py: 1, borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
255
+ <Box>
256
+ <Typography variant="body2" sx={{ fontWeight: 'bold', textTransform: 'capitalize' }}>
257
+ {t.type?.replace(/_/g, ' ')}
258
+ </Typography>
259
+ <Typography variant="caption" sx={{ opacity: 0.6 }}>
260
+ {t.description || t.id} — {new Date(t.created * 1000).toLocaleDateString()}
261
+ </Typography>
262
+ </Box>
263
+ <Typography variant="body2" sx={{ fontWeight: 'bold', color: t.amount < 0 ? 'var(--red, red)' : 'inherit' }}>
264
+ ${(t.amount / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
265
+ </Typography>
266
+ </Box>
267
+ ))}
268
+ </DialogContent>
269
+ </Dialog>
270
+
224
271
  {/* Sales Table */}
225
272
  <Box
226
273
  sx={{
@@ -0,0 +1,91 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { calculateGrossSales } from '../calculateGrossSales.js';
3
+ import {
4
+ simpleSale,
5
+ saleWithRefund,
6
+ multiLineItem1,
7
+ multiLineItem2,
8
+ shippingSale,
9
+ disputedSale,
10
+ paymentIntentSale
11
+ } from '../../__fixtures__/sampleSales.js';
12
+
13
+ describe('calculateGrossSales', () => {
14
+ test('simple sale: grossSales equals totalWithTax', () => {
15
+ const { grossSales, shippingTotal } = calculateGrossSales([simpleSale]);
16
+
17
+ expect(grossSales).toBe(5350);
18
+ expect(shippingTotal).toBe(0);
19
+ });
20
+
21
+ test('shipping is included in gross but tracked separately', () => {
22
+ const { grossSales, shippingTotal } = calculateGrossSales([simpleSale, shippingSale]);
23
+
24
+ expect(grossSales).toBe(5350 + 1605);
25
+ expect(shippingTotal).toBe(1605);
26
+ });
27
+
28
+ test('refund is subtracted from gross', () => {
29
+ const { grossSales } = calculateGrossSales([saleWithRefund]);
30
+
31
+ // totalWithTax (10700) - refund.total (10700) = 0
32
+ expect(grossSales).toBe(0);
33
+ });
34
+
35
+ test('multi-line invoice refund is deduplicated by ID', () => {
36
+ const { grossSales } = calculateGrossSales([multiLineItem1, multiLineItem2]);
37
+
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
41
+ expect(grossSales).toBe(10700 - 5000);
42
+ });
43
+
44
+ test('refund without ID is still counted (fallback)', () => {
45
+ const saleWithUnidentifiedRefund = {
46
+ ...simpleSale,
47
+ refunds: [{
48
+ total: 1000,
49
+ adjustedTotal: 1000,
50
+ // No id field
51
+ }]
52
+ };
53
+
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
+ expect(grossSales).toBe(5350);
58
+ });
59
+
60
+ test('disputed sale: disputeDeduction is NOT subtracted from grossSales', () => {
61
+ const { grossSales } = calculateGrossSales([disputedSale]);
62
+
63
+ // totalWithTax is 21400, disputeDeduction is stored separately
64
+ // grossSales should be the full 21400
65
+ expect(grossSales).toBe(21400);
66
+ });
67
+
68
+ test('payment intent sale works the same as invoice', () => {
69
+ const { grossSales } = calculateGrossSales([paymentIntentSale]);
70
+
71
+ expect(grossSales).toBe(8000);
72
+ });
73
+
74
+ test('empty array returns zero', () => {
75
+ const { grossSales, shippingTotal } = calculateGrossSales([]);
76
+
77
+ expect(grossSales).toBe(0);
78
+ expect(shippingTotal).toBe(0);
79
+ });
80
+
81
+ test('mixed sales: products + shipping + refund', () => {
82
+ const { grossSales, shippingTotal } = calculateGrossSales([
83
+ simpleSale, // 5350
84
+ shippingSale, // 1605
85
+ saleWithRefund, // 10700 - 10700 refund = 0
86
+ ]);
87
+
88
+ expect(grossSales).toBe(5350 + 1605 + 10700 - 10700);
89
+ expect(shippingTotal).toBe(1605);
90
+ });
91
+ });
@@ -0,0 +1,31 @@
1
+ // Pure function for calculating gross sales — extracted for testability
2
+ /**
3
+ * Calculates gross sales from sales data, matching Stripe's charge+refund total
4
+ * @param {Array} sales - Array of sale objects
5
+ * @returns {Object} grossSales total and shipping breakdown (in cents)
6
+ */
7
+ export function calculateGrossSales(sales) {
8
+ let grossSales = 0;
9
+ let shippingTotal = 0;
10
+ const processedRefunds = new Set();
11
+
12
+ sales.forEach(sale => {
13
+ // Use totalWithTax for all items — this is what Stripe actually charged
14
+ grossSales += sale?.totalWithTax || 0;
15
+
16
+ if (sale?.shippingProvision) {
17
+ shippingTotal += sale?.totalWithTax || 0;
18
+ }
19
+
20
+ // Deduplicate refunds by ID — multi-line invoices repeat the same refunds
21
+ // Use refund.total (actual Stripe amount) not adjustedTotal (proportional)
22
+ sale?.refunds?.forEach(refund => {
23
+ if (refund?.id && !processedRefunds.has(refund.id)) {
24
+ processedRefunds.add(refund.id);
25
+ grossSales -= (refund.total || 0);
26
+ }
27
+ });
28
+ });
29
+
30
+ return { grossSales, shippingTotal };
31
+ }
@@ -1,21 +1,9 @@
1
1
  // Shared hook for Stripe balance reconciliation
2
2
  import { useState, useEffect } from 'react';
3
+ import { calculateGrossSales } from './calculateGrossSales.js';
3
4
 
4
- /**
5
- * Calculates gross sales from sales data
6
- * @param {Array} sales - Array of sale objects
7
- * @returns {number} Gross sales total in cents
8
- */
9
- function calculateGrossSales(sales) {
10
- let grossSales = 0;
11
- sales.forEach(sale => {
12
- sale?.refunds?.map((refund) => {
13
- grossSales -= (refund.adjustedTotal || 0);
14
- });
15
- grossSales += sale?.shippingProvision ? sale?.total : sale?.totalWithTax;
16
- });
17
- return grossSales;
18
- }
5
+ // Re-export for backward compatibility
6
+ export { calculateGrossSales };
19
7
 
20
8
  /**
21
9
  * Hook for fetching and comparing Stripe balance transactions against sales data
@@ -33,7 +21,9 @@ export function useReconciliation({ designer, admin, sales, date, dateRange }) {
33
21
  isReconciled: false,
34
22
  totalBalanceChange: 0,
35
23
  grossSales: 0,
24
+ shippingTotal: 0,
36
25
  difference: 0,
26
+ flaggedTransactions: [],
37
27
  error: null
38
28
  });
39
29
 
@@ -67,14 +57,17 @@ export function useReconciliation({ designer, admin, sales, date, dateRange }) {
67
57
 
68
58
  if (data.success) {
69
59
  const totalBalanceChange = data.data.totalBalanceChange;
70
- const grossSales = calculateGrossSales(sales);
60
+ const flaggedTransactions = data.data.flaggedTransactions || [];
61
+ const { grossSales, shippingTotal } = calculateGrossSales(sales);
71
62
 
72
63
  setReconciliationData(prev => ({
73
64
  ...prev,
74
65
  totalBalanceChange,
75
66
  grossSales,
67
+ shippingTotal,
76
68
  difference: totalBalanceChange - grossSales,
77
69
  isReconciled: Math.abs(totalBalanceChange - grossSales) < 1,
70
+ flaggedTransactions,
78
71
  isLoading: false
79
72
  }));
80
73
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sales-portal",
3
- "version": "1.8.1",
3
+ "version": "2.0.0",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -49,7 +49,10 @@
49
49
  }
50
50
  },
51
51
  "scripts": {
52
- "test": "echo \"No tests yet\" && exit 0"
52
+ "test": "vitest run",
53
+ "link:tdf": "npm link && cd ../../sites/tdf && npm link @liiift-studio/sales-portal",
54
+ "link:darden": "npm link && cd ../../sites/darden && npm link @liiift-studio/sales-portal",
55
+ "link:positype": "npm link && cd ../../sites/positype && npm link @liiift-studio/sales-portal"
53
56
  },
54
57
  "keywords": [
55
58
  "sales",
@@ -82,6 +85,7 @@
82
85
  "@emotion/react": "^11.14.0",
83
86
  "@emotion/styled": "^11.14.1",
84
87
  "@mui/icons-material": "^5.18.0",
85
- "slugify": "^1.6.6"
88
+ "slugify": "^1.6.6",
89
+ "vitest": "^4.1.1"
86
90
  }
87
91
  }