diff --git a/apps/web/modules/settings/billing/components/BillingCredits.tsx b/apps/web/modules/settings/billing/components/BillingCredits.tsx index 18bb117fb06a4b..4580b47b50bb8f 100644 --- a/apps/web/modules/settings/billing/components/BillingCredits.tsx +++ b/apps/web/modules/settings/billing/components/BillingCredits.tsx @@ -1,3 +1,4 @@ +/* eslint-disable prettier/prettier */ "use client"; import { useSession } from "next-auth/react"; @@ -17,15 +18,26 @@ import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@calcom/ui/components/dialog"; import { Select } from "@calcom/ui/components/form"; import { TextField, Label, InputError } from "@calcom/ui/components/form"; +import { Checkbox } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { ProgressBar } from "@calcom/ui/components/progress-bar"; +// Fix Switch import import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton"; +/* eslint-disable prettier/prettier */ + +/* eslint-disable prettier/prettier */ + +/* eslint-disable prettier/prettier */ + +/* eslint-disable prettier/prettier */ + type MonthOption = { value: string; label: string; @@ -100,6 +112,8 @@ export default function BillingCredits() { const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false); const utils = trpc.useUtils(); + const [showAutoRechargeModal, setShowAutoRechargeModal] = useState(false); + const { register, handleSubmit, @@ -177,6 +191,8 @@ export default function BillingCredits() { const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0; const numberFormatter = new Intl.NumberFormat(); + const autoRechargeSettings = creditsData?.settings; + return ( <>
@@ -260,6 +276,68 @@ export default function BillingCredits() { {t("buy")}
+ + + ) : ( + <> + )} + +
{creditsData.credits.additionalCredits}
+ + {/* Auto-recharge section */} +
+
+
+
+
+ +

+ {autoRechargeSettings?.enabled + ? t("auto_recharge_enabled_description", { + threshold: autoRechargeSettings?.threshold, + amount: autoRechargeSettings?.amount, + }) + : t("auto_recharge_disabled_description")} +

+ {autoRechargeSettings?.lastAutoRechargeAt && ( +

+ {t("last_auto_recharged_at", { + date: dayjs(autoRechargeSettings.lastAutoRechargeAt).format("MMM D, YYYY HH:mm"), + })} +

+ )} +
+ +
+ +
+
+
+
+
+ +
+ setValue("quantity", Number(e.target.value))} + min={50} + addOnSuffix={<>{t("credits")}} + /> {errors.quantity && }
@@ -308,6 +386,17 @@ export default function BillingCredits() { />
+ + {/* Auto-recharge modal */} + {showAutoRechargeModal && ( + setShowAutoRechargeModal(false)} + isLoading={updateAutoRechargeMutation.isLoading} + /> + )} + {teamId && ( ); } + +function AutoRechargeModal({ + defaultValues, + onSubmit, + onCancel, + isLoading, +}: { + defaultValues?: { + enabled: boolean; + threshold: number; + amount: number; + }; + onSubmit: (data: { enabled: boolean; threshold: number; amount: number }) => void; + onCancel: () => void; + isLoading: boolean; +}) { + const { t } = useLocale(); + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm<{ + enabled: boolean; + threshold: number; + amount: number; + }>({ + defaultValues: { + enabled: defaultValues?.enabled ?? false, + threshold: defaultValues?.threshold ?? 50, + amount: defaultValues?.amount ?? 100, + }, + }); + + const enabled = watch("enabled"); + + return ( + + + +

{t("auto_recharge_description")}

+
+
+
+ + +
+ + {enabled && ( + <> +
+ + + {errors.threshold && ( + + )} +

{t("threshold_description")}

+
+ +
+ + + {errors.amount && } +

{t("amount_description")}

+
+ + )} +
+ + + + + +
+
+
+ ); +} diff --git a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx index 23353747987c89..9290487b3c23a8 100644 --- a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx +++ b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx @@ -17,45 +17,103 @@ export const CreditBalanceLimitReachedEmail = ( email: string; t: TFunction; }; + autoRechargeEnabled?: boolean; + autoRechargeFailed?: boolean; } & Partial> ) => { - const { team, user } = props; + const { team, user, autoRechargeEnabled, autoRechargeFailed } = props; + + // Show different content based on auto-recharge status + const getContent = () => { + if (autoRechargeFailed) { + return ( + <> +

+ {user.t("hi_user_name", { name: user.name })}, +

+

+ {team + ? user.t("auto_recharge_payment_failed", { teamName: team.name }) + : user.t("auto_recharge_payment_failed_user")} +

+
+ +
+ + ); + } + + if (autoRechargeEnabled) { + return ( + <> +

+ {user.t("hi_user_name", { name: user.name })}, +

+

+ {team + ? user.t("credit_limit_reached_auto_recharge", { teamName: team.name }) + : user.t("credit_limit_reached_auto_recharge_user")} +

+ + ); + } + + // Default content (no auto-recharge) + if (team) { + return ( + <> +

