lsh-framework 3.1.5 → 3.1.7

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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * LSH Error Handling Utilities
3
+ *
4
+ * Provides standardized error handling across the LSH codebase:
5
+ * - LSHError class for structured errors with codes and context
6
+ * - Error extraction utilities for safe error handling in catch blocks
7
+ * - Error code constants for consistent error identification
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { LSHError, ErrorCodes, extractErrorMessage } from './lsh-error.js';
12
+ *
13
+ * // Throw a structured error
14
+ * throw new LSHError(
15
+ * ErrorCodes.SECRETS_ENCRYPTION_FAILED,
16
+ * 'Failed to encrypt secret',
17
+ * { secretKey: 'DATABASE_URL', teamId: 'team_123' }
18
+ * );
19
+ *
20
+ * // Safe error extraction in catch block
21
+ * try {
22
+ * await doSomething();
23
+ * } catch (error) {
24
+ * console.error(extractErrorMessage(error));
25
+ * }
26
+ * ```
27
+ */
28
+ // ============================================================================
29
+ // ERROR CODES
30
+ // ============================================================================
31
+ /**
32
+ * Standardized error codes for LSH operations.
33
+ * Use these instead of magic strings for consistent error handling.
34
+ *
35
+ * Naming convention: CATEGORY_SPECIFIC_ERROR
36
+ * - AUTH_* : Authentication errors
37
+ * - SECRETS_* : Secrets management errors
38
+ * - DB_* : Database errors
39
+ * - DAEMON_* : Daemon/job errors
40
+ * - API_* : API server errors
41
+ * - CONFIG_* : Configuration errors
42
+ * - VALIDATION_* : Input validation errors
43
+ */
44
+ export const ErrorCodes = {
45
+ // Authentication errors
46
+ AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
47
+ AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
48
+ AUTH_EMAIL_NOT_VERIFIED: 'AUTH_EMAIL_NOT_VERIFIED',
49
+ AUTH_EMAIL_ALREADY_EXISTS: 'AUTH_EMAIL_ALREADY_EXISTS',
50
+ AUTH_INVALID_TOKEN: 'AUTH_INVALID_TOKEN',
51
+ AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
52
+ AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
53
+ AUTH_INSUFFICIENT_PERMISSIONS: 'AUTH_INSUFFICIENT_PERMISSIONS',
54
+ // Secrets management errors
55
+ SECRETS_ENCRYPTION_FAILED: 'SECRETS_ENCRYPTION_FAILED',
56
+ SECRETS_DECRYPTION_FAILED: 'SECRETS_DECRYPTION_FAILED',
57
+ SECRETS_KEY_NOT_FOUND: 'SECRETS_KEY_NOT_FOUND',
58
+ SECRETS_PUSH_FAILED: 'SECRETS_PUSH_FAILED',
59
+ SECRETS_PULL_FAILED: 'SECRETS_PULL_FAILED',
60
+ SECRETS_ROTATION_FAILED: 'SECRETS_ROTATION_FAILED',
61
+ SECRETS_ENV_PARSE_FAILED: 'SECRETS_ENV_PARSE_FAILED',
62
+ SECRETS_NOT_FOUND: 'SECRETS_NOT_FOUND',
63
+ // Database errors
64
+ DB_CONNECTION_FAILED: 'DB_CONNECTION_FAILED',
65
+ DB_QUERY_FAILED: 'DB_QUERY_FAILED',
66
+ DB_NOT_FOUND: 'DB_NOT_FOUND',
67
+ DB_ALREADY_EXISTS: 'DB_ALREADY_EXISTS',
68
+ DB_CONSTRAINT_VIOLATION: 'DB_CONSTRAINT_VIOLATION',
69
+ DB_TIMEOUT: 'DB_TIMEOUT',
70
+ // Daemon errors
71
+ DAEMON_NOT_RUNNING: 'DAEMON_NOT_RUNNING',
72
+ DAEMON_ALREADY_RUNNING: 'DAEMON_ALREADY_RUNNING',
73
+ DAEMON_START_FAILED: 'DAEMON_START_FAILED',
74
+ DAEMON_STOP_FAILED: 'DAEMON_STOP_FAILED',
75
+ DAEMON_CONNECTION_FAILED: 'DAEMON_CONNECTION_FAILED',
76
+ DAEMON_IPC_ERROR: 'DAEMON_IPC_ERROR',
77
+ // Job errors
78
+ JOB_NOT_FOUND: 'JOB_NOT_FOUND',
79
+ JOB_ALREADY_RUNNING: 'JOB_ALREADY_RUNNING',
80
+ JOB_START_FAILED: 'JOB_START_FAILED',
81
+ JOB_STOP_FAILED: 'JOB_STOP_FAILED',
82
+ JOB_TIMEOUT: 'JOB_TIMEOUT',
83
+ JOB_INVALID_SCHEDULE: 'JOB_INVALID_SCHEDULE',
84
+ // API errors
85
+ API_NOT_CONFIGURED: 'API_NOT_CONFIGURED',
86
+ API_INVALID_REQUEST: 'API_INVALID_REQUEST',
87
+ API_RATE_LIMITED: 'API_RATE_LIMITED',
88
+ API_INTERNAL_ERROR: 'API_INTERNAL_ERROR',
89
+ API_WEBHOOK_VERIFICATION_FAILED: 'API_WEBHOOK_VERIFICATION_FAILED',
90
+ // Configuration errors
91
+ CONFIG_MISSING_ENV_VAR: 'CONFIG_MISSING_ENV_VAR',
92
+ CONFIG_INVALID_VALUE: 'CONFIG_INVALID_VALUE',
93
+ CONFIG_FILE_NOT_FOUND: 'CONFIG_FILE_NOT_FOUND',
94
+ CONFIG_PARSE_ERROR: 'CONFIG_PARSE_ERROR',
95
+ // Validation errors
96
+ VALIDATION_REQUIRED_FIELD: 'VALIDATION_REQUIRED_FIELD',
97
+ VALIDATION_INVALID_FORMAT: 'VALIDATION_INVALID_FORMAT',
98
+ VALIDATION_OUT_OF_RANGE: 'VALIDATION_OUT_OF_RANGE',
99
+ VALIDATION_COMMAND_INJECTION: 'VALIDATION_COMMAND_INJECTION',
100
+ // Billing errors
101
+ BILLING_TIER_LIMIT_EXCEEDED: 'BILLING_TIER_LIMIT_EXCEEDED',
102
+ BILLING_SUBSCRIPTION_REQUIRED: 'BILLING_SUBSCRIPTION_REQUIRED',
103
+ BILLING_PAYMENT_REQUIRED: 'BILLING_PAYMENT_REQUIRED',
104
+ BILLING_STRIPE_ERROR: 'BILLING_STRIPE_ERROR',
105
+ // Resource errors
106
+ RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
107
+ RESOURCE_ALREADY_EXISTS: 'RESOURCE_ALREADY_EXISTS',
108
+ RESOURCE_CONFLICT: 'RESOURCE_CONFLICT',
109
+ // General errors
110
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
111
+ NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
112
+ SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
113
+ };
114
+ // ============================================================================
115
+ // LSH ERROR CLASS
116
+ // ============================================================================
117
+ /**
118
+ * Structured error class for LSH operations.
119
+ *
120
+ * Features:
121
+ * - Error code for programmatic handling
122
+ * - Context object for debugging information
123
+ * - HTTP status code mapping for API responses
124
+ * - Stack trace preservation
125
+ * - Serialization support
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * throw new LSHError(
130
+ * ErrorCodes.SECRETS_NOT_FOUND,
131
+ * 'Secret not found',
132
+ * { secretId: 'secret_123', environment: 'production' }
133
+ * );
134
+ * ```
135
+ */
136
+ export class LSHError extends Error {
137
+ /** Error code for programmatic handling */
138
+ code;
139
+ /** Additional context for debugging */
140
+ context;
141
+ /** HTTP status code for API responses */
142
+ statusCode;
143
+ /** Timestamp when error occurred */
144
+ timestamp;
145
+ constructor(code, message, context, statusCode) {
146
+ super(message);
147
+ this.name = 'LSHError';
148
+ this.code = code;
149
+ this.context = context;
150
+ this.statusCode = statusCode ?? getDefaultStatusCode(code);
151
+ this.timestamp = new Date();
152
+ // Maintain proper stack trace in V8 environments
153
+ if (Error.captureStackTrace) {
154
+ Error.captureStackTrace(this, LSHError);
155
+ }
156
+ }
157
+ /**
158
+ * Serialize error for logging or API response.
159
+ */
160
+ toJSON() {
161
+ return {
162
+ name: this.name,
163
+ code: this.code,
164
+ message: this.message,
165
+ context: this.context,
166
+ statusCode: this.statusCode,
167
+ timestamp: this.timestamp.toISOString(),
168
+ stack: this.stack,
169
+ };
170
+ }
171
+ /**
172
+ * Create user-friendly string representation.
173
+ */
174
+ toString() {
175
+ const contextStr = this.context ? ` (${JSON.stringify(this.context)})` : '';
176
+ return `[${this.code}] ${this.message}${contextStr}`;
177
+ }
178
+ }
179
+ // ============================================================================
180
+ // ERROR UTILITIES
181
+ // ============================================================================
182
+ /**
183
+ * Safely extract error message from unknown error type.
184
+ * Use this in catch blocks instead of (error as Error).message.
185
+ *
186
+ * @example
187
+ * ```typescript
188
+ * try {
189
+ * await riskyOperation();
190
+ * } catch (error) {
191
+ * console.error('Failed:', extractErrorMessage(error));
192
+ * }
193
+ * ```
194
+ */
195
+ export function extractErrorMessage(error) {
196
+ if (error instanceof LSHError) {
197
+ return error.toString();
198
+ }
199
+ if (error instanceof Error) {
200
+ return error.message;
201
+ }
202
+ if (typeof error === 'string') {
203
+ return error;
204
+ }
205
+ if (error && typeof error === 'object' && 'message' in error) {
206
+ return String(error.message);
207
+ }
208
+ return String(error);
209
+ }
210
+ /**
211
+ * Safely extract error details for logging.
212
+ * Returns structured object with message, stack, and code if available.
213
+ *
214
+ * @example
215
+ * ```typescript
216
+ * try {
217
+ * await riskyOperation();
218
+ * } catch (error) {
219
+ * logger.error('Operation failed', extractErrorDetails(error));
220
+ * }
221
+ * ```
222
+ */
223
+ export function extractErrorDetails(error) {
224
+ if (error instanceof LSHError) {
225
+ return {
226
+ message: error.message,
227
+ stack: error.stack,
228
+ code: error.code,
229
+ context: error.context,
230
+ };
231
+ }
232
+ if (error instanceof Error) {
233
+ return {
234
+ message: error.message,
235
+ stack: error.stack,
236
+ code: error.code,
237
+ };
238
+ }
239
+ return {
240
+ message: extractErrorMessage(error),
241
+ };
242
+ }
243
+ /**
244
+ * Check if an error is an LSHError with a specific code.
245
+ *
246
+ * @example
247
+ * ```typescript
248
+ * try {
249
+ * await getSecret();
250
+ * } catch (error) {
251
+ * if (isLSHError(error, ErrorCodes.SECRETS_NOT_FOUND)) {
252
+ * // Handle not found case
253
+ * }
254
+ * throw error;
255
+ * }
256
+ * ```
257
+ */
258
+ export function isLSHError(error, code) {
259
+ if (!(error instanceof LSHError))
260
+ return false;
261
+ if (code && error.code !== code)
262
+ return false;
263
+ return true;
264
+ }
265
+ /**
266
+ * Wrap an unknown error as an LSHError.
267
+ * Useful for ensuring consistent error types in APIs.
268
+ *
269
+ * @example
270
+ * ```typescript
271
+ * try {
272
+ * await externalApi.call();
273
+ * } catch (error) {
274
+ * throw wrapAsLSHError(error, ErrorCodes.API_INTERNAL_ERROR);
275
+ * }
276
+ * ```
277
+ */
278
+ export function wrapAsLSHError(error, code = ErrorCodes.INTERNAL_ERROR, context) {
279
+ if (error instanceof LSHError) {
280
+ // Add additional context if provided
281
+ if (context) {
282
+ return new LSHError(error.code, error.message, {
283
+ ...error.context,
284
+ ...context,
285
+ });
286
+ }
287
+ return error;
288
+ }
289
+ const message = extractErrorMessage(error);
290
+ const details = extractErrorDetails(error);
291
+ return new LSHError(code, message, {
292
+ ...context,
293
+ originalStack: details.stack,
294
+ originalCode: details.code,
295
+ });
296
+ }
297
+ // ============================================================================
298
+ // HTTP STATUS CODE MAPPING
299
+ // ============================================================================
300
+ /**
301
+ * Get default HTTP status code for an error code.
302
+ * Used by LSHError constructor and API responses.
303
+ */
304
+ function getDefaultStatusCode(code) {
305
+ // 400 Bad Request
306
+ if (code.startsWith('VALIDATION_'))
307
+ return 400;
308
+ if (code === ErrorCodes.API_INVALID_REQUEST)
309
+ return 400;
310
+ if (code === ErrorCodes.CONFIG_INVALID_VALUE)
311
+ return 400;
312
+ // 401 Unauthorized
313
+ if (code === ErrorCodes.AUTH_UNAUTHORIZED)
314
+ return 401;
315
+ if (code === ErrorCodes.AUTH_INVALID_CREDENTIALS)
316
+ return 401;
317
+ if (code === ErrorCodes.AUTH_INVALID_TOKEN)
318
+ return 401;
319
+ if (code === ErrorCodes.AUTH_TOKEN_EXPIRED)
320
+ return 401;
321
+ // 402 Payment Required
322
+ if (code === ErrorCodes.BILLING_PAYMENT_REQUIRED)
323
+ return 402;
324
+ if (code === ErrorCodes.BILLING_SUBSCRIPTION_REQUIRED)
325
+ return 402;
326
+ // 403 Forbidden
327
+ if (code === ErrorCodes.AUTH_FORBIDDEN)
328
+ return 403;
329
+ if (code === ErrorCodes.AUTH_INSUFFICIENT_PERMISSIONS)
330
+ return 403;
331
+ if (code === ErrorCodes.AUTH_EMAIL_NOT_VERIFIED)
332
+ return 403;
333
+ // 404 Not Found
334
+ if (code.endsWith('_NOT_FOUND'))
335
+ return 404;
336
+ if (code === ErrorCodes.DB_NOT_FOUND)
337
+ return 404;
338
+ // 409 Conflict
339
+ if (code.endsWith('_ALREADY_EXISTS'))
340
+ return 409;
341
+ if (code === ErrorCodes.RESOURCE_CONFLICT)
342
+ return 409;
343
+ if (code === ErrorCodes.DB_CONSTRAINT_VIOLATION)
344
+ return 409;
345
+ // 429 Too Many Requests
346
+ if (code === ErrorCodes.API_RATE_LIMITED)
347
+ return 429;
348
+ if (code === ErrorCodes.BILLING_TIER_LIMIT_EXCEEDED)
349
+ return 429;
350
+ // 501 Not Implemented
351
+ if (code === ErrorCodes.NOT_IMPLEMENTED)
352
+ return 501;
353
+ // 503 Service Unavailable
354
+ if (code === ErrorCodes.SERVICE_UNAVAILABLE)
355
+ return 503;
356
+ if (code === ErrorCodes.DB_CONNECTION_FAILED)
357
+ return 503;
358
+ // 504 Gateway Timeout
359
+ if (code === ErrorCodes.DB_TIMEOUT)
360
+ return 504;
361
+ if (code === ErrorCodes.JOB_TIMEOUT)
362
+ return 504;
363
+ // Default to 500 Internal Server Error
364
+ return 500;
365
+ }
366
+ // ============================================================================
367
+ // FACTORY FUNCTIONS
368
+ // ============================================================================
369
+ /**
370
+ * Create a not found error.
371
+ */
372
+ export function notFoundError(resource, id, context) {
373
+ const message = id ? `${resource} '${id}' not found` : `${resource} not found`;
374
+ return new LSHError(ErrorCodes.RESOURCE_NOT_FOUND, message, { resource, id, ...context });
375
+ }
376
+ /**
377
+ * Create an already exists error.
378
+ */
379
+ export function alreadyExistsError(resource, identifier, context) {
380
+ const message = identifier
381
+ ? `${resource} '${identifier}' already exists`
382
+ : `${resource} already exists`;
383
+ return new LSHError(ErrorCodes.RESOURCE_ALREADY_EXISTS, message, {
384
+ resource,
385
+ identifier,
386
+ ...context,
387
+ });
388
+ }
389
+ /**
390
+ * Create a validation error.
391
+ */
392
+ export function validationError(message, field, context) {
393
+ return new LSHError(ErrorCodes.VALIDATION_REQUIRED_FIELD, message, { field, ...context });
394
+ }
395
+ /**
396
+ * Create an unauthorized error.
397
+ */
398
+ export function unauthorizedError(message = 'Unauthorized', context) {
399
+ return new LSHError(ErrorCodes.AUTH_UNAUTHORIZED, message, context);
400
+ }
401
+ /**
402
+ * Create a forbidden error.
403
+ */
404
+ export function forbiddenError(message = 'Insufficient permissions', context) {
405
+ return new LSHError(ErrorCodes.AUTH_FORBIDDEN, message, context);
406
+ }
@@ -378,7 +378,29 @@ export class AuthService {
378
378
  await this.supabase.from('users').update({ password_hash: newHash }).eq('id', userId);
379
379
  }
