@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.
- package/.env.example +43 -0
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/routers/auth.js +1 -1
- package/mcq-test.cjs +36 -0
- package/package.json +10 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +485 -292
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +194 -112
- package/src/lib/constants.ts +14 -0
- package/src/lib/email.ts +230 -0
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +3 -3
- package/src/lib/logger.ts +26 -9
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +90 -6
- package/src/lib/retry.ts +61 -0
- package/src/lib/storage.ts +2 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/lib/workspace-access.ts +13 -0
- package/src/routers/_app.ts +11 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +227 -0
- package/src/routers/auth.ts +432 -33
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +207 -80
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +133 -108
- package/src/routers/studyguide.ts +80 -74
- package/src/routers/worksheets.ts +300 -80
- package/src/routers/workspace.ts +538 -328
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +140 -12
- package/src/services/flashcard-progress.service.ts +52 -43
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
- 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
|
+
}
|