@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.
- package/api/getBalanceTransactions.js +58 -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 +10 -4
- package/api/utils/processors/paymentProcessor.js +12 -6
- package/components/DateRangeSalesTable.js +92 -46
- package/components/LoginForm.js +31 -36
- package/components/Sales.js +6 -6
- package/components/SalesPortalPage.js +1 -1
- package/components/SalesTable.js +90 -44
- package/hooks/__tests__/useReconciliation.test.js +96 -0
- package/hooks/calculateGrossSales.js +39 -0
- package/hooks/useReconciliation.js +110 -21
- package/package.json +7 -3
- package/utils/currencyUtils.js +57 -32
|
@@ -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
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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)
|
|
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
|
|
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
|
|
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,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
|
|
317
|
-
total: payment.shipping_cost
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
<
|
|
536
|
-
<
|
|
537
|
-
|
|
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
|
|
548
|
+
<strong>Stripe Balance:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
562
549
|
</Typography>
|
|
563
550
|
<Typography variant="body1">
|
|
564
|
-
<strong>Gross
|
|
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
|
<>
|