@blackcode_sa/metaestetics-api 1.15.2 → 1.15.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.15.2",
4
+ "version": "1.15.4",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -1855,7 +1855,12 @@ export class AppointmentService extends BaseService {
1855
1855
  throw new Error('No zone data available for billing calculation');
1856
1856
  }
1857
1857
 
1858
- const finalbilling = calculateFinalBilling(zonesData, taxRate);
1858
+ // Preserve existing discount when recalculating
1859
+ const existingDiscount = appointment.metadata?.finalbilling?.discount;
1860
+ const discountParam = existingDiscount
1861
+ ? { type: existingDiscount.type, value: existingDiscount.value }
1862
+ : undefined;
1863
+ const finalbilling = calculateFinalBilling(zonesData, taxRate, discountParam);
1859
1864
 
1860
1865
  const currentMetadata = appointment.metadata || {
1861
1866
  selectedZones: null,
@@ -1909,6 +1914,78 @@ export class AppointmentService extends BaseService {
1909
1914
  }
1910
1915
  }
1911
1916
 
1917
+ /**
1918
+ * Applies or removes an overall discount on the appointment total
1919
+ * @param appointmentId ID of the appointment
1920
+ * @param discount Discount to apply (null to remove)
1921
+ * @param taxRate Optional tax rate override
1922
+ * @returns The updated appointment
1923
+ */
1924
+ async applyOverallDiscount(
1925
+ appointmentId: string,
1926
+ discount: { type: 'percentage' | 'fixed'; value: number } | null,
1927
+ taxRate?: number,
1928
+ ): Promise<Appointment> {
1929
+ try {
1930
+ console.log(
1931
+ `[APPOINTMENT_SERVICE] Applying overall discount for appointment ${appointmentId}`,
1932
+ discount,
1933
+ );
1934
+
1935
+ const appointment = await this.getAppointmentById(appointmentId);
1936
+ if (!appointment) {
1937
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1938
+ }
1939
+
1940
+ const zonesData = appointment.metadata?.zonesData;
1941
+ if (!zonesData || Object.keys(zonesData).length === 0) {
1942
+ throw new Error('No zone data available for billing calculation');
1943
+ }
1944
+
1945
+ const finalbilling = calculateFinalBilling(zonesData, taxRate, discount);
1946
+
1947
+ const currentMetadata = appointment.metadata || {
1948
+ selectedZones: null,
1949
+ zonePhotos: null,
1950
+ zonesData: null,
1951
+ appointmentProducts: [],
1952
+ extendedProcedures: [],
1953
+ recommendedProcedures: [],
1954
+ finalbilling: null,
1955
+ finalizationNotesShared: null,
1956
+ finalizationNotesInternal: null,
1957
+ finalizationNotes: null,
1958
+ };
1959
+
1960
+ const updateData: UpdateAppointmentData = {
1961
+ metadata: {
1962
+ selectedZones: currentMetadata.selectedZones,
1963
+ zonePhotos: currentMetadata.zonePhotos,
1964
+ zonesData: currentMetadata.zonesData,
1965
+ appointmentProducts: currentMetadata.appointmentProducts || [],
1966
+ extendedProcedures: currentMetadata.extendedProcedures || [],
1967
+ recommendedProcedures: currentMetadata.recommendedProcedures || [],
1968
+ ...(currentMetadata.zoneBilling !== undefined && {
1969
+ zoneBilling: currentMetadata.zoneBilling,
1970
+ }),
1971
+ finalbilling,
1972
+ finalizationNotesShared: currentMetadata.finalizationNotesShared ?? null,
1973
+ finalizationNotesInternal: currentMetadata.finalizationNotesInternal ?? null,
1974
+ finalizationNotes:
1975
+ currentMetadata.finalizationNotes ??
1976
+ currentMetadata.finalizationNotesShared ??
1977
+ null,
1978
+ },
1979
+ updatedAt: serverTimestamp(),
1980
+ };
1981
+
1982
+ return await this.updateAppointment(appointmentId, updateData);
1983
+ } catch (error) {
1984
+ console.error(`[APPOINTMENT_SERVICE] Error applying overall discount:`, error);
1985
+ throw error;
1986
+ }
1987
+ }
1988
+
1912
1989
  /**
1913
1990
  * Adds a recommended procedure to an appointment
1914
1991
  * Multiple recommendations of the same procedure are allowed (e.g., touch-up in 2 weeks, full treatment in 3 months)
@@ -31,6 +31,7 @@ import { ClinicInfo, PatientProfileInfo, PractitionerProfileInfo } from '../../.
31
31
  import { PRACTITIONERS_COLLECTION } from '../../../types/practitioner';
32
32
  import { PATIENTS_COLLECTION } from '../../../types/patient';
33
33
  import { CLINICS_COLLECTION } from '../../../types/clinic';
34
+ import { getSensitiveInfoDocRef } from '../../patient/utils/docs.utils';
34
35
  import { BlockingCondition } from '../../../backoffice/types/static/blocking-condition.types';
35
36
  import { Requirement } from '../../../backoffice/types/requirement.types';
36
37
  import { PROCEDURES_COLLECTION } from '../../../types/procedure';
@@ -64,13 +65,15 @@ export async function fetchAggregatedInfoUtil(
64
65
  postProcedureRequirements: Requirement[];
65
66
  }> {
66
67
  try {
67
- // Fetch all data in parallel for efficiency
68
- const [clinicDoc, practitionerDoc, patientDoc, procedureDoc] = await Promise.all([
69
- getDoc(doc(db, CLINICS_COLLECTION, clinicId)),
70
- getDoc(doc(db, PRACTITIONERS_COLLECTION, practitionerId)),
71
- getDoc(doc(db, PATIENTS_COLLECTION, patientId)),
72
- getDoc(doc(db, PROCEDURES_COLLECTION, procedureId)),
73
- ]);
68
+ // Fetch all data in parallel for efficiency (including patient sensitive-info for PII)
69
+ const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveDoc, procedureDoc] =
70
+ await Promise.all([
71
+ getDoc(doc(db, CLINICS_COLLECTION, clinicId)),
72
+ getDoc(doc(db, PRACTITIONERS_COLLECTION, practitionerId)),
73
+ getDoc(doc(db, PATIENTS_COLLECTION, patientId)),
74
+ getDoc(getSensitiveInfoDocRef(db, patientId)),
75
+ getDoc(doc(db, PROCEDURES_COLLECTION, procedureId)),
76
+ ]);
74
77
 
75
78
  // Check if all required entities exist
76
79
  if (!clinicDoc.exists()) {
@@ -89,6 +92,9 @@ export async function fetchAggregatedInfoUtil(
89
92
  const clinicData = clinicDoc.data();
90
93
  const practitionerData = practitionerDoc.data();
91
94
  const patientData = patientDoc.data();
95
+ const patientSensitiveData = patientSensitiveDoc.exists()
96
+ ? (patientSensitiveDoc.data() as { firstName?: string; lastName?: string; email?: string; phoneNumber?: string; dateOfBirth?: any; gender?: string })
97
+ : null;
92
98
  const procedureData = procedureDoc.data();
93
99
 
94
100
  // Extract relevant info for ClinicInfo
@@ -114,14 +120,17 @@ export async function fetchAggregatedInfoUtil(
114
120
  };
115
121
 
116
122
  // Extract relevant info for PatientProfileInfo
117
- // Note: This may need adjustment depending on how patient data is structured
123
+ // Prefer sensitive-info (patient PII) over profile; profile is fallback for displayName from manual creation
118
124
  const patientInfo: PatientProfileInfo = {
119
125
  id: patientId,
120
- fullName: patientData.displayName || '',
121
- email: patientData.email || '',
122
- phone: patientData.phoneNumber || null,
123
- dateOfBirth: patientData.dateOfBirth || Timestamp.now(),
124
- gender: patientData.gender || 'other',
126
+ fullName:
127
+ patientSensitiveData?.firstName && patientSensitiveData?.lastName
128
+ ? `${patientSensitiveData.firstName} ${patientSensitiveData.lastName}`.trim()
129
+ : patientData.displayName || '',
130
+ email: patientSensitiveData?.email ?? patientData.email ?? '',
131
+ phone: patientSensitiveData?.phoneNumber ?? patientData.phoneNumber ?? null,
132
+ dateOfBirth: patientSensitiveData?.dateOfBirth ?? patientData.dateOfBirth ?? Timestamp.now(),
133
+ gender: patientSensitiveData?.gender ?? patientData.gender ?? 'other',
125
134
  };
126
135
 
127
136
  // Extract procedureInfo from the procedure document
@@ -9,6 +9,15 @@ import {
9
9
  import { getAppointmentByIdUtil } from './appointment.utils';
10
10
  import { doc } from 'firebase/firestore';
11
11
 
12
+ /**
13
+ * Swiss 5-centime rounding (nearest 0.05 CHF)
14
+ * @param value Amount to round
15
+ * @returns Rounded amount
16
+ */
17
+ export function swissRound(value: number): number {
18
+ return Math.round(value / 0.05) * 0.05;
19
+ }
20
+
12
21
  /**
13
22
  * Validates that a zone key follows the category.zone format
14
23
  * @param zoneKey Zone key to validate
@@ -49,11 +58,13 @@ export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
49
58
  * Recalculates final billing based on all zone items
50
59
  * @param zonesData Zone items data
51
60
  * @param taxRate Tax rate (e.g., 0.20 for 20%)
61
+ * @param discount Optional overall discount to apply
52
62
  * @returns Calculated final billing
53
63
  */
