@blackcode_sa/metaestetics-api 1.15.13 → 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,20 +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 location
58
- * - Connect (CHF 59/mo): unlimited providers, 15 procedures, 100 appts/mo, unlimited msgs, 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,
71
+ maxProvidersPerBranch: 1,
72
+ maxProceduresPerProvider: 3,
70
73
  maxBranches: 1,
71
74
  },
72
75
  },
@@ -74,10 +77,8 @@ export const TIER_CONFIG: Record<string, TierConfig> = {
74
77
  tier: 'connect',
75
78
  name: 'Connect',
76
79
  limits: {
77
- maxProviders: -1,
78
- maxProcedures: 15,
79
- maxAppointmentsPerMonth: 100,
80
- maxMessagesPerMonth: -1,
80
+ maxProvidersPerBranch: 3,
81
+ maxProceduresPerProvider: 10,
81
82
  maxBranches: 1,
82
83
  },
83
84
  },
@@ -85,11 +86,9 @@ export const TIER_CONFIG: Record<string, TierConfig> = {
85
86
  tier: 'pro',
86
87
  name: 'Pro',
87
88
  limits: {
88
- maxProviders: -1,
89
- maxProcedures: -1,
90
- maxAppointmentsPerMonth: -1,
91
- maxMessagesPerMonth: -1,
92
- maxBranches: -1,
89
+ maxProvidersPerBranch: 10,
90
+ maxProceduresPerProvider: 20,
91
+ maxBranches: 3,
93
92
  },
94
93
  },
95
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,88 +46,82 @@ 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;
116
+ const proceduresSnap = await getDocs(proceduresQuery);
110
117
 
111
118
  const uniqueTechnologyIds = new Set<string>();
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
- proceduresSnap.docs.forEach(d => {
121
- const technologyId = d.data().technologyId;
122
- if (technologyId) {
123
- uniqueTechnologyIds.add(technologyId);
124
- }
125
- });
126
- }
119
+ proceduresSnap.docs.forEach(d => {
120
+ const technologyId = d.data().technologyId;
121
+ if (technologyId) {
122
+ uniqueTechnologyIds.add(technologyId);
123
+ }
124
+ });
127
125
 
128
126
  return uniqueTechnologyIds.size;
129
127
  }
@@ -145,124 +143,98 @@ async function countBranchesInGroup(
145
143
  }
146
144
 
147
145
  /**
148
- * Enforces tier limit before adding a provider (practitioner/doctor/nurse/etc.).
149
- * Throws TierLimitError if the limit would be exceeded.
146
+ * Resolves the clinicGroupId for a given branch.
150
147
  */
