@liiift-studio/sales-portal 1.2.1

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.
Files changed (52) hide show
  1. package/README.md +461 -0
  2. package/SETUP.md +230 -0
  3. package/api/getAnalytics.d.ts +38 -0
  4. package/api/getAnalytics.js +346 -0
  5. package/api/getBalanceTransactions.d.ts +29 -0
  6. package/api/getBalanceTransactions.js +125 -0
  7. package/api/getDesignerInfo.d.ts +37 -0
  8. package/api/getDesignerInfo.js +98 -0
  9. package/api/getDesigners.d.ts +28 -0
  10. package/api/getDesigners.js +63 -0
  11. package/api/getPreviousSales.d.ts +23 -0
  12. package/api/getPreviousSales.js +82 -0
  13. package/api/getSales.d.ts +29 -0
  14. package/api/getSales.js +50 -0
  15. package/api/getSalesRange.d.ts +23 -0
  16. package/api/getSalesRange.js +58 -0
  17. package/api/utils/authMiddleware.js +84 -0
  18. package/api/utils/dateUtils.js +69 -0
  19. package/api/utils/feeCalculator.js +148 -0
  20. package/api/utils/processors/invoiceProcessor.js +337 -0
  21. package/api/utils/processors/paymentProcessor.js +462 -0
  22. package/api/utils/salesDataProcessing.js +596 -0
  23. package/api/utils/salesDataProcessor.js +224 -0
  24. package/api/utils/stripeFetcher.js +248 -0
  25. package/components/DateRangeSalesTable.js +1072 -0
  26. package/components/DebugValues.js +48 -0
  27. package/components/LicenseTypeList.js +193 -0
  28. package/components/LoginForm.js +219 -0
  29. package/components/PeriodComparison.js +501 -0
  30. package/components/Sales.js +773 -0
  31. package/components/SalesChart.js +307 -0
  32. package/components/SalesPortalPage.js +147 -0
  33. package/components/SalesTable.js +677 -0
  34. package/components/SummaryCards.js +345 -0
  35. package/components/TopPerformers.js +331 -0
  36. package/components/TypefaceList.js +154 -0
  37. package/components/table-columns.js +70 -0
  38. package/components/table-row-cells.js +295 -0
  39. package/data/countryCode.json +318 -0
  40. package/hooks/useSalesDateQuery.d.ts +20 -0
  41. package/hooks/useSalesDateQuery.js +71 -0
  42. package/index.d.ts +172 -0
  43. package/index.js +33 -0
  44. package/package.json +87 -0
  45. package/styles/sales-portal.module.scss +383 -0
  46. package/styles/sales-portal.theme.d.ts +5 -0
  47. package/styles/sales-portal.theme.js +799 -0
  48. package/utils/currencyUtils.d.ts +20 -0
  49. package/utils/currencyUtils.js +79 -0
  50. package/utils/salesDataProcessing.d.ts +44 -0
  51. package/utils/salesDataProcessing.js +596 -0
  52. package/utils/useSalesDateQuery.js +71 -0
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Processor for Stripe payment intent data
3
+ */
4
+
5
+ import { calculateFees, formatRefunds } from '../feeCalculator';
6
+
7
+ const debuggingAccounts = ["colby@liiift.studio", "quinn@liiift.studio", "qkeave@gmail.com", "quinn@quitetype.com"];
8
+
9
+ /**
10
+ * Finds a matching product based on price and creation time
11
+ * @param {Object} payment - Payment intent
12
+ * @param {Object} item - Payment item
13
+ * @param {Array} sanitySales - Sanity orders to search
14
+ * @returns {string} Product description or null
15
+ */
16
+ function findMatchingProduct(payment, item, sanitySales) {
17
+ if (!payment?.amount || !item?.amount || item.amount <= 0) return null;
18
+
19
+ // Look for orders with similar price and creation time
20
+ const matchingOrders = sanitySales.filter(order => {
21
+ if (!order?._createdAt) return false;
22
+
23
+ const orderTime = new Date(order._createdAt).getTime() / 1000;
24
+ const paymentTime = payment.created;
25
+ const timeDiff = Math.abs(orderTime - paymentTime);
26
+ const dayInSeconds = 24 * 60 * 60;
27
+
28
+ // Match if:
29
+ // 1. Price matches exactly
30
+ // 2. Created within 2 days of each other
31
+ return Math.abs(order.cost * 100 - item.amount) < 1 && // Compare in cents with 1 cent tolerance
32
+ timeDiff < (2 * dayInSeconds); // Within 2 days
33
+ });
34
+
35
+ if (matchingOrders.length > 0) {
36
+ // Get the order closest in time
37
+ const closestOrder = matchingOrders.reduce((closest, current) => {
38
+ const closestTime = new Date(closest._createdAt).getTime() / 1000;
39
+ const currentTime = new Date(current._createdAt).getTime() / 1000;
40
+ const closestDiff = Math.abs(closestTime - payment.created);
41
+ const currentDiff = Math.abs(currentTime - payment.created);
42
+ return currentDiff < closestDiff ? current : closest;
43
+ });
44
+
45
+ // Generate description from typefaces
46
+ if (closestOrder?.typefaces?.length) {
47
+ return closestOrder.typefaces.map(typeface =>
48
+ `${typeface?.typeface?.title?.toUpperCase()} (${(typeface?.fonts?.length || 0) + 1} styles)`
49
+ ).join(', ');
50
+ }
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Creates a new order object from payment intent data
58
+ * @param {Object} payment - Payment intent
59
+ * @param {Object} item - Payment item
60
+ * @returns {Object} Created order object
61
+ */
62
+ function createOrderFromPayment(payment, item) {
63
+ const email = payment.customer?.email || payment.latest_charge?.billing_details?.email || '';
64
+ const orderNumber = payment.metadata?.orderNumber || payment.id || '';
65
+
66
+ // Create empty address structure
67
+ const emptyAddress = {
68
+ city: "",
69
+ postalCode: "",
70
+ email: "",
71
+ address: "",
72
+ phone: "",
73
+ state: "",
74
+ country: "",
75
+ name: "",
76
+ address2: ""
77
+ };
78
+
79
+ // Get billing address from payment
80
+ const billingAddress = {
81
+ ...emptyAddress,
82
+ address: payment.latest_charge?.billing_details?.address?.line1 || "",
83
+ address2: payment.latest_charge?.billing_details?.address?.line2 || "",
84
+ postalCode: payment.latest_charge?.billing_details?.address?.postal_code || "",
85
+ email: email,
86
+ name: payment.latest_charge?.billing_details?.name || "",
87
+ country: payment.latest_charge?.billing_details?.address?.country || "",
88
+ city: payment.latest_charge?.billing_details?.address?.city || "",
89
+ phone: payment.latest_charge?.billing_details?.phone || "",
90
+ state: payment.latest_charge?.billing_details?.address?.state || ""
91
+ };
92
+
93
+ // Create order object with minimal required fields
94
+ return {
95
+ _type: "order",
96
+ _id: orderNumber,
97
+ title: email,
98
+ orderNumber,
99
+ cost: payment.amount / 100,
100
+ _createdAt: new Date(payment.created * 1000).toISOString(),
101
+ _updatedAt: new Date(payment.created * 1000).toISOString(),
102
+ orderSuccess: true,
103
+ billingAddress,
104
+ orderStatus: {
105
+ status: "verified",
106
+ paymentIntentId: payment.id,
107
+ orderNumber: orderNumber
108
+ },
109
+ typefaces: [{
110
+ typeface: {
111
+ title: item.description || "Unknown Typeface"
112
+ }
113
+ }],
114
+ slug: {
115
+ current: `${email}-${orderNumber}`,
116
+ _type: "slug"
117
+ }
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Finds matching order and typeface for a payment item
123
+ * @param {Object} params - Search parameters
124
+ * @param {Object} params.payment - Payment to match
125
+ * @param {Object} [params.item] - Payment item (optional)
126
+ * @param {Array} params.sanitySales - Sanity orders to search
127
+ * @returns {Object} Matching order and typeface
128
+ */
129
+ function findMatches({ payment, item = {}, sanitySales = [] }) {
130
+ if (!payment || !Array.isArray(sanitySales)) {
131
+ return { associatedOrder: null, associatedTypeface: null };
132
+ }
133
+
134
+ // Find matching order
135
+ let associatedOrder = sanitySales.find(order => {
136
+ if (!order?.orderStatus) return false;
137
+
138
+ const isPaymentMatch = order.orderStatus.paymentIntentId === payment.id;
139
+ const isTimeMatch = payment?.customer?.email &&
140
+ order._createdAt &&
141
+ Math.abs(new Date(order._createdAt) - new Date(payment.created * 1000)) < (1000 * 60 * 60);
142
+
143
+ return isPaymentMatch || isTimeMatch;
144
+ });
145
+
146
+ // If no order found, create one from payment data
147
+ if (!associatedOrder && item) {
148
+ associatedOrder = createOrderFromPayment(payment, item);
149
+ }
150
+
151
+ // Fix description for unknown items before finding typeface
152
+ if (item?.description === 'Unknown item' && associatedOrder?.typefaces?.length) {
153
+ item.description = associatedOrder.typefaces.map(typeface =>
154
+ `${typeface?.typeface?.title} ${typeface?.fonts?.length ? `(${(typeface?.fonts?.length) + 1} styles)` : ``}`
155
+ ).join(', ');
156
+ }
157
+
158
+ // Find matching typeface
159
+ let associatedTypeface = null;
160
+ if (associatedOrder?.typefaces?.length) {
161
+ try {
162
+ const sortedTypefaces = [...associatedOrder.typefaces]
163
+ .filter(t => t?.typeface?.title)
164
+ .sort((a, b) => b.typeface.title.length - a.typeface.title.length);
165
+
166
+ if (item?.description) {
167
+ const matchingTypeface = sortedTypefaces.find(typeface =>
168
+ item.description.toLowerCase().includes(typeface.typeface.title.toLowerCase())
169
+ );
170
+ associatedTypeface = matchingTypeface?.typeface || null;
171
+ }
172
+ } catch (error) {
173
+ console.warn('Error finding matching typeface:', error);
174
+ }
175
+ }
176
+
177
+ return { associatedOrder, associatedTypeface };
178
+ }
179
+
180
+ /**
181
+ * Processes a single item from a payment intent
182
+ * @param {Object} params - Processing parameters
183
+ * @param {Object} params.item - Payment item
184
+ * @param {Object} params.payment - Parent payment intent
185
+ * @param {Object} params.associatedOrder - Matching Sanity order if found
186
+ * @param {Object} params.associatedTypeface - Matching typeface if found
187
+ * @param {number} params.itemIndex - Index of item in payment
188
+ * @param {number} params.totalItems - Total number of items in payment
189
+ * @param {boolean} params.isPrimaryItem - Whether this is the first/primary item that gets transaction fees
190
+ * @param {number} [params.totalFee] - Total transaction fee for the payment (for non-primary items)
191
+ * @returns {Object} Processed payment item data
192
+ */
193
+ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTypeface, itemIndex, totalItems, sanitySales, isPrimaryItem = false, totalFee = null }) {
194
+ // Check if this is a refund - we'll consider it a refund if it has a negative amount
195
+ // or if the payment status indicates it's a refund
196
+ const isRefund = (item.amount || 0) < 0 || payment.status === 'refunded';
197
+
198
+ // Calculate proper amounts that account for discounts
199
+ const discountAmount = payment.metadata?.discount ? Number(payment.metadata.discount) * 100 : 0; // Convert to cents
200
+ const itemDiscountPortion = discountAmount * ((item.amount || 0) / payment.amount);
201
+ const itemAmountAdjusted = (item.amount || 0) - itemDiscountPortion;
202
+ const paymentTotalAdjusted = payment.amount - discountAmount;
203
+
204
+ // Calculate fees and dispute amounts using discount-adjusted values
205
+ const result = calculateFees({
206
+ charge: payment.latest_charge,
207
+ amount: itemAmountAdjusted, // Use discount-adjusted amount
208
+ total: paymentTotalAdjusted, // Use discount-adjusted total
209
+ isPrimaryItem,
210
+ isRefund,
211
+ totalFee
212
+ });
213
+
214
+ const { disputed, disputeAmount, totalFees, disputeDetails, totalTransactionFee } = result;
215
+
216
+ const itemTaxAmount = (associatedOrder?.tax || 0) / totalItems;
217
+ const itemTotal = (item.amount || 0) - itemTaxAmount - disputeAmount;
218
+ const itemTotalWithTax = (item.amount || 0) - disputeAmount;
219
+
220
+ // Try to find matching product if no description
221
+ let description = item.description;
222
+ if (!description && payment.saleType === "paymentIntent" && item.amount > 0) {
223
+ description = findMatchingProduct(payment, item, sanitySales);
224
+ }
225
+ console.log("description", description);
226
+ console.log("payment", payment);
227
+ return {
228
+ totalWithTax: itemTotalWithTax,
229
+ total: itemTotal,
230
+ invoiceTotal: payment.amount,
231
+ invoiceTotalWithTax: payment.amount,
232
+ discountAmounts: [],
233
+ stripeFees: totalFees,
234
+ disputed,
235
+ disputeDetails,
236
+ taxAmounts: [{
237
+ amount: itemTaxAmount,
238
+ inclusive: true,
239
+ tax_rate: null,
240
+ taxability_reason: null,
241
+ taxable_amount: item.amount
242
+ }],
243
+ refunds: formatRefunds(
244
+ payment.latest_charge?.refunds?.data,
245
+ item.amount || 0,
246
+ payment.amount
247
+ ),
248
+ description: description || payment.metadata?.product_description || 'Unknown item',
249
+ discounts: payment.metadata?.discount ? [{
250
+ amount: payment.metadata.discount,
251
+ description: 'Discount applied'
252
+ }] : [],
253
+ lineId: `${payment.id}-${itemIndex}`,
254
+ created: payment.created,
255
+ id: payment.id,
256
+ invoicePdf: payment?.latest_charge?.receipt_url || null,
257
+ number: payment?.latest_charge?.receipt_number || null,
258
+ customer: payment.customer,
259
+ testSale: debuggingAccounts.includes(payment?.customer?.email) ||
260
+ debuggingAccounts.includes(payment?.shipping?.address?.email) ||
261
+ debuggingAccounts.includes(payment?.latest_charge?.billing_details?.email) ||
262
+ debuggingAccounts.includes(associatedOrder?.billingAddress?.email) ||
263
+ debuggingAccounts.includes(associatedOrder?.shippingAddress?.email) ||
264
+ debuggingAccounts.includes(associatedOrder?.licenseeAddress?.email),
265
+ customerAddress: payment.shipping?.address || payment.latest_charge?.billing_details?.address || null,
266
+ // Payment method data
267
+ paymentMethod: {
268
+ type: payment?.payment_method?.type,
269
+ card: payment?.payment_method?.card,
270
+ origin: payment?.payment_method?.card?.country ? {
271
+ country: payment?.payment_method?.card?.country
272
+ } : null
273
+ },
274
+ order: associatedOrder || null,
275
+ orderNumber: associatedOrder?.orderNumber || null,
276
+ author: associatedTypeface?.author || null,
277
+ typeface: associatedTypeface || null,
278
+ saleType: "paymentIntent"
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Processes shipping costs from a payment intent
284
+ * @param {Object} params - Processing parameters
285
+ * @param {Object} params.payment - Parent payment intent
286
+ * @param {Object} params.associatedOrder - Matching Sanity order if found
287
+ * @param {number} [params.totalFee] - Total transaction fee for the payment (for proportional distribution)
288
+ * @returns {Object} Processed shipping data
289
+ */
290
+ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
291
+ if (!payment?.shipping_cost) {
292
+ return null;
293
+ }
294
+
295
+ // Check if this is a refund shipping cost
296
+ const isRefund = payment.shipping_cost < 0 || payment.status === 'refunded';
297
+
298
+ // Shipping should get proportional fee allocation
299
+ const { disputed, disputeAmount, totalFees, disputeDetails } = calculateFees({
300
+ charge: payment.latest_charge,
301
+ amount: payment.shipping_cost,
302
+ total: payment.amount,
303
+ isPrimaryItem: false, // Shipping never gets the primary transaction fee
304
+ isRefund,
305
+ totalFee // Use the total fee for proportional distribution
306
+ });
307
+
308
+ return {
309
+ totalWithTax: payment.shipping_cost - disputeAmount,
310
+ total: payment.shipping_cost - disputeAmount,
311
+ invoiceTotal: payment.amount,
312
+ invoiceTotalWithTax: payment.amount,
313
+ discountAmounts: [],
314
+ stripeFees: totalFees,
315
+ disputed,
316
+ disputeDetails,
317
+ taxAmounts: [],
318
+ refunds: formatRefunds(
319
+ payment.latest_charge?.refunds?.data,
320
+ payment.shipping_cost,
321
+ payment.amount
322
+ ),
323
+ shippingProvision: true,
324
+ description: `Shipping for ${associatedOrder?.orderNumber || "?"} : ${payment.id}`,
325
+ discounts: [],
326
+ lineId: `${payment.id}-shipping`,
327
+ created: payment.created,
328
+ id: payment.id,
329
+ invoicePdf: null,
330
+ number: null,
331
+ customer: payment.customer,
332
+ testSale: debuggingAccounts.includes(payment?.customer?.email) ||
333
+ debuggingAccounts.includes(payment?.shipping?.address?.email) ||
334
+ debuggingAccounts.includes(payment?.latest_charge?.billing_details?.email) ||
335
+ debuggingAccounts.includes(associatedOrder?.billingAddress?.email) ||
336
+ debuggingAccounts.includes(associatedOrder?.shippingAddress?.email) ||
337
+ debuggingAccounts.includes(associatedOrder?.licenseeAddress?.email),
338
+ customerAddress: payment.shipping?.address || payment.latest_charge?.billing_details?.address || null,
339
+ // Payment method data
340
+ paymentMethod: {
341
+ type: payment?.payment_method?.type,
342
+ card: payment?.payment_method?.card,
343
+ origin: payment?.payment_method?.card?.country ? {
344
+ country: payment?.payment_method?.card?.country
345
+ } : null
346
+ },
347
+ order: associatedOrder || null,
348
+ orderNumber: associatedOrder?.orderNumber || null,
349
+ author: null,
350
+ typeface: null,
351
+ saleType: "paymentIntent"
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Processes a payment intent and its items
357
+ * @param {Object} params - Processing parameters
358
+ * @param {Object} params.payment - Payment intent to process
359
+ * @param {Array} params.sanitySales - Sanity orders to match against
360
+ * @returns {Array} Processed sales data
361
+ */
362
+ export function processPaymentIntent({ payment, sanitySales = [] }) {
363
+ if (!payment?.amount) {
364
+ console.warn('Invalid payment intent received:', payment);
365
+ return [];
366
+ }
367
+
368
+ // Make sure we're using the payment's ID consistently and extract payment method data
369
+ let paymentMethodData = null;
370
+
371
+ if (payment.id && typeof payment.id === 'object' && payment.id.id) {
372
+ console.log(`Payment intent ID is an object, extracting ID: ${payment.id.id}`);
373
+ payment.id = payment.id.id;
374
+ }
375
+
376
+ // Extract and store payment method data
377
+ if (payment.payment_method) {
378
+ paymentMethodData = payment.payment_method;
379
+ console.log(`Payment intent ${payment.id} payment method data:`,
380
+ JSON.stringify({
381
+ type: paymentMethodData.type,
382
+ card_country: paymentMethodData.card?.country
383
+ }));
384
+ }
385
+
386
+ console.log(`Processing payment intent: ${payment.id}`);
387
+
388
+ const sales = [];
389
+ let totalStripeFee = null;
390
+
391
+ // Parse items from metadata or create default item
392
+ let items;
393
+ try {
394
+ items = payment.metadata?.items ? JSON.parse(payment.metadata.items) : null;
395
+ } catch (error) {
396
+ console.warn(`Failed to parse payment items for ${payment.id}:`, error);
397
+ items = null;
398
+ }
399
+
400
+ // Validate parsed items
401
+ if (!Array.isArray(items) || items.length === 0 || !items.every(item => item && typeof item === 'object')) {
402
+ items = [{
403
+ description: payment.description || 'Unknown item',
404
+ amount: payment.amount
405
+ }];
406
+ }
407
+
408
+ // Process each item
409
+ items.forEach((item, index) => {
410
+ try {
411
+ const { associatedOrder, associatedTypeface } = findMatches({ payment, item, sanitySales });
412
+ // Check if this is a refund item
413
+ const isRefund = (item.amount || 0) < 0 || payment.status === 'refunded';
414
+
415
+ // Calculate parameters for fee calculation
416
+ const itemParams = {
417
+ item,
418
+ payment,
419
+ associatedOrder,
420
+ associatedTypeface,
421
+ itemIndex: index,
422
+ totalItems: items.length,
423
+ sanitySales,
424
+ isPrimaryItem: index === 0, // First item calculates the total fee
425
+ isRefund
426
+ };
427
+
428
+ // For the first non-refund item, calculate the total fee
429
+ if (index === 0 && !isRefund) {
430
+ const processedItem = processPaymentItem(itemParams);
431
+ // Store the total transaction fee for distribution
432
+ totalStripeFee = processedItem.totalTransactionFee || processedItem.stripeFees;
433
+ sales.push(processedItem);
434
+ } else {
435
+ // For subsequent items, pass the total fee for proportional distribution
436
+ const processedItem = processPaymentItem({
437
+ ...itemParams,
438
+ totalFee: totalStripeFee
439
+ });
440
+ sales.push(processedItem);
441
+ }
442
+ } catch (error) {
443
+ console.error(`Error processing payment item ${index} for payment ${payment.id}:`, error);
444
+ }
445
+ });
446
+
447
+ // Process shipping if present
448
+ try {
449
+ const shippingCost = processShippingCost({
450
+ payment,
451
+ associatedOrder: findMatches({ payment, sanitySales }).associatedOrder,
452
+ totalFee: totalStripeFee // Pass the total fee for proportional distribution
453
+ });
454
+ if (shippingCost) {
455
+ sales.push(shippingCost);
456
+ }
457
+ } catch (error) {
458
+ console.error(`Error processing shipping cost for payment ${payment.id}:`, error);
459
+ }
460
+
461
+ return sales;
462
+ }