54
64
  export function calculateFinalBilling(
55
65
  zonesData: Record<string, ZoneItemData[]>,
56
66
  taxRate: number = 0.081,
67
+ discount?: { type: 'percentage' | 'fixed'; value: number } | null,
57
68
  ): FinalBilling {
58
69
  let subtotalAll = 0;
59
70
 
@@ -66,9 +77,6 @@ export function calculateFinalBilling(
66
77
  });
67
78
  });
68
79
 
69
- const taxPrice = subtotalAll * taxRate;
70
- const finalPrice = subtotalAll + taxPrice;
71
-
72
80
  // Get currency from first item (assuming all same currency)
73
81
  let currency: any = 'CHF'; // Default
74
82
 
@@ -80,6 +88,39 @@ export function calculateFinalBilling(
80
88
  }
81
89
  }
82
90
 
91
+ // Apply overall discount if provided
92
+ if (discount && discount.value > 0) {
93
+ let discountAmount: number;
94
+ if (discount.type === 'percentage') {
95
+ discountAmount = subtotalAll * (discount.value / 100);
96
+ } else {
97
+ discountAmount = discount.value;
98
+ }
99
+ // Cap discount at subtotal (can't go negative)
100
+ discountAmount = Math.min(discountAmount, subtotalAll);
101
+
102
+ const discountedSubtotal = subtotalAll - discountAmount;
103
+ const taxPrice = discountedSubtotal * taxRate;
104
+ const finalPrice = swissRound(discountedSubtotal + taxPrice);
105
+
106
+ return {
107
+ subtotalAll,
108
+ discount: {
109
+ type: discount.type,
110
+ value: discount.value,
111
+ amount: discountAmount,
112
+ },
113
+ discountedSubtotal,
114
+ taxRate,
115
+ taxPrice,
116
+ finalPrice,
117
+ currency,
118
+ };
119
+ }
120
+
121
+ const taxPrice = subtotalAll * taxRate;
122
+ const finalPrice = swissRound(subtotalAll + taxPrice);
123
+
83
124
  return {
84
125
  subtotalAll,
85
126
  taxRate,
@@ -183,8 +224,12 @@ export async function addItemToZoneUtil(
183
224
  // Add item to zone
184
225
  zonesData[zoneId].push(itemWithSubtotal);
185
226
 
186
- // Recalculate final billing with Swiss tax rate (8.1%)
187
- const finalbilling = calculateFinalBilling(zonesData, 0.081);
227
+ // Recalculate final billing with Swiss tax rate (8.1%), preserving existing discount
228
+ const existingDiscount = metadata.finalbilling?.discount;
229
+ const discountParam = existingDiscount
230
+ ? { type: existingDiscount.type, value: existingDiscount.value }
231
+ : undefined;
232
+ const finalbilling = calculateFinalBilling(zonesData, 0.081, discountParam);
188
233
 
189
234
  // Update appointment
190
235
  const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
@@ -234,8 +279,12 @@ export async function removeItemFromZoneUtil(
234
279
  delete metadata.zonesData[zoneId];
235
280
  }
236
281
 
237
- // Recalculate final billing with Swiss tax rate (8.1%)
238
- const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081);
282
+ // Recalculate final billing with Swiss tax rate (8.1%), preserving existing discount
283
+ const existingDiscount = metadata.finalbilling?.discount;
284
+ const discountParam = existingDiscount
285
+ ? { type: existingDiscount.type, value: existingDiscount.value }
286
+ : undefined;
287
+ const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081, discountParam);
239
288
 