151
- export async function enforceProviderLimit(
148
+ async function resolveClinicGroupId(
152
149
  db: Firestore,
153
- clinicGroupId: string
154
- ): Promise<void> {
155
- const tier = await getEffectiveTier(db, clinicGroupId);
156
- const config = TIER_CONFIG[tier];
157
- if (!config) return;
158
-
159
- const max = config.limits.maxProviders;
160
- if (max === -1) return;
161
-
162
- const currentCount = await countProvidersInGroup(db, clinicGroupId);
163
- if (currentCount + 1 > max) {
164
- throw new TierLimitError('providers', 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`);
156
+ }
157
+ const clinicGroupId = clinicSnap.data().clinicGroupId;
158
+ if (!clinicGroupId) {
159
+ throw new Error(`Clinic branch ${branchId} has no clinicGroupId`);
165
160
  }
161
+ return clinicGroupId;
166
162
  }
167
163
 
168
164
  /**
169
- * Enforces tier limit before creating a procedure.
170
- * @param count - Number of procedures being created (default 1, higher for bulk)
165
+ * Enforces tier limit before adding a provider to a specific branch.
166
+ * Add-on aware: effective limit = base + per-branch extraProviders.
171
167
  */
172
- export async function enforceProcedureLimit(
168
+ export async function enforceProviderLimit(
173
169
  db: Firestore,
174
170
  clinicGroupId: string,
175
- count: number = 1
171
+ branchId: string
176
172
  ): Promise<void> {
177
- const tier = await getEffectiveTier(db, clinicGroupId);
173
+ const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
178
174
  const config = TIER_CONFIG[tier];
179
175
  if (!config) return;
180
176
 
181
- const max = config.limits.maxProcedures;
182
- if (max === -1) return;
177
+ const baseMax = config.limits.maxProvidersPerBranch;
178
+ if (baseMax === -1) return;
183
179
 
184
- const currentCount = await countProceduresInGroup(db, clinicGroupId);
185
- if (currentCount + count > max) {
186
- throw new TierLimitError('procedures', tier, currentCount, max);
187
- }
188
- }
180
+ const addOns = billing?.addOns || {};
181
+ const extraProviders = addOns[branchId]?.extraProviders || 0;
182
+ const effectiveMax = baseMax + extraProviders;
189
183
 
190
- /**
191
- * Enforces tier limit before creating a clinic branch.
192
- */
193
- export async function enforceBranchLimit(
194
- db: Firestore,
195
- clinicGroupId: string
196
- ): Promise<void> {
197
- const tier = await getEffectiveTier(db, clinicGroupId);
198
- const config = TIER_CONFIG[tier];
199
- if (!config) return;
200
-
201
- const max = config.limits.maxBranches;
202
- if (max === -1) return;
203
-
204
- const currentCount = await countBranchesInGroup(db, clinicGroupId);
205
- if (currentCount + 1 > max) {
206
- throw new TierLimitError('clinic branches', 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);
207
187
  }
208
188
  }
209
189
 
210
190
  /**
211
- * Enforces tier limit before creating an appointment.
212
- * 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).
213
193
  */
214
- export async function enforceAppointmentLimit(
194
+ export async function enforceProcedureLimit(
215
195
  db: Firestore,
216
- clinicGroupId: string
196
+ clinicGroupId: string,
197
+ branchId: string,
198
+ providerId: string,
199
+ count: number = 1
217
200
  ): Promise<void> {
218
- const tier = await getEffectiveTier(db, clinicGroupId);
201
+ const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
219
202
  const config = TIER_CONFIG[tier];
220
203
  if (!config) return;
221
204
 
222
- const max = config.limits.maxAppointmentsPerMonth;
223
- if (max === -1) return;
224
-
225
- const now = new Date();
226
- const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
205
+ const baseMax = config.limits.maxProceduresPerProvider;
206
+ if (baseMax === -1) return;
227
207
 
228
- const counterRef = doc(
229
- db,
230
- `${CLINIC_GROUPS_COLLECTION}/${clinicGroupId}/usage_counters/${yearMonth}`
231
- );
232
- const counterSnap = await getDoc(counterRef);
233
- 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);
234
211
 
235
- if (currentCount + 1 > max) {
236
- 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);
237
215
  }
238
216
  }
239
217
 
240
218
  /**
241
- * Enforces tier limit before sending a message.
242
- * 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.
243
221
  */
244
- export async function enforceMessageLimit(
222
+ export async function enforceBranchLimit(
245
223
  db: Firestore,
246
224
  clinicGroupId: string
247
225
  ): Promise<void> {
248
- const tier = await getEffectiveTier(db, clinicGroupId);
226
+ const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
249
227
  const config = TIER_CONFIG[tier];
250
228
  if (!config) return;
251
229
 
252
- const max = config.limits.maxMessagesPerMonth;
253
- if (max === -1) return;
254
-
255
- const now = new Date();
256
- const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
230
+ const baseMax = config.limits.maxBranches;
231
+ if (baseMax === -1) return;
257
232
 
258
- const counterRef = doc(
259
- db,
260
- `${CLINIC_GROUPS_COLLECTION}/${clinicGroupId}/usage_counters/${yearMonth}`
261
- );
262
- const counterSnap = await getDoc(counterRef);
263
- const currentCount = counterSnap.exists() ? (counterSnap.data()?.messagesCount || 0) : 0;
233
+ const branchAddonCount = billing?.branchAddonCount || 0;
234
+ const effectiveMax = baseMax + branchAddonCount;
264
235
 
265
- if (currentCount + 1 > max) {
266
- 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);
267
239
  }
268
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,12 +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
+ maxProvidersPerBranch: number;
51
+ maxProceduresPerProvider: number;
50
52
  maxBranches: number;
51
53
  }
52
54