@goscribe/server 1.1.7 → 1.3.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.
Files changed (56) hide show
  1. package/.env.example +43 -0
  2. package/check-difficulty.cjs +14 -0
  3. package/check-questions.cjs +14 -0
  4. package/db-summary.cjs +22 -0
  5. package/dist/routers/auth.js +1 -1
  6. package/mcq-test.cjs +36 -0
  7. package/package.json +10 -2
  8. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  9. package/prisma/schema.prisma +485 -292
  10. package/src/context.ts +4 -1
  11. package/src/lib/activity_human_description.test.ts +28 -0
  12. package/src/lib/activity_human_description.ts +239 -0
  13. package/src/lib/activity_log_service.test.ts +37 -0
  14. package/src/lib/activity_log_service.ts +353 -0
  15. package/src/lib/ai-session.ts +194 -112
  16. package/src/lib/constants.ts +14 -0
  17. package/src/lib/email.ts +230 -0
  18. package/src/lib/env.ts +23 -6
  19. package/src/lib/inference.ts +3 -3
  20. package/src/lib/logger.ts +26 -9
  21. package/src/lib/notification-service.test.ts +106 -0
  22. package/src/lib/notification-service.ts +677 -0
  23. package/src/lib/prisma.ts +6 -1
  24. package/src/lib/pusher.ts +90 -6
  25. package/src/lib/retry.ts +61 -0
  26. package/src/lib/storage.ts +2 -2
  27. package/src/lib/stripe.ts +39 -0
  28. package/src/lib/subscription_service.ts +722 -0
  29. package/src/lib/usage_service.ts +74 -0
  30. package/src/lib/worksheet-generation.test.ts +31 -0
  31. package/src/lib/worksheet-generation.ts +139 -0
  32. package/src/lib/workspace-access.ts +13 -0
  33. package/src/routers/_app.ts +11 -0
  34. package/src/routers/admin.ts +710 -0
  35. package/src/routers/annotations.ts +227 -0
  36. package/src/routers/auth.ts +432 -33
  37. package/src/routers/copilot.ts +719 -0
  38. package/src/routers/flashcards.ts +207 -80
  39. package/src/routers/members.ts +280 -80
  40. package/src/routers/notifications.ts +142 -0
  41. package/src/routers/payment.ts +448 -0
  42. package/src/routers/podcast.ts +133 -108
  43. package/src/routers/studyguide.ts +80 -74
  44. package/src/routers/worksheets.ts +300 -80
  45. package/src/routers/workspace.ts +538 -328
  46. package/src/scripts/purge-deleted-users.ts +167 -0
  47. package/src/server.ts +140 -12
  48. package/src/services/flashcard-progress.service.ts +52 -43
  49. package/src/trpc.ts +184 -5
  50. package/test-generate.js +30 -0
  51. package/test-ratio.cjs +9 -0
  52. package/zod-test.cjs +22 -0
  53. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  54. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  55. package/prisma/seed.mjs +0 -135
  56. package/src/routers/meetingsummary.ts +0 -416
