@blackcode_sa/metaestetics-api 1.15.10 → 1.15.14

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.
@@ -53,21 +53,23 @@ export type PermissionKey = keyof typeof PERMISSION_KEYS;
53
53
  * All features are available on every tier — tiers differ only in usage caps.
54
54
  * -1 means unlimited.
55
55
  *
56
- * Confirmed by client on 2026-03-10:
57
- * - Free: 1 provider, 3 procedures, 3 appts/mo, 10 msgs/mo, 1 staff, 1 location
58
- * - Connect (CHF 59/mo): unlimited providers, 15 procedures, 100 appts/mo, unlimited msgs, 5 staff, 1 location
59
- * - Pro (CHF 149/mo): unlimited everything
56
+ * Updated 2026-03-17 (client meeting):
57
+ * - Appointments and messages are NEVER limited (bad patient UX)
58
+ * - Providers are per branch, not per clinic group
59
+ * - Procedures are per provider (each provider gets this allowance)
60
+ * - Add-ons available on Connect/Pro: +1 branch, +1 provider, +10 procedures
61
+ *
62
+ * Free: 1 branch, 1 provider/branch, 3 procedures/provider
63
+ * Connect (CHF 59/mo): 1 branch, 3 providers/branch, 10 procedures/provider
64
+ * Pro (CHF 149/mo): 3 branches, 10 providers/branch, 20 procedures/provider
60
65
  */
61
66
  export const TIER_CONFIG: Record<string, TierConfig> = {
62
67
  free: {
63
68
  tier: 'free',
64
69
  name: 'Free',
65
70
  limits: {
66
- maxProviders: 1,
67
- maxProcedures: 3,
68
- maxAppointmentsPerMonth: 3,
69
- maxMessagesPerMonth: 10,
70
- maxStaff: 1,
71
+ maxProvidersPerBranch: 1,
72
+ maxProceduresPerProvider: 3,
71
73
  maxBranches: 1,
72
74
  },
73
75
  },
@@ -75,11 +77,8 @@ export const TIER_CONFIG: Record<string, TierConfig> = {
75
77
  tier: 'connect',
76
78
  name: 'Connect',
77
79
  limits: {
78
- maxProviders: -1,
79
- maxProcedures: 15,
80
- maxAppointmentsPerMonth: 100,
81
- maxMessagesPerMonth: -1,
82
- maxStaff: 5,
80
+ maxProvidersPerBranch: 3,
81
+ maxProceduresPerProvider: 10,
83
82
  maxBranches: 1,
84
83
  },
85
84
  },
@@ -87,12 +86,9 @@ export const TIER_CONFIG: Record<string, TierConfig> = {
87
86
  tier: 'pro',
88
87
  name: 'Pro',
89
88
  limits: {
90
- maxProviders: -1,
91
- maxProcedures: -1,
92
- maxAppointmentsPerMonth: -1,
93
- maxMessagesPerMonth: -1,
94
- maxStaff: -1,
95
- maxBranches: -1,
89
+ maxProvidersPerBranch: 10,
90
+ maxProceduresPerProvider: 20,
91
+ maxBranches: 3,
96
92
  },
97
93
  },
98
94
  };
@@ -192,7 +192,7 @@ export class PractitionerService extends BaseService {
192
192
  if (clinicSnap.exists()) {
193
193
  const clinicGroupId = (clinicSnap.data() as any).clinicGroupId;
194
194
  if (clinicGroupId) {
195
- await enforceProviderLimit(this.db, clinicGroupId);
195
+ await enforceProviderLimit(this.db, clinicGroupId, validData.clinics[0]);
196
196
  }
197
197
  }
198
198
  }
@@ -296,9 +296,9 @@ export class PractitionerService extends BaseService {
296
296
  throw new Error(`Clinic ${clinicId} not found`);
297
297
  }
298
298
 
299
- // Enforce tier limit before creating draft practitioner
299
+ // Enforce tier limit before creating draft practitioner (per-branch)
300
300
  if (clinic.clinicGroupId) {
301
- await enforceProviderLimit(this.db, clinic.clinicGroupId);
301
+ await enforceProviderLimit(this.db, clinic.clinicGroupId, clinicId);
302
302
  }
303
303
 
304
304
  // Make sure the primary clinic (clinicId) is always included
@@ -363,8 +363,8 @@ export class ProcedureService extends BaseService {
363
363
  }
364
364
  const clinic = clinicSnapshot.data() as Clinic; // Assert type
365
365
 
