@blackcode_sa/metaestetics-api 1.15.2 → 1.15.3
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/dist/admin/index.d.mts +8 -0
- package/dist/admin/index.d.ts +8 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +4993 -4899
- package/dist/index.mjs +5158 -5064
- package/package.json +1 -1
- package/src/services/appointment/appointment.service.ts +78 -1
- package/src/services/appointment/utils/appointment.utils.ts +22 -13
- package/src/services/appointment/utils/zone-management.utils.ts +62 -9
- package/src/services/patient/utils/clinic.utils.ts +5 -5
- package/src/types/appointment/index.ts +8 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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] =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
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:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
123
|
+
// Merge sensitive info into profile (sensitive-info is source of truth for PII)
|
|
124
124
|
return {
|
|
125
125
|
...profile,
|
|
126
|
-
//
|
|
127
|
-
phoneNumber:
|
|
128
|
-
//
|
|
129
|
-
dateOfBirth:
|
|
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 */
|