@liiift-studio/sales-portal 1.8.1 → 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,18 +22,70 @@ 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
+ // Build detailed reconciliation data
26
+ const RECONCILIATION_TYPES = ['charge', 'refund'];
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;
35
+ const flaggedTransactions = [];
36
+
37
+ balanceTransactions.forEach(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;
50
+
51
+ if (RECONCILIATION_TYPES.includes(transaction.type)) {
52
+ rawTotal += originalAmount;
53
+ if (transaction.type === 'charge') {
54
+ chargeCount++;
55
+ chargeTotal += originalAmount;
56
+ } else {
57
+ refundCount++;
58
+ refundTotal += originalAmount;
59
+ }
60
+ } else if (transaction.type !== 'payout' && transaction.type !== 'stripe_fee') {
61
+ flaggedTransactions.push({
62
+ id: transaction.id,
63
+ type: transaction.type,
64
+ amount: Math.round(originalAmount),
65
+ created: transaction.created,
66
+ description: transaction.description,
67
+ source: transaction.source,
68
+ });
29
69
  }
30
- return total + transaction.amount;
31
- }, 0);
70
+ });
71
+
72
+ // Round the accumulated total once to minimize rounding error
73
+ const totalBalanceChange = Math.round(rawTotal);
32
74
 
33
75
  return res.status(200).json({
34
76
  success: true,
35
77
  data: {
36
78
  totalBalanceChange,
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
+ },
37
89
  transactions: balanceTransactions
38
90
  }
39
91
  });
@@ -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,14 +85,16 @@ 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,
97
+ chargeAmount: invoice.charge?.amount || invoice.total,
96
98
  discountAmounts: line.discount_amounts,
97
99
  taxAmounts: line.tax_amounts,
98
100
  stripeFees: totalFees,
@@ -139,7 +141,8 @@ function processLineItem({ line, invoice, associatedOrder, associatedTypeface, i
139
141
  orderNumber: associatedOrder?.orderNumber || null,
140
142
  author: associatedTypeface?.author || null,
141
143
  typeface: associatedTypeface || null,
142
- saleType: "invoice"
144
+ saleType: "invoice",
145
+ currency: invoice.currency || 'usd'
143
146
  };
144
147
  }
145
148
 
@@ -171,10 +174,12 @@ function processShippingCost({ invoice, associatedOrder, totalFee = null, paymen
171
174
 
172
175
  return {
173
176
  // Quantities
174
- totalWithTax: invoice.shipping_cost.amount_total - disputeAmount,
177
+ totalWithTax: invoice.shipping_cost.amount_total,
175
178
  total: invoice.shipping_cost.amount_subtotal, // Pre-tax total
179
+ disputeDeduction: disputeAmount || 0,
176
180
  invoiceTotal: invoice.total_excluding_tax,
177
181
  invoiceTotalWithTax: invoice.total,
182
+ chargeAmount: invoice.charge?.amount || invoice.total,
178
183
  discountAmounts: null,
179
184
  stripeFees: totalFees,
180
185
  disputed,
@@ -228,7 +233,8 @@ function processShippingCost({ invoice, associatedOrder, totalFee = null, paymen
228
233
  orderNumber: associatedOrder?.orderNumber || null,
229
234
  author: null,
230
235
  typeface: null,
231
- saleType: "invoice"
236
+ saleType: "invoice",
237
+ currency: invoice.currency || 'usd'
232
238
  };
233
239
  }
234
240
 
@@ -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,8 +232,10 @@ 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,
238
+ chargeAmount: payment.latest_charge?.amount || payment.amount,
237
239
  discountAmounts: [],
238
240
  stripeFees: totalFees,
239
241
  disputed,
@@ -282,7 +284,8 @@ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTyp
282
284
  orderNumber: associatedOrder?.orderNumber || null,
283
285
  author: associatedTypeface?.author || null,
284
286
  typeface: associatedTypeface || null,
285
- saleType: "paymentIntent"
287
+ saleType: "paymentIntent",
288
+ currency: payment.currency || 'usd'
286
289
  };
287
290
  }
288
291
 
@@ -313,10 +316,12 @@ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
313
316
  });
314
317
 
315
318
  return {
316
- totalWithTax: payment.shipping_cost - disputeAmount,
317
- total: payment.shipping_cost - disputeAmount,
319
+ totalWithTax: payment.shipping_cost,
320
+ total: payment.shipping_cost,
321
+ disputeDeduction: disputeAmount || 0,
318
322
  invoiceTotal: payment.amount,
319
323
  invoiceTotalWithTax: payment.amount,
324
+ chargeAmount: payment.latest_charge?.amount || payment.amount,
320
325
  discountAmounts: [],
321
326
  stripeFees: totalFees,
322
327
  disputed,
@@ -357,7 +362,8 @@ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
357
362
  orderNumber: associatedOrder?.orderNumber || null,
358
363
  author: null,
359
364
  typeface: null,
360
- saleType: "paymentIntent"
365
+ saleType: "paymentIntent",
366
+ currency: payment.currency || 'usd'
361
367
  };