+ {user.t("hi_user_name", { name: user.name })}, +

+

+ {user.t("credit_limit_reached_message", { teamName: team.name })} +

+
+ +
+ + ); + } - if (team) { return ( - -

- <> {user.t("hi_user_name", { name: user.name })}, -

+ <> +

{user.t("hi_user_name", { name: user.name })},

- <>{user.t("credit_limit_reached_message", { teamName: team.name })} + {user.t("credit_limit_reached_message_user")}

-
{" "} -
+ + ); - } - - return ( - -

- <> {user.t("hi_user_name", { name: user.name })}, -

-

- <>{user.t("credit_limit_reached_message_user")} -

-
- -
-
- ); + }; + + // Determine email subject based on status + const getSubject = () => { + if (autoRechargeFailed) { + return user.t("auto_recharge_failed"); + } + + if (team) { + return user.t("action_required_out_of_credits", { teamName: team.name }); + } + + return user.t("action_required_user_out_of_credits"); + }; + + return {getContent()}; }; diff --git a/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx b/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx index 35f75108b9738d..6855ad3f6ab1c5 100644 --- a/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx +++ b/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx @@ -11,60 +11,79 @@ export const CreditBalanceLowWarningEmail = ( id: number; name: string; }; - balance: number; user: { id: number; name: string; email: string; t: TFunction; }; + balance: number; + autoRechargeEnabled?: boolean; } & Partial> ) => { - const { team, balance, user } = props; + const { team, user, balance, autoRechargeEnabled } = props; + + const getContent = () => { + if (autoRechargeEnabled) { + return ( + <> +

+ {user.t("hi_user_name", { name: user.name })}, +

+

+ {team + ? user.t("team_credits_low_auto_recharge", { teamName: team.name, balance }) + : user.t("user_credits_low_auto_recharge", { balance })} +

