@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.
- package/api/getBalanceTransactions.js +22 -6
- package/api/utils/__tests__/dateUtils.test.js +111 -0
- package/api/utils/__tests__/feeCalculator.test.js +100 -0
- package/api/utils/processors/invoiceProcessor.js +4 -2
- package/api/utils/processors/paymentProcessor.js +6 -4
- package/components/DateRangeSalesTable.js +51 -3
- package/components/Sales.js +4 -5
- package/components/SalesTable.js +51 -4
- package/hooks/__tests__/useReconciliation.test.js +91 -0
- package/hooks/calculateGrossSales.js +31 -0
- package/hooks/useReconciliation.js +9 -16
- package/package.json +7 -3
|
@@ -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
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
225
|
-
const itemTotalWithTax = (item.amount || 0)
|
|
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
|
|
317
|
-
total: payment.shipping_cost
|
|
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
|
|
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
|
|
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
|
<>
|
package/components/Sales.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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;
|
package/components/SalesTable.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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": "
|
|
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
|
}
|