362
368
  }
363
369
 
@@ -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({
@@ -501,25 +505,21 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
501
505
  {/* Reconciliation Check - Only show for admin users */}
502
506
  {admin && dateRangeSales.length > 0 && (
503
507
  <Box sx={{ width: '100%', mb: 2 }}>
504
- <Box
505
- sx={{
506
- display: 'inline-block',
507
- gap: 2,
508
- p: 2,
509
- ml: -2,
510
- borderRadius: '4px',
511
- border: reconciliationData.isReconciled
512
- ? '2px solid var(--green, green)'
513
- : '2px solid var(--red, red)',
514
- color: reconciliationData.isReconciled
515
- ? 'var(--green, green)'
516
- : 'var(--red, red)',
517
- backgroundColor: 'rgba(255, 255, 255, 0.9)',
518
- filter: 'invert(0) !important',
519
- '& *': { filter: 'invert(0) !important' },
520
- }}
521
- >
522
-
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
+ }}>
523
523
  {reconciliationData.error ? (
524
524
  <Typography variant="body1" sx={{ color: 'var(--red, red)' }}>
525
525
  <WarningIcon fontSize="small" sx={{ mr: 1 }} />
@@ -528,47 +528,93 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
528
528
  ) : reconciliationData.isLoading ? (
529
529
  <Typography variant="body1"><CircularProgress size={24} sx={{ mr: 1 }} /> Checking reconciliation...</Typography>
530
530
  ) : reconciliationData.isReconciled ? (
531
- <Typography variant="body1" sx={{ fontWeight: 'bold'}}>
531
+ <Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
532
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
+ )}
533
538
  </Typography>
534
539
  ) : (
535
- <Box sx={{ display: 'flex', alignItems: 'center' }}>
536
- <Tooltip title="The difference between Stripe balance change and gross sales">
537
- <Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
538
- <WarningIcon/> Needs Reconciliation (Difference: ${(Math.abs(reconciliationData.difference) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })})
539
- </Typography>
540
- </Tooltip>
541
- <Tooltip
542
- title={
543
- <React.Fragment>
544
- <Typography variant="body2" sx={{ fontWeight: 'bold' }}>Possible causes:</Typography>
545
- <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>
546
- </React.Fragment>
547
- }
548
- placement="right"
549
- arrow
550
- >
551
- <IconButton size="small" sx={{ ml: 1, color: 'inherit' }}>
552
- <InfoIcon fontSize="small" />
553
- </IconButton>
554
- </Tooltip>
555
- </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>
556
543
  )}
557
-
558
- {!reconciliationData.isLoading && (
544
+
545
+ {!reconciliationData.isLoading && !reconciliationData.error && (
559
546
  <>
560
547
  <Typography variant="body1">
561
- <strong>Stripe Balance Change:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
548
+ <strong>Stripe Balance:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
562
549
  </Typography>
563
550
  <Typography variant="body1">
564
- <strong>Gross Sales:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
551
+ <strong>Calculated Gross:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
552
+ {reconciliationData.shippingTotal > 0 && (
553
+ <span style={{ opacity: 0.6 }}> (incl. ${(reconciliationData.shippingTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)</span>
554
+ )}
565
555
  </Typography>
556
+
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
+ )}
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
+ )}
566
587
  </>
567
588
  )}
568
589
  </Box>
569
590
  </Box>
570
591
  )}
571
592
 
593
+ {/* Flagged Transactions Modal */}
594
+ <Dialog open={flaggedOpen} onClose={() => setFlaggedOpen(false)} maxWidth="sm" fullWidth>
595
+ <DialogTitle>Flagged Transactions</DialogTitle>
596
+ <DialogContent>
597
+ <Typography variant="body2" sx={{ mb: 2, opacity: 0.7 }}>
598
+ These transactions are in Stripe's balance but excluded from the charges+refunds reconciliation.
599
+ </Typography>
600
+ {reconciliationData.flaggedTransactions?.map((t, i) => (
601
+ <Box key={t.id || i} sx={{ display: 'flex', justifyContent: 'space-between', py: 1, borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
602
+ <Box>
603
+ <Typography variant="body2" sx={{ fontWeight: 'bold', textTransform: 'capitalize' }}>
604
+ {t.type?.replace(/_/g, ' ')}
605
+ </Typography>
606
+ <Typography variant="caption" sx={{ opacity: 0.6 }}>
607
+ {t.description || t.id} — {new Date(t.created * 1000).toLocaleDateString()}
608
+ </Typography>
609
+ </Box>
610
+ <Typography variant="body2" sx={{ fontWeight: 'bold', color: t.amount < 0 ? 'var(--red, red)' : 'inherit' }}>
611
+ ${(t.amount / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
612
+ </Typography>
613
+ </Box>
614
+ ))}
615
+ </DialogContent>
616
+ </Dialog>
617
+
572
618
  {/* Summary Dashboard and Analysis Components - only show when data is loaded */}
573
619
  {!!dateRangeSales.length && (
574
620
  <>