@@ -0,0 +1,722 @@
1
+ import { prisma } from './prisma.js';
2
+ import { logger } from './logger.js';
3
+ import { stripe } from './stripe.js';
4
+ import Stripe from 'stripe';
5
+ import {
6
+ notifyPaymentFailed,
7
+ notifyPaymentSucceeded,
8
+ notifySubscriptionActivated,
9
+ notifySubscriptionCanceled,
10
+ notifySubscriptionPaymentSucceeded,
11
+ } from './notification-service.js';
12
+ import { ArtifactType } from '@prisma/client';
13
+ import PusherService from './pusher.js';
14
+
15
+ /**
16
+ * Handle checkout.session.completed event
17
+ */
18
+ export async function handleCheckoutCompleted(event: Stripe.Event) {
19
+ const session = event.data.object as Stripe.Checkout.Session;
20
+ const metadata = session.metadata;
21
+
22
+ // 0. Webhook Idempotency: We'll record this inside the fulfillment logic
23
+ // to ensure atomicity. If we record it too early and the DB crashes,
24
+ // we might skip a legitimate retry.
25
+
26
+ if (!metadata || !metadata.userId) {
27
+ logger.error('Missing userId metadata in Stripe checkout session', 'STRIPE', { sessionId: session.id });
28
+ return; // This is a permanent failure (misconfiguration), don't retry?
29
+ // Actually, rethrowing might be better just in case it's transient,
30
+ // but if metadata is missing, it's usually a bug.
31
+ }
32
+
33
+ const userId = metadata.userId;
34
+
35
+ // 1. Handle Resource Top-ups (One-time purchases)
36
+ if (metadata.isPurchase === 'true') {
37
+ const resourceType = metadata.resourceType as ArtifactType;
38
+ const quantity = parseInt(metadata.quantity || "1", 10);
39
+
40
+ try {
41
+ await prisma.$transaction(async (tx) => {
42
+ // 1. Release the Idempotency Lock
43
+ if (metadata.attemptId) {
44
+ await tx.idempotencyRecord.update({
45
+ where: { id: metadata.attemptId },
46
+ data: { status: 'completed', activeLockKey: null }
47
+ }).catch(err => logger.warn(`Could not release lock for attempt ${metadata.attemptId}: ${err.message}`, 'STRIPE'));
48
+ }
49
+
50
+ await tx.userCredit.create({
51
+ data: {
52
+ userId,
53
+ resourceType,
54
+ amount: quantity,
55
+ stripeSessionId: session.id,
56
+ }
57
+ });
58
+
59
+ // Create an Invoice record for top-up revenue tracking
60
+ const stripeInvoiceId = (session as any).invoice;
61
+ let invoicePdfUrl = null;
62
+ let hostedInvoiceUrl = null;
63
+
64
+ if (stripeInvoiceId && stripe) {
65
+ try {
66
+ const stripeInvoice = await stripe.invoices.retrieve(stripeInvoiceId);
67
+ invoicePdfUrl = stripeInvoice.invoice_pdf;
68
+ hostedInvoiceUrl = stripeInvoice.hosted_invoice_url;
69
+ } catch (err) {
70
+ logger.warn(`Could not retrieve stripe invoice ${stripeInvoiceId} for top-up`, 'STRIPE');
71
+ }
72
+ }
73
+
74
+ await (tx as any).invoice.create({
75
+ data: {
76
+ userId,
77
+ stripeInvoiceId: stripeInvoiceId || `session_${session.id}`,
78
+ amountPaid: session.amount_total || 0,
79
+ status: 'paid',
80
+ type: 'TOPUP',
81
+ paidAt: new Date(),
82
+ invoicePdfUrl,
83
+ hostedInvoiceUrl,
84
+ }
85
+ });
86
+ });
87
+
88
+ logger.info(`✅ Resource Top-up fulfilled: User ${userId} gets ${quantity} ${resourceType}`, 'STRIPE');
89
+
90
+ // Notify UI
91
+ await PusherService.emitPaymentSuccess(userId, {
92
+ type: 'topup',
93
+ resourceType,
94
+ quantity: quantity.toString()
95
+ });
96
+
97
+ } catch (err: any) {
98
+ // Prisma error P2002 means duplicate stripeSessionId (already fulfilled)
99
+ if (err.code === 'P2002') {
100
+ logger.warn(`Top-up for session ${session.id} already fulfilled. Skipping.`, 'STRIPE');
101
+ } else {
102
+ logger.error('Failed to fulfill resource top-up', 'STRIPE', undefined, err);
103
+ throw err; // RETHROW: Trigger Stripe retry
104
+ }
105
+ }
106
+ return;
107
+ }
108
+
109
+ // 2. Handle Subscription Plans (Legacy or specific plan ID)
110
+ if (!metadata.planId) {
111
+ logger.error('Missing planId metadata in legacy checkout flow', 'STRIPE', { sessionId: session.id });
112
+ return;
113
+ }
114
+ const planId = metadata.planId;
115
+
116
+ logger.info(`Checkout completed for user ${userId}, plan ${planId}`, 'STRIPE', { sessionId: session.id });
117
+
118
+ // Release the Idempotency Lock for ALL successful checkouts
119
+ if (metadata.attemptId) {
120
+ await prisma.idempotencyRecord.update({
121
+ where: { id: metadata.attemptId },
122
+ data: { status: 'completed', activeLockKey: null }
123
+ }).catch(err => logger.warn(`Could not release lock for attempt ${metadata.attemptId}: ${err.message}`, 'STRIPE'));
124
+ }
125
+
126
+ // Handle one-time credit purchases (Starter plan)
127
+ // Subscription plans will be handled by customer.subscription.created
128
+ if (session.mode === 'payment' && metadata.isPurchase !== 'true') {
129
+ const now = new Date();
130
+ const distantFuture = new Date();
131
+ distantFuture.setFullYear(now.getFullYear() + 100);
132
+
133
+ try {
134
+ await prisma.subscription.create({
135
+ data: {
136
+ userId,
137
+ planId,
138
+ stripeSubscriptionId: `ot_${session.id}`, // One-time identifier
139
+ stripeCustomerId: session.customer as string,
140
+ status: 'active',
141
+ currentPeriodStart: now,
142
+ currentPeriodEnd: distantFuture,
143
+ },
144
+ });
145
+ const plan = await prisma.plan.findUnique({ where: { id: planId } });
146
+ await notifyPaymentSucceeded(prisma, {
147
+ userId,
148
+ planId,
149
+ planName: plan?.name,
150
+ stripeSessionId: session.id,
151
+ amountPaid: session.amount_total ?? undefined,
152
+ });
153
+ logger.info(`One-time plan (credits) fulfilled for user ${userId}`, 'STRIPE');
154
+ } catch (err: any) {
155
+ if (err.code === 'P2002') {
156
+ logger.warn(`One-time sub for session ${session.id} already exists. Skipping.`, 'STRIPE');
157
+ } else {
158
+ logger.error('Failed to fulfill one-time checkout session', 'STRIPE', undefined, err);
159
+ throw err; // RETHROW: Trigger Stripe retry
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Handle customer.subscription.created event
167
+ */
168
+ export async function handleSubscriptionCreated(event: Stripe.Event) {
169
+ const subscription = event.data.object as Stripe.Subscription;
170
+
171
+ logger.info(`Subscription created: ${subscription.id}`, 'STRIPE');
172
+ // upsertSubscriptionFromStripe now handles its own transaction
173
+ await upsertSubscriptionFromStripe(subscription);
174
+ // 1. Keep the notification logic from 'main'
175
+ const dbSubscription = await prisma.subscription.findUnique({
176
+ where: { stripeSubscriptionId: subscription.id },
177
+ include: { plan: true },
178
+ });
179
+
180
+ if (dbSubscription) {
181
+ await notifySubscriptionActivated(prisma, {
182
+ userId: dbSubscription.userId,
183
+ planId: dbSubscription.planId,
184
+ planName: dbSubscription.plan.name,
185
+ stripeSubscriptionId: dbSubscription.stripeSubscriptionId,
186
+ });
187
+ }
188
+
189
+ // 2. Keep the event tracking logic from my fixes
190
+ await prisma.stripeEvent.create({
191
+ data: {
192
+ stripeEventId: event.id,
193
+ type: event.type,
194
+ status: 'processed',
195
+ processedAt: new Date(),
196
+ }
197
+ }).catch(err => {
198
+ if (err.code !== 'P2002') throw err;
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Handle customer.subscription.updated event
204
+ */
205
+ export async function handleSubscriptionUpdated(event: Stripe.Event) {
206
+ const subscription = event.data.object as Stripe.Subscription;
207
+
208
+ logger.info(`Subscription updated: ${subscription.id}`, 'STRIPE');
209
+ await upsertSubscriptionFromStripe(subscription);
210
+
211
+ await prisma.stripeEvent.create({
212
+ data: {
213
+ stripeEventId: event.id,
214
+ type: event.type,
215
+ status: 'processed',
216
+ processedAt: new Date(),
217
+ }
218
+ }).catch(err => {
219
+ if (err.code !== 'P2002') throw err;
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Handle customer.subscription.deleted event
225
+ */
226
+ export async function handleSubscriptionDeleted(event: Stripe.Event) {
227
+ const subscription = event.data.object as Stripe.Subscription;
228
+
229
+ logger.info(`Subscription deleted: ${subscription.id}`, 'STRIPE');
230
+ try {
231
+ const existing = await prisma.subscription.findUnique({
232
+ where: { stripeSubscriptionId: subscription.id },
233
+ include: { plan: true },
234
+ });
235
+
236
+ await prisma.$transaction(async (tx) => {
237
+ // Mark event as processed
238
+ await tx.stripeEvent.create({
239
+ data: {
240
+ stripeEventId: event.id,
241
+ type: event.type,
242
+ status: 'processed',
243
+ processedAt: new Date(),
244
+ }
245
+ });
246
+
247
+ await tx.subscription.update({
248
+ where: { stripeSubscriptionId: subscription.id },
249
+ data: {
250
+ status: 'canceled',
251
+ canceledAt: new Date(),
252
+ },
253
+ });
254
+ });
255
+
256
+ if (existing) {
257
+ await notifySubscriptionCanceled(prisma, {
258
+ userId: existing.userId,
259
+ planId: existing.planId,
260
+ planName: existing.plan.name,
261
+ stripeSubscriptionId: existing.stripeSubscriptionId,
262
+ });
263
+ }
264
+ logger.info(`Subscription ${subscription.id} marked as canceled in DB`, 'STRIPE');
265
+ } catch (err: any) {
266
+ if (err.code === 'P2002') return; // Event already processed
267
+ // If the subscription doesn't exist in our DB, just log it
268
+ logger.warn(`Could not mark subscription ${subscription.id} as canceled: ${err.message}`, 'STRIPE');
269
+ // We don't necessarily need to rethrow here if it's a 404 (P2025) on our end,
270
+ // but if it's a DB error, we should.
271
+ if (err.code !== 'P2025') throw err;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Handle invoice.paid event
277
+ */
278
+ export async function handleInvoicePaid(event: Stripe.Event) {
279
+ const invoice = event.data.object as Stripe.Invoice;
280
+
281
+ const stripeInvoiceId = invoice.id;
282
+ const stripeSubscriptionId =
283
+ (invoice as any).subscription as string ||
284
+ (invoice as any).parent?.subscription_details?.subscription as string;
285
+ const stripeCustomerId = invoice.customer as string;
286
+ const amountPaid = invoice.amount_paid;
287
+ const status = 'paid';
288
+ const invoicePdfUrl = invoice.invoice_pdf;
289
+ const hostedInvoiceUrl = invoice.hosted_invoice_url;
290
+ const paidAt = invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : new Date();
291
+
292
+ logger.info(`Invoice paid: ${stripeInvoiceId} for customer ${stripeCustomerId}, sub ${stripeSubscriptionId}`, 'STRIPE');
293
+
294
+ let userId: string | null = null;
295
+ let subscriptionId: string | null = null;
296
+ let resolvedPlanId: string | undefined;
297
+ let resolvedPlanName: string | undefined;
298
+
299
+ // 1. If it's a subscription invoice, sync subscription first
300
+ if (stripeSubscriptionId) {
301
+ logger.info(`Syncing subscription ${stripeSubscriptionId} from Stripe...`, 'STRIPE');
302
+ await upsertSubscriptionFromStripe(stripeSubscriptionId);
303
+
304
+ const subscription = await prisma.subscription.findUnique({
305
+ where: { stripeSubscriptionId },
306
+ include: { plan: true },
307
+ });
308
+
309
+ if (subscription) {
310
+ userId = subscription.userId;
311
+ subscriptionId = subscription.id;
312
+ resolvedPlanId = subscription.planId;
313
+ resolvedPlanName = subscription.plan?.name;
314
+ logger.info(`Found existing subscription ${subscriptionId} for user ${userId}`, 'STRIPE');
315
+ } else {
316
+ logger.warn(`Subscription ${stripeSubscriptionId} STILL not found after sync attempt.`, 'STRIPE');
317
+ }
318
+ }
319
+
320
+ // 2. If no userId found yet (or one-time payment), try finding user by Stripe Customer ID
321
+ if (!userId && stripeCustomerId) {
322
+ const user = await prisma.user.findUnique({
323
+ where: { stripe_customer_id: stripeCustomerId }
324
+ });
325
+ userId = user?.id || null;
326
+ }
327
+
328
+ if (!userId) {
329
+ logger.error(`Could not determine user for invoice ${stripeInvoiceId} (Customer: ${stripeCustomerId}, Sub: ${stripeSubscriptionId})`, 'STRIPE');
330
+ return;
331
+ }
332
+
333
+ // 3. Create/Update Invoice record within transaction that marks event as processed
334
+ try {
335
+ logger.info(`Upserting invoice ${stripeInvoiceId} for user ${userId}...`, 'STRIPE');
336
+ await prisma.$transaction(async (tx) => {
337
+ // Mark event as processed
338
+ await tx.stripeEvent.create({
339
+ data: {
340
+ stripeEventId: event.id,
341
+ type: event.type,
342
+ status: 'processed',
343
+ processedAt: new Date(),
344
+ }
345
+ });
346
+
347
+ await (tx as any).invoice.upsert({
348
+ where: { stripeInvoiceId },
349
+ create: {
350
+ userId,
351
+ subscriptionId,
352
+ stripeInvoiceId,
353
+ amountPaid,
354
+ status,
355
+ type: 'SUBSCRIPTION',
356
+ invoicePdfUrl,
357
+ hostedInvoiceUrl,
358
+ paidAt,
359
+ createdAt: new Date(invoice.created * 1000),
360
+ },
361
+ update: {
362
+ status,
363
+ paidAt,
364
+ invoicePdfUrl,
365
+ hostedInvoiceUrl,
366
+ ...(subscriptionId ? { subscriptionId } : {}),
367
+ }
368
+ });
369
+ });
370
+ } catch (err: any) {
371
+ if (err.code === 'P2002') return; // Already processed
372
+ logger.error(`Failed to upsert invoice ${stripeInvoiceId}`, 'STRIPE', undefined, err);
373
+ throw err;
374
+ }
375
+
376
+ logger.info(`Invoice ${stripeInvoiceId} saved successfully for user ${userId}`, 'STRIPE');
377
+ await notifySubscriptionPaymentSucceeded(prisma, {
378
+ userId,
379
+ planId: resolvedPlanId,
380
+ planName: resolvedPlanName,
381
+ stripeInvoiceId,
382
+ amountPaid,
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Handle invoice.payment_failed event
388
+ */
389
+ export async function handlePaymentFailed(event: Stripe.Event) {
390
+ const invoice = event.data.object as Stripe.Invoice;
391
+
392
+ const stripeInvoiceId = invoice.id;
393
+ const stripeSubscriptionId = (invoice as any).subscription as string;
394
+
395
+ logger.warn(`Invoice payment failed: ${stripeInvoiceId} for sub ${stripeSubscriptionId}`, 'STRIPE');
396
+
397
+ try {
398
+ await prisma.$transaction(async (tx) => {
399
+ // Mark event as processed
400
+ await tx.stripeEvent.create({
401
+ data: {
402
+ stripeEventId: event.id,
403
+ type: event.type,
404
+ status: 'processed',
405
+ processedAt: new Date(),
406
+ }
407
+ });
408
+
409
+ if (stripeSubscriptionId) {
410
+ // Mark subscription as past_due in DB
411
+ await tx.subscription.updateMany({
412
+ where: { stripeSubscriptionId },
413
+ data: { status: 'past_due' }
414
+ });
415
+ }
416
+
417
+ // Fetch info for legacy components if needed (main branch logic fallback)
418
+ const subscriptionInfo = await tx.subscription.findUnique({
419
+ where: { stripeSubscriptionId: stripeSubscriptionId || "" },
420
+ include: { plan: true },
421
+ });
422
+
423
+ // Create a failed invoice record for history
424
+ const subscription = await tx.subscription.findUnique({
425
+ where: { stripeSubscriptionId: stripeSubscriptionId || "" }
426
+ });
427
+
428
+ if (subscription) {
429
+ await (tx as any).invoice.upsert({
430
+ where: { stripeInvoiceId },
431
+ create: {
432
+ userId: subscription.userId,
433
+ subscriptionId: subscription.id,
434
+ stripeInvoiceId,
435
+ amountPaid: invoice.amount_paid,
436
+ status: 'failed',
437
+ type: 'SUBSCRIPTION',
438
+ invoicePdfUrl: invoice.invoice_pdf,
439
+ hostedInvoiceUrl: invoice.hosted_invoice_url,
440
+ createdAt: new Date(invoice.created * 1000),
441
+ },
442
+ update: {
443
+ status: 'failed'
444
+ }
445
+ });
446
+ }
447
+ });
448
+ const targetSub = await prisma.subscription.findUnique({
449
+ where: { stripeSubscriptionId: stripeSubscriptionId || "" },
450
+ include: { plan: true }
451
+ });
452
+
453
+ if (targetSub) {
454
+ await notifyPaymentFailed(prisma, {
455
+ userId: targetSub.userId,
456
+ planId: targetSub.planId,
457
+ planName: targetSub.plan.name,
458
+ stripeInvoiceId,
459
+ });
460
+ }
461
+ } catch (err: any) {
462
+ if (err.code === 'P2002') return; // Already processed
463
+ logger.error(`Failed to record payment failure for invoice ${stripeInvoiceId}`, 'STRIPE', undefined, err);
464
+ throw err;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Handle payment_intent.payment_failed event (One-time payments)
470
+ */
471
+ export async function handlePaymentIntentFailed(event: Stripe.Event) {
472
+ const paymentIntent = event.data.object as Stripe.PaymentIntent;
473
+
474
+ const stripePaymentIntentId = paymentIntent.id;
475
+ const stripeCustomerId = paymentIntent.customer as string;
476
+ const metadata = paymentIntent.metadata;
477
+ const userId = metadata?.userId;
478
+
479
+ logger.warn(`Payment Intent failed: ${stripePaymentIntentId} for user ${userId}`, 'STRIPE');
480
+
481
+ try {
482
+ await prisma.$transaction(async (tx) => {
483
+ // Mark event as processed
484
+ await tx.stripeEvent.create({
485
+ data: {
486
+ stripeEventId: event.id,
487
+ type: event.type,
488
+ status: 'processed',
489
+ processedAt: new Date(),
490
+ }
491
+ });
492
+
493
+ if (userId) {
494
+ // Create a failed invoice record for history (using PI ID as invoice ID placeholder for one-time failed attempts)
495
+ await (tx as any).invoice.upsert({
496
+ where: { stripeInvoiceId: `pi_${stripePaymentIntentId}` },
497
+ create: {
498
+ userId,
499
+ stripeInvoiceId: `pi_${stripePaymentIntentId}`,
500
+ amountPaid: paymentIntent.amount,
501
+ status: 'failed',
502
+ type: 'TOPUP',
503
+ createdAt: new Date(),
504
+ },
505
+ update: {
506
+ status: 'failed'
507
+ }
508
+ });
509
+
510
+ await notifyPaymentFailed(tx as any, {
511
+ userId,
512
+ planId: metadata?.planId,
513
+ stripePaymentIntentId: stripePaymentIntentId,
514
+ });
515
+ } else if (stripeCustomerId) {
516
+ // Try finding user by customer ID if metadata missing
517
+ const user = await tx.user.findUnique({
518
+ where: { stripe_customer_id: stripeCustomerId }
519
+ });
520
+ if (user) {
521
+ await (tx as any).invoice.upsert({
522
+ where: { stripeInvoiceId: `pi_${stripePaymentIntentId}` },
523
+ create: {
524
+ userId: user.id,
525
+ stripeInvoiceId: `pi_${stripePaymentIntentId}`,
526
+ amountPaid: paymentIntent.amount,
527
+ status: 'failed',
528
+ createdAt: new Date(),
529
+ },
530
+ update: {
531
+ status: 'failed'
532
+ }
533
+ });
534
+
535
+ await notifyPaymentFailed(tx as any, {
536
+ userId: user.id,
537
+ planId: metadata?.planId,
538
+ stripePaymentIntentId: stripePaymentIntentId,
539
+ });
540
+ }
541
+ }
542
+ });
543
+ } catch (err: any) {
544
+ if (err.code === 'P2002') return; // Already processed
545
+ logger.error(`Failed to record payment intent failure ${stripePaymentIntentId}`, 'STRIPE', undefined, err);
546
+ throw err;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Get the storage limit for a user based on their active subscription
552
+ */
553
+ export async function getUserStorageLimit(userId: string): Promise<number> {
554
+ const activeSub = await prisma.subscription.findFirst({
555
+ where: {
556
+ userId,
557
+ status: 'active',
558
+ },
559
+ include: {
560
+ plan: {
561
+ include: {
562
+ limit: true,
563
+ },
564
+ },
565
+ },
566
+ orderBy: {
567
+ createdAt: 'desc',
568
+ },
569
+ });
570
+
571
+ // If an active subscription with a plan limit exists, use it
572
+ if (activeSub?.plan?.limit?.maxStorageBytes) {
573
+ return Number(activeSub.plan.limit.maxStorageBytes);
574
+ }
575
+
576
+ // Default limit (fallback for users with no active subscription)
577
+ return 0;
578
+ }
579
+
580
+ /**
581
+ * Core logic to sync Stripe subscription state with Prisma database
582
+ */
583
+ export async function upsertSubscriptionFromStripe(subscriptionIdOrObject: Stripe.Subscription | string) {
584
+ if (!stripe) {
585
+ logger.error('Stripe not initialized', 'STRIPE');
586
+ return;
587
+ }
588
+
589
+ try {
590
+ let subscription: Stripe.Subscription;
591
+
592
+ if (typeof subscriptionIdOrObject === 'string') {
593
+ subscription = await stripe.subscriptions.retrieve(subscriptionIdOrObject, {
594
+ expand: ['items.data.price'],
595
+ });
596
+ } else {
597
+ // Always retrieve fresh to ensure we have expanded fields and latest state
598
+ subscription = await stripe.subscriptions.retrieve(subscriptionIdOrObject.id, {
599
+ expand: ['items.data.price'],
600
+ });
601
+ }
602
+
603
+ const stripeSubscriptionId = subscription.id;
604
+ const stripeCustomerId = subscription.customer as string;
605
+ const status = subscription.status;
606
+
607
+ // Retrieve period fields from the first subscription item if available (newer API pattern)
608
+ // fall back to the main subscription object
609
+ const subItem = subscription.items.data[0];
610
+ const periodStartRaw = (subItem as any)?.current_period_start || (subscription as any).current_period_start;
611
+ const periodEndRaw = (subItem as any)?.current_period_end || (subscription as any).current_period_end;
612
+
613
+ const currentPeriodStart = new Date(periodStartRaw * 1000);
614
+ const currentPeriodEnd = new Date(periodEndRaw * 1000);
615
+ const cancelAt = subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null;
616
+ const canceledAt = subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null;
617
+
618
+ let userId = subscription.metadata?.userId;
619
+ let planId = subscription.metadata?.planId;
620
+
621
+ // Fallback: look up in DB if metadata is missing from Stripe object
622
+ if (!userId || !planId) {
623
+ const existing = await prisma.subscription.findUnique({
624
+ where: { stripeSubscriptionId },
625
+ });
626
+
627
+ if (existing) {
628
+ userId = userId || existing.userId;
629
+ planId = planId || existing.planId;
630
+ } else {
631
+ // If new subscription and metadata is missing, try matching by Customer ID
632
+ const user = await prisma.user.findUnique({
633
+ where: { stripe_customer_id: stripeCustomerId },
634
+ });
635
+ if (user) {
636
+ userId = userId || user.id;
637
+ }
638
+
639
+ // Match Plan by price ID
640
+ const priceId = subscription.items.data[0]?.price.id;
641
+ if (priceId) {
642
+ const plan = await prisma.plan.findFirst({
643
+ where: { stripePriceId: priceId },
644
+ });
645
+ if (plan) {
646
+ planId = planId || plan.id;
647
+ }
648
+ }
649
+ }
650
+ }
651
+
652
+ if (!userId || !planId) {
653
+ logger.error('ABORTING: Could not determine userId or planId for subscription', 'STRIPE', {
654
+ subscriptionId: stripeSubscriptionId,
655
+ customerId: stripeCustomerId,
656
+ priceId: subscription.items.data[0]?.price.id,
657
+ foundUserId: userId,
658
+ foundPlanId: planId
659
+ });
660
+ throw new Error(`Missing metadata for subscription ${stripeSubscriptionId}`);
661
+ }
662
+
663
+ logger.info(`SYNC: Valid data found for sub ${stripeSubscriptionId}. User: ${userId}, Plan: ${planId}`, 'STRIPE');
664
+
665
+ await prisma.$transaction(async (tx) => {
666
+ // Record the event if it was passed (for idempotency)
667
+ // Note: upsertSubscriptionFromStripe is sometimes called manually without an event
668
+ if (typeof subscriptionIdOrObject !== 'string' && (subscriptionIdOrObject as any).eventId) {
669
+ // Skip if already processed logic would go here if we wanted to be super strict
670
+ // but usually the calling handler handles it.
671
+ }
672
+
673
+ // Link Stripe Customer ID to User if not already linked
674
+ await tx.user.update({
675
+ where: { id: userId },
676
+ data: { stripe_customer_id: stripeCustomerId }
677
+ });
678
+
679
+ // Release the Idempotency Lock if this is a new sub being synced
680
+ // For subscriptions, we might find the attempt by metadata if available
681
+ const attemptId = subscription.metadata?.attemptId;
682
+ if (attemptId) {
683
+ await tx.idempotencyRecord.update({
684
+ where: { id: attemptId },
685
+ data: { status: 'completed', activeLockKey: null }
686
+ }).catch(() => { }); // SILENT: might already be released by checkout.completed
687
+ }
688
+
689
+ await tx.subscription.upsert({
690
+ where: { stripeSubscriptionId },
691
+ create: {
692
+ userId,
693
+ planId,
694
+ stripeSubscriptionId,
695
+ stripeCustomerId,
696
+ status,
697
+ currentPeriodStart,
698
+ currentPeriodEnd,
699
+ cancelAt,
700
+ canceledAt,
701
+ },
702
+ update: {
703
+ stripeCustomerId, // Keep customer ID synced in subscription row too
704
+ status,
705
+ currentPeriodStart,
706
+ currentPeriodEnd,
707
+ cancelAt,
708
+ canceledAt,
709
+ },
710
+ });
711
+ });
712
+
713
+ logger.info(`Subscription ${stripeSubscriptionId} synced for user ${userId}`, 'STRIPE');
714
+
715
+ // Real-time UI refresh
716
+ await PusherService.emitPaymentSuccess(userId, { type: 'subscription' });
717
+
718
+ } catch (err: any) {
719
+ logger.error(`Error syncing subscription from Stripe: ${err.message}`, 'STRIPE');
720
+ throw err;
721
+ }
722
+ }