380
380
  /**
381
- * Map database user to User type
381
+ * Transform Supabase user record to domain model.
382
+ *
383
+ * Maps database snake_case columns to TypeScript camelCase properties:
384
+ * - `email_verified` → `emailVerified` (boolean)
385
+ * - `email_verification_token` → `emailVerificationToken` (nullable string)
386
+ * - `email_verification_expires_at` → `emailVerificationExpiresAt` (nullable Date)
387
+ * - `password_hash` → `passwordHash` (nullable, null for OAuth-only users)
388
+ * - `oauth_provider` → `oauthProvider` ('google' | 'github' | 'microsoft' | null)
389
+ * - `oauth_provider_id` → `oauthProviderId` (nullable)
390
+ * - `first_name` → `firstName` (nullable)
391
+ * - `last_name` → `lastName` (nullable)
392
+ * - `avatar_url` → `avatarUrl` (nullable)
393
+ * - `last_login_at` → `lastLoginAt` (nullable Date)
394
+ * - `last_login_ip` → `lastLoginIp` (nullable)
395
+ * - `deleted_at` → `deletedAt` (nullable Date, for soft delete)
396
+ *
397
+ * Security note: passwordHash is included in domain model but should
398
+ * never be exposed in API responses.
399
+ *
400
+ * @param dbUser - Supabase record from 'users' table
401
+ * @returns Domain User object
402
+ * @see DbUserRecord in database-types.ts for input shape
403
+ * @see User in saas-types.ts for output shape
382
404
  */
