@goscribe/server 1.3.0 → 1.3.1

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