366
- // Enforce tier limit before creating procedure
367
- await enforceProcedureLimit(this.db, clinic.clinicGroupId);
366
+ // Enforce tier limit before creating procedure (per-provider, per-branch)
367
+ await enforceProcedureLimit(this.db, clinic.clinicGroupId, validatedData.clinicBranchId, validatedData.practitionerId);
368
368
 
369
369
  const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, validatedData.practitionerId);
370
370
  const practitionerSnapshot = await getDoc(practitionerRef);
@@ -885,8 +885,10 @@ export class ProcedureService extends BaseService {
885
885
  }
886
886
  const clinic = clinicSnapshot.data() as Clinic;
887
887
 
888
- // Enforce tier limit before bulk creating procedures
889
- await enforceProcedureLimit(this.db, clinic.clinicGroupId, practitionerIds.length);
888
+ // Enforce tier limit for each practitioner (per-provider, per-branch)
889
+ for (const practitionerId of practitionerIds) {
890
+ await enforceProcedureLimit(this.db, clinic.clinicGroupId, validatedData.clinicBranchId, practitionerId);
891
+ }
890
892
 
891
893
  // 3. Handle media uploads once for efficiency
892
894
  let processedPhotos: string[] = [];
@@ -29,9 +29,13 @@ export class TierLimitError extends Error {
29
29
  maxAllowed: number
30
30
  ) {
31
31
  const tierLabel = currentTier.charAt(0).toUpperCase() + currentTier.slice(1);
32
+ const canBuyAddons = currentTier === 'connect' || currentTier === 'pro';
33
+ const actionHint = canBuyAddons
34
+ ? 'Please upgrade your plan or purchase an add-on.'
35
+ : 'Please upgrade to Connect or Pro to add more.';
32
36
  super(
33
37
  `Your ${tierLabel} plan allows a maximum of ${maxAllowed} ${resource}. ` +
34
- `You currently have ${currentCount}. Please upgrade your plan to add more.`
38
+ `You currently have ${currentCount}. ${actionHint}`
35
39
  );
36
40
  this.name = 'TierLimitError';
37
41
  this.currentTier = currentTier;
@@ -42,85 +46,84 @@ export class TierLimitError extends Error {
42
46
  }
43
47
 
44
48
  /**
45
- * Fetches the effective tier for a clinic group.
46
- * Returns the resolved tier string (e.g., 'free', 'connect', 'pro').
49
+ * Reads the clinic group doc and returns tier + billing data in one call.
47
50
  */
48
- export async function getEffectiveTier(
51
+ async function getClinicGroupTierData(
49
52
  db: Firestore,
50
53
  clinicGroupId: string
51
- ): Promise<string> {
54
+ ): Promise<{
55
+ tier: string;
56
+ billing: Record<string, any> | undefined;
57
+ }> {
52
58
  const groupRef = doc(db, CLINIC_GROUPS_COLLECTION, clinicGroupId);
53
59
  const groupSnap = await getDoc(groupRef);
54
60
  if (!groupSnap.exists()) {
55
61
  throw new Error(`Clinic group ${clinicGroupId} not found`);
56
62
  }
57
- const subscriptionModel = groupSnap.data().subscriptionModel || 'no_subscription';
58
- return resolveEffectiveTier(subscriptionModel);
63
+ const data = groupSnap.data();
64
+ const subscriptionModel = data.subscriptionModel || 'no_subscription';
65
+ return {
66
+ tier: resolveEffectiveTier(subscriptionModel),
67
+ billing: data.billing,
68
+ };
59
69
  }
60
70
 
61
71
  /**
62
- * Counts active providers (practitioners) across all clinics in a clinic group.
72
+ * Fetches the effective tier for a clinic group.
63
73
  */
64
- async function countProvidersInGroup(
74
+ export async function getEffectiveTier(
65
75
  db: Firestore,
66
76
  clinicGroupId: string
67
- ): Promise<number> {
68
- const clinicsQuery = query(
69
- collection(db, CLINICS_COLLECTION),
70
- where('clinicGroupId', '==', clinicGroupId),
71
- where('isActive', '==', true)
72
- );
73
- const clinicsSnap = await getDocs(clinicsQuery);
74
- const clinicIds = clinicsSnap.docs.map(d => d.id);
75
-
76
- if (clinicIds.length === 0) return 0;
77
+ ): Promise<string> {
78
+ const { tier } = await getClinicGroupTierData(db, clinicGroupId);
79
+ return tier;
80
+ }
77
81
 
82
+ /**
83
+ * Counts active providers (practitioners) assigned to a specific clinic branch.
84
+ */
85
+ async function countProvidersInBranch(
86
+ db: Firestore,
87
+ branchId: string
88
+ ): Promise<number> {
78
89
  const practitionersQuery = query(
79
90
  collection(db, PRACTITIONERS_COLLECTION),
80
91
  where('isActive', '==', true)
81
92
  );
82
93
  const practitionersSnap = await getDocs(practitionersQuery);
83
94
 
84
- const clinicIdSet = new Set(clinicIds);
85
- const uniqueProviders = practitionersSnap.docs.filter(d => {
86
- const data = d.data();
87
- const clinics: string[] = data.clinics || [];
88
- return clinics.some(c => clinicIdSet.has(c));
89
- });
90
-
91
- return uniqueProviders.length;
95
+ return practitionersSnap.docs.filter(d => {
96
+ const clinics: string[] = d.data().clinics || [];
97
+ return clinics.includes(branchId);
98
+ }).length;
92
99
  }
93
100
 
94
101
  /**
95
- * Counts active procedures across all clinics in a clinic group.
102
+ * Counts active procedures for a specific provider in a specific branch.
103
+ * Uses unique technologyId to avoid double-counting same procedure type.
96
104
  */
97
- async function countProceduresInGroup(
105
+ async function countProceduresForProvider(
98
106
  db: Firestore,
99
- clinicGroupId: string
107
+ branchId: string,
108
+ providerId: string
100
109
  ): Promise<number> {
101
- const clinicsQuery = query(
102
- collection(db, CLINICS_COLLECTION),
103
- where('clinicGroupId', '==', clinicGroupId),
110
+ const proceduresQuery = query(
111
+ collection(db, PROCEDURES_COLLECTION),
112
+ where('clinicBranchId', '==', branchId),
113
+ where('practitionerId', '==', providerId),
104
114
  where('isActive', '==', true)
105
115
  );
106
- const clinicsSnap = await getDocs(clinicsQuery);
107
- const clinicIds = clinicsSnap.docs.map(d => d.id);
108
-
109
- if (clinicIds.length === 0) return 0;
110
-
111
- let totalProcedures = 0;
112
- for (let i = 0; i < clinicIds.length; i += 30) {
113
- const batch = clinicIds.slice(i, i + 30);
114
- const proceduresQuery = query(
115
- collection(db, PROCEDURES_COLLECTION),
116
- where('clinicBranchId', 'in', batch),
117
- where('isActive', '==', true)
118
- );
119
- const proceduresSnap = await getDocs(proceduresQuery);
120
- totalProcedures += proceduresSnap.size;
121
- }
116
+ const proceduresSnap = await getDocs(proceduresQuery);
117
+
118
+ const uniqueTechnologyIds = new Set<string>();
119
+ proceduresSnap.docs.forEach(d => {
120
+ const technologyId = d.data().technologyId;
121
+ if (technologyId) {
122
+ uniqueTechnologyIds.add(technologyId);
123
+ }
124
+ });
122
125
 
123
- return totalProcedures;
126
+ return uniqueTechnologyIds.size;
124
127
  }
125
128
 
126
129
  /**
@@ -140,151 +143,98 @@ async function countBranchesInGroup(
140
143
  }
141
144
 
142
145
  /**
143
- * Enforces tier limit before adding a provider (practitioner/doctor/nurse/etc.).
144
- * Throws TierLimitError if the limit would be exceeded.
145
- */
146
- export async function enforceProviderLimit(
147
- db: Firestore,
148
- clinicGroupId: string
149
- ): Promise<void> {
150
- const tier = await getEffectiveTier(db, clinicGroupId);
151
- const config = TIER_CONFIG[tier];
152
- if (!config) return;
153
-
154
- const max = config.limits.maxProviders;
155
- if (max === -1) return;
156
-
157
- const currentCount = await countProvidersInGroup(db, clinicGroupId);
158
- if (currentCount + 1 > max) {
159
- throw new TierLimitError('providers', tier, currentCount, max);
160
- }
161
- }
162
-
163
- /**
164
- * Enforces tier limit before creating a procedure.
165
- * @param count - Number of procedures being created (default 1, higher for bulk)
146
+ * Resolves the clinicGroupId for a given branch.
166
147
  */
167
- export async function enforceProcedureLimit(
148
+ async function resolveClinicGroupId(
168
149
  db: Firestore,
169
- clinicGroupId: string,
170
- count: number = 1
171
- ): Promise<void> {
172
- const tier = await getEffectiveTier(db, clinicGroupId);
173
- const config = TIER_CONFIG[tier];
174
- if (!config) return;
175
-
176
- const max = config.limits.maxProcedures;
177
- if (max === -1) return;
178
-
179
- const currentCount = await countProceduresInGroup(db, clinicGroupId);
180
- if (currentCount + count > max) {
181
- throw new TierLimitError('procedures', tier, currentCount, max);
150
+ branchId: string
151
+ ): Promise<string> {
152
+ const clinicRef = doc(db, CLINICS_COLLECTION, branchId);
153
+ const clinicSnap = await getDoc(clinicRef);
154
+ if (!clinicSnap.exists()) {
155
+ throw new Error(`Clinic branch ${branchId} not found`);
182
156
  }
183
- }
184
-
185
- /**
186
- * Enforces tier limit before creating a clinic branch.
187
- */
188
- export async function enforceBranchLimit(
189
- db: Firestore,
190
- clinicGroupId: string
191
- ): Promise<void> {
192
- const tier = await getEffectiveTier(db, clinicGroupId);
193
- const config = TIER_CONFIG[tier];
194
- if (!config) return;
195
-
196
- const max = config.limits.maxBranches;
197
- if (max === -1) return;
198
-
199
- const currentCount = await countBranchesInGroup(db, clinicGroupId);
200
- if (currentCount + 1 > max) {
201
- throw new TierLimitError('clinic branches', tier, currentCount, max);
157
+ const clinicGroupId = clinicSnap.data().clinicGroupId;
158
+ if (!clinicGroupId) {
159
+ throw new Error(`Clinic branch ${branchId} has no clinicGroupId`);
202
160
  }
161
+ return clinicGroupId;
203
162
  }
204
163
 
205
164
  /**
206
- * Enforces tier limit before creating a staff member.
165
+ * Enforces tier limit before adding a provider to a specific branch.
166
+ * Add-on aware: effective limit = base + per-branch extraProviders.
207
167
  */
208
- export async function enforceStaffLimit(
168
+ export async function enforceProviderLimit(
209
169
  db: Firestore,
210
- clinicGroupId: string
170
+ clinicGroupId: string,
171
+ branchId: string
211
172
  ): Promise<void> {
212
- const tier = await getEffectiveTier(db, clinicGroupId);
173
+ const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
213
174
  const config = TIER_CONFIG[tier];
214
175
  if (!config) return;
215
176
 
216
- const max = config.limits.maxStaff;
217
- if (max === -1) return;
177
+ const baseMax = config.limits.maxProvidersPerBranch;
178
+ if (baseMax === -1) return;
218
179
 
219
- const staffQuery = query(
220
- collection(db, 'clinic_staff_members'),
221
- where('clinicGroupId', '==', clinicGroupId),
222
- where('isActive', '==', true)
223
- );
224
- const staffSnap = await getDocs(staffQuery);
225
- const currentCount = staffSnap.size;
180
+ const addOns = billing?.addOns || {};
181
+ const extraProviders = addOns[branchId]?.extraProviders || 0;
182
+ const effectiveMax = baseMax + extraProviders;
226
183
 
227
- if (currentCount + 1 > max) {
228
- throw new TierLimitError('staff members', tier, currentCount, max);
184
+ const currentCount = await countProvidersInBranch(db, branchId);
185
+ if (currentCount + 1 > effectiveMax) {
186
+ throw new TierLimitError('providers in this branch', tier, currentCount, effectiveMax);
229
187
  }
230
188
  }
231
189
 
232
190
  /**
233
- * Enforces tier limit before creating an appointment.
234
- * Reads the monthly counter from clinic_groups/{id}/usage_counters/{YYYY-MM}.
191
+ * Enforces tier limit before creating a procedure for a provider in a branch.
192
+ * Add-on aware: effective limit = base + (procedureBlocks × 10).
235
193
  */
236
- export async function enforceAppointmentLimit(
194
+ export async function enforceProcedureLimit(
237
195
  db: Firestore,
238
- clinicGroupId: string
196
+ clinicGroupId: string,
197
+ branchId: string,
198
+ providerId: string,
199
+ count: number = 1
239
200
  ): Promise<void> {
240
- const tier = await getEffectiveTier(db, clinicGroupId);
201
+ const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
241
202
  const config = TIER_CONFIG[tier];
242
203
  if (!config) return;
243
204
 
244
- const max = config.limits.maxAppointmentsPerMonth;
245
- if (max === -1) return;
205
+ const baseMax = config.limits.maxProceduresPerProvider;
206
+ if (baseMax === -1) return;
246
207
 
247
- const now = new Date();
248
- const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
249
-
250
- const counterRef = doc(
251
- db,
252
- `${CLINIC_GROUPS_COLLECTION}/${clinicGroupId}/usage_counters/${yearMonth}`
253
- );
254
- const counterSnap = await getDoc(counterRef);
255
- const currentCount = counterSnap.exists() ? (counterSnap.data()?.appointmentsCreated || 0) : 0;
208
+ const addOns = billing?.addOns || {};
209
+ const procedureBlocks = addOns[branchId]?.procedureBlocks || 0;
210
+ const effectiveMax = baseMax + (procedureBlocks * 10);
256
211
 
257
- if (currentCount + 1 > max) {
258
- throw new TierLimitError('appointments this month', tier, currentCount, max);
212
+ const currentCount = await countProceduresForProvider(db, branchId, providerId);
213
+ if (currentCount + count > effectiveMax) {
214
+ throw new TierLimitError('procedures for this provider', tier, currentCount, effectiveMax);
259
215
  }
260
216
  }
261
217
 
262
218
  /**
263
- * Enforces tier limit before sending a message.
264
- * Reads the monthly counter from clinic_groups/{id}/usage_counters/{YYYY-MM}.
219
+ * Enforces tier limit before creating a clinic branch.
220
+ * Add-on aware: effective limit = base + branchAddonCount.
265
221
  */
266
- export async function enforceMessageLimit(
222
+ export async function enforceBranchLimit(
267
223
  db: Firestore,
268
224
  clinicGroupId: string
269
225
  ): Promise<void> {
270
- const tier = await getEffectiveTier(db, clinicGroupId);
226
+ const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
271
227
  const config = TIER_CONFIG[tier];
272
228
  if (!config) return;
273
229
 
274
- const max = config.limits.maxMessagesPerMonth;
275
- if (max === -1) return;
230
+ const baseMax = config.limits.maxBranches;
231
+ if (baseMax === -1) return;
276
232
 
277
- const now = new Date();
278
- const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
233
+ const branchAddonCount = billing?.branchAddonCount || 0;
234
+ const effectiveMax = baseMax + branchAddonCount;
279
235
 
280
- const counterRef = doc(
281
- db,
282
- `${CLINIC_GROUPS_COLLECTION}/${clinicGroupId}/usage_counters/${yearMonth}`
283
- );
284
- const counterSnap = await getDoc(counterRef);
285
- const currentCount = counterSnap.exists() ? (counterSnap.data()?.messagesCount || 0) : 0;
286
-
287
- if (currentCount + 1 > max) {
288
- throw new TierLimitError('messages this month', tier, currentCount, max);
236
+ const currentCount = await countBranchesInGroup(db, clinicGroupId);
237
+ if (currentCount + 1 > effectiveMax) {
238
+ throw new TierLimitError('clinic branches', tier, currentCount, effectiveMax);
289
239
  }
290
240
  }
@@ -210,6 +210,19 @@ export interface BillingInfo {
210
210
  updatedAt: Timestamp;
211
211
  seatCount?: number;
212
212
  seatPriceId?: string;
213
+ /** Number of extra branches purchased (group-level add-on) */
214
+ branchAddonCount?: number;
215
+ branchAddonPriceId?: string;
216
+ /** Total procedure blocks across all branches (for Stripe sync) */
217
+ procedureAddonTotalBlocks?: number;
218
+ procedureAddonPriceId?: string;
219
+ /** Per-branch add-on tracking */
220
+ addOns?: {
221
+ [branchId: string]: {
222
+ extraProviders?: number;
223
+ procedureBlocks?: number;
224
+ };
225
+ };
213
226
  }
214
227
 
215
228
  /**
@@ -41,13 +41,14 @@ export interface RolePermissionConfig {
41
41
  * Usage limits for a given subscription tier.
42
42
  * All features are available on every tier — only usage is capped.
43
43
  * -1 means unlimited.
44
+ *
45
+ * Providers and procedures are scoped per branch, not per clinic group.
46
+ * Procedures are per provider (each provider in a branch gets this allowance).
47
+ * Appointments and messages are never limited.
44
48
  */
45
49
  export interface TierLimits {
46
- maxProviders: number;
47
- maxProcedures: number;
48
- maxAppointmentsPerMonth: number;
49
- maxMessagesPerMonth: number;
50
- maxStaff: number;
50
+ maxProvidersPerBranch: number;
51
+ maxProceduresPerProvider: number;
51
52
  maxBranches: number;
52
53
  }
53
54