383
405
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
384
406
  mapDbUserToUser(dbUser) {
@@ -404,7 +426,22 @@ export class AuthService {
404
426
  };
405
427
  }
406
428
  /**
407
- * Map database organization to Organization type
429
+ * Transform Supabase organization record to domain model.
430
+ *
431
+ * Used when fetching user's organizations via the organization_members join.
432
+ * Identical mapping logic to OrganizationService.mapDbOrgToOrg().
433
+ *
434
+ * Maps database snake_case columns to TypeScript camelCase properties:
435
+ * - `stripe_customer_id` → `stripeCustomerId`
436
+ * - `subscription_tier` → `subscriptionTier` (SubscriptionTier type)
437
+ * - `subscription_status` → `subscriptionStatus` (SubscriptionStatus type)
438
+ * - `subscription_expires_at` → `subscriptionExpiresAt` (nullable Date)
439
+ * - `deleted_at` → `deletedAt` (nullable Date)
440
+ *
441
+ * @param dbOrg - Supabase record from 'organizations' table (via join)
442
+ * @returns Domain Organization object
443
+ * @see DbOrganizationRecord in database-types.ts for input shape
444
+ * @see Organization in saas-types.ts for output shape
408
445
  */
409
446
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
410
447
  mapDbOrgToOrg(dbOrg) {
@@ -154,7 +154,22 @@ export class BillingService {
154
154
  }
155
155
  }