+ + ); + } + + if (team) { + return ( + <> +

+ {user.t("hi_user_name", { name: user.name })}, +

+

+ {user.t("team_credits_low_warning_message", { teamName: team.name, balance })} +

+
+ +
+ + ); + } - if (team) { return ( - -

- <> {user.t("hi_user_name", { name: user.name })}, -

+ <> +

{user.t("hi_user_name", { name: user.name })},

- <>{user.t("low_credits_warning_message", { teamName: team.name })} -

-

- {user.t("current_credit_balance", { balance })} + {user.t("user_credits_low_warning_message", { balance })}

-
+ ); - } + }; return ( - -

- <> {user.t("hi_user_name", { name: user.name })}, -

-

- <>{user.t("low_credits_warning_message_user")} -

-
- -
+ + {getContent()} ); }; diff --git a/packages/emails/templates/credit-balance-limit-reached-email.ts b/packages/emails/templates/credit-balance-limit-reached-email.ts index c437aedbcba51c..e4cb8c92a08827 100644 --- a/packages/emails/templates/credit-balance-limit-reached-email.ts +++ b/packages/emails/templates/credit-balance-limit-reached-email.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ import type { TFunction } from "i18next"; import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; @@ -16,35 +17,55 @@ export default class CreditBalanceLimitReachedEmail extends BaseEmail { id: number; name: string; }; + autoRechargeEnabled: boolean; + autoRechargeFailed: boolean; constructor({ user, team, + autoRechargeEnabled = false, + autoRechargeFailed = false, }: { user: { id: number; name: string | null; email: string; t: TFunction }; team?: { id: number; name: string | null }; + autoRechargeEnabled?: boolean; + autoRechargeFailed?: boolean; }) { super(); this.user = { ...user, name: user.name || "" }; this.team = team ? { ...team, name: team.name || "" } : undefined; + this.autoRechargeEnabled = autoRechargeEnabled; + this.autoRechargeFailed = autoRechargeFailed; } protected async getNodeMailerPayload(): Promise> { + const subject = this.autoRechargeFailed + ? this.user.t("auto_recharge_failed") + : this.team + ? this.user.t("action_required_out_of_credits", { teamName: this.team.name }) + : this.user.t("action_required_user_out_of_credits"); + return { from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, to: this.user.email, - subject: this.team - ? this.user.t("action_required_out_of_credits", { teamName: this.team.name }) - : this.user.t("action_required_user_out_of_credits"), + subject, html: await renderEmail("CreditBalanceLimitReachedEmail", { team: this.team, user: this.user, + autoRechargeEnabled: this.autoRechargeEnabled, + autoRechargeFailed: this.autoRechargeFailed, + calEvent: undefined, }), text: this.getTextBody(), }; } protected getTextBody(): string { + if (this.autoRechargeFailed) { + return "Your auto-recharge payment failed. Your team is out of credits. Please update your payment method and purchase more credits."; + } else if (this.autoRechargeEnabled) { + return "Your team ran out of credits, but auto-recharge is enabled. A recharge will be attempted soon."; + } return "Your team ran out of credits. Please buy more credits."; } } diff --git a/packages/emails/templates/credit-balance-low-warning-email.ts b/packages/emails/templates/credit-balance-low-warning-email.ts index bc9c9f34357e47..d288e2116858b8 100644 --- a/packages/emails/templates/credit-balance-low-warning-email.ts +++ b/packages/emails/templates/credit-balance-low-warning-email.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ import type { TFunction } from "i18next"; import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; @@ -17,20 +18,24 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail { name: string; }; balance: number; + autoRechargeEnabled: boolean; constructor({ user, balance, team, + autoRechargeEnabled = false, }: { user: { id: number; name: string | null; email: string; t: TFunction }; balance: number; team?: { id: number; name: string | null }; + autoRechargeEnabled?: boolean; }) { super(); this.user = { ...user, name: user.name || "" }; this.team = team ? { ...team, name: team.name || "" } : undefined; this.balance = balance; + this.autoRechargeEnabled = autoRechargeEnabled; } protected async getNodeMailerPayload(): Promise> { @@ -44,12 +49,17 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail { balance: this.balance, team: this.team, user: this.user, + autoRechargeEnabled: this.autoRechargeEnabled, + calEvent: undefined, }), text: this.getTextBody(), }; } protected getTextBody(): string { + if (this.autoRechargeEnabled) { + return "Your team is running low on credits. Auto-recharge will be triggered soon."; + } return "Your team is running low on credits. Please buy more credits."; } } diff --git a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts index 418e8c96a98125..43c3e5659f7ad5 100644 --- a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts +++ b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts @@ -53,18 +53,26 @@ async function saveToCreditBalance({ }) { const creditBalance = await CreditsRepository.findCreditBalance({ teamId, userId }); + const stripeCustomerId = session.customer as string; + let creditBalanceId = creditBalance?.id; if (creditBalance) { await CreditsRepository.updateCreditBalance({ id: creditBalance.id, - data: { additionalCredits: { increment: nrOfCredits }, limitReachedAt: null, warningSentAt: null }, + data: { + additionalCredits: { increment: nrOfCredits }, + limitReachedAt: null, + warningSentAt: null, + stripeCustomerId: stripeCustomerId, // Store customer ID for future auto-recharge + }, }); } else { const newCreditBalance = await CreditsRepository.createCreditBalance({ teamId: teamId, userId: !teamId ? userId : undefined, additionalCredits: nrOfCredits, + stripeCustomerId: stripeCustomerId, // Store customer ID for future auto-recharge }); creditBalanceId = newCreditBalance.id; } diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 3f80f16d816fac..9cd89891b24e84 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ import type { TFunction } from "i18next"; import dayjs from "@calcom/dayjs"; @@ -131,19 +134,33 @@ export class CreditService { }); let lowCreditBalanceResult = null; + let autoRechargeResult = null; + if (credits) { - lowCreditBalanceResult = await this._handleLowCreditBalance({ + // Check for auto-recharge first + autoRechargeResult = await this._handleAutoRecharge({ teamId: teamIdToCharge, userId: userIdToCharge, remainingCredits: remainingCredits ?? 0, tx, }); + + // Only send low balance warnings if auto-recharge didn't happen + if (!autoRechargeResult) { + lowCreditBalanceResult = await this._handleLowCreditBalance({ + teamId: teamIdToCharge, + userId: userIdToCharge, + remainingCredits: remainingCredits ?? 0, + tx, + }); + } } return { teamId: teamIdToCharge, userId: userIdToCharge, lowCreditBalanceResult, + autoRechargeResult, }; }) .then(async (result) => { @@ -151,9 +168,16 @@ export class CreditService { // send emails after transaction is successfully committed await this._handleLowCreditBalanceResult(result.lowCreditBalanceResult); } + + if (result?.autoRechargeResult) { + // Process auto-recharge after transaction is committed + await this._processAutoRecharge(result.autoRechargeResult); + } + return { teamId: result?.teamId, userId: result?.userId, + autoRecharged: !!result?.autoRechargeResult, }; }); } @@ -387,7 +411,6 @@ export class CreditService { creditBalanceId: creditBalance.id, credits, creditType, - creditFor, date: new Date(), bookingUid, smsSid, @@ -445,7 +468,7 @@ export class CreditService { ? { ...creditBalance.team, adminAndOwners: await Promise.all( - creditBalance.team.members.map(async (member) => ({ + creditBalance.team.members.map(async (member: any) => ({ id: member.user.id, name: member.user.name, email: member.user.email, @@ -676,17 +699,196 @@ export class CreditService { ); const totalMonthlyCredits = await this.getMonthlyCredits(teamId); - const totalMonthlyCreditsUsed = - creditBalance?.expenseLogs.reduce((sum, log) => sum + (log?.credits ?? 0), 0) || 0; + const totalMonthlyCreditsUsed = Array.isArray(creditBalance?.expenseLogs) + ? creditBalance.expenseLogs.reduce( + (sum: number, log: { credits: number | null }) => sum + (log.credits ?? 0), + 0 + ) + : 0; const additionalCredits = creditBalance?.additionalCredits ?? 0; const totalCreditsUsedThisMonth = totalMonthlyCreditsUsed; return { totalMonthlyCredits, + totalRemainingMonthlyCredits: Math.max( + Number(totalMonthlyCredits) - Number(totalMonthlyCreditsUsed), + 0 + ), + additionalCredits: creditBalance?.additionalCredits ?? 0, totalRemainingMonthlyCredits: Math.max(totalMonthlyCredits - totalMonthlyCreditsUsed, 0), additionalCredits, totalCreditsUsedThisMonth, }; } + + protected async _handleAutoRecharge({ + teamId, + userId, + remainingCredits, + tx, + }: { + teamId?: number | null; + userId?: number | null; + remainingCredits: number; + tx: PrismaTransaction; + }) { + const creditBalance = await CreditsRepository.findCreditBalanceWithAutoRechargeSettings( + { teamId, userId }, + tx + ); + + if ( + !creditBalance || + !creditBalance.autoRechargeEnabled || + !creditBalance.autoRechargeThreshold || + !creditBalance.autoRechargeAmount + ) { + return null; + } + + // Check if balance is below threshold and auto-recharge is enabled + if (remainingCredits < creditBalance.autoRechargeThreshold) { + log.info("Auto recharge triggered", { + teamId, + userId, + remainingCredits, + threshold: creditBalance.autoRechargeThreshold, + }); + + // Don't update balance here - just return the info needed to process the payment + return { + id: creditBalance.id, + teamId, + userId, + stripeCustomerId: creditBalance.stripeCustomerId, + amount: creditBalance.autoRechargeAmount, + }; + } + + return null; + } + + private async _processAutoRecharge(rechargeInfo: { + id: string; + teamId?: number | null; + userId?: number | null; + stripeCustomerId?: string | null; + amount: number; + }) { + try { + // Create a checkout session or charge directly using saved payment method + const billingService = new StripeBillingService(); + + if (!rechargeInfo.stripeCustomerId) { + log.error("Auto-recharge failed: No Stripe customer ID", rechargeInfo); + return; + } + + const result = await billingService.createAutoRechargePaymentIntent({ + customerId: rechargeInfo.stripeCustomerId, + amount: rechargeInfo.amount, + metadata: { + creditBalanceId: rechargeInfo.id, + teamId: rechargeInfo.teamId ? rechargeInfo.teamId.toString() : "", + userId: rechargeInfo.userId ? rechargeInfo.userId.toString() : "", + autoRecharge: "true", + }, + }); + + if (result.success) { + // Update credit balance with new credits + await CreditsRepository.updateCreditBalance({ + id: rechargeInfo.id, + data: { + additionalCredits: { increment: rechargeInfo.amount }, + limitReachedAt: null, + warningSentAt: null, + lastAutoRechargeAt: new Date(), + }, + }); + + // Create purchase log + await CreditsRepository.createCreditPurchaseLog({ + credits: rechargeInfo.amount, + creditBalanceId: rechargeInfo.id, + autoRecharged: true, + }); + + log.info("Auto-recharge successful", { + teamId: rechargeInfo.teamId, + userId: rechargeInfo.userId, + amount: rechargeInfo.amount, + }); + } else { + log.error("Auto-recharge payment failed", { + teamId: rechargeInfo.teamId, + userId: rechargeInfo.userId, + error: result.error, + }); + } + } catch (error) { + log.error("Error processing auto-recharge", error, { rechargeInfo }); + } + } + + async updateAutoRechargeSettings({ + teamId, + userId, + enabled, + threshold, + amount, + stripeCustomerId, + }: { + teamId?: number; + userId?: number; + enabled: boolean; + threshold?: number; + amount?: number; + stripeCustomerId?: string; + }) { + if (!teamId && !userId) { + throw new Error("Either teamId or userId must be provided"); + } + + const creditBalance = await CreditsRepository.findCreditBalance({ teamId, userId }); + + if (creditBalance) { + return await CreditsRepository.updateCreditBalance({ + id: creditBalance.id, + data: { + autoRechargeEnabled: enabled, + autoRechargeThreshold: threshold, + autoRechargeAmount: amount, + stripeCustomerId: stripeCustomerId, + }, + }); + } else { + return await CreditsRepository.createCreditBalance({ + teamId, + userId: !teamId ? userId : undefined, + additionalCredits: 0, + autoRechargeEnabled: enabled, + autoRechargeThreshold: threshold, + autoRechargeAmount: amount, + stripeCustomerId: stripeCustomerId, + }); + } + } + + async getAutoRechargeSettings({ teamId, userId }: { teamId?: number; userId?: number }) { + if (!teamId && !userId) { + throw new Error("Either teamId or userId must be provided"); + } + + const creditBalance = await CreditsRepository.findCreditBalance({ teamId, userId }); + + return { + enabled: creditBalance?.autoRechargeEnabled ?? false, + threshold: creditBalance?.autoRechargeThreshold ?? 50, + amount: creditBalance?.autoRechargeAmount ?? 100, + stripeCustomerId: creditBalance?.stripeCustomerId ?? null, + lastAutoRechargeAt: creditBalance?.lastAutoRechargeAt ?? null, + }; + } } diff --git a/packages/features/ee/billing/stripe-billling-service.ts b/packages/features/ee/billing/stripe-billling-service.ts index a0be6aa29acd5c..11686aacbe3cc9 100644 --- a/packages/features/ee/billing/stripe-billling-service.ts +++ b/packages/features/ee/billing/stripe-billling-service.ts @@ -1,16 +1,17 @@ -import Stripe from "stripe"; +/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { BillingService } from "./billing-service"; +/* eslint-disable turbo/no-undeclared-env-vars */ +import Stripe from "stripe"; -export class StripeBillingService implements BillingService { +export class StripeBillingService { private stripe: Stripe; - constructor() { - this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY!, { + constructor(apiKey?: string) { + this.stripe = new Stripe(apiKey || (process.env.STRIPE_SECRET_KEY as string), { apiVersion: "2020-08-27", }); } - async createCustomer(args: Parameters[0]) { + async createCustomer(args: any) { const { email, metadata } = args; const customer = await this.stripe.customers.create({ email, @@ -22,7 +23,7 @@ export class StripeBillingService implements BillingService { return { stripeCustomerId: customer.id }; } - async createPaymentIntent(args: Parameters[0]) { + async createPaymentIntent(args: any) { const { customerId, amount, metadata } = args; const paymentIntent = await this.stripe.paymentIntents.create({ customer: customerId, @@ -58,7 +59,7 @@ export class StripeBillingService implements BillingService { invoice_creation: { enabled: true, }, - } as any); + } as never); return { checkoutUrl: session.url, @@ -66,7 +67,7 @@ export class StripeBillingService implements BillingService { }; } - async createSubscriptionCheckout(args: Parameters[0]) { + async createSubscriptionCheckout(args: any) { const { customerId, successUrl, @@ -107,7 +108,7 @@ export class StripeBillingService implements BillingService { }; } - async createPrice(args: Parameters[0]) { + async createPrice(args: any) { const { amount, currency, interval, productId, nickname, metadata } = args; const price = await this.stripe.prices.create({ @@ -126,7 +127,7 @@ export class StripeBillingService implements BillingService { }; } - async handleSubscriptionCreation(subscriptionId: string) { + async handleSubscriptionCreation(_args: any) { throw new Error("Method not implemented."); } @@ -134,7 +135,7 @@ export class StripeBillingService implements BillingService { await this.stripe.subscriptions.cancel(subscriptionId); } - async handleSubscriptionUpdate(args: Parameters[0]) { + async handleSubscriptionUpdate(args: any) { const { subscriptionId, subscriptionItemId, membershipCount } = args; const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); const subscriptionQuantity = subscription.items.data.find( @@ -184,7 +185,7 @@ export class StripeBillingService implements BillingService { return subscriptions.data; } - async updateCustomer(args: Parameters[0]) { + async updateCustomer(args: any) { const { customerId, email, userId } = args; const metadata: { email?: string; userId?: number } = {}; if (email) metadata.email = email; @@ -196,4 +197,69 @@ export class StripeBillingService implements BillingService { const price = await this.stripe.prices.retrieve(priceId); return price; } + + async createAutoRechargePaymentIntent({ + customerId, + amount, // quantity of credits to buy + metadata, + }: { + customerId: string; + amount: number; + metadata: { + creditBalanceId: string; + teamId: string; + userId: string; + autoRecharge: string; + }; + }): Promise<{ success: boolean; error?: string }> { + try { + const creditsPriceId = process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID; + if (!creditsPriceId) { + return { success: false, error: "CREDITS price not configured" }; + } + const price = await this.stripe.prices.retrieve(creditsPriceId); + const unitAmount = price.unit_amount; + if (!unitAmount) { + return { success: false, error: "Unit amount missing on credits price" }; + } + const totalAmount = unitAmount * amount; + + // Get customer's payment methods + const paymentMethods = await this.stripe.paymentMethods.list({ + customer: customerId, + type: "card", + }); + + if (!paymentMethods.data.length) { + return { success: false, error: "No payment methods found for customer" }; + } + + // Use default payment method if set; else first available + const customer = await this.stripe.customers.retrieve(customerId); + const defaultPm = (customer as Stripe.Customer).invoice_settings?.default_payment_method as + | string + | null; + const defaultPaymentMethod = defaultPm ?? paymentMethods.data[0].id; + + // Create and confirm a payment intent + await this.stripe.paymentIntents.create({ + amount: totalAmount, + currency: "usd", + customer: customerId, + payment_method: defaultPaymentMethod, + off_session: true, + confirm: true, + error_on_requires_action: true, + metadata, + }); + + return { success: true }; + } catch (error) { + console.error("Error processing auto-recharge payment:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } } diff --git a/packages/lib/server/repository/credits.ts b/packages/lib/server/repository/credits.ts index d2828d3d8150d8..8c7eb6b55e4f0c 100644 --- a/packages/lib/server/repository/credits.ts +++ b/packages/lib/server/repository/credits.ts @@ -1,37 +1,86 @@ -import dayjs from "@calcom/dayjs"; -import prisma, { type PrismaTransaction } from "@calcom/prisma"; -import { Prisma } from "@calcom/prisma/client"; -import type { CreditType } from "@calcom/prisma/enums"; +/* eslint-disable @calcom/eslint/no-prisma-include-true */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* eslint-disable @typescript-eslint/adjacent-overload-signatures */ +import type { PrismaTransaction } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import type { CreditType } from "@calcom/prisma/client"; export class CreditsRepository { static async findCreditBalance( - { teamId, userId }: { teamId?: number; userId?: number }, - tx?: PrismaTransaction + where: { teamId?: number | null; userId?: number | null }, + tx: PrismaTransaction = prisma ) { - const prismaClient = tx ?? prisma; - - const select = { - id: true, - additionalCredits: true, - limitReachedAt: true, - warningSentAt: true, - }; - - if (teamId) { - return await prismaClient.creditBalance.findUnique({ - where: { - teamId, + if (where.teamId != null && where.userId != null) { + throw new Error("Provide exactly one of teamId or userId"); + } + if (where.teamId != null) { + return tx.creditBalance.findUnique({ + where: { teamId: where.teamId ?? undefined }, + select: { + id: true, + additionalCredits: true, + limitReachedAt: true, + warningSentAt: true, + autoRechargeEnabled: true, + autoRechargeThreshold: true, + autoRechargeAmount: true, + stripeCustomerId: true, + lastAutoRechargeAt: true, + }, + }); + } else if (where.userId != null) { + return tx.creditBalance.findUnique({ + where: { userId: where.userId ?? undefined }, + select: { + id: true, + additionalCredits: true, + limitReachedAt: true, + warningSentAt: true, + autoRechargeEnabled: true, + autoRechargeThreshold: true, + autoRechargeAmount: true, + stripeCustomerId: true, + lastAutoRechargeAt: true, }, - select, }); } + return null; + } - if (userId) { - return await prismaClient.creditBalance.findUnique({ - where: { userId }, - select, + static async findCreditBalanceWithAutoRechargeSettings( + where: { teamId?: number | null; userId?: number | null }, + tx: PrismaTransaction = prisma + ) { + if (where.teamId != null && where.userId != null) { + throw new Error("Provide exactly one of teamId or userId"); + } + if (where.teamId != null) { + return tx.creditBalance.findUnique({ + where: { teamId: where.teamId }, + select: { + id: true, + autoRechargeEnabled: true, + autoRechargeThreshold: true, + autoRechargeAmount: true, + stripeCustomerId: true, + }, + }); + } else if (where.userId != null) { + return tx.creditBalance.findUnique({ + where: { userId: where.userId }, + select: { + id: true, + autoRechargeEnabled: true, + autoRechargeThreshold: true, + autoRechargeAmount: true, + stripeCustomerId: true, + }, }); } + return null; } static async findCreditExpenseLogByExternalRef(externalRef: string, tx?: PrismaTransaction) { @@ -49,75 +98,61 @@ export class CreditsRepository { } static async findCreditBalanceWithTeamOrUser( - { - teamId, - userId, - }: { - teamId?: number | null; - userId?: number | null; - }, - tx?: PrismaTransaction + { teamId, userId }: { teamId?: number | null; userId?: number | null }, + tx: PrismaTransaction = prisma ) { const prismaClient = tx ?? prisma; - - const select = { - id: true, - additionalCredits: true, - limitReachedAt: true, - warningSentAt: true, - team: { - select: { - id: true, - name: true, - members: { - select: { - user: { - select: { - id: true, - name: true, - email: true, - locale: true, + if (teamId) { + return prismaClient.creditBalance.findUnique({ + where: { teamId: teamId ?? undefined }, + include: { + team: { + include: { + members: { + include: { + user: true, }, }, }, }, + user: true, }, - }, - user: { - select: { - id: true, - name: true, - email: true, - locale: true, - }, - }, - }; - if (teamId) { - return await prismaClient.creditBalance.findUnique({ - where: { - teamId, - }, - select, }); - } - - if (userId) { - return await prismaClient.creditBalance.findUnique({ - where: { userId }, - select, + } else if (userId) { + return prismaClient.creditBalance.findUnique({ + where: { userId: userId ?? undefined }, + include: { + team: { + include: { + members: { + include: { + user: true, + }, + }, + }, + }, + user: true, + }, }); } + return null; } static async findCreditBalanceWithExpenseLogs( { teamId, userId, - startDate = dayjs().startOf("month").toDate(), - endDate = new Date(), + startDate, + endDate, creditType, - }: { teamId?: number; userId?: number; startDate?: Date; endDate?: Date; creditType?: CreditType }, - tx?: PrismaTransaction + }: { + teamId?: number | null; + userId?: number | null; + startDate?: Date; + endDate?: Date; + creditType?: CreditType; + }, + tx: PrismaTransaction = prisma ) { if (!teamId && !userId) return null; @@ -125,8 +160,8 @@ export class CreditsRepository { return await prismaClient.creditBalance.findUnique({ where: { - teamId, - ...(!teamId ? { userId } : {}), + teamId: teamId ?? undefined, + ...(!teamId ? { userId: userId ?? undefined } : {}), }, select: { additionalCredits: true, @@ -168,75 +203,139 @@ export class CreditsRepository { id?: string; teamId?: number | null; userId?: number | null; - data: Prisma.CreditBalanceUncheckedUpdateInput; + data: Prisma.CreditBalanceUpdateInput; }, - tx?: PrismaTransaction + tx: PrismaTransaction = prisma ) { - const prismaClient = tx ?? prisma; - if (id) { - return prismaClient.creditBalance.update({ - where: { id }, - data, - }); - } + let where: Prisma.CreditBalanceWhereUniqueInput; - if (teamId) { - return prismaClient.creditBalance.update({ - where: { teamId }, - data, - }); - } - - if (userId) { - return prismaClient.creditBalance.update({ - where: { userId }, - data, - }); + if (id) { + where = { id }; + } else if (teamId) { + where = { teamId }; + } else if (userId) { + where = { userId }; + } else { + throw new Error("Either id, teamId or userId must be provided"); } - return null; + return tx.creditBalance.update({ + where, + data, + }); } - static async createCreditBalance(data: Prisma.CreditBalanceUncheckedCreateInput, tx?: PrismaTransaction) { - const { teamId, userId } = data; - - if (!teamId && !userId) { - throw new Error("Team or user ID is required"); - } - - return (tx ?? prisma).creditBalance.create({ + static async createCreditBalance( + { + teamId, + userId, + additionalCredits = 0, + autoRechargeEnabled = false, + autoRechargeThreshold = null, + autoRechargeAmount = null, + stripeCustomerId = null, + }: { + teamId?: number; + userId?: number; + additionalCredits?: number; + autoRechargeEnabled?: boolean; + autoRechargeThreshold?: number | null; + autoRechargeAmount?: number | null; + stripeCustomerId?: string | null; + }, + tx: PrismaTransaction = prisma + ) { + return tx.creditBalance.create({ data: { - ...data, - ...(!teamId ? { userId } : {}), + additionalCredits, + autoRechargeEnabled, + autoRechargeThreshold, + autoRechargeAmount, + stripeCustomerId, + team: teamId + ? { + connect: { + id: teamId, + }, + } + : undefined, + user: userId + ? { + connect: { + id: userId, + }, + } + : undefined, }, }); } - static async createCreditExpenseLog( - data: Prisma.CreditExpenseLogUncheckedCreateInput, - tx?: PrismaTransaction + static async createCreditPurchaseLog( + { + credits, + creditBalanceId, + autoRecharged = false, + }: { + credits: number; + creditBalanceId: string; + autoRecharged?: boolean; + }, + tx: PrismaTransaction = prisma ) { - const prismaClient = tx ?? prisma; - try { - return await prismaClient.creditExpenseLog.create({ - data, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { - throw new Error(`Duplicate external reference - already processed: ${data?.externalRef}`); - } - throw error; - } - } - - static async createCreditPurchaseLog(data: { credits: number; creditBalanceId: string }) { - const { credits, creditBalanceId } = data; - - return prisma.creditPurchaseLog.create({ + return tx.creditPurchaseLog.create({ data: { credits, creditBalanceId, + autoRecharged, + date: new Date(), }, }); } + + static async createCreditExpenseLog( + { + creditBalanceId, + credits, + creditType, + date, + bookingUid, + smsSid, + smsSegments, + phoneNumber, + email, + callDuration, + externalRef, + }: { + creditBalanceId: string; + credits: number | null; + creditType: CreditType; + date: Date; + bookingUid?: string; + smsSid?: string; + smsSegments?: number; + phoneNumber?: string; + email?: string; + callDuration?: number; + externalRef?: string; + }, + tx?: PrismaTransaction + ) { + const prismaClient = tx || prisma; + const data: any = { + credits, + creditType, + date, + smsSid, + smsSegments, + phoneNumber, + email, + callDuration, + externalRef, + creditBalance: { connect: { id: creditBalanceId } }, + }; + if (typeof bookingUid !== "undefined") { + data.bookingUid = bookingUid; + } + return prismaClient.creditExpenseLog.create({ data }); + } } diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 3c5e1ee85865f5..6ca28b77060697 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -138,3 +138,4 @@ export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publ export { TeamService } from "@calcom/lib/server/service/teamService"; export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; +export { CreditService } from "@calcom/features/ee/billing/credit-service"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4f08fa2c63adcb..a90cb9e19418b7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -596,24 +596,33 @@ model Team { model CreditBalance { id String @id @default(uuid()) - team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) - teamId Int? @unique - // user credit balances will be supported in the future - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int? @unique + teamId Int? @unique additionalCredits Int @default(0) limitReachedAt DateTime? warningSentAt DateTime? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) expenseLogs CreditExpenseLog[] purchaseLogs CreditPurchaseLog[] + + // Add auto-recharge fields + autoRechargeEnabled Boolean @default(false) + autoRechargeThreshold Int? + autoRechargeAmount Int? + stripeCustomerId String? + lastAutoRechargeAt DateTime? + + @@unique([teamId, userId]) } model CreditPurchaseLog { id String @id @default(uuid()) - creditBalanceId String creditBalance CreditBalance @relation(fields: [creditBalanceId], references: [id], onDelete: Cascade) + creditBalanceId String credits Int - createdAt DateTime @default(now()) + date DateTime + autoRecharged Boolean @default(false) } enum CreditUsageType { @@ -2174,7 +2183,6 @@ model DelegationCredential { // Should be fair to assume that one domain can be only on one workspace platform at a time. So, one can't have two different workspace platforms for the same domain // Because we don't know which domain the organization might have, we couldn't make "domain" unique here as that would prevent an actual owner of the domain to be unable to use that domain if it is used by someone else. @@unique([organizationId, domain]) - @@index([enabled]) } // Deprecated and probably unused - Use DelegationCredential instead diff --git a/packages/trpc/server/routers/viewer/credits.tsx b/packages/trpc/server/routers/viewer/credits.tsx new file mode 100644 index 00000000000000..48c12cfdade0cf --- /dev/null +++ b/packages/trpc/server/routers/viewer/credits.tsx @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import { CreditService } from "@calcom/features/ee/billing/credit-service"; +import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants"; + +import { TRPCError } from "@trpc/server"; + +import { router, authedProcedure } from "../../trpc"; + +export const creditsRouter = router({ + getAutoRechargeSettings: authedProcedure + .input( + z.object({ + teamId: z.number().optional(), + }) + ) + .query(async ({ input, ctx }) => { + if (!IS_SMS_CREDITS_ENABLED) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "SMS credits are not enabled", + }); + } + + const { teamId } = input; + const userId = teamId ? undefined : ctx.user.id; + + const creditService = new CreditService(); + const settings = await creditService.getAutoRechargeSettings({ + teamId, + userId, + }); + + return { settings }; + }), + + updateAutoRechargeSettings: authedProcedure + .input( + z.object({ + teamId: z.number().optional(), + enabled: z.boolean(), + threshold: z.number().min(10).optional(), + amount: z.number().min(50).optional(), + stripeCustomerId: z.string().optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + if (!IS_SMS_CREDITS_ENABLED) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "SMS credits are not enabled", + }); + } + + const { teamId, enabled, threshold, amount, stripeCustomerId } = input; + const userId = teamId ? undefined : ctx.user.id; + + const creditService = new CreditService(); + await creditService.updateAutoRechargeSettings({ + teamId, + userId, + enabled, + threshold, + amount, + stripeCustomerId, + }); + + return { success: true }; + }), + + // ...existing code... +}); diff --git a/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts b/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts index c88e62e6f46159..cdd712f22b9f48 100644 --- a/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts @@ -1,5 +1,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { CreditsRepository } from "@calcom/lib/server/repository/credits"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import type { CreditType } from "@calcom/prisma/client"; import { TeamService } from "@calcom/lib/server/service/teamService"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -58,17 +60,33 @@ export const downloadExpenseLogHandler = async ({ ctx, input }: DownloadExpenseL return { csvData: headers.join(",") }; } - const rows = creditBalance.expenseLogs.map((log) => [ - log.date.toISOString(), - log.credits?.toString() ?? "", - log.creditType, - log.bookingUid ?? "", - log.smsSegments?.toString() ?? "-", - log.phoneNumber ?? "", - log.email ?? "", - log.callDuration?.toString() ?? "-", - log.externalRef ?? "-", - ]); + const rows = Array.isArray(creditBalance.expenseLogs) + ? creditBalance.expenseLogs.map( + (log: { + date: Date; + credits: number | null; + creditType: CreditType; + bookingUid: string | null; + smsSid: string | null; + smsSegments: number | null; + phoneNumber: string | null; + email: string | null; + callDuration: number | null; + externalRef: string | null; + }) => [ + log.date, + log.credits, + log.creditType, + log.bookingUid ?? undefined, + log.smsSid ?? undefined, + log.smsSegments ?? undefined, + log.phoneNumber ?? undefined, + log.email ?? undefined, + log.callDuration ?? undefined, + log.externalRef ?? undefined, + ] + ) + : []; const csvData = [headers, ...rows].map((row) => row.join(",")).join("\n");