@liiift-studio/sales-portal 1.8.0 → 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/LoginForm.js +6 -6
- package/components/Sales.js +8 -6
- 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/LoginForm.js
CHANGED
|
@@ -123,18 +123,17 @@ export default function LoginForm({
|
|
|
123
123
|
<Box sx={{
|
|
124
124
|
flex: 1,
|
|
125
125
|
pt: { xs: 4, sm: 6 },
|
|
126
|
-
pr: { xs: 4, sm: 6 },
|
|
127
126
|
pb: 0,
|
|
128
|
-
|
|
127
|
+
px: 0,
|
|
129
128
|
display: 'flex',
|
|
130
129
|
flexDirection: 'column',
|
|
131
|
-
justifyContent: '
|
|
130
|
+
justifyContent: 'space-between',
|
|
132
131
|
}}>
|
|
133
|
-
<Typography component="span" variant="h2" sx={{ mb: 5, display: 'block',
|
|
132
|
+
<Typography component="span" variant="h2" sx={{ mb: 5, display: 'block', px: { xs: 4, sm: 6 } }}>
|
|
134
133
|
{title}
|
|
135
134
|
</Typography>
|
|
136
135
|
|
|
137
|
-
<Box sx={{
|
|
136
|
+
<Box sx={{ px: { xs: 4, sm: 6 } }}>
|
|
138
137
|
<Input
|
|
139
138
|
placeholder="Email"
|
|
140
139
|
onChange={(e) => setUser(e.target.value)}
|
|
@@ -205,7 +204,7 @@ export default function LoginForm({
|
|
|
205
204
|
fullWidth
|
|
206
205
|
sx={{
|
|
207
206
|
m: 0,
|
|
208
|
-
mt: '
|
|
207
|
+
mt: '1em',
|
|
209
208
|
border: 'none',
|
|
210
209
|
typography: 'h4',
|
|
211
210
|
color: 'var(--black)',
|
|
@@ -214,6 +213,7 @@ export default function LoginForm({
|
|
|
214
213
|
alignItems: 'center',
|
|
215
214
|
bgcolor: 'var(--green)',
|
|
216
215
|
minHeight: '64px',
|
|
216
|
+
maxWidth: 'initial',
|
|
217
217
|
borderRadius: '0 0 0 12px',
|
|
218
218
|
opacity: 1,
|
|
219
219
|
flexShrink: 0,
|
package/components/Sales.js
CHANGED
|
@@ -151,12 +151,15 @@ export default function Sales(props) {
|
|
|
151
151
|
}
|
|
152
152
|
}, [date, designer?.admin, month, year, updateDate]);
|
|
153
153
|
|
|
154
|
+
// Status codes worth retrying (rate limit and transient server errors)
|
|
155
|
+
const RETRYABLE_STATUSES = [429, 500];
|
|
156
|
+
|
|
154
157
|
// Fetch with retry logic for rate-limited and network errors
|
|
155
158
|
async function fetchWithRetry(url, options, label, attempt = 0) {
|
|
156
159
|
try {
|
|
157
160
|
const response = await fetch(url, options);
|
|
158
161
|
|
|
159
|
-
if (!response.ok && attempt < MAX_RETRIES) {
|
|
162
|
+
if (!response.ok && attempt < MAX_RETRIES && RETRYABLE_STATUSES.includes(response.status)) {
|
|
160
163
|
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
|
|
161
164
|
setRetryInfo({ retrying: true, attempt: attempt + 1, label });
|
|
162
165
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
@@ -318,10 +321,9 @@ export default function Sales(props) {
|
|
|
318
321
|
|
|
319
322
|
if (previousSales && previousSales.length > 0) {
|
|
320
323
|
previousPeriodTotal = previousSales.reduce((sum, sale) => {
|
|
324
|
+
if (sale.shippingProvision) return sum; // Exclude shipping from revenue
|
|
321
325
|
const saleTotal = sale.total || 0;
|
|
322
|
-
|
|
323
|
-
const saleShipping = sale.shippingCost || 0;
|
|
324
|
-
return sum + (saleTotal - saleTax - saleShipping) / 100; // Convert cents to dollars
|
|
326
|
+
return sum + saleTotal / 100; // Convert cents to dollars
|
|
325
327
|
}, 0);
|
|
326
328
|
|
|
327
329
|
if (previousPeriodTotal > 0) {
|
|
@@ -376,7 +378,7 @@ export default function Sales(props) {
|
|
|
376
378
|
};
|
|
377
379
|
}
|
|
378
380
|
|
|
379
|
-
const saleRevenue =
|
|
381
|
+
const saleRevenue = sale.shippingProvision ? 0 : (sale.total || 0) / 100;
|
|
380
382
|
locationSales[location].totalRevenue += saleRevenue;
|
|
381
383
|
locationSales[location].orders += 1;
|
|
382
384
|
});
|
|
@@ -413,7 +415,7 @@ export default function Sales(props) {
|
|
|
413
415
|
};
|
|
414
416
|
}
|
|
415
417
|
|
|
416
|
-
const saleRevenue =
|
|
418
|
+
const saleRevenue = sale.shippingProvision ? 0 : (sale.total || 0) / 100;
|
|
417
419
|
prevLocationSales[location].totalRevenue += saleRevenue;
|
|
418
420
|
prevLocationSales[location].orders += 1;
|
|
419
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
|
}
|