156
156
  /**
157
- * Verify webhook signature
157
+ * Verify Stripe webhook signature and parse event payload.
158
+ *
159
+ * In production, this should use Stripe's signature verification:
160
+ * ```typescript
161
+ * const event = stripe.webhooks.constructEvent(
162
+ * payload, signature, this.stripeWebhookSecret
163
+ * );
164
+ * ```
165
+ *
166
+ * Current implementation parses JSON without verification (TODO: implement proper verification).
167
+ *
168
+ * @param payload - Raw webhook body as string
169
+ * @param _signature - Stripe-Signature header value (not yet used)
170
+ * @returns Parsed Stripe event object
171
+ * @throws Error if payload is not valid JSON
172
+ * @see https://stripe.com/docs/webhooks/signatures
158
173
  */
159
174
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe event structure
160
175
  verifyWebhookSignature(payload, _signature) {
@@ -168,7 +183,16 @@ export class BillingService {
168
183
  }
169
184
  }
170
185
  /**
171
- * Handle checkout completed
186
+ * Handle Stripe checkout.session.completed webhook event.
187
+ *
188
+ * Called when a customer completes checkout. The actual subscription
189
+ * creation is handled by the customer.subscription.created event.
190
+ *
191
+ * Extracts organization_id from session.metadata to link the checkout
192
+ * to the correct organization.
193
+ *
194
+ * @param session - Stripe checkout session object
195
+ * @see StripeCheckoutSession in database-types.ts for partial type
172
196
  */
