diff --git a/app/components/subscription/customer-portal-form.tsx b/app/components/subscription/customer-portal-form.tsx index 09a7e8729..e53219a3b 100644 --- a/app/components/subscription/customer-portal-form.tsx +++ b/app/components/subscription/customer-portal-form.tsx @@ -10,7 +10,10 @@ export const CustomerPortalForm = ({ const customerPortalFetcher = useFetcher(); const isProcessing = isFormProcessing(customerPortalFetcher.state); return ( - + diff --git a/app/components/subscription/no-subscription.tsx b/app/components/subscription/no-subscription.tsx new file mode 100644 index 000000000..65896d6ef --- /dev/null +++ b/app/components/subscription/no-subscription.tsx @@ -0,0 +1,41 @@ +import { useCurrentOrganization } from "~/hooks/use-current-organization-id"; +import { useUserData } from "~/hooks/use-user-data"; +import { CustomerPortalForm } from "./customer-portal-form"; +import { plansIconsMap } from "./prices"; +import { Button } from "../shared/button"; + +export const NoSubscription = () => { + const currentOrganization = useCurrentOrganization(); + const user = useUserData(); + + const userIsOwner = user?.id === currentOrganization?.owner.id; + + return ( +
+
+
+ + {plansIconsMap["tier_2"]} + +
+

Workspace disabled

+

+ {userIsOwner + ? "The subscription for this workspace has expired and is therefore set to inactive. Renew your subscription to start using this Team workspace again." + : "The subscription for this workspace has expired and is therefore set to inactive. Please contact the owner of the workspace for more information."} +

+
+ {userIsOwner && ( + + )} + +
+
+
+ ); +}; diff --git a/app/components/subscription/price-cta.tsx b/app/components/subscription/price-cta.tsx index 529515e4e..0ec43ec29 100644 --- a/app/components/subscription/price-cta.tsx +++ b/app/components/subscription/price-cta.tsx @@ -1,4 +1,5 @@ -import { Form } from "@remix-run/react"; +import { Form, useLoaderData } from "@remix-run/react"; +import type { loader } from "~/routes/_layout+/settings.subscription"; import { CustomerPortalForm } from "./customer-portal-form"; import type { Price } from "./prices"; import { Button } from "../shared/button"; @@ -10,8 +11,13 @@ export const PriceCta = ({ price: Price; subscription: Object | null; }) => { + const { usedFreeTrial } = useLoaderData(); + if (price.id === "free") return null; + const isTeamSubscriptionColumn = + price.product.metadata.shelf_tier === "tier_2"; + if (subscription) { return ( - - - + <> +
+ + + + + {isTeamSubscriptionColumn && !subscription && !usedFreeTrial && ( + + )} +
+ ); }; diff --git a/app/components/subscription/prices.tsx b/app/components/subscription/prices.tsx index 4f836d043..47633f5f4 100644 --- a/app/components/subscription/prices.tsx +++ b/app/components/subscription/prices.tsx @@ -46,7 +46,7 @@ export interface Price { } | null; } -const plansIconsMap: { [key: string]: JSX.Element } = { +export const plansIconsMap: { [key: string]: JSX.Element } = { free: , tier_1: , tier_2: , diff --git a/app/components/subscription/successful-subscription-modal.tsx b/app/components/subscription/successful-subscription-modal.tsx index 004d0a9fd..b98d962d4 100644 --- a/app/components/subscription/successful-subscription-modal.tsx +++ b/app/components/subscription/successful-subscription-modal.tsx @@ -7,6 +7,7 @@ import { Button } from "../shared/button"; export default function SuccessfulSubscriptionModal() { const [params, setParams] = useSearchParams(); const success = params.get("success") || false; + const isTeam = params.get("team") || false; const handleBackdropClose = useCallback( (e: React.MouseEvent) => { if (e.target !== e.currentTarget) return; @@ -41,9 +42,19 @@ export default function SuccessfulSubscriptionModal() { Thank you, all {activeProduct?.name} features are unlocked.

- + {isTeam ? ( + + ) : ( + + )} diff --git a/app/database/migrations/20240418133119_add_free_trial_flag_to_user/migration.sql b/app/database/migrations/20240418133119_add_free_trial_flag_to_user/migration.sql new file mode 100644 index 000000000..f6cd5c451 --- /dev/null +++ b/app/database/migrations/20240418133119_add_free_trial_flag_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "usedFreeTrial" BOOLEAN NOT NULL DEFAULT false; diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 4cf24404b..88f13618d 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -36,6 +36,7 @@ model User { firstName String? lastName String? profilePicture String? + usedFreeTrial Boolean @default(false) onboarded Boolean @default(false) customerId String? @unique // Stripe customer id tierId TierId @default(free) diff --git a/app/modules/organization/service.server.ts b/app/modules/organization/service.server.ts index 4bc2c5537..e59617f03 100644 --- a/app/modules/organization/service.server.ts +++ b/app/modules/organization/service.server.ts @@ -184,6 +184,12 @@ export async function getUserOrganizations({ userId }: { userId: string }) { userId: true, updatedAt: true, currency: true, + owner: { + select: { + id: true, + email: true, + }, + }, }, }, }, diff --git a/app/modules/tier/service.server.ts b/app/modules/tier/service.server.ts index 1280f52d7..070625006 100644 --- a/app/modules/tier/service.server.ts +++ b/app/modules/tier/service.server.ts @@ -253,13 +253,10 @@ export async function getOrganizationTierLimit({ organizations, }: { organizationId?: string; - organizations: { - id: string; - type: OrganizationType; - name: string; - imageId: string | null; - userId: string; - }[]; + organizations: Pick< + Organization, + "id" | "type" | "name" | "imageId" | "userId" + >[]; }) { try { /** Find the current organization as we need the owner */ diff --git a/app/modules/user/types.ts b/app/modules/user/types.ts index b5c6aca12..e15dbfcdd 100644 --- a/app/modules/user/types.ts +++ b/app/modules/user/types.ts @@ -10,6 +10,7 @@ export interface UpdateUserPayload { onboarded?: User["onboarded"]; password?: string; confirmPassword?: string; + usedFreeTrial?: boolean; } export interface UpdateUserResponse { diff --git a/app/routes/_layout+/_layout.tsx b/app/routes/_layout+/_layout.tsx index 38797bc9f..9ce6d44b7 100644 --- a/app/routes/_layout+/_layout.tsx +++ b/app/routes/_layout+/_layout.tsx @@ -10,6 +10,7 @@ import Sidebar from "~/components/layout/sidebar/sidebar"; import { useCrisp } from "~/components/marketing/crisp"; import { Spinner } from "~/components/shared/spinner"; import { Toaster } from "~/components/shared/toast"; +import { NoSubscription } from "~/components/subscription/no-subscription"; import { config } from "~/config/shelf.config"; import { db } from "~/database/db.server"; import { getSelectedOrganisation } from "~/modules/organization/context.server"; @@ -24,6 +25,7 @@ import { data, error } from "~/utils/http.server"; import type { CustomerWithSubscriptions } from "~/utils/stripe.server"; import { + disabledTeamOrg, getCustomerActiveSubscription, getStripeCustomer, stripe, @@ -115,6 +117,11 @@ export async function loader({ context, request }: LoaderFunctionArgs) { minimizedSidebar: cookie.minimizedSidebar, isAdmin: user?.roles.some((role) => role.name === Roles["ADMIN"]), canUseBookings: canUseBookings(currentOrganization), + /** THis is used to disable team organizations when the currentOrg is Team and no subscription is present */ + disabledTeamOrg: await disabledTeamOrg({ + currentOrganization, + organizations, + }), }), { headers: [setCookie(await userPrefs.serialize(cookie))], @@ -128,7 +135,8 @@ export async function loader({ context, request }: LoaderFunctionArgs) { export default function App() { useCrisp(); - const { currentOrganizationId } = useLoaderData(); + const { currentOrganizationId, disabledTeamOrg } = + useLoaderData(); const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); return ( @@ -142,7 +150,9 @@ export default function App() {
- {workspaceSwitching ? ( + {disabledTeamOrg ? ( + + ) : workspaceSwitching ? (

Activating workspace...

diff --git a/app/routes/_layout+/settings.subscription.tsx b/app/routes/_layout+/settings.subscription.tsx index 3847bdecf..34d55c1bb 100644 --- a/app/routes/_layout+/settings.subscription.tsx +++ b/app/routes/_layout+/settings.subscription.tsx @@ -19,7 +19,7 @@ import { Prices } from "~/components/subscription/prices"; import SuccessfulSubscriptionModal from "~/components/subscription/successful-subscription-modal"; import { db } from "~/database/db.server"; -import { getUserByID } from "~/modules/user/service.server"; +import { getUserByID, updateUser } from "~/modules/user/service.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { ENABLE_PREMIUM_FEATURES } from "~/utils/env"; import { ShelfError, makeShelfError } from "~/utils/error"; @@ -39,7 +39,6 @@ import { getStripeCustomer, getActiveProduct, getCustomerActiveSubscription, - getCustomerTrialSubscription, } from "~/utils/stripe.server"; export async function loader({ context, request }: LoaderFunctionArgs) { @@ -67,24 +66,19 @@ export async function loader({ context, request }: LoaderFunctionArgs) { )) as CustomerWithSubscriptions) : null; - /** Get the trial subscription */ - const trialSubscription = getCustomerTrialSubscription({ customer }); - /** Get a normal subscription */ const subscription = getCustomerActiveSubscription({ customer }); - const activeSubscription = subscription || trialSubscription; - /* Get the prices and products from Stripe */ const prices = await getStripePricesAndProducts(); let activeProduct = null; - if (customer && activeSubscription) { + if (customer && subscription) { /** Get the active subscription ID */ activeProduct = getActiveProduct({ prices, - priceId: activeSubscription?.items.data[0].plan.id || null, + priceId: subscription?.items.data[0].plan.id || null, }); } @@ -94,17 +88,18 @@ export async function loader({ context, request }: LoaderFunctionArgs) { subTitle: "Pick an account plan that fits your workflow.", prices, customer, - subscription: activeSubscription, + subscription: subscription, activeProduct, + usedFreeTrial: user.usedFreeTrial, expiration: { date: new Date( - (activeSubscription?.current_period_end as number) * 1000 + (subscription?.current_period_end as number) * 1000 ).toLocaleDateString(), time: new Date( - (activeSubscription?.current_period_end as number) * 1000 + (subscription?.current_period_end as number) * 1000 ).toLocaleTimeString(), }, - isTrialSubscription: !!activeSubscription?.trial_end, + isTrialSubscription: !!subscription?.trial_end, }) ); } catch (cause) { @@ -125,9 +120,13 @@ export async function action({ context, request }: ActionFunctionArgs) { action: PermissionAction.update, }); - const { priceId } = parseData( + const { priceId, intent, shelfTier } = parseData( await request.formData(), - z.object({ priceId: z.string() }) + z.object({ + priceId: z.string(), + intent: z.enum(["trial", "subscribe"]), + shelfTier: z.enum(["tier_1", "tier_2"]), + }) ); const user = await db.user @@ -170,8 +169,15 @@ export async function action({ context, request }: ActionFunctionArgs) { priceId, domainUrl: getDomainUrl(request), customerId: customerId, + intent, + shelfTier, }); + /** Update the user flag to mark them for having a trial */ + if (intent === "trial" && stripeRedirectUrl) { + await updateUser({ id: userId, usedFreeTrial: true }); + } + return redirect(stripeRedirectUrl); } catch (cause) { const reason = makeShelfError(cause, { userId }); diff --git a/app/routes/api+/stripe-webhook.ts b/app/routes/api+/stripe-webhook.ts index 8942f0264..f00128021 100644 --- a/app/routes/api+/stripe-webhook.ts +++ b/app/routes/api+/stripe-webhook.ts @@ -40,6 +40,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "No subscription ID found", additionalData: { event }, label: "Stripe webhook", + status: 500, }); } @@ -62,6 +63,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "No tier ID found", additionalData: { event, subscription }, label: "Stripe webhook", + status: 500, }); } @@ -79,6 +81,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "Failed to update user tier", additionalData: { customerId, tierId, event }, label: "Stripe webhook", + status: 500, }); }); @@ -95,6 +98,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "No tier ID found", additionalData: { event, subscription }, label: "Stripe webhook", + status: 500, }); } @@ -117,6 +121,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "Failed to update user tier", additionalData: { customerId, tierId, event }, label: "Stripe webhook", + status: 500, }); }); } @@ -135,6 +140,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "No tier ID found", additionalData: { event, subscription }, label: "Stripe webhook", + status: 500, }); } @@ -155,6 +161,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "Failed to update user tier", additionalData: { customerId, tierId, event }, label: "Stripe webhook", + status: 500, }); }); } @@ -163,7 +170,8 @@ export async function action({ request }: ActionFunctionArgs) { } case "customer.subscription.updated": { - const { customerId, tierId } = await getDataFromStripeEvent(event); + const { subscription, customerId, tierId } = + await getDataFromStripeEvent(event); if (!tierId) { throw new ShelfError({ @@ -171,25 +179,36 @@ export async function action({ request }: ActionFunctionArgs) { message: "No tier ID found", additionalData: { event }, label: "Stripe webhook", + status: 500, }); } - /** Update the user's tier in the database */ - await db.user - .update({ - where: { customerId }, - data: { - tierId: tierId as TierId, - }, - }) - .catch((cause) => { - throw new ShelfError({ - cause, - message: "Failed to update user tier", - additionalData: { customerId, tierId, event }, - label: "Stripe webhook", + console.log("subscription", subscription); + + /** Update the user's tier in the database + * + * We only update the tier if the subscription is not paused + * We only do it if the subscription is active because this event gets triggered when cancelling or pausing for example + */ + if (subscription.status === "active") { + console.log("SUBSCRIPTION IS ACTIVE"); + await db.user + .update({ + where: { customerId }, + data: { + tierId: tierId as TierId, + }, + }) + .catch((cause) => { + throw new ShelfError({ + cause, + message: "Failed to update user tier", + additionalData: { customerId, tierId, event }, + label: "Stripe webhook", + status: 500, + }); }); - }); + } return new Response(null, { status: 200 }); } @@ -212,6 +231,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "Failed to delete user subscription", additionalData: { customerId, event }, label: "Stripe webhook", + status: 500, }); }); @@ -229,6 +249,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "No tier ID found", additionalData: { event, subscription }, label: "Stripe webhook", + status: 500, }); } /** Check if its a trial subscription */ @@ -246,6 +267,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "No user found", additionalData: { customerId }, label: "Stripe webhook", + status: 500, }); }); @@ -273,7 +295,7 @@ export async function action({ request }: ActionFunctionArgs) { "Unhandled event. Maybe you forgot to handle this event type? Check the Stripe dashboard.", additionalData: { event }, label: "Stripe webhook", - status: 400, + status: 500, shouldBeCaptured: false, }); } diff --git a/app/routes/qr+/$qrId.tsx b/app/routes/qr+/$qrId.tsx index 319d970d6..0aee50cb2 100644 --- a/app/routes/qr+/$qrId.tsx +++ b/app/routes/qr+/$qrId.tsx @@ -88,7 +88,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { const userOrganizationIds = userOrganizations.map((org) => org.id); const personalOrganization = userOrganizations.find( (org) => org.type === "PERSONAL" - ) as Organization; + ) as Pick; if (!userOrganizationIds.includes(qr.organizationId)) { return redirect(`contact-owner?scanId=${scan.id}`); diff --git a/app/utils/env.ts b/app/utils/env.ts index 3bde07394..bc9c15a49 100644 --- a/app/utils/env.ts +++ b/app/utils/env.ts @@ -64,7 +64,6 @@ function getEnv( const value = source[name as keyof typeof source]; if (!value && isRequired) { - // FIXME: @TODO Solve error handling throw new ShelfError({ message: `${name} is not set`, cause: null, diff --git a/app/utils/stripe.server.ts b/app/utils/stripe.server.ts index 4e2d23e81..07742eebd 100644 --- a/app/utils/stripe.server.ts +++ b/app/utils/stripe.server.ts @@ -1,8 +1,9 @@ -import type { User } from "@prisma/client"; +import type { Organization, User } from "@prisma/client"; import Stripe from "stripe"; import type { PriceWithProduct } from "~/components/subscription/prices"; import { config } from "~/config/shelf.config"; import { db } from "~/database/db.server"; +import { getOrganizationTierLimit } from "~/modules/tier/service.server"; import { STRIPE_SECRET_KEY } from "./env"; import type { ErrorLabel } from "./error"; import { ShelfError } from "./error"; @@ -63,11 +64,15 @@ export async function createStripeCheckoutSession({ userId, domainUrl, customerId, + intent, + shelfTier, }: { priceId: Stripe.Price["id"]; userId: User["id"]; domainUrl: string; customerId: string; + intent: "trial" | "subscribe"; + shelfTier: "tier_1" | "tier_2"; }): Promise { try { if (!stripe) { @@ -101,10 +106,23 @@ export async function createStripeCheckoutSession({ mode: "subscription", payment_method_types: ["card"], line_items: lineItems, - success_url: `${domainUrl}/settings/subscription?success=true`, + success_url: `${domainUrl}/settings/subscription?success=true${ + shelfTier === "tier_2" ? "&team=true" : "" + }`, cancel_url: `${domainUrl}/settings/subscription?canceled=true`, client_reference_id: userId, customer: customerId, + ...(intent === "trial" && { + subscription_data: { + trial_settings: { + end_behavior: { + missing_payment_method: "pause", + }, + }, + trial_period_days: 14, + }, + payment_method_collection: "if_required", + }), // Add trial period if intent is trial }); if (!url) { @@ -276,7 +294,8 @@ export function getActiveProduct({ return null; } -export function getCustomerActiveSubscription({ +/** Gets the customer's paid subscription */ +export function getCustomerPaidSubscription({ customer, }: { customer: CustomerWithSubscriptions | null; @@ -285,6 +304,8 @@ export function getCustomerActiveSubscription({ customer?.subscriptions?.data.find((sub) => sub.status === "active") || null ); } + +/** Gets the trial subscription from customers subscription */ export function getCustomerTrialSubscription({ customer, }: { @@ -296,6 +317,21 @@ export function getCustomerTrialSubscription({ ); } +export function getCustomerActiveSubscription({ + customer, +}: { + customer: CustomerWithSubscriptions | null; +}) { + /** Get the trial subscription */ + const trialSubscription = getCustomerTrialSubscription({ customer }); + + /** Get a normal subscription */ + const paidSubscription = getCustomerPaidSubscription({ customer }); + + /** WE prioritize active subscrption over trial */ + return paidSubscription || trialSubscription; +} + export async function fetchStripeSubscription(id: string) { try { return await stripe.subscriptions.retrieve(id, { @@ -333,6 +369,32 @@ export async function getDataFromStripeEvent(event: Stripe.Event) { message: "Something went wrong while fetching data from Stripe event", additionalData: { event }, label, + status: 500, }); } } + +export const disabledTeamOrg = async ({ + currentOrganization, + organizations, +}: { + organizations: Pick< + Organization, + "id" | "type" | "name" | "imageId" | "userId" + >[]; + currentOrganization: Pick; +}) => { + /** + * We need to check a few things before disabling team orgs + * + * 1. The current organization is a team + * 2. The current tier has to be tier_2. Anything else is not allowed + */ + + const tierLimit = await getOrganizationTierLimit({ + organizationId: currentOrganization.id, + organizations, + }); + + return currentOrganization.type === "TEAM" && tierLimit?.id !== "tier_2"; +};