diff --git a/desci-server/prisma/migrations/20260518120000_add_bundle_auto_replenishment/migration.sql b/desci-server/prisma/migrations/20260518120000_add_bundle_auto_replenishment/migration.sql new file mode 100644 index 000000000..13643061e --- /dev/null +++ b/desci-server/prisma/migrations/20260518120000_add_bundle_auto_replenishment/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "BundleAutoReplenishment" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "isEnabled" BOOLEAN NOT NULL DEFAULT false, + "threshold" INTEGER NOT NULL DEFAULT 5, + "replenishmentInProgress" BOOLEAN NOT NULL DEFAULT false, + "lastAttemptedAt" TIMESTAMP(3), + "lastSucceededAt" TIMESTAMP(3), + "lastFailedAt" TIMESTAMP(3), + "lastFailureReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BundleAutoReplenishment_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "BundleAutoReplenishment_userId_key" ON "BundleAutoReplenishment"("userId"); +CREATE INDEX "BundleAutoReplenishment_isEnabled_replenishmentInProgress_idx" ON "BundleAutoReplenishment"("isEnabled", "replenishmentInProgress"); + +ALTER TABLE "BundleAutoReplenishment" +ADD CONSTRAINT "BundleAutoReplenishment_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/desci-server/prisma/schema.prisma b/desci-server/prisma/schema.prisma index 2a8e679d9..ec00b4048 100755 --- a/desci-server/prisma/schema.prisma +++ b/desci-server/prisma/schema.prisma @@ -283,6 +283,7 @@ model User { Invoice Invoice[] ImportTaskQueue ImportTaskQueue[] AbandonedCheckout AbandonedCheckout[] + BundleAutoReplenishment BundleAutoReplenishment? accountDeletionRequest AccountDeletionRequest? StripeCheckoutFulfillment StripeCheckoutFulfillment[] RevenueCatPurchaseFulfillment RevenueCatPurchaseFulfillment[] @@ -1517,6 +1518,24 @@ model PaymentMethod { @@index([stripePaymentMethodId]) } +model BundleAutoReplenishment { + id Int @id @default(autoincrement()) + userId Int @unique + isEnabled Boolean @default(false) + threshold Int @default(5) + replenishmentInProgress Boolean @default(false) + lastAttemptedAt DateTime? + lastSucceededAt DateTime? + lastFailedAt DateTime? + lastFailureReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + + @@index([isEnabled, replenishmentInProgress]) +} + model Invoice { id Int @id @default(autoincrement()) userId Int diff --git a/desci-server/src/controllers/stripe/subscription.ts b/desci-server/src/controllers/stripe/subscription.ts index 29ad3cd48..e4f39a84d 100644 --- a/desci-server/src/controllers/stripe/subscription.ts +++ b/desci-server/src/controllers/stripe/subscription.ts @@ -35,6 +35,11 @@ const getUserStripePurchasesSchema = z.object({ }), }); +const bundleAutoReplenishmentSchema = z.object({ + enabled: z.boolean(), + threshold: z.coerce.number().int().min(1).max(50).optional(), +}); + const formatZodIssues = (error: ZodError) => error.issues.map((issue) => ({ path: issue.path.join('.'), @@ -133,6 +138,12 @@ export const createSubscriptionCheckout = async (req: RequestWithUser, res: Resp }, }; + if (isBundleCheckout) { + sessionConfig.payment_intent_data = { + setup_future_usage: 'off_session', + }; + } + // Add promotion codes or specific coupon support if (allowPromotionCodes) { sessionConfig.allow_promotion_codes = true; @@ -262,20 +273,19 @@ export const createCustomerPortal = async (req: RequestWithUser, res: Response): logger.info({ userId }, 'Creating customer portal session'); - // Get user's Stripe customer ID from any subscription (not just active ones) - const subscription = await SubscriptionService.getUserSubscriptionWithDetails(userId); - if (!subscription?.stripeCustomerId) { + const stripeCustomerId = await SubscriptionService.getStripeCustomerId(userId); + if (!stripeCustomerId) { logger.warn({ userId }, 'No customer found for user'); return res.status(404).json({ error: 'No customer found' }); } - logger.info({ customerId: subscription.stripeCustomerId }, 'Found customer for portal'); + logger.info({ customerId: stripeCustomerId }, 'Found customer for portal'); // Create portal session try { const stripe = getStripe(); const portalSession = await stripe.billingPortal.sessions.create({ - customer: subscription.stripeCustomerId, + customer: stripeCustomerId, return_url: returnUrl || `${req.headers.origin}/settings/subscription`, }); @@ -287,7 +297,7 @@ export const createCustomerPortal = async (req: RequestWithUser, res: Response): err: stripeError, type: stripeError.type, code: stripeError.code, - customerId: subscription.stripeCustomerId, + customerId: stripeCustomerId, }, 'Stripe portal creation failed', ); @@ -392,6 +402,81 @@ export const getUserStripePurchases = async (req: RequestWithUser, res: Response } }; +export const getBundleAutoReplenishment = async (req: RequestWithUser, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const status = await SubscriptionService.getBundleAutoReplenishmentStatus(userId); + + return res.status(200).json({ + enabled: status.enabled, + threshold: status.threshold, + replenishmentInProgress: status.replenishmentInProgress, + lastAttemptedAt: status.lastAttemptedAt, + lastSucceededAt: status.lastSucceededAt, + lastFailedAt: status.lastFailedAt, + lastFailureReason: status.lastFailureReason, + latestBundlePurchase: status.latestBundlePurchase + ? { + purchasedUnits: status.latestBundlePurchase.purchasedUnits, + fulfilledAt: status.latestBundlePurchase.fulfilledAt, + } + : null, + hasSavedPaymentMethod: status.hasSavedPaymentMethod, + paymentMethodSummary: status.paymentMethodSummary, + }); + } catch (error: any) { + logger.error({ err: error, userId: req.user?.id }, 'Failed to get bundle auto-replenishment status'); + return res.status(500).json({ error: 'Failed to get bundle auto-replenishment status' }); + } +}; + +export const updateBundleAutoReplenishment = async (req: RequestWithUser, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { enabled, threshold } = bundleAutoReplenishmentSchema.parse(req.body); + const status = await SubscriptionService.updateBundleAutoReplenishment(userId, { enabled, threshold }); + + return res.status(200).json({ + enabled: status.enabled, + threshold: status.threshold, + replenishmentInProgress: status.replenishmentInProgress, + lastAttemptedAt: status.lastAttemptedAt, + lastSucceededAt: status.lastSucceededAt, + lastFailedAt: status.lastFailedAt, + lastFailureReason: status.lastFailureReason, + latestBundlePurchase: status.latestBundlePurchase + ? { + purchasedUnits: status.latestBundlePurchase.purchasedUnits, + fulfilledAt: status.latestBundlePurchase.fulfilledAt, + } + : null, + hasSavedPaymentMethod: status.hasSavedPaymentMethod, + paymentMethodSummary: status.paymentMethodSummary, + }); + } catch (error: any) { + if (error instanceof ZodError) { + return res.status(400).json({ + error: 'Invalid request body', + details: formatZodIssues(error), + }); + } + + logger.error({ err: error, userId: req.user?.id }, 'Failed to update bundle auto-replenishment'); + + return res.status(400).json({ + error: error instanceof Error ? error.message : 'Failed to update bundle auto-replenishment', + }); + } +}; + export const updateSubscription = async (req: RequestWithUser, res: Response): Promise => { try { const userId = req.user?.id; diff --git a/desci-server/src/controllers/stripe/webhook.ts b/desci-server/src/controllers/stripe/webhook.ts index 8cf54975b..f30c0a47a 100644 --- a/desci-server/src/controllers/stripe/webhook.ts +++ b/desci-server/src/controllers/stripe/webhook.ts @@ -98,6 +98,9 @@ export const handleStripeWebhook = async (req: Request, res: Response): Promise< case 'payment_intent.succeeded': await handlePaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent); break; + case 'payment_intent.payment_failed': + await handlePaymentIntentPaymentFailed(event.data.object as Stripe.PaymentIntent); + break; // Charge events case 'charge.succeeded': @@ -136,10 +139,13 @@ async function handleCustomerCreated(customer: Stripe.Customer) { try { await SubscriptionService.handleCustomerCreated(customer); } catch (err: any) { - logger.error({ - customerId: customer.id, - err, - }, 'Failed to handle customer created'); + logger.error( + { + customerId: customer.id, + err, + }, + 'Failed to handle customer created', + ); throw err; } } @@ -150,10 +156,13 @@ async function handleSubscriptionCreated(subscription: Stripe.Subscription) { try { await SubscriptionService.handleSubscriptionCreated(subscription); } catch (err: any) { - logger.error({ - subscriptionId: subscription.id, - err, - }, 'Failed to handle subscription created'); + logger.error( + { + subscriptionId: subscription.id, + err, + }, + 'Failed to handle subscription created', + ); throw err; } } @@ -164,10 +173,13 @@ async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { try { await SubscriptionService.handleSubscriptionUpdated(subscription); } catch (err: any) { - logger.error({ - subscriptionId: subscription.id, - err, - }, 'Failed to handle subscription updated'); + logger.error( + { + subscriptionId: subscription.id, + err, + }, + 'Failed to handle subscription updated', + ); throw err; } } @@ -178,10 +190,13 @@ async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { try { await SubscriptionService.handleSubscriptionDeleted(subscription); } catch (err: any) { - logger.error({ - subscriptionId: subscription.id, - err, - }, 'Failed to handle subscription deleted'); + logger.error( + { + subscriptionId: subscription.id, + err, + }, + 'Failed to handle subscription deleted', + ); throw err; } } @@ -192,10 +207,13 @@ async function handleInvoiceCreated(invoice: Stripe.Invoice) { try { await SubscriptionService.handleInvoiceCreated(invoice); } catch (err: any) { - logger.error({ - invoiceId: invoice.id, - err, - }, 'Failed to handle invoice created'); + logger.error( + { + invoiceId: invoice.id, + err, + }, + 'Failed to handle invoice created', + ); throw err; } } @@ -206,10 +224,13 @@ async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) { try { await SubscriptionService.handleInvoicePaymentSucceeded(invoice); } catch (err: any) { - logger.error({ - invoiceId: invoice.id, - err, - }, 'Failed to handle invoice payment succeeded'); + logger.error( + { + invoiceId: invoice.id, + err, + }, + 'Failed to handle invoice payment succeeded', + ); throw err; } } @@ -220,10 +241,13 @@ async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { try { await SubscriptionService.handleInvoicePaymentFailed(invoice); } catch (err: any) { - logger.error({ - invoiceId: invoice.id, - err, - }, 'Failed to handle invoice payment failed'); + logger.error( + { + invoiceId: invoice.id, + err, + }, + 'Failed to handle invoice payment failed', + ); throw err; } } @@ -234,10 +258,13 @@ async function handlePaymentMethodAttached(paymentMethod: Stripe.PaymentMethod) try { await SubscriptionService.handlePaymentMethodAttached(paymentMethod); } catch (err: any) { - logger.error({ - paymentMethodId: paymentMethod.id, - err, - }, 'Failed to handle payment method attached'); + logger.error( + { + paymentMethodId: paymentMethod.id, + err, + }, + 'Failed to handle payment method attached', + ); throw err; } } @@ -248,10 +275,13 @@ async function handlePaymentMethodDetached(paymentMethod: Stripe.PaymentMethod) try { await SubscriptionService.handlePaymentMethodDetached(paymentMethod); } catch (err: any) { - logger.error({ - paymentMethodId: paymentMethod.id, - err, - }, 'Failed to handle payment method detached'); + logger.error( + { + paymentMethodId: paymentMethod.id, + err, + }, + 'Failed to handle payment method detached', + ); throw err; } } @@ -262,10 +292,13 @@ async function handleTrialWillEnd(subscription: Stripe.Subscription) { try { await SubscriptionService.handleTrialWillEnd(subscription); } catch (err: any) { - logger.error({ - subscriptionId: subscription.id, - err, - }, 'Failed to handle trial will end'); + logger.error( + { + subscriptionId: subscription.id, + err, + }, + 'Failed to handle trial will end', + ); throw err; } } @@ -276,8 +309,13 @@ async function handlePaymentIntentCreated(paymentIntent: Stripe.PaymentIntent) { } async function handlePaymentIntentSucceeded(paymentIntent: Stripe.PaymentIntent) { - logger.info({ paymentIntentId: paymentIntent.customer }, 'stripe::handlePaymentIntentSucceeded'); - // Payment success is handled via invoice.payment_succeeded, no action needed + logger.info({ paymentIntentId: paymentIntent.id }, 'stripe::handlePaymentIntentSucceeded'); + const handledAutoReplenishment = await SubscriptionService.handleBundleAutoReplenishmentSucceeded(paymentIntent); + if (handledAutoReplenishment) { + return; + } + + // Payment success is handled via invoice.payment_succeeded for recurring subscriptions. const stripe = getStripe(); const subscriptions = await stripe.subscriptions.list({ customer: paymentIntent.customer as string, @@ -293,6 +331,11 @@ async function handlePaymentIntentSucceeded(paymentIntent: Stripe.PaymentIntent) await SubscriptionService.handleSubscriptionCreated(activeSubscription); } +async function handlePaymentIntentPaymentFailed(paymentIntent: Stripe.PaymentIntent) { + logger.info({ paymentIntentId: paymentIntent.id }, 'stripe::handlePaymentIntentPaymentFailed'); + await SubscriptionService.handleBundleAutoReplenishmentFailed(paymentIntent); +} + async function handleChargeSucceeded(charge: Stripe.Charge) { logger.info({ chargeId: charge.id }, 'Processing charge succeeded'); // Charges are handled via invoice events, no action needed diff --git a/desci-server/src/routes/v1/stripe.ts b/desci-server/src/routes/v1/stripe.ts index ea8483abd..bba84058d 100644 --- a/desci-server/src/routes/v1/stripe.ts +++ b/desci-server/src/routes/v1/stripe.ts @@ -11,6 +11,8 @@ import { getPricingOptions, createPaymentIntent, resetStripeTestStateForCurrentUser, + getBundleAutoReplenishment, + updateBundleAutoReplenishment, } from '../../controllers/stripe/subscription.js'; import { handleStripeWebhook } from '../../controllers/stripe/webhook.js'; import { ensureAdmin } from '../../middleware/ensureAdmin.js'; @@ -29,6 +31,8 @@ router.post('/subscription/checkout', [requireStripe, ensureUser], asyncHandler( router.post('/subscription/portal', [requireStripe, ensureUser], asyncHandler(createCustomerPortal)); router.get('/subscription', [requireStripe, ensureUser], asyncHandler(getUserSubscription)); router.get('/purchases', [requireStripe, ensureUser], asyncHandler(getUserStripePurchases)); +router.get('/bundle-auto-replenishment', [requireStripe, ensureUser], asyncHandler(getBundleAutoReplenishment)); +router.post('/bundle-auto-replenishment', [requireStripe, ensureUser], asyncHandler(updateBundleAutoReplenishment)); router.put('/subscription', [requireStripe, ensureUser], asyncHandler(updateSubscription)); router.delete('/subscription', [requireStripe, ensureUser], asyncHandler(cancelSubscription)); router.post('/test/reset-current-user', [requireStripe, ensureUser, ensureAdmin], asyncHandler(resetStripeTestStateForCurrentUser)); diff --git a/desci-server/src/services/FeatureLimits/FeatureUsageService.ts b/desci-server/src/services/FeatureLimits/FeatureUsageService.ts index 4d903dc3b..0d505c125 100644 --- a/desci-server/src/services/FeatureLimits/FeatureUsageService.ts +++ b/desci-server/src/services/FeatureLimits/FeatureUsageService.ts @@ -6,9 +6,9 @@ import { logger as parentLogger } from '../../logger.js'; import { sendEmail } from '../email/email.js'; import { SciweaveEmailTypes } from '../email/sciweaveEmailTypes.js'; // import { isUserStudentSciweave } from '../interactionLog.js'; // unused after coupon campaign disable +import { getUserNameByUser } from '../user.js'; import { FeatureLimitsService } from './FeatureLimitsService.js'; -import { getUserNameByUser } from '../user.js'; const logger = parentLogger.child({ module: 'FeatureUsageService' }); @@ -68,6 +68,8 @@ async function consumeUsage(request: ConsumeUsageRequest): Promise + SubscriptionService.triggerBundleAutoReplenishmentIfNeeded({ + userId, + remainingUses: result.remainingUsesAfterConsume, + }), + ) + .catch((triggerError) => { + logger.error({ triggerError, userId }, 'Failed to trigger bundle auto-replenishment'); + }); + // Send out-of-chats email if user just hit their limit (do this after successful transaction) if (result.shouldSendLimitEmail) { try { diff --git a/desci-server/src/services/SubscriptionService.ts b/desci-server/src/services/SubscriptionService.ts index a45baa083..0ac5f6bae 100644 --- a/desci-server/src/services/SubscriptionService.ts +++ b/desci-server/src/services/SubscriptionService.ts @@ -9,6 +9,7 @@ import { PlanCodename, Period, RevenueCatPurchaseFulfillmentType, + StripeCheckoutFulfillmentType, SentEmailType, } from '@prisma/client'; import Stripe from 'stripe'; @@ -30,6 +31,29 @@ const logger = parentLogger.child({ const prisma = new PrismaClient(); const BUNDLE_ENTITLEMENT_TYPE = 'bundle_chats'; +const DEFAULT_BUNDLE_AUTO_REPLENISHMENT_THRESHOLD = 5; + +export interface BundleAutoReplenishmentStatus { + enabled: boolean; + threshold: number; + replenishmentInProgress: boolean; + lastAttemptedAt: Date | null; + lastSucceededAt: Date | null; + lastFailedAt: Date | null; + lastFailureReason: string | null; + latestBundlePurchase: { + stripePriceId: string; + purchasedUnits: number; + fulfilledAt: Date; + } | null; + hasSavedPaymentMethod: boolean; + paymentMethodSummary: { + brand: string | null; + last4: string | null; + expiryMonth: number | null; + expiryYear: number | null; + } | null; +} export class SubscriptionService { static async getOrCreateStripeCustomer(userId: number) { @@ -1107,18 +1131,11 @@ export class SubscriptionService { return; } - await prisma.paymentMethod.create({ - data: { - userId, - stripeCustomerId: paymentMethod.customer as string, - stripePaymentMethodId: paymentMethod.id, - type: this.mapStripePaymentMethodType(paymentMethod.type), - last4: paymentMethod.card?.last4, - brand: paymentMethod.card?.brand, - expiryMonth: paymentMethod.card?.exp_month, - expiryYear: paymentMethod.card?.exp_year, - isDefault: false, // Will be updated if set as default - }, + await this.upsertPaymentMethodRecord({ + userId, + stripeCustomerId: paymentMethod.customer as string, + paymentMethod, + isDefault: false, }); logger.info('Payment method attached processed', { paymentMethodId: paymentMethod.id }); @@ -1229,6 +1246,14 @@ export class SubscriptionService { }, 'Processed bundle checkout completion', ); + + if (paymentIntentId && customerId) { + await this.saveBundlePaymentMethodForReuse({ + userId, + customerId, + paymentIntentId, + }); + } return; } @@ -1523,6 +1548,312 @@ export class SubscriptionService { }; } + static async getBundleAutoReplenishmentStatus(userId: number): Promise { + const [settings, latestBundlePurchase, paymentMethod] = await Promise.all([ + prisma.bundleAutoReplenishment.findUnique({ + where: { userId }, + }), + this.getLatestBundlePurchase(userId), + prisma.paymentMethod.findFirst({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }], + }), + ]); + + return { + enabled: settings?.isEnabled ?? false, + threshold: settings?.threshold ?? DEFAULT_BUNDLE_AUTO_REPLENISHMENT_THRESHOLD, + replenishmentInProgress: settings?.replenishmentInProgress ?? false, + lastAttemptedAt: settings?.lastAttemptedAt ?? null, + lastSucceededAt: settings?.lastSucceededAt ?? null, + lastFailedAt: settings?.lastFailedAt ?? null, + lastFailureReason: settings?.lastFailureReason ?? null, + latestBundlePurchase: latestBundlePurchase + ? { + stripePriceId: latestBundlePurchase.stripePriceId, + purchasedUnits: latestBundlePurchase.purchasedUnits, + fulfilledAt: latestBundlePurchase.fulfilledAt, + } + : null, + hasSavedPaymentMethod: !!paymentMethod, + paymentMethodSummary: paymentMethod + ? { + brand: paymentMethod.brand ?? null, + last4: paymentMethod.last4 ?? null, + expiryMonth: paymentMethod.expiryMonth ?? null, + expiryYear: paymentMethod.expiryYear ?? null, + } + : null, + }; + } + + static async updateBundleAutoReplenishment( + userId: number, + { + enabled, + threshold = DEFAULT_BUNDLE_AUTO_REPLENISHMENT_THRESHOLD, + }: { + enabled: boolean; + threshold?: number; + }, + ): Promise { + const latestBundlePurchase = await this.getLatestBundlePurchase(userId); + + if (enabled) { + if (!latestBundlePurchase) { + throw new Error('You need a previous chat bundle purchase before enabling auto-replenishment.'); + } + + const activePaidSubscription = await prisma.subscription.findFirst({ + where: { + userId, + status: SubscriptionStatus.ACTIVE, + planType: { not: PlanType.FREE }, + }, + select: { id: true }, + }); + + if (activePaidSubscription) { + throw new Error('Auto-replenishment is only available for chat bundle users without an active subscription.'); + } + + const customerId = await this.getStripeCustomerId(userId); + if (!customerId) { + throw new Error('No Stripe customer found for this user.'); + } + + const paymentMethodId = await this.getReusablePaymentMethodId(userId, customerId); + if (!paymentMethodId) { + throw new Error('No saved payment method found. Buy a bundle or add a card first.'); + } + } + + await prisma.bundleAutoReplenishment.upsert({ + where: { userId }, + update: { + isEnabled: enabled, + threshold, + replenishmentInProgress: false, + ...(enabled ? { lastFailureReason: null } : {}), + }, + create: { + userId, + isEnabled: enabled, + threshold, + replenishmentInProgress: false, + }, + }); + + return await this.getBundleAutoReplenishmentStatus(userId); + } + + static async triggerBundleAutoReplenishmentIfNeeded({ + userId, + remainingUses, + }: { + userId: number; + remainingUses: number | null; + }): Promise { + if (remainingUses === null) { + return false; + } + + const settings = await prisma.bundleAutoReplenishment.findUnique({ + where: { userId }, + }); + + if (!settings?.isEnabled || settings.replenishmentInProgress || remainingUses > settings.threshold) { + return false; + } + + const latestBundlePurchase = await this.getLatestBundlePurchase(userId); + if (!latestBundlePurchase) { + await prisma.bundleAutoReplenishment.update({ + where: { userId }, + data: { + isEnabled: false, + replenishmentInProgress: false, + lastFailedAt: new Date(), + lastFailureReason: 'No prior bundle purchase is available for auto-replenishment.', + }, + }); + return false; + } + + const claim = await prisma.bundleAutoReplenishment.updateMany({ + where: { + userId, + isEnabled: true, + replenishmentInProgress: false, + }, + data: { + replenishmentInProgress: true, + lastAttemptedAt: new Date(), + lastFailureReason: null, + }, + }); + + if (claim.count === 0) { + return false; + } + + try { + const customerId = await this.getStripeCustomerId(userId); + if (!customerId) { + throw new Error('No Stripe customer found.'); + } + + const paymentMethodId = await this.getReusablePaymentMethodId(userId, customerId); + if (!paymentMethodId) { + throw new Error('No reusable payment method found.'); + } + + const stripe = getStripe(); + const price = await stripe.prices.retrieve(latestBundlePurchase.stripePriceId); + if (!price.unit_amount || !price.currency) { + throw new Error('Last bundle price is missing amount or currency.'); + } + + const bundleChatsPerUnit = Number.parseInt(price.metadata?.bundle_chats || '', 10); + if (!Number.isFinite(bundleChatsPerUnit) || bundleChatsPerUnit <= 0) { + throw new Error('Last bundle price is missing valid bundle metadata.'); + } + + await stripe.paymentIntents.create({ + amount: price.unit_amount, + currency: price.currency, + customer: customerId, + payment_method: paymentMethodId, + off_session: true, + confirm: true, + description: `SciWeave bundle auto-replenishment (${bundleChatsPerUnit} chats)`, + metadata: { + type: 'bundle_auto_replenishment', + userId: userId.toString(), + priceId: latestBundlePurchase.stripePriceId, + bundleChats: bundleChatsPerUnit.toString(), + quantity: '1', + }, + }); + + return true; + } catch (error) { + logger.error({ error, userId }, 'Failed to trigger bundle auto-replenishment'); + + await prisma.bundleAutoReplenishment.update({ + where: { userId }, + data: { + isEnabled: false, + replenishmentInProgress: false, + lastFailedAt: new Date(), + lastFailureReason: error instanceof Error ? error.message : 'Failed to trigger auto-replenishment.', + }, + }); + + return false; + } + } + + static async handleBundleAutoReplenishmentSucceeded(paymentIntent: Stripe.PaymentIntent) { + if (paymentIntent.metadata?.type !== 'bundle_auto_replenishment') { + return false; + } + + const metadataUserId = paymentIntent.metadata?.userId ? Number.parseInt(paymentIntent.metadata.userId, 10) : null; + const userId = + metadataUserId && Number.isFinite(metadataUserId) + ? metadataUserId + : paymentIntent.customer + ? await this.getUserIdFromCustomer(paymentIntent.customer as string) + : null; + + if (!userId) { + throw new Error(`Unable to resolve user for auto-replenishment payment intent ${paymentIntent.id}`); + } + + const priceId = paymentIntent.metadata?.priceId; + if (!priceId) { + throw new Error(`Missing priceId metadata for auto-replenishment payment intent ${paymentIntent.id}`); + } + + const bundleChatsPerUnit = Number.parseInt(paymentIntent.metadata?.bundleChats || '', 10); + if (!Number.isFinite(bundleChatsPerUnit) || bundleChatsPerUnit <= 0) { + throw new Error(`Invalid bundleChats metadata for auto-replenishment payment intent ${paymentIntent.id}`); + } + + await this.handleBundleCheckoutFulfillment({ + sessionId: `auto_replenishment:${paymentIntent.id}`, + userId, + customerId: + typeof paymentIntent.customer === 'string' ? paymentIntent.customer : (paymentIntent.customer?.id ?? null), + priceId, + paymentIntentId: paymentIntent.id, + amountPaid: paymentIntent.amount_received || paymentIntent.amount || null, + currency: paymentIntent.currency ?? null, + quantity: Math.max(1, Number.parseInt(paymentIntent.metadata?.quantity || '1', 10) || 1), + bundleChatsPerUnit, + }); + + await prisma.bundleAutoReplenishment.upsert({ + where: { userId }, + update: { + replenishmentInProgress: false, + lastSucceededAt: new Date(), + lastFailureReason: null, + }, + create: { + userId, + isEnabled: false, + threshold: DEFAULT_BUNDLE_AUTO_REPLENISHMENT_THRESHOLD, + replenishmentInProgress: false, + lastSucceededAt: new Date(), + }, + }); + + return true; + } + + static async handleBundleAutoReplenishmentFailed(paymentIntent: Stripe.PaymentIntent) { + if (paymentIntent.metadata?.type !== 'bundle_auto_replenishment') { + return false; + } + + const metadataUserId = paymentIntent.metadata?.userId ? Number.parseInt(paymentIntent.metadata.userId, 10) : null; + const userId = + metadataUserId && Number.isFinite(metadataUserId) + ? metadataUserId + : paymentIntent.customer + ? await this.getUserIdFromCustomer(paymentIntent.customer as string) + : null; + + if (!userId) { + logger.warn({ paymentIntentId: paymentIntent.id }, 'Failed auto-replenishment could not resolve user'); + return false; + } + + await prisma.bundleAutoReplenishment.upsert({ + where: { userId }, + update: { + isEnabled: false, + replenishmentInProgress: false, + lastFailedAt: new Date(), + lastFailureReason: + paymentIntent.last_payment_error?.message || 'Automatic bundle replenishment payment failed.', + }, + create: { + userId, + isEnabled: false, + threshold: DEFAULT_BUNDLE_AUTO_REPLENISHMENT_THRESHOLD, + replenishmentInProgress: false, + lastFailedAt: new Date(), + lastFailureReason: + paymentIntent.last_payment_error?.message || 'Automatic bundle replenishment payment failed.', + }, + }); + + return true; + } + static async updateSubscriptionPlan(userId: number, planType: string) { // TODO: Implement plan change logic with Stripe API throw new Error('Plan updates not yet implemented'); @@ -1585,6 +1916,170 @@ export class SubscriptionService { } // Helper methods + private static async getLatestBundlePurchase(userId: number) { + return await prisma.stripeCheckoutFulfillment.findFirst({ + where: { + userId, + fulfillmentType: StripeCheckoutFulfillmentType.BUNDLE_CHATS, + grantedUnits: { + gt: 0, + }, + }, + orderBy: { fulfilledAt: 'desc' }, + select: { + stripePriceId: true, + purchasedUnits: true, + fulfilledAt: true, + }, + }); + } + + private static async getReusablePaymentMethodId(userId: number, customerId: string): Promise { + const stripe = getStripe(); + + try { + const customer = await stripe.customers.retrieve(customerId, { + expand: ['invoice_settings.default_payment_method'], + }); + + if (customer && !customer.deleted) { + const defaultPaymentMethod = (customer as Stripe.Customer).invoice_settings?.default_payment_method; + if (typeof defaultPaymentMethod === 'string') { + return defaultPaymentMethod; + } + if (defaultPaymentMethod && typeof defaultPaymentMethod !== 'string') { + await this.upsertPaymentMethodRecord({ + userId, + stripeCustomerId: customerId, + paymentMethod: defaultPaymentMethod, + isDefault: true, + }); + return defaultPaymentMethod.id; + } + } + } catch (error) { + logger.error({ error, customerId, userId }, 'Failed to read Stripe customer default payment method'); + } + + const latestPaymentMethod = await prisma.paymentMethod.findFirst({ + where: { userId, stripeCustomerId: customerId }, + orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }], + select: { stripePaymentMethodId: true }, + }); + + if (latestPaymentMethod?.stripePaymentMethodId) { + return latestPaymentMethod.stripePaymentMethodId; + } + + const stripePaymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + type: 'card', + limit: 1, + }); + + const fallbackPaymentMethod = stripePaymentMethods.data[0]; + if (!fallbackPaymentMethod) { + return null; + } + + await this.upsertPaymentMethodRecord({ + userId, + stripeCustomerId: customerId, + paymentMethod: fallbackPaymentMethod, + isDefault: false, + }); + + return fallbackPaymentMethod.id; + } + + private static async saveBundlePaymentMethodForReuse({ + userId, + customerId, + paymentIntentId, + }: { + userId: number; + customerId: string; + paymentIntentId: string; + }) { + try { + const stripe = getStripe(); + const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId); + const paymentMethodId = + typeof paymentIntent.payment_method === 'string' + ? paymentIntent.payment_method + : (paymentIntent.payment_method?.id ?? null); + + if (!paymentMethodId) { + return; + } + + await stripe.customers.update(customerId, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + + const paymentMethod = + typeof paymentIntent.payment_method === 'string' + ? await stripe.paymentMethods.retrieve(paymentMethodId) + : paymentIntent.payment_method; + + await this.upsertPaymentMethodRecord({ + userId, + stripeCustomerId: customerId, + paymentMethod, + isDefault: true, + }); + } catch (error) { + logger.error({ error, userId, customerId, paymentIntentId }, 'Failed to save bundle payment method for reuse'); + } + } + + private static async upsertPaymentMethodRecord({ + userId, + stripeCustomerId, + paymentMethod, + isDefault, + }: { + userId: number; + stripeCustomerId: string; + paymentMethod: Stripe.PaymentMethod; + isDefault: boolean; + }) { + await prisma.$transaction(async (tx) => { + if (isDefault) { + await tx.paymentMethod.updateMany({ + where: { userId }, + data: { isDefault: false }, + }); + } + + await tx.paymentMethod.upsert({ + where: { stripePaymentMethodId: paymentMethod.id }, + update: { + stripeCustomerId, + type: this.mapStripePaymentMethodType(paymentMethod.type), + last4: paymentMethod.card?.last4, + brand: paymentMethod.card?.brand, + expiryMonth: paymentMethod.card?.exp_month, + expiryYear: paymentMethod.card?.exp_year, + isDefault, + }, + create: { + userId, + stripeCustomerId, + stripePaymentMethodId: paymentMethod.id, + type: this.mapStripePaymentMethodType(paymentMethod.type), + last4: paymentMethod.card?.last4, + brand: paymentMethod.card?.brand, + expiryMonth: paymentMethod.card?.exp_month, + expiryYear: paymentMethod.card?.exp_year, + isDefault, + }, + }); + }); + } + private static async getUserIdFromCustomer(customerId: string): Promise { // First try local DB lookup const subscription = await prisma.subscription.findFirst({ diff --git a/desci-server/test/integration/bundleAutoReplenishment.test.ts b/desci-server/test/integration/bundleAutoReplenishment.test.ts new file mode 100644 index 000000000..bd5caf0b3 --- /dev/null +++ b/desci-server/test/integration/bundleAutoReplenishment.test.ts @@ -0,0 +1,227 @@ +import { Feature, PaymentMethodType, StripeCheckoutFulfillmentType, User } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const stripeMock = { + customers: { + retrieve: vi.fn(), + update: vi.fn(), + }, + prices: { + retrieve: vi.fn(), + }, + paymentIntents: { + create: vi.fn(), + retrieve: vi.fn(), + }, + paymentMethods: { + list: vi.fn(), + retrieve: vi.fn(), + }, +}; + +import { prisma } from '../../src/client.js'; +import { SCIWEAVE_FREE_LIMIT } from '../../src/config.js'; +import { SubscriptionService } from '../../src/services/SubscriptionService.js'; +import * as stripeUtils from '../../src/utils/stripe.js'; + +describe('Bundle auto-replenishment', () => { + let user: User; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(stripeUtils, 'getStripe').mockReturnValue(stripeMock as any); + + await prisma.$queryRaw`TRUNCATE TABLE "BundleAutoReplenishment" CASCADE;`; + await prisma.$queryRaw`TRUNCATE TABLE "StripeCheckoutFulfillment" CASCADE;`; + await prisma.$queryRaw`TRUNCATE TABLE "PaymentMethod" CASCADE;`; + await prisma.$queryRaw`TRUNCATE TABLE "Subscription" CASCADE;`; + await prisma.$queryRaw`TRUNCATE TABLE "UserFeatureLimit" CASCADE;`; + await prisma.$queryRaw`TRUNCATE TABLE "User" CASCADE;`; + + user = await prisma.user.create({ + data: { + email: 'bundle-auto-replenishment-test@desci.com', + name: 'Bundle Auto Replenishment Test User', + stripeUserId: 'cus_bundle_auto_123', + }, + }); + + stripeMock.customers.retrieve.mockResolvedValue({ + id: 'cus_bundle_auto_123', + deleted: false, + invoice_settings: { + default_payment_method: 'pm_bundle_default_123', + }, + }); + stripeMock.customers.update.mockResolvedValue({}); + stripeMock.paymentMethods.list.mockResolvedValue({ data: [] }); + }); + + it('enables auto-replenishment for a user with a previous bundle and saved card', async () => { + await prisma.stripeCheckoutFulfillment.create({ + data: { + userId: user.id, + stripeSessionId: 'cs_bundle_30', + stripePriceId: 'price_bundle_30', + stripePaymentIntentId: 'pi_bundle_30', + stripeCustomerId: 'cus_bundle_auto_123', + fulfillmentType: StripeCheckoutFulfillmentType.BUNDLE_CHATS, + purchasedUnits: 30, + grantedUnits: 30, + }, + }); + + await prisma.paymentMethod.create({ + data: { + userId: user.id, + stripeCustomerId: 'cus_bundle_auto_123', + stripePaymentMethodId: 'pm_bundle_default_123', + type: PaymentMethodType.CARD, + isDefault: true, + brand: 'visa', + last4: '4242', + expiryMonth: 12, + expiryYear: 2030, + }, + }); + + const status = await SubscriptionService.updateBundleAutoReplenishment(user.id, { + enabled: true, + }); + + expect(status.enabled).toBe(true); + expect(status.threshold).toBe(5); + expect(status.latestBundlePurchase?.purchasedUnits).toBe(30); + expect(status.hasSavedPaymentMethod).toBe(true); + }); + + it('charges the most recently purchased bundle when remaining chats reach the threshold', async () => { + await prisma.bundleAutoReplenishment.create({ + data: { + userId: user.id, + isEnabled: true, + threshold: 5, + }, + }); + + await prisma.stripeCheckoutFulfillment.createMany({ + data: [ + { + userId: user.id, + stripeSessionId: 'cs_bundle_old', + stripePriceId: 'price_bundle_10', + stripePaymentIntentId: 'pi_bundle_old', + stripeCustomerId: 'cus_bundle_auto_123', + fulfillmentType: StripeCheckoutFulfillmentType.BUNDLE_CHATS, + purchasedUnits: 10, + grantedUnits: 10, + fulfilledAt: new Date('2026-05-01T00:00:00Z'), + }, + { + userId: user.id, + stripeSessionId: 'cs_bundle_latest', + stripePriceId: 'price_bundle_30', + stripePaymentIntentId: 'pi_bundle_latest', + stripeCustomerId: 'cus_bundle_auto_123', + fulfillmentType: StripeCheckoutFulfillmentType.BUNDLE_CHATS, + purchasedUnits: 30, + grantedUnits: 30, + fulfilledAt: new Date('2026-05-10T00:00:00Z'), + }, + ], + }); + + await prisma.paymentMethod.create({ + data: { + userId: user.id, + stripeCustomerId: 'cus_bundle_auto_123', + stripePaymentMethodId: 'pm_bundle_default_123', + type: PaymentMethodType.CARD, + isDefault: true, + }, + }); + + stripeMock.prices.retrieve.mockResolvedValue({ + id: 'price_bundle_30', + unit_amount: 999, + currency: 'usd', + metadata: { + bundle_chats: '30', + }, + }); + stripeMock.paymentIntents.create.mockResolvedValue({ + id: 'pi_auto_replenishment_123', + status: 'succeeded', + }); + + const triggered = await SubscriptionService.triggerBundleAutoReplenishmentIfNeeded({ + userId: user.id, + remainingUses: 5, + }); + + expect(triggered).toBe(true); + expect(stripeMock.paymentIntents.create).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 999, + customer: 'cus_bundle_auto_123', + payment_method: 'pm_bundle_default_123', + metadata: expect.objectContaining({ + priceId: 'price_bundle_30', + bundleChats: '30', + type: 'bundle_auto_replenishment', + }), + }), + ); + + const settings = await prisma.bundleAutoReplenishment.findUnique({ + where: { userId: user.id }, + }); + expect(settings?.replenishmentInProgress).toBe(true); + }); + + it('fulfills a successful auto-replenishment payment intent and clears the in-progress flag', async () => { + await prisma.bundleAutoReplenishment.create({ + data: { + userId: user.id, + isEnabled: true, + threshold: 5, + replenishmentInProgress: true, + }, + }); + + const handled = await SubscriptionService.handleBundleAutoReplenishmentSucceeded({ + id: 'pi_auto_success_123', + amount: 999, + amount_received: 999, + currency: 'usd', + customer: 'cus_bundle_auto_123', + metadata: { + type: 'bundle_auto_replenishment', + userId: String(user.id), + priceId: 'price_bundle_30', + bundleChats: '30', + quantity: '1', + }, + } as any); + + expect(handled).toBe(true); + + const activeLimit = await prisma.userFeatureLimit.findFirst({ + where: { userId: user.id, feature: Feature.RESEARCH_ASSISTANT, isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + expect(activeLimit?.useLimit).toBe(SCIWEAVE_FREE_LIMIT + 30); + + const fulfillment = await prisma.stripeCheckoutFulfillment.findUnique({ + where: { stripeSessionId: 'auto_replenishment:pi_auto_success_123' }, + }); + expect(fulfillment?.stripePriceId).toBe('price_bundle_30'); + expect(fulfillment?.grantedUnits).toBe(30); + + const settings = await prisma.bundleAutoReplenishment.findUnique({ + where: { userId: user.id }, + }); + expect(settings?.replenishmentInProgress).toBe(false); + expect(settings?.lastSucceededAt).not.toBeNull(); + }); +});