173
197
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe checkout session object
174
198
  async handleCheckoutCompleted(session) {
@@ -181,7 +205,20 @@ export class BillingService {
181
205
  console.log(`Checkout completed for organization ${organizationId}`);
182
206
  }
183
207
  /**
184
- * Handle subscription updated
208
+ * Handle Stripe customer.subscription.created/updated webhook events.
209
+ *
210
+ * Creates or updates subscription record in database and syncs tier
211
+ * to the organization. Key operations:
212
+ * 1. Extracts organization_id from subscription.metadata
213
+ * 2. Determines tier from price ID (maps Stripe price → 'free' | 'pro' | 'enterprise')
214
+ * 3. Upserts subscription record with all billing details
215
+ * 4. Updates organization's subscription_tier and subscription_status
216
+ * 5. Logs audit event
217
+ *
218
+ * Timestamps from Stripe are Unix timestamps (seconds), converted to ISO strings.
219
+ *
220
+ * @param subscription - Stripe subscription object
221
+ * @see StripeSubscriptionEvent in database-types.ts for partial type
185
222
  */
186
223
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe subscription object
187
224
  async handleSubscriptionUpdated(subscription) {
@@ -235,7 +272,17 @@ export class BillingService {
235
272
  });
236
273
  }
237
274
  /**
238
- * Handle subscription deleted
275
+ * Handle Stripe customer.subscription.deleted webhook event.
276
+ *
277
+ * Called when a subscription is canceled (immediate or at period end).
278
+ * Operations:
279
+ * 1. Marks subscription as 'canceled' with canceled_at timestamp
280
+ * 2. Downgrades organization to 'free' tier
281
+ * 3. Updates organization subscription_status to 'canceled'
282
+ * 4. Logs audit event
283
+ *
284
+ * @param subscription - Stripe subscription object
285
+ * @see StripeSubscriptionEvent in database-types.ts for partial type
239
286
  */
