@blackcode_sa/metaestetics-api 1.15.14 → 1.15.17-staging.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/dist/admin/index.d.mts +377 -222
- package/dist/admin/index.d.ts +377 -222
- package/dist/admin/index.js +625 -206
- package/dist/admin/index.mjs +624 -206
- package/dist/backoffice/index.d.mts +24 -0
- package/dist/backoffice/index.d.ts +24 -0
- package/dist/index.d.mts +297 -9
- package/dist/index.d.ts +297 -9
- package/dist/index.js +1144 -632
- package/dist/index.mjs +1139 -619
- package/package.json +2 -1
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +151 -129
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +2137 -2091
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -966
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- package/src/admin/booking/README.md +184 -125
- package/src/admin/booking/booking.admin.ts +1330 -1073
- package/src/admin/booking/booking.calculator.ts +850 -712
- package/src/admin/booking/booking.types.ts +76 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +62 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +2 -1
- package/src/admin/calendar/resource-calendar.admin.ts +198 -0
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +83 -83
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +139 -139
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +1253 -1253
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/clinicWelcome/clinicWelcome.mailing.ts +292 -292
- package/src/admin/mailing/clinicWelcome/index.ts +1 -1
- package/src/admin/mailing/clinicWelcome/templates/welcome.template.ts +225 -225
- package/src/admin/mailing/index.ts +5 -5
- package/src/admin/mailing/patientInvite/index.ts +2 -2
- package/src/admin/mailing/patientInvite/patientInvite.mailing.ts +415 -415
- package/src/admin/mailing/patientInvite/templates/invitation.template.ts +105 -105
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +818 -818
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +260 -260
- package/src/backoffice/services/category.service.ts +384 -384
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +10 -10
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +557 -557
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +461 -461
- package/src/backoffice/services/technology.service.ts +1153 -1153
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +67 -67
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +239 -239
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +17 -17
- package/src/config/tiers.config.ts +255 -229
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +211 -211
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +298 -298
- package/src/services/__tests__/auth.service.test.ts +310 -310
- package/src/services/__tests__/base.service.test.ts +36 -36
- package/src/services/__tests__/user.service.test.ts +530 -530
- package/src/services/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2148 -2148
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2943 -2941
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +620 -620
- package/src/services/appointment/utils/extended-procedure.utils.ts +354 -354
- package/src/services/appointment/utils/form-initialization.utils.ts +516 -516
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +468 -468
- package/src/services/appointment/utils/zone-photo.utils.ts +302 -302
- package/src/services/auth/auth.service.ts +1435 -1435
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1693 -1693
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +676 -676
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +265 -265
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +222 -222
- package/src/services/clinic/__tests__/clinic.service.test.ts +302 -302
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +720 -720
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +1023 -1023
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +462 -462
- package/src/services/clinic/utils/index.ts +10 -10
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +83 -83
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +597 -597
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +16 -15
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +286 -286
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +1021 -1021
- package/src/services/patient/patientRequirements.service.ts +309 -309
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/body-assessment.utils.ts +159 -159
- package/src/services/patient/utils/clinic.utils.ts +159 -159
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/hair-scalp-assessment.utils.ts +158 -158
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/pre-surgical-assessment.utils.ts +161 -161
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/skin-quality-assessment.utils.ts +160 -160
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +2355 -2354
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2521 -2521
- package/src/services/resource/README.md +119 -0
- package/src/services/resource/index.ts +1 -0
- package/src/services/resource/resource.service.ts +555 -0
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +745 -745
- package/src/services/tier-enforcement.ts +240 -240
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +533 -533
- package/src/services/user/user.v2.service.ts +467 -467
- package/src/types/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +524 -517
- package/src/types/calendar/index.ts +261 -260
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +530 -529
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/rbac.types.ts +64 -63
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +50 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +300 -300
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/body-assessment.types.ts +93 -93
- package/src/types/patient/hair-scalp-assessment.types.ts +98 -98
- package/src/types/patient/index.ts +279 -279
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/pre-surgical-assessment.types.ts +95 -95
- package/src/types/patient/skin-quality-assessment.types.ts +105 -105
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +208 -208
- package/src/types/procedure/index.ts +189 -183
- package/src/types/profile/index.ts +39 -39
- package/src/types/resource/README.md +153 -0
- package/src/types/resource/index.ts +199 -0
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +60 -60
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/README.md +94 -0
- package/src/validations/appointment.schema.ts +589 -589
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -494
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +21 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/body-assessment.schema.ts +82 -82
- package/src/validations/patient/hair-scalp-assessment.schema.ts +70 -70
- package/src/validations/patient/medical-info.schema.ts +177 -177
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/pre-surgical-assessment.schema.ts +78 -78
- package/src/validations/patient/skin-quality-assessment.schema.ts +70 -70
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +224 -224
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +136 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/resource.schema.ts +57 -0
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +109 -109
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,468 +1,468 @@
|
|
|
1
|
-
import { Firestore, getDoc, updateDoc, serverTimestamp } from 'firebase/firestore';
|
|
2
|
-
import {
|
|
3
|
-
ZoneItemData,
|
|
4
|
-
AppointmentMetadata,
|
|
5
|
-
FinalBilling,
|
|
6
|
-
Appointment,
|
|
7
|
-
APPOINTMENTS_COLLECTION,
|
|
8
|
-
} from '../../../types/appointment';
|
|
9
|
-
import { getAppointmentByIdUtil } from './appointment.utils';
|
|
10
|
-
import { doc } from 'firebase/firestore';
|
|
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
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Validates that a zone key follows the category.zone format
|
|
23
|
-
* @param zoneKey Zone key to validate
|
|
24
|
-
* @throws Error if format is invalid
|
|
25
|
-
*/
|
|
26
|
-
export function validateZoneKeyFormat(zoneKey: string): void {
|
|
27
|
-
const parts = zoneKey.split('.');
|
|
28
|
-
if (parts.length !== 2) {
|
|
29
|
-
throw new Error(
|
|
30
|
-
`Invalid zone key format: "${zoneKey}". Must be "category.zone" (e.g., "face.forehead")`,
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Calculates subtotal for a zone item
|
|
37
|
-
* @param item Zone item data
|
|
38
|
-
* @returns Calculated subtotal
|
|
39
|
-
*/
|
|
40
|
-
export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
|
|
41
|
-
if (item.type === 'note') {
|
|
42
|
-
return 0;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const quantity = item.quantity || 0;
|
|
46
|
-
|
|
47
|
-
// If price override amount is set, use it as price per unit
|
|
48
|
-
if (item.priceOverrideAmount !== undefined && item.priceOverrideAmount !== null) {
|
|
49
|
-
return item.priceOverrideAmount * quantity;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Calculate normally: price * quantity
|
|
53
|
-
const price = item.price || 0;
|
|
54
|
-
return price * quantity;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Recalculates final billing based on all zone items
|
|
59
|
-
* @param zonesData Zone items data
|
|
60
|
-
* @param taxRate Tax rate (e.g., 0.20 for 20%)
|
|
61
|
-
* @param discount Optional overall discount to apply
|
|
62
|
-
* @returns Calculated final billing
|
|
63
|
-
*/
|
|
64
|
-
export function calculateFinalBilling(
|
|
65
|
-
zonesData: Record<string, ZoneItemData[]>,
|
|
66
|
-
taxRate: number = 0.081,
|
|
67
|
-
discount?: { type: 'percentage' | 'fixed'; value: number } | null,
|
|
68
|
-
): FinalBilling {
|
|
69
|
-
let subtotalAll = 0;
|
|
70
|
-
|
|
71
|
-
// Sum up all zone items
|
|
72
|
-
Object.values(zonesData).forEach(items => {
|
|
73
|
-
items.forEach(item => {
|
|
74
|
-
if (item.type === 'item' && item.subtotal) {
|
|
75
|
-
subtotalAll += item.subtotal;
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
// Get currency from first item (assuming all same currency)
|
|
81
|
-
let currency: any = 'CHF'; // Default
|
|
82
|
-
|
|
83
|
-
for (const items of Object.values(zonesData)) {
|
|
84
|
-
const firstItem = items.find(i => i.type === 'item');
|
|
85
|
-
if (firstItem) {
|
|
86
|
-
currency = firstItem.currency || currency;
|
|
87
|
-
break;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
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
|
-
|
|
124
|
-
return {
|
|
125
|
-
subtotalAll,
|
|
126
|
-
taxRate,
|
|
127
|
-
taxPrice,
|
|
128
|
-
finalPrice,
|
|
129
|
-
currency,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Gets appointment and validates it exists
|
|
135
|
-
* @param db Firestore instance
|
|
136
|
-
* @param appointmentId Appointment ID
|
|
137
|
-
* @returns Appointment document
|
|
138
|
-
*/
|
|
139
|
-
export async function getAppointmentOrThrow(
|
|
140
|
-
db: Firestore,
|
|
141
|
-
appointmentId: string,
|
|
142
|
-
): Promise<Appointment> {
|
|
143
|
-
const appointment = await getAppointmentByIdUtil(db, appointmentId);
|
|
144
|
-
if (!appointment) {
|
|
145
|
-
throw new Error(`Appointment with ID ${appointmentId} not found`);
|
|
146
|
-
}
|
|
147
|
-
return appointment;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Initializes appointment metadata if it doesn't exist
|
|
152
|
-
* @param appointment Appointment document
|
|
153
|
-
* @returns Initialized metadata
|
|
154
|
-
*/
|
|
155
|
-
export function initializeMetadata(appointment: Appointment): AppointmentMetadata {
|
|
156
|
-
return (
|
|
157
|
-
appointment.metadata || {
|
|
158
|
-
selectedZones: null,
|
|
159
|
-
zonePhotos: null,
|
|
160
|
-
zonesData: null,
|
|
161
|
-
appointmentProducts: [],
|
|
162
|
-
extendedProcedures: [],
|
|
163
|
-
recommendedProcedures: [],
|
|
164
|
-
finalbilling: null,
|
|
165
|
-
finalizationNotesShared: null,
|
|
166
|
-
finalizationNotesInternal: null,
|
|
167
|
-
finalizationNotes: null, // @deprecated - kept for backward compatibility
|
|
168
|
-
}
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Adds an item to a specific zone
|
|
174
|
-
* @param db Firestore instance
|
|
175
|
-
* @param appointmentId Appointment ID
|
|
176
|
-
* @param zoneId Zone ID (must be category.zone format)
|
|
177
|
-
* @param item Zone item data to add (without parentZone)
|
|
178
|
-
* @returns Updated appointment
|
|
179
|
-
*/
|
|
180
|
-
export async function addItemToZoneUtil(
|
|
181
|
-
db: Firestore,
|
|
182
|
-
appointmentId: string,
|
|
183
|
-
zoneId: string,
|
|
184
|
-
item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>,
|
|
185
|
-
): Promise<Appointment> {
|
|
186
|
-
// Validate zone key format
|
|
187
|
-
validateZoneKeyFormat(zoneId);
|
|
188
|
-
|
|
189
|
-
// Get appointment
|
|
190
|
-
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
191
|
-
const metadata = initializeMetadata(appointment);
|
|
192
|
-
|
|
193
|
-
// Initialize zonesData if needed
|
|
194
|
-
const zonesData = metadata.zonesData || {};
|
|
195
|
-
|
|
196
|
-
// Initialize zone array if needed
|
|
197
|
-
if (!zonesData[zoneId]) {
|
|
198
|
-
zonesData[zoneId] = [];
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Calculate subtotal for the item
|
|
202
|
-
const now = new Date().toISOString();
|
|
203
|
-
|
|
204
|
-
// Filter out undefined values from item (Firestore doesn't allow undefined)
|
|
205
|
-
const cleanItem = Object.fromEntries(
|
|
206
|
-
Object.entries(item).filter(([_, value]) => value !== undefined)
|
|
207
|
-
) as Omit<ZoneItemData, 'subtotal' | 'parentZone'>;
|
|
208
|
-
|
|
209
|
-
// Determine notesVisibleToPatient value (privacy-first: default to false if notes exist, otherwise omit)
|
|
210
|
-
const notesVisibleToPatientValue = cleanItem.notesVisibleToPatient !== undefined
|
|
211
|
-
? cleanItem.notesVisibleToPatient
|
|
212
|
-
: (cleanItem.notes ? false : undefined);
|
|
213
|
-
|
|
214
|
-
const itemWithSubtotal: ZoneItemData = {
|
|
215
|
-
...cleanItem,
|
|
216
|
-
parentZone: zoneId, // Set parentZone to the zone key
|
|
217
|
-
subtotal: calculateItemSubtotal(cleanItem),
|
|
218
|
-
createdAt: now,
|
|
219
|
-
updatedAt: now,
|
|
220
|
-
// Only include notesVisibleToPatient if it has a defined boolean value
|
|
221
|
-
...(typeof notesVisibleToPatientValue === 'boolean' && { notesVisibleToPatient: notesVisibleToPatientValue }),
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
// Add item to zone
|
|
225
|
-
zonesData[zoneId].push(itemWithSubtotal);
|
|
226
|
-
|
|
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);
|
|
233
|
-
|
|
234
|
-
// Update appointment
|
|
235
|
-
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
236
|
-
await updateDoc(appointmentRef, {
|
|
237
|
-
'metadata.zonesData': zonesData,
|
|
238
|
-
'metadata.finalbilling': finalbilling,
|
|
239
|
-
updatedAt: serverTimestamp(),
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// Return updated appointment
|
|
243
|
-
return getAppointmentOrThrow(db, appointmentId);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Removes an item from a specific zone
|
|
248
|
-
* @param db Firestore instance
|
|
249
|
-
* @param appointmentId Appointment ID
|
|
250
|
-
* @param zoneId Zone ID
|
|
251
|
-
* @param itemIndex Index of item to remove
|
|
252
|
-
* @returns Updated appointment
|
|
253
|
-
*/
|
|
254
|
-
export async function removeItemFromZoneUtil(
|
|
255
|
-
db: Firestore,
|
|
256
|
-
appointmentId: string,
|
|
257
|
-
zoneId: string,
|
|
258
|
-
itemIndex: number,
|
|
259
|
-
): Promise<Appointment> {
|
|
260
|
-
validateZoneKeyFormat(zoneId);
|
|
261
|
-
|
|
262
|
-
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
263
|
-
const metadata = initializeMetadata(appointment);
|
|
264
|
-
|
|
265
|
-
if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
|
|
266
|
-
throw new Error(`No items found for zone ${zoneId}`);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const items = metadata.zonesData[zoneId];
|
|
270
|
-
if (itemIndex < 0 || itemIndex >= items.length) {
|
|
271
|
-
throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Remove item
|
|
275
|
-
items.splice(itemIndex, 1);
|
|
276
|
-
|
|
277
|
-
// If zone is now empty, remove it
|
|
278
|
-
if (items.length === 0) {
|
|
279
|
-
delete metadata.zonesData[zoneId];
|
|
280
|
-
}
|
|
281
|
-
|
|
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);
|
|
288
|
-
|
|
289
|
-
// Update appointment
|
|
290
|
-
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
291
|
-
await updateDoc(appointmentRef, {
|
|
292
|
-
'metadata.zonesData': metadata.zonesData,
|
|
293
|
-
'metadata.finalbilling': finalbilling,
|
|
294
|
-
updatedAt: serverTimestamp(),
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
return getAppointmentOrThrow(db, appointmentId);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Updates a specific item in a zone
|
|
302
|
-
* @param db Firestore instance
|
|
303
|
-
* @param appointmentId Appointment ID
|
|
304
|
-
* @param zoneId Zone ID
|
|
305
|
-
* @param itemIndex Index of item to update
|
|
306
|
-
* @param updates Partial updates to apply
|
|
307
|
-
* @returns Updated appointment
|
|
308
|
-
*/
|
|
309
|
-
export async function updateZoneItemUtil(
|
|
310
|
-
db: Firestore,
|
|
311
|
-
appointmentId: string,
|
|
312
|
-
zoneId: string,
|
|
313
|
-
itemIndex: number,
|
|
314
|
-
updates: Partial<ZoneItemData>,
|
|
315
|
-
): Promise<Appointment> {
|
|
316
|
-
validateZoneKeyFormat(zoneId);
|
|
317
|
-
|
|
318
|
-
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
319
|
-
const metadata = initializeMetadata(appointment);
|
|
320
|
-
|
|
321
|
-
if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
|
|
322
|
-
throw new Error(`No items found for zone ${zoneId}`);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const items = metadata.zonesData[zoneId];
|
|
326
|
-
if (itemIndex < 0 || itemIndex >= items.length) {
|
|
327
|
-
throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Filter out undefined values from updates (Firestore doesn't store undefined)
|
|
331
|
-
// Keep null values as they're used to explicitly delete/clear fields
|
|
332
|
-
// Convert empty strings to null for consistency
|
|
333
|
-
const cleanUpdates = Object.fromEntries(
|
|
334
|
-
Object.entries(updates)
|
|
335
|
-
.filter(([_, value]) => value !== undefined)
|
|
336
|
-
.map(([key, value]) => [
|
|
337
|
-
key,
|
|
338
|
-
value === '' ? null : value, // Convert empty strings to null
|
|
339
|
-
])
|
|
340
|
-
) as Partial<ZoneItemData>;
|
|
341
|
-
|
|
342
|
-
console.log(`[updateZoneItemUtil] Updates received:`, {
|
|
343
|
-
itemIndex,
|
|
344
|
-
zoneId,
|
|
345
|
-
rawUpdates: updates,
|
|
346
|
-
cleanUpdates,
|
|
347
|
-
hasIonNumber: 'ionNumber' in cleanUpdates,
|
|
348
|
-
hasExpiryDate: 'expiryDate' in cleanUpdates,
|
|
349
|
-
ionNumberValue: cleanUpdates.ionNumber,
|
|
350
|
-
expiryDateValue: cleanUpdates.expiryDate,
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
// Update item with updatedAt timestamp
|
|
354
|
-
items[itemIndex] = {
|
|
355
|
-
...items[itemIndex],
|
|
356
|
-
...cleanUpdates,
|
|
357
|
-
updatedAt: new Date().toISOString(),
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
console.log(`[updateZoneItemUtil] Item after update:`, {
|
|
361
|
-
itemIndex,
|
|
362
|
-
ionNumber: items[itemIndex].ionNumber,
|
|
363
|
-
expiryDate: items[itemIndex].expiryDate,
|
|
364
|
-
quantity: items[itemIndex].quantity,
|
|
365
|
-
priceOverrideAmount: items[itemIndex].priceOverrideAmount,
|
|
366
|
-
price: items[itemIndex].price,
|
|
367
|
-
oldSubtotal: items[itemIndex].subtotal,
|
|
368
|
-
allItemKeys: Object.keys(items[itemIndex]),
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// Recalculate subtotal for this item
|
|
372
|
-
items[itemIndex].subtotal = calculateItemSubtotal(items[itemIndex]);
|
|
373
|
-
|
|
374
|
-
console.log(`[updateZoneItemUtil] AFTER recalculation:`, {
|
|
375
|
-
itemIndex,
|
|
376
|
-
newSubtotal: items[itemIndex].subtotal,
|
|
377
|
-
});
|
|
378
|
-
|
|
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);
|
|
385
|
-
|
|
386
|
-
// Log what we're about to save to Firestore
|
|
387
|
-
console.log(`[updateZoneItemUtil] Saving to Firestore:`, {
|
|
388
|
-
appointmentId,
|
|
389
|
-
zoneId,
|
|
390
|
-
itemIndex,
|
|
391
|
-
itemToSave: items[itemIndex],
|
|
392
|
-
itemIonNumber: items[itemIndex].ionNumber,
|
|
393
|
-
itemExpiryDate: items[itemIndex].expiryDate,
|
|
394
|
-
zonesDataKeys: Object.keys(metadata.zonesData),
|
|
395
|
-
zoneItemsCount: metadata.zonesData[zoneId]?.length,
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
// Update appointment
|
|
399
|
-
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
400
|
-
await updateDoc(appointmentRef, {
|
|
401
|
-
'metadata.zonesData': metadata.zonesData,
|
|
402
|
-
'metadata.finalbilling': finalbilling,
|
|
403
|
-
updatedAt: serverTimestamp(),
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// Verify what was actually saved
|
|
407
|
-
const savedAppointment = await getAppointmentOrThrow(db, appointmentId);
|
|
408
|
-
const savedItem = savedAppointment.metadata?.zonesData?.[zoneId]?.[itemIndex];
|
|
409
|
-
console.log(`[updateZoneItemUtil] Verification after save:`, {
|
|
410
|
-
savedItemIonNumber: savedItem?.ionNumber,
|
|
411
|
-
savedItemExpiryDate: savedItem?.expiryDate,
|
|
412
|
-
savedItemKeys: savedItem ? Object.keys(savedItem) : [],
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
return savedAppointment;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Overrides price for a specific zone item
|
|
420
|
-
* @param db Firestore instance
|
|
421
|
-
* @param appointmentId Appointment ID
|
|
422
|
-
* @param zoneId Zone ID
|
|
423
|
-
* @param itemIndex Index of item
|
|
424
|
-
* @param newPrice New price amount
|
|
425
|
-
* @returns Updated appointment
|
|
426
|
-
*/
|
|
427
|
-
export async function overridePriceForZoneItemUtil(
|
|
428
|
-
db: Firestore,
|
|
429
|
-
appointmentId: string,
|
|
430
|
-
zoneId: string,
|
|
431
|
-
itemIndex: number,
|
|
432
|
-
newPrice: number,
|
|
433
|
-
): Promise<Appointment> {
|
|
434
|
-
return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
|
|
435
|
-
priceOverrideAmount: newPrice,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Updates subzones for a specific zone item
|
|
441
|
-
* @param db Firestore instance
|
|
442
|
-
* @param appointmentId Appointment ID
|
|
443
|
-
* @param zoneId Zone ID
|
|
444
|
-
* @param itemIndex Index of item
|
|
445
|
-
* @param subzones Array of subzone keys (empty array = entire zone)
|
|
446
|
-
* @returns Updated appointment
|
|
447
|
-
*/
|
|
448
|
-
export async function updateSubzonesUtil(
|
|
449
|
-
db: Firestore,
|
|
450
|
-
appointmentId: string,
|
|
451
|
-
zoneId: string,
|
|
452
|
-
itemIndex: number,
|
|
453
|
-
subzones: string[],
|
|
454
|
-
): Promise<Appointment> {
|
|
455
|
-
// Validate subzone format if provided
|
|
456
|
-
subzones.forEach(subzone => {
|
|
457
|
-
const parts = subzone.split('.');
|
|
458
|
-
if (parts.length !== 3) {
|
|
459
|
-
throw new Error(
|
|
460
|
-
`Invalid subzone format: "${subzone}". Must be "category.zone.subzone" (e.g., "face.forehead.left")`,
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
|
|
466
|
-
subzones,
|
|
467
|
-
});
|
|
468
|
-
}
|
|
1
|
+
import { Firestore, getDoc, updateDoc, serverTimestamp } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
ZoneItemData,
|
|
4
|
+
AppointmentMetadata,
|
|
5
|
+
FinalBilling,
|
|
6
|
+
Appointment,
|
|
7
|
+
APPOINTMENTS_COLLECTION,
|
|
8
|
+
} from '../../../types/appointment';
|
|
9
|
+
import { getAppointmentByIdUtil } from './appointment.utils';
|
|
10
|
+
import { doc } from 'firebase/firestore';
|
|
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
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates that a zone key follows the category.zone format
|
|
23
|
+
* @param zoneKey Zone key to validate
|
|
24
|
+
* @throws Error if format is invalid
|
|
25
|
+
*/
|
|
26
|
+
export function validateZoneKeyFormat(zoneKey: string): void {
|
|
27
|
+
const parts = zoneKey.split('.');
|
|
28
|
+
if (parts.length !== 2) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid zone key format: "${zoneKey}". Must be "category.zone" (e.g., "face.forehead")`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Calculates subtotal for a zone item
|
|
37
|
+
* @param item Zone item data
|
|
38
|
+
* @returns Calculated subtotal
|
|
39
|
+
*/
|
|
40
|
+
export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
|
|
41
|
+
if (item.type === 'note') {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const quantity = item.quantity || 0;
|
|
46
|
+
|
|
47
|
+
// If price override amount is set, use it as price per unit
|
|
48
|
+
if (item.priceOverrideAmount !== undefined && item.priceOverrideAmount !== null) {
|
|
49
|
+
return item.priceOverrideAmount * quantity;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Calculate normally: price * quantity
|
|
53
|
+
const price = item.price || 0;
|
|
54
|
+
return price * quantity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recalculates final billing based on all zone items
|
|
59
|
+
* @param zonesData Zone items data
|
|
60
|
+
* @param taxRate Tax rate (e.g., 0.20 for 20%)
|
|
61
|
+
* @param discount Optional overall discount to apply
|
|
62
|
+
* @returns Calculated final billing
|
|
63
|
+
*/
|
|
64
|
+
export function calculateFinalBilling(
|
|
65
|
+
zonesData: Record<string, ZoneItemData[]>,
|
|
66
|
+
taxRate: number = 0.081,
|
|
67
|
+
discount?: { type: 'percentage' | 'fixed'; value: number } | null,
|
|
68
|
+
): FinalBilling {
|
|
69
|
+
let subtotalAll = 0;
|
|
70
|
+
|
|
71
|
+
// Sum up all zone items
|
|
72
|
+
Object.values(zonesData).forEach(items => {
|
|
73
|
+
items.forEach(item => {
|
|
74
|
+
if (item.type === 'item' && item.subtotal) {
|
|
75
|
+
subtotalAll += item.subtotal;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Get currency from first item (assuming all same currency)
|
|
81
|
+
let currency: any = 'CHF'; // Default
|
|
82
|
+
|
|
83
|
+
for (const items of Object.values(zonesData)) {
|
|
84
|
+
const firstItem = items.find(i => i.type === 'item');
|
|
85
|
+
if (firstItem) {
|
|
86
|
+
currency = firstItem.currency || currency;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
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
|
+
|
|
124
|
+
return {
|
|
125
|
+
subtotalAll,
|
|
126
|
+
taxRate,
|
|
127
|
+
taxPrice,
|
|
128
|
+
finalPrice,
|
|
129
|
+
currency,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Gets appointment and validates it exists
|
|
135
|
+
* @param db Firestore instance
|
|
136
|
+
* @param appointmentId Appointment ID
|
|
137
|
+
* @returns Appointment document
|
|
138
|
+
*/
|
|
139
|
+
export async function getAppointmentOrThrow(
|
|
140
|
+
db: Firestore,
|
|
141
|
+
appointmentId: string,
|
|
142
|
+
): Promise<Appointment> {
|
|
143
|
+
const appointment = await getAppointmentByIdUtil(db, appointmentId);
|
|
144
|
+
if (!appointment) {
|
|
145
|
+
throw new Error(`Appointment with ID ${appointmentId} not found`);
|
|
146
|
+
}
|
|
147
|
+
return appointment;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Initializes appointment metadata if it doesn't exist
|
|
152
|
+
* @param appointment Appointment document
|
|
153
|
+
* @returns Initialized metadata
|
|
154
|
+
*/
|
|
155
|
+
export function initializeMetadata(appointment: Appointment): AppointmentMetadata {
|
|
156
|
+
return (
|
|
157
|
+
appointment.metadata || {
|
|
158
|
+
selectedZones: null,
|
|
159
|
+
zonePhotos: null,
|
|
160
|
+
zonesData: null,
|
|
161
|
+
appointmentProducts: [],
|
|
162
|
+
extendedProcedures: [],
|
|
163
|
+
recommendedProcedures: [],
|
|
164
|
+
finalbilling: null,
|
|
165
|
+
finalizationNotesShared: null,
|
|
166
|
+
finalizationNotesInternal: null,
|
|
167
|
+
finalizationNotes: null, // @deprecated - kept for backward compatibility
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Adds an item to a specific zone
|
|
174
|
+
* @param db Firestore instance
|
|
175
|
+
* @param appointmentId Appointment ID
|
|
176
|
+
* @param zoneId Zone ID (must be category.zone format)
|
|
177
|
+
* @param item Zone item data to add (without parentZone)
|
|
178
|
+
* @returns Updated appointment
|
|
179
|
+
*/
|
|
180
|
+
export async function addItemToZoneUtil(
|
|
181
|
+
db: Firestore,
|
|
182
|
+
appointmentId: string,
|
|
183
|
+
zoneId: string,
|
|
184
|
+
item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>,
|
|
185
|
+
): Promise<Appointment> {
|
|
186
|
+
// Validate zone key format
|
|
187
|
+
validateZoneKeyFormat(zoneId);
|
|
188
|
+
|
|
189
|
+
// Get appointment
|
|
190
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
191
|
+
const metadata = initializeMetadata(appointment);
|
|
192
|
+
|
|
193
|
+
// Initialize zonesData if needed
|
|
194
|
+
const zonesData = metadata.zonesData || {};
|
|
195
|
+
|
|
196
|
+
// Initialize zone array if needed
|
|
197
|
+
if (!zonesData[zoneId]) {
|
|
198
|
+
zonesData[zoneId] = [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Calculate subtotal for the item
|
|
202
|
+
const now = new Date().toISOString();
|
|
203
|
+
|
|
204
|
+
// Filter out undefined values from item (Firestore doesn't allow undefined)
|
|
205
|
+
const cleanItem = Object.fromEntries(
|
|
206
|
+
Object.entries(item).filter(([_, value]) => value !== undefined)
|
|
207
|
+
) as Omit<ZoneItemData, 'subtotal' | 'parentZone'>;
|
|
208
|
+
|
|
209
|
+
// Determine notesVisibleToPatient value (privacy-first: default to false if notes exist, otherwise omit)
|
|
210
|
+
const notesVisibleToPatientValue = cleanItem.notesVisibleToPatient !== undefined
|
|
211
|
+
? cleanItem.notesVisibleToPatient
|
|
212
|
+
: (cleanItem.notes ? false : undefined);
|
|
213
|
+
|
|
214
|
+
const itemWithSubtotal: ZoneItemData = {
|
|
215
|
+
...cleanItem,
|
|
216
|
+
parentZone: zoneId, // Set parentZone to the zone key
|
|
217
|
+
subtotal: calculateItemSubtotal(cleanItem),
|
|
218
|
+
createdAt: now,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
// Only include notesVisibleToPatient if it has a defined boolean value
|
|
221
|
+
...(typeof notesVisibleToPatientValue === 'boolean' && { notesVisibleToPatient: notesVisibleToPatientValue }),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Add item to zone
|
|
225
|
+
zonesData[zoneId].push(itemWithSubtotal);
|
|
226
|
+
|
|
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);
|
|
233
|
+
|
|
234
|
+
// Update appointment
|
|
235
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
236
|
+
await updateDoc(appointmentRef, {
|
|
237
|
+
'metadata.zonesData': zonesData,
|
|
238
|
+
'metadata.finalbilling': finalbilling,
|
|
239
|
+
updatedAt: serverTimestamp(),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Return updated appointment
|
|
243
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Removes an item from a specific zone
|
|
248
|
+
* @param db Firestore instance
|
|
249
|
+
* @param appointmentId Appointment ID
|
|
250
|
+
* @param zoneId Zone ID
|
|
251
|
+
* @param itemIndex Index of item to remove
|
|
252
|
+
* @returns Updated appointment
|
|
253
|
+
*/
|
|
254
|
+
export async function removeItemFromZoneUtil(
|
|
255
|
+
db: Firestore,
|
|
256
|
+
appointmentId: string,
|
|
257
|
+
zoneId: string,
|
|
258
|
+
itemIndex: number,
|
|
259
|
+
): Promise<Appointment> {
|
|
260
|
+
validateZoneKeyFormat(zoneId);
|
|
261
|
+
|
|
262
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
263
|
+
const metadata = initializeMetadata(appointment);
|
|
264
|
+
|
|
265
|
+
if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
|
|
266
|
+
throw new Error(`No items found for zone ${zoneId}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const items = metadata.zonesData[zoneId];
|
|
270
|
+
if (itemIndex < 0 || itemIndex >= items.length) {
|
|
271
|
+
throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Remove item
|
|
275
|
+
items.splice(itemIndex, 1);
|
|
276
|
+
|
|
277
|
+
// If zone is now empty, remove it
|
|
278
|
+
if (items.length === 0) {
|
|
279
|
+
delete metadata.zonesData[zoneId];
|
|
280
|
+
}
|
|
281
|
+
|
|
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);
|
|
288
|
+
|
|
289
|
+
// Update appointment
|
|
290
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
291
|
+
await updateDoc(appointmentRef, {
|
|
292
|
+
'metadata.zonesData': metadata.zonesData,
|
|
293
|
+
'metadata.finalbilling': finalbilling,
|
|
294
|
+
updatedAt: serverTimestamp(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Updates a specific item in a zone
|
|
302
|
+
* @param db Firestore instance
|
|
303
|
+
* @param appointmentId Appointment ID
|
|
304
|
+
* @param zoneId Zone ID
|
|
305
|
+
* @param itemIndex Index of item to update
|
|
306
|
+
* @param updates Partial updates to apply
|
|
307
|
+
* @returns Updated appointment
|
|
308
|
+
*/
|
|
309
|
+
export async function updateZoneItemUtil(
|
|
310
|
+
db: Firestore,
|
|
311
|
+
appointmentId: string,
|
|
312
|
+
zoneId: string,
|
|
313
|
+
itemIndex: number,
|
|
314
|
+
updates: Partial<ZoneItemData>,
|
|
315
|
+
): Promise<Appointment> {
|
|
316
|
+
validateZoneKeyFormat(zoneId);
|
|
317
|
+
|
|
318
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
319
|
+
const metadata = initializeMetadata(appointment);
|
|
320
|
+
|
|
321
|
+
if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
|
|
322
|
+
throw new Error(`No items found for zone ${zoneId}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const items = metadata.zonesData[zoneId];
|
|
326
|
+
if (itemIndex < 0 || itemIndex >= items.length) {
|
|
327
|
+
throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Filter out undefined values from updates (Firestore doesn't store undefined)
|
|
331
|
+
// Keep null values as they're used to explicitly delete/clear fields
|
|
332
|
+
// Convert empty strings to null for consistency
|
|
333
|
+
const cleanUpdates = Object.fromEntries(
|
|
334
|
+
Object.entries(updates)
|
|
335
|
+
.filter(([_, value]) => value !== undefined)
|
|
336
|
+
.map(([key, value]) => [
|
|
337
|
+
key,
|
|
338
|
+
value === '' ? null : value, // Convert empty strings to null
|
|
339
|
+
])
|
|
340
|
+
) as Partial<ZoneItemData>;
|
|
341
|
+
|
|
342
|
+
console.log(`[updateZoneItemUtil] Updates received:`, {
|
|
343
|
+
itemIndex,
|
|
344
|
+
zoneId,
|
|
345
|
+
rawUpdates: updates,
|
|
346
|
+
cleanUpdates,
|
|
347
|
+
hasIonNumber: 'ionNumber' in cleanUpdates,
|
|
348
|
+
hasExpiryDate: 'expiryDate' in cleanUpdates,
|
|
349
|
+
ionNumberValue: cleanUpdates.ionNumber,
|
|
350
|
+
expiryDateValue: cleanUpdates.expiryDate,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Update item with updatedAt timestamp
|
|
354
|
+
items[itemIndex] = {
|
|
355
|
+
...items[itemIndex],
|
|
356
|
+
...cleanUpdates,
|
|
357
|
+
updatedAt: new Date().toISOString(),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
console.log(`[updateZoneItemUtil] Item after update:`, {
|
|
361
|
+
itemIndex,
|
|
362
|
+
ionNumber: items[itemIndex].ionNumber,
|
|
363
|
+
expiryDate: items[itemIndex].expiryDate,
|
|
364
|
+
quantity: items[itemIndex].quantity,
|
|
365
|
+
priceOverrideAmount: items[itemIndex].priceOverrideAmount,
|
|
366
|
+
price: items[itemIndex].price,
|
|
367
|
+
oldSubtotal: items[itemIndex].subtotal,
|
|
368
|
+
allItemKeys: Object.keys(items[itemIndex]),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Recalculate subtotal for this item
|
|
372
|
+
items[itemIndex].subtotal = calculateItemSubtotal(items[itemIndex]);
|
|
373
|
+
|
|
374
|
+
console.log(`[updateZoneItemUtil] AFTER recalculation:`, {
|
|
375
|
+
itemIndex,
|
|
376
|
+
newSubtotal: items[itemIndex].subtotal,
|
|
377
|
+
});
|
|
378
|
+
|
|
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);
|
|
385
|
+
|
|
386
|
+
// Log what we're about to save to Firestore
|
|
387
|
+
console.log(`[updateZoneItemUtil] Saving to Firestore:`, {
|
|
388
|
+
appointmentId,
|
|
389
|
+
zoneId,
|
|
390
|
+
itemIndex,
|
|
391
|
+
itemToSave: items[itemIndex],
|
|
392
|
+
itemIonNumber: items[itemIndex].ionNumber,
|
|
393
|
+
itemExpiryDate: items[itemIndex].expiryDate,
|
|
394
|
+
zonesDataKeys: Object.keys(metadata.zonesData),
|
|
395
|
+
zoneItemsCount: metadata.zonesData[zoneId]?.length,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Update appointment
|
|
399
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
400
|
+
await updateDoc(appointmentRef, {
|
|
401
|
+
'metadata.zonesData': metadata.zonesData,
|
|
402
|
+
'metadata.finalbilling': finalbilling,
|
|
403
|
+
updatedAt: serverTimestamp(),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Verify what was actually saved
|
|
407
|
+
const savedAppointment = await getAppointmentOrThrow(db, appointmentId);
|
|
408
|
+
const savedItem = savedAppointment.metadata?.zonesData?.[zoneId]?.[itemIndex];
|
|
409
|
+
console.log(`[updateZoneItemUtil] Verification after save:`, {
|
|
410
|
+
savedItemIonNumber: savedItem?.ionNumber,
|
|
411
|
+
savedItemExpiryDate: savedItem?.expiryDate,
|
|
412
|
+
savedItemKeys: savedItem ? Object.keys(savedItem) : [],
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return savedAppointment;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Overrides price for a specific zone item
|
|
420
|
+
* @param db Firestore instance
|
|
421
|
+
* @param appointmentId Appointment ID
|
|
422
|
+
* @param zoneId Zone ID
|
|
423
|
+
* @param itemIndex Index of item
|
|
424
|
+
* @param newPrice New price amount
|
|
425
|
+
* @returns Updated appointment
|
|
426
|
+
*/
|
|
427
|
+
export async function overridePriceForZoneItemUtil(
|
|
428
|
+
db: Firestore,
|
|
429
|
+
appointmentId: string,
|
|
430
|
+
zoneId: string,
|
|
431
|
+
itemIndex: number,
|
|
432
|
+
newPrice: number,
|
|
433
|
+
): Promise<Appointment> {
|
|
434
|
+
return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
|
|
435
|
+
priceOverrideAmount: newPrice,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Updates subzones for a specific zone item
|
|
441
|
+
* @param db Firestore instance
|
|
442
|
+
* @param appointmentId Appointment ID
|
|
443
|
+
* @param zoneId Zone ID
|
|
444
|
+
* @param itemIndex Index of item
|
|
445
|
+
* @param subzones Array of subzone keys (empty array = entire zone)
|
|
446
|
+
* @returns Updated appointment
|
|
447
|
+
*/
|
|
448
|
+
export async function updateSubzonesUtil(
|
|
449
|
+
db: Firestore,
|
|
450
|
+
appointmentId: string,
|
|
451
|
+
zoneId: string,
|
|
452
|
+
itemIndex: number,
|
|
453
|
+
subzones: string[],
|
|
454
|
+
): Promise<Appointment> {
|
|
455
|
+
// Validate subzone format if provided
|
|
456
|
+
subzones.forEach(subzone => {
|
|
457
|
+
const parts = subzone.split('.');
|
|
458
|
+
if (parts.length !== 3) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`Invalid subzone format: "${subzone}". Must be "category.zone.subzone" (e.g., "face.forehead.left")`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
|
|
466
|
+
subzones,
|
|
467
|
+
});
|
|
468
|
+
}
|