@blackcode_sa/metaestetics-api 1.13.0 → 1.13.2

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.
@@ -67,7 +67,11 @@ export function calculateAppointmentCost(appointment: Appointment): {
67
67
  /**
68
68
  * Calculates total revenue from an array of appointments
69
69
  *
70
- * @param appointments - Array of appointments
70
+ * IMPORTANT: This function should only be called with COMPLETED appointments.
71
+ * Only completed procedures generate revenue. Confirmed, pending, canceled, and no-show
72
+ * appointments should be filtered out before calling this function.
73
+ *
74
+ * @param appointments - Array of appointments (should be filtered to COMPLETED only)
71
75
  * @returns Total revenue and currency
72
76
  */
73
77
  export function calculateTotalRevenue(appointments: Appointment[]): {
@@ -96,8 +100,19 @@ export function calculateTotalRevenue(appointments: Appointment[]): {
96
100
  /**
97
101
  * Extracts product usage from appointment metadata
98
102
  *
99
- * @param appointment - The appointment to extract products from
100
- * @returns Array of product usage data
103
+ * IMPORTANT: This function should only be called with COMPLETED appointments.
104
+ * Products are only considered "used" when the procedure has been completed.
105
+ * Only completed procedures generate product usage and revenue.
106
+ *
107
+ * NOTE ON PRICING:
108
+ * - Price overrides (priceOverrideAmount) are always applied if available
109
+ * - Subtotal is recalculated to ensure price overrides are reflected
110
+ * - Product revenue shows SUBTOTAL (before tax) - tax is applied at appointment level
111
+ * - Tax is included in appointment revenue (finalbilling.finalPrice) but not in product revenue
112
+ * - Product subtotals should sum to finalbilling.subtotalAll (before tax)
113
+ *
114
+ * @param appointment - The appointment to extract products from (should be COMPLETED)
115
+ * @returns Array of product usage data with subtotal (before tax)
101
116
  */
102
117
  export function extractProductUsage(appointment: Appointment): Array<{
103
118
  productId: string;
@@ -131,9 +146,22 @@ export function extractProductUsage(appointment: Appointment): Array<{
131
146
  Object.values(zonesData).forEach(items => {
132
147
  items.forEach(item => {
133
148
  if (item.type === 'item' && item.productId) {
149
+ // Always use priceOverrideAmount if available, otherwise use price
150
+ // This ensures price overrides are properly applied to product calculations
134
151
  const price = item.priceOverrideAmount || item.price || 0;
135
152
  const quantity = item.quantity || 1;
136
- const subtotal = item.subtotal || price * quantity;
153
+
154
+ // Always recalculate subtotal based on the actual price (with override)
155
+ // This ensures price overrides are reflected in product revenue
156
+ // Use stored subtotal only if it matches the calculated value (to handle rounding)
157
+ const calculatedSubtotal = price * quantity;
158
+ const storedSubtotal = item.subtotal || 0;
159
+
160
+ // Use stored subtotal if it's close to calculated (within 0.01 for rounding differences)
161
+ // Otherwise recalculate to ensure price override is applied correctly
162
+ const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01
163
+ ? storedSubtotal
164
+ : calculatedSubtotal;
137
165
 
138
166
  products.push({
139
167
  productId: item.productId,
@@ -101,6 +101,9 @@ export function groupAppointmentsByEntity(
101
101
 
102
102
  /**
103
103
  * Calculates grouped revenue metrics
104
+ *
105
+ * IMPORTANT: Only COMPLETED appointments are included in revenue calculations.
106
+ * Confirmed, pending, canceled, and no-show appointments are excluded from financial metrics.
104
107
  */
105
108
  export function calculateGroupedRevenueMetrics(
106
109
  appointments: Appointment[],
@@ -139,6 +142,15 @@ export function calculateGroupedRevenueMetrics(
139
142
  }
140
143
  });
141
144
 
145
+ // Get practitioner info when grouping by procedure
146
+ let practitionerId: string | undefined;
147
+ let practitionerName: string | undefined;
148
+ if (entityType === 'procedure' && entityAppointments.length > 0) {
149
+ const firstAppointment = entityAppointments[0];
150
+ practitionerId = firstAppointment.practitionerId;
151
+ practitionerName = firstAppointment.practitionerInfo?.name;
152
+ }
153
+
142
154
  return {
143
155
  entityId,
144
156
  entityName: data.name,
@@ -153,12 +165,18 @@ export function calculateGroupedRevenueMetrics(
153
165
  refundedRevenue,
154
166
  totalTax,
155
167
  totalSubtotal,
168
+ ...(practitionerId && { practitionerId }),
169
+ ...(practitionerName && { practitionerName }),
156
170
  };
157
171
  });
158
172
  }
159
173
 
160
174
  /**
161
175
  * Calculates grouped product usage metrics
176
+ *
177
+ * IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
178
+ * Products are only considered "used" when the procedure has been completed.
179
+ * Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
162
180
  */
163
181
  export function calculateGroupedProductUsageMetrics(
164
182
  appointments: Appointment[],
@@ -220,6 +238,15 @@ export function calculateGroupedProductUsageMetrics(
220
238
  const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
221
239
  const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
222
240
 
241
+ // Get practitioner info when grouping by procedure
242
+ let practitionerId: string | undefined;
243
+ let practitionerName: string | undefined;
244
+ if (entityType === 'procedure' && entityAppointments.length > 0) {
245
+ const firstAppointment = entityAppointments[0];
246
+ practitionerId = firstAppointment.practitionerId;
247
+ practitionerName = firstAppointment.practitionerInfo?.name;
248
+ }
249
+
223
250
  return {
224
251
  entityId,
225
252
  entityName: data.name,
@@ -231,6 +258,8 @@ export function calculateGroupedProductUsageMetrics(
231
258
  averageProductsPerAppointment:
232
259
  entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
233
260
  topProducts,
261
+ ...(practitionerId && { practitionerId }),
262
+ ...(practitionerName && { practitionerName }),
234
263
  };
235
264
  });
236
265
  }
@@ -253,6 +282,15 @@ export function calculateGroupedTimeEfficiencyMetrics(
253
282
 
254
283
  const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
255
284
 
285
+ // Get practitioner info when grouping by procedure
286
+ let practitionerId: string | undefined;
287
+ let practitionerName: string | undefined;
288
+ if (entityType === 'procedure' && entityAppointments.length > 0) {
289
+ const firstAppointment = entityAppointments[0];
290
+ practitionerId = firstAppointment.practitionerId;
291
+ practitionerName = firstAppointment.practitionerInfo?.name;
292
+ }
293
+
256
294
  return {
257
295
  entityId,
258
296
  entityName: data.name,
@@ -266,6 +304,8 @@ export function calculateGroupedTimeEfficiencyMetrics(
266
304
  totalUnderutilization: timeMetrics.totalUnderutilization,
267
305
  averageOverrun: timeMetrics.averageOverrun,
268
306
  averageUnderutilization: timeMetrics.averageUnderutilization,
307
+ ...(practitionerId && { practitionerId }),
308
+ ...(practitionerName && { practitionerName }),
269
309
  };
270
310
  });
271
311
  }
@@ -0,0 +1,200 @@
1
+ import { Appointment } from '../../../types/appointment';
2
+ import { Timestamp } from 'firebase/firestore';
3
+
4
+ /**
5
+ * Trend period type
6
+ */
7
+ export type TrendPeriod = 'week' | 'month' | 'quarter' | 'year';
8
+
9
+ /**
10
+ * Period information for trend grouping
11
+ */
12
+ export interface PeriodInfo {
13
+ period: string; // e.g., "2024-W01", "2024-01", "2024-Q1", "2024"
14
+ startDate: Date;
15
+ endDate: Date;
16
+ }
17
+
18
+ /**
19
+ * Calculates the start and end dates for a given period
20
+ */
21
+ export function getPeriodDates(date: Date, period: TrendPeriod): PeriodInfo {
22
+ const year = date.getFullYear();
23
+ const month = date.getMonth();
24
+ const day = date.getDate();
25
+
26
+ let startDate: Date;
27
+ let endDate: Date;
28
+ let periodString: string;
29
+
30
+ switch (period) {
31
+ case 'week': {
32
+ // Get Monday of the week
33
+ const dayOfWeek = date.getDay();
34
+ const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
35
+ startDate = new Date(year, month, diff);
36
+ startDate.setHours(0, 0, 0, 0);
37
+
38
+ endDate = new Date(startDate);
39
+ endDate.setDate(endDate.getDate() + 6);
40
+ endDate.setHours(23, 59, 59, 999);
41
+
42
+ // ISO week format: YYYY-Www
43
+ const weekNumber = getWeekNumber(date);
44
+ periodString = `${year}-W${weekNumber.toString().padStart(2, '0')}`;
45
+ break;
46
+ }
47
+
48
+ case 'month': {
49
+ startDate = new Date(year, month, 1);
50
+ startDate.setHours(0, 0, 0, 0);
51
+
52
+ endDate = new Date(year, month + 1, 0);
53
+ endDate.setHours(23, 59, 59, 999);
54
+
55
+ periodString = `${year}-${(month + 1).toString().padStart(2, '0')}`;
56
+ break;
57
+ }
58
+
59
+ case 'quarter': {
60
+ const quarter = Math.floor(month / 3);
61
+ const quarterStartMonth = quarter * 3;
62
+
63
+ startDate = new Date(year, quarterStartMonth, 1);
64
+ startDate.setHours(0, 0, 0, 0);
65
+
66
+ endDate = new Date(year, quarterStartMonth + 3, 0);
67
+ endDate.setHours(23, 59, 59, 999);
68
+
69
+ periodString = `${year}-Q${quarter + 1}`;
70
+ break;
71
+ }
72
+
73
+ case 'year': {
74
+ startDate = new Date(year, 0, 1);
75
+ startDate.setHours(0, 0, 0, 0);
76
+
77
+ endDate = new Date(year, 11, 31);
78
+ endDate.setHours(23, 59, 59, 999);
79
+
80
+ periodString = `${year}`;
81
+ break;
82
+ }
83
+ }
84
+
85
+ return {
86
+ period: periodString,
87
+ startDate,
88
+ endDate,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Gets ISO week number for a date
94
+ */
95
+ function getWeekNumber(date: Date): number {
96
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
97
+ const dayNum = d.getUTCDay() || 7;
98
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
99
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
100
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
101
+ }
102
+
103
+ /**
104
+ * Groups appointments by trend period
105
+ */
106
+ export function groupAppointmentsByPeriod(
107
+ appointments: Appointment[],
108
+ period: TrendPeriod,
109
+ ): Map<string, Appointment[]> {
110
+ const periodMap = new Map<string, Appointment[]>();
111
+
112
+ appointments.forEach(appointment => {
113
+ const appointmentDate = appointment.appointmentStartTime.toDate();
114
+ const periodInfo = getPeriodDates(appointmentDate, period);
115
+ const periodKey = periodInfo.period;
116
+
117
+ if (!periodMap.has(periodKey)) {
118
+ periodMap.set(periodKey, []);
119
+ }
120
+ periodMap.get(periodKey)!.push(appointment);
121
+ });
122
+
123
+ return periodMap;
124
+ }
125
+
126
+ /**
127
+ * Generates all periods between start and end date for a given period type
128
+ */
129
+ export function generatePeriods(
130
+ startDate: Date,
131
+ endDate: Date,
132
+ period: TrendPeriod,
133
+ ): PeriodInfo[] {
134
+ const periods: PeriodInfo[] = [];
135
+ const current = new Date(startDate);
136
+
137
+ while (current <= endDate) {
138
+ const periodInfo = getPeriodDates(current, period);
139
+
140
+ // Only add if period overlaps with our date range
141
+ if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
142
+ periods.push(periodInfo);
143
+ }
144
+
145
+ // Move to next period
146
+ switch (period) {
147
+ case 'week':
148
+ current.setDate(current.getDate() + 7);
149
+ break;
150
+ case 'month':
151
+ current.setMonth(current.getMonth() + 1);
152
+ break;
153
+ case 'quarter':
154
+ current.setMonth(current.getMonth() + 3);
155
+ break;
156
+ case 'year':
157
+ current.setFullYear(current.getFullYear() + 1);
158
+ break;
159
+ }
160
+ }
161
+
162
+ return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
163
+ }
164
+
165
+ /**
166
+ * Calculates percentage change between two values
167
+ * @param current - Current value
168
+ * @param previous - Previous value
169
+ * @returns Percentage change (positive = increase, negative = decrease)
170
+ */
171
+ export function calculatePercentageChange(current: number, previous: number): number {
172
+ if (previous === 0) {
173
+ return current > 0 ? 100 : 0;
174
+ }
175
+ return ((current - previous) / previous) * 100;
176
+ }
177
+
178
+ /**
179
+ * Gets trend direction and percentage change
180
+ */
181
+ export interface TrendChange {
182
+ value: number;
183
+ previousValue: number;
184
+ percentageChange: number;
185
+ direction: 'up' | 'down' | 'stable';
186
+ }
187
+
188
+ export function getTrendChange(current: number, previous: number): TrendChange {
189
+ const percentageChange = calculatePercentageChange(current, previous);
190
+ const direction: 'up' | 'down' | 'stable' =
191
+ percentageChange > 0.01 ? 'up' : percentageChange < -0.01 ? 'down' : 'stable';
192
+
193
+ return {
194
+ value: current,
195
+ previousValue: previous,
196
+ percentageChange: Math.abs(percentageChange),
197
+ direction,
198
+ };
199
+ }
200
+
@@ -1833,6 +1833,12 @@ export class AppointmentService extends BaseService {
1833
1833
  finalizationNotes: null,
1834
1834
  };
1835
1835
 
1836
+ // Update payment status if billing data exists but status is NOT_APPLICABLE
1837
+ // This handles cases where appointment was created with price 0 but billing was added later
1838
+ const shouldUpdatePaymentStatus =
1839
+ finalbilling.finalPrice > 0 &&
1840
+ appointment.paymentStatus === PaymentStatus.NOT_APPLICABLE;
1841
+
1836
1842
  const updateData: UpdateAppointmentData = {
1837
1843
  metadata: {
1838
1844
  selectedZones: currentMetadata.selectedZones,
@@ -1848,6 +1854,9 @@ export class AppointmentService extends BaseService {
1848
1854
  finalbilling,
1849
1855
  finalizationNotes: currentMetadata.finalizationNotes,
1850
1856
  },
1857
+ ...(shouldUpdatePaymentStatus && {
1858
+ paymentStatus: PaymentStatus.UNPAID,
1859
+ }),
1851
1860
  updatedAt: serverTimestamp(),
1852
1861
  };
1853
1862