240
287
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe subscription object
241
288
  async handleSubscriptionDeleted(subscription) {
@@ -268,7 +315,15 @@ export class BillingService {
268
315
  });
269
316
  }
270
317
  /**
271
- * Handle invoice paid
318
+ * Handle Stripe invoice.paid webhook event.
319
+ *
320
+ * Records successful payment in the invoices table. Extracts organization_id
321
+ * from invoice.subscription_metadata (set during checkout).
322
+ *
323
+ * Amounts are in cents (e.g., 1000 = $10.00). Currency is uppercased.
324
+ *
325
+ * @param invoice - Stripe invoice object
326
+ * @see StripeInvoiceEvent in database-types.ts for partial type
272
327
  */
273
328
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe invoice object
274
329
  async handleInvoicePaid(invoice) {
@@ -291,7 +346,16 @@ export class BillingService {
291
346
  }, { onConflict: 'stripe_invoice_id' });
292
347
  }
293
348
  /**
294
- * Handle invoice payment failed
349
+ * Handle Stripe invoice.payment_failed webhook event.
350
+ *
351
+ * Called when payment fails (declined card, insufficient funds, etc.).
352
+ * Updates organization subscription_status to 'past_due' and logs audit event.
353
+ *
354
+ * Note: Does not immediately downgrade tier. Stripe will retry payment
355
+ * according to your dunning settings. Downgrade happens on subscription.deleted.
356
+ *
357
+ * @param invoice - Stripe invoice object
358
+ * @see StripeInvoiceEvent in database-types.ts for partial type
295
359
  */
