Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions desci-server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ model User {
Invoice Invoice[]
ImportTaskQueue ImportTaskQueue[]
AbandonedCheckout AbandonedCheckout[]
BundleAutoReplenishment BundleAutoReplenishment?
accountDeletionRequest AccountDeletionRequest?
StripeCheckoutFulfillment StripeCheckoutFulfillment[]
RevenueCatPurchaseFulfillment RevenueCatPurchaseFulfillment[]
Expand Down Expand Up @@ -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
Expand Down
97 changes: 91 additions & 6 deletions desci-server/src/controllers/stripe/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('.'),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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`,
});

Expand All @@ -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',
);
Expand Down Expand Up @@ -392,6 +402,81 @@ export const getUserStripePurchases = async (req: RequestWithUser, res: Response
}
};

export const getBundleAutoReplenishment = async (req: RequestWithUser, res: Response): Promise<Response> => {
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<Response> => {
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<Response> => {
try {
const userId = req.user?.id;
Expand Down
Loading
Loading