240
289
  // Update appointment
241
290
  const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
@@ -327,8 +376,12 @@ export async function updateZoneItemUtil(
327
376
  newSubtotal: items[itemIndex].subtotal,
328
377
  });
329
378
 
330
- // Recalculate final billing with Swiss tax rate (8.1%)
331
- const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081);
379
+ // Recalculate final billing with Swiss tax rate (8.1%), preserving existing discount
380
+ const existingDiscount = metadata.finalbilling?.discount;
381
+ const discountParam = existingDiscount
382
+ ? { type: existingDiscount.type, value: existingDiscount.value }
383
+ : undefined;
384
+ const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081, discountParam);
332
385
 
333
386
  // Log what we're about to save to Firestore
334
387
  console.log(`[updateZoneItemUtil] Saving to Firestore:`, {
@@ -120,13 +120,13 @@ export const getPatientsByClinicWithDetailsUtil = async (
120
120
  ? (sensitiveInfoDoc.data() as PatientSensitiveInfo)
121
121
  : null;
122
122
 
123
- // Merge sensitive info into profile (sensitive info takes precedence)
123
+ // Merge sensitive info into profile (sensitive-info is source of truth for PII)
124
124
  return {
125
125
  ...profile,
126
- // Merge phoneNumber from sensitive info if not in profile
127
- phoneNumber: profile.phoneNumber || sensitiveInfo?.phoneNumber || null,
128
- // Merge dateOfBirth from sensitive info if not in profile
129
- dateOfBirth: profile.dateOfBirth || sensitiveInfo?.dateOfBirth || null,
126
+ // Prefer phoneNumber from sensitive-info (patient self-updated); fallback to profile (manual creation)
127
+ phoneNumber: sensitiveInfo?.phoneNumber ?? profile.phoneNumber ?? null,
128
+ // Prefer dateOfBirth from sensitive-info
129
+ dateOfBirth: sensitiveInfo?.dateOfBirth ?? profile.dateOfBirth ?? null,
130
130
  // Merge photoUrl from sensitive info if available
131
131
  photoUrl: sensitiveInfo?.photoUrl || null,
132
132
  } as PatientProfile & { photoUrl?: string | null };
@@ -196,6 +196,14 @@ export interface BillingPerZone {
196
196
  export interface FinalBilling {
197
197
  /** Total of all subtotals from all zones */
198
198
  subtotalAll: number;
199
+ /** Overall discount applied to the subtotal */
200
+ discount?: {
201
+ type: 'percentage' | 'fixed';
202
+ value: number; // e.g., 10 for 10% or 50 for CHF 50
203
+ amount: number; // calculated discount in currency
204
+ };
205
+ /** Subtotal after discount (subtotalAll - discount.amount) */
206
+ discountedSubtotal?: number;
199
207
  /** Tax rate as percentage (e.g., 0.20 for 20%) */
200
208
  taxRate: number;
201
209
  /** Calculated tax amount */
@@ -172,6 +172,12 @@ export const billingPerZoneSchema = z.object({
172
172
  */
173
173
  export const finalBillingSchema = z.object({
174
174
  subtotalAll: z.number().min(0, 'Subtotal all must be non-negative'),
175
+ discount: z.object({
176
+ type: z.enum(['percentage', 'fixed']),
177
+ value: z.number().min(0),
178
+ amount: z.number().min(0),
179
+ }).optional(),
180
+ discountedSubtotal: z.number().min(0).optional(),
175
181
  taxRate: z.number().min(0).max(1, 'Tax rate must be between 0 and 1'),
176
182
  taxPrice: z.number().min(0, 'Tax price must be non-negative'),
177
183
  finalPrice: z.number().min(0, 'Final price must be non-negative'),