296
360
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe invoice object
297
361
  async handleInvoicePaymentFailed(invoice) {
@@ -358,7 +422,26 @@ export class BillingService {
358
422
  return (data || []).map(this.mapDbInvoiceToInvoice);
359
423
  }
360
424
  /**
361
- * Map database subscription to Subscription type
425
+ * Transform Supabase subscription record to domain model.
426
+ *
427
+ * Maps database snake_case columns to TypeScript camelCase properties:
428
+ * - `organization_id` → `organizationId`
429
+ * - `stripe_subscription_id` → `stripeSubscriptionId` (Stripe sub_xxx ID)
430
+ * - `stripe_price_id` → `stripePriceId` (Stripe price_xxx ID)
431
+ * - `stripe_product_id` → `stripeProductId` (Stripe prod_xxx ID)
432
+ * - `tier` → `tier` (SubscriptionTier: 'free' | 'pro' | 'enterprise')
433
+ * - `status` → `status` (SubscriptionStatus)
434
+ * - `current_period_start` → `currentPeriodStart` (nullable Date)
435
+ * - `current_period_end` → `currentPeriodEnd` (nullable Date)
436
+ * - `cancel_at_period_end` → `cancelAtPeriodEnd` (boolean)
437
+ * - `trial_start` → `trialStart` (nullable Date)
438
+ * - `trial_end` → `trialEnd` (nullable Date)
439
+ * - `canceled_at` → `canceledAt` (nullable Date)
440
+ *
441
+ * @param dbSub - Supabase record from 'subscriptions' table
442
+ * @returns Domain Subscription object
443
+ * @see DbSubscriptionRecord in database-types.ts for input shape
444
+ * @see Subscription in saas-types.ts for output shape
362
445
  */
363
446
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
364
447
  mapDbSubscriptionToSubscription(dbSub) {
@@ -383,7 +466,22 @@ export class BillingService {
383
466
  };
384
467
  }
385
468
  /**
386
- * Map database invoice to Invoice type
469
+ * Transform Supabase invoice record to domain model.
470
+ *
471
+ * Maps database snake_case columns to TypeScript camelCase properties:
472
+ * - `organization_id` → `organizationId`
473
+ * - `stripe_invoice_id` → `stripeInvoiceId` (Stripe in_xxx ID)
474
+ * - `amount_due` → `amountDue` (in cents, e.g., 1000 = $10.00)
475
+ * - `amount_paid` → `amountPaid` (in cents)
476
+ * - `invoice_date` → `invoiceDate` (Date)
477
+ * - `due_date` → `dueDate` (nullable Date)
478
+ * - `paid_at` → `paidAt` (nullable Date)
479
+ * - `invoice_pdf_url` → `invoicePdfUrl` (Stripe-hosted PDF URL)
480
+ *
481
+ * @param dbInvoice - Supabase record from 'invoices' table
482
+ * @returns Domain Invoice object
483
+ * @see DbInvoiceRecord in database-types.ts for input shape
484
+ * @see Invoice in saas-types.ts for output shape
387
485
  */
388
486
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
389
487
  mapDbInvoiceToInvoice(dbInvoice) {