From abd74c0943a59243eb7dc3a9787c7497d64f46ec Mon Sep 17 00:00:00 2001 From: Donkoko Date: Mon, 8 Apr 2024 18:23:49 +0300 Subject: [PATCH 01/12] created functionality that blocks team workspace when subscription has expired or is not present --- .../subscription/customer-portal-form.tsx | 5 ++- .../subscription/no-subscription.tsx | 41 +++++++++++++++++++ app/components/subscription/prices.tsx | 2 +- app/modules/organization/service.server.ts | 6 +++ app/routes/_layout+/_layout.tsx | 14 ++++++- app/routes/_layout+/settings.subscription.tsx | 18 +++----- app/routes/qr+/$qrId.tsx | 2 +- app/utils/stripe.server.ts | 38 ++++++++++++++++- 8 files changed, 107 insertions(+), 19 deletions(-) create mode 100644 app/components/subscription/no-subscription.tsx diff --git a/app/components/subscription/customer-portal-form.tsx b/app/components/subscription/customer-portal-form.tsx index 0fbac8d77..18eb4e6e1 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..91c937e84 --- /dev/null +++ b/app/components/subscription/no-subscription.tsx @@ -0,0 +1,41 @@ +import { useUserData } from "~/hooks"; +import { useCurrentOrganization } from "~/hooks/use-current-organization-id"; +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/prices.tsx b/app/components/subscription/prices.tsx index 38fd474dc..b513212f6 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/modules/organization/service.server.ts b/app/modules/organization/service.server.ts index c0cefac2e..93df909de 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/routes/_layout+/_layout.tsx b/app/routes/_layout+/_layout.tsx index 6bb7b39f6..219372745 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"; import { getSelectedOrganisation } from "~/modules/organization/context.server"; @@ -23,6 +24,7 @@ import { import type { CustomerWithSubscriptions } from "~/utils/stripe.server"; import { + disabledTeamOrg, getCustomerActiveSubscription, getStripeCustomer, stripe, @@ -114,6 +116,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: disabledTeamOrg({ + currentOrganization, + tierId: user.tierId, + }), }), { headers: [setCookie(await userPrefs.serialize(cookie))], @@ -127,7 +134,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 ( @@ -141,7 +149,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 8ea438fe8..e6e564d94 100644 --- a/app/routes/_layout+/settings.subscription.tsx +++ b/app/routes/_layout+/settings.subscription.tsx @@ -35,7 +35,6 @@ import { getStripeCustomer, getActiveProduct, getCustomerActiveSubscription, - getCustomerTrialSubscription, } from "~/utils/stripe.server"; export async function loader({ context, request }: LoaderFunctionArgs) { @@ -63,24 +62,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, }); } @@ -90,17 +84,17 @@ export async function loader({ context, request }: LoaderFunctionArgs) { subTitle: "Pick an account plan that fits your workflow.", prices, customer, - subscription: activeSubscription, + subscription: subscription, activeProduct, 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) { diff --git a/app/routes/qr+/$qrId.tsx b/app/routes/qr+/$qrId.tsx index 79ad58e25..1cfefee42 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/stripe.server.ts b/app/utils/stripe.server.ts index a9fe78e15..b3e4f6028 100644 --- a/app/utils/stripe.server.ts +++ b/app/utils/stripe.server.ts @@ -1,4 +1,4 @@ -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"; @@ -276,7 +276,8 @@ export function getActiveProduct({ return null; } -export function getCustomerActiveSubscription({ +/** Gets the customer's paid subscription */ +export function getCustomerPaidSubscription({ customer, }: { customer: CustomerWithSubscriptions | null; @@ -285,6 +286,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 +299,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, { @@ -336,3 +354,19 @@ export async function getDataFromStripeEvent(event: Stripe.Event) { }); } } + +export const disabledTeamOrg = ({ + currentOrganization, + tierId, +}: { + currentOrganization: Pick; + tierId: string; +}) => + /** + * 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 + */ + + currentOrganization.type === "TEAM" && tierId !== "tier_2"; From 599e2be614d77a5ff18bcaa611014c3c99cc6f26 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Mon, 8 Apr 2024 20:43:23 +0300 Subject: [PATCH 02/12] fixing limit to check owner's subscription --- app/modules/tier/service.server.ts | 11 ++++------- app/routes/_layout+/_layout.tsx | 4 ++-- app/utils/stripe.server.ts | 22 ++++++++++++++++------ 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/modules/tier/service.server.ts b/app/modules/tier/service.server.ts index 677d047ba..40d8dffc4 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/routes/_layout+/_layout.tsx b/app/routes/_layout+/_layout.tsx index 219372745..8033830b0 100644 --- a/app/routes/_layout+/_layout.tsx +++ b/app/routes/_layout+/_layout.tsx @@ -117,9 +117,9 @@ export async function loader({ context, request }: LoaderFunctionArgs) { 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: disabledTeamOrg({ + disabledTeamOrg: await disabledTeamOrg({ currentOrganization, - tierId: user.tierId, + organizations, }), }), { diff --git a/app/utils/stripe.server.ts b/app/utils/stripe.server.ts index b3e4f6028..b88013d4d 100644 --- a/app/utils/stripe.server.ts +++ b/app/utils/stripe.server.ts @@ -3,6 +3,7 @@ import Stripe from "stripe"; import type { PriceWithProduct } from "~/components/subscription/prices"; import { config } from "~/config/shelf.config"; import { db } from "~/database"; +import { getOrganizationTierLimit } from "~/modules/tier"; import type { ErrorLabel } from "."; import { ShelfError } from "."; import { STRIPE_SECRET_KEY } from "./env"; @@ -355,13 +356,16 @@ export async function getDataFromStripeEvent(event: Stripe.Event) { } } -export const disabledTeamOrg = ({ +export const disabledTeamOrg = async ({ currentOrganization, - tierId, + organizations, }: { - currentOrganization: Pick; - tierId: string; -}) => + organizations: Pick< + Organization, + "id" | "type" | "name" | "imageId" | "userId" + >[]; + currentOrganization: Pick; +}) => { /** * We need to check a few things before disabling team orgs * @@ -369,4 +373,10 @@ export const disabledTeamOrg = ({ * 2. The current tier has to be tier_2. Anything else is not allowed */ - currentOrganization.type === "TEAM" && tierId !== "tier_2"; + const tierLimit = await getOrganizationTierLimit({ + organizationId: currentOrganization.id, + organizations, + }); + + return currentOrganization.type === "TEAM" && tierLimit?.id !== "tier_2"; +}; From bb53e69d566c1494c7632ee91755639e1046c4b6 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Tue, 9 Apr 2024 10:35:31 +0300 Subject: [PATCH 03/12] starting implementation of free trial --- app/components/subscription/price-cta.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/components/subscription/price-cta.tsx b/app/components/subscription/price-cta.tsx index 252c7140b..a69f44836 100644 --- a/app/components/subscription/price-cta.tsx +++ b/app/components/subscription/price-cta.tsx @@ -11,6 +11,8 @@ export const PriceCta = ({ subscription: Object | null; }) => { if (price.id === "free") return null; + console.log(price); + const isTeamSubscription = price.id === "tier_2"; if (subscription) { return ( @@ -21,9 +23,13 @@ export const PriceCta = ({ } return ( -
- - -
+ <> +
+ + +
+ + {isTeamSubscription && } + ); }; From 56c451ffa4f4ae56364d868b300d4db174c1e518 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Thu, 11 Apr 2024 18:37:13 +0300 Subject: [PATCH 04/12] free trial via the subscription dashboard --- app/components/subscription/price-cta.tsx | 23 +++++++++++++++---- app/routes/_layout+/settings.subscription.tsx | 5 ++-- app/utils/stripe.server.ts | 13 +++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/components/subscription/price-cta.tsx b/app/components/subscription/price-cta.tsx index a69f44836..482be227d 100644 --- a/app/components/subscription/price-cta.tsx +++ b/app/components/subscription/price-cta.tsx @@ -11,8 +11,9 @@ export const PriceCta = ({ subscription: Object | null; }) => { if (price.id === "free") return null; - console.log(price); - const isTeamSubscription = price.id === "tier_2"; + + const isTeamSubscriptionColumn = + price.product.metadata.shelf_tier === "tier_2"; if (subscription) { return ( @@ -26,10 +27,22 @@ export const PriceCta = ({ <>
- -
+ - {isTeamSubscription && } + {isTeamSubscriptionColumn && !subscription && ( + + )} + ); }; diff --git a/app/routes/_layout+/settings.subscription.tsx b/app/routes/_layout+/settings.subscription.tsx index e6e564d94..2285cee48 100644 --- a/app/routes/_layout+/settings.subscription.tsx +++ b/app/routes/_layout+/settings.subscription.tsx @@ -115,9 +115,9 @@ export async function action({ context, request }: ActionFunctionArgs) { action: PermissionAction.update, }); - const { priceId } = parseData( + const { priceId, intent } = parseData( await request.formData(), - z.object({ priceId: z.string() }) + z.object({ priceId: z.string(), intent: z.enum(["trial", "subscribe"]) }) ); const user = await db.user @@ -160,6 +160,7 @@ export async function action({ context, request }: ActionFunctionArgs) { priceId, domainUrl: getDomainUrl(request), customerId: customerId, + intent, }); return redirect(stripeRedirectUrl); diff --git a/app/utils/stripe.server.ts b/app/utils/stripe.server.ts index b88013d4d..4447a0ea8 100644 --- a/app/utils/stripe.server.ts +++ b/app/utils/stripe.server.ts @@ -64,11 +64,13 @@ export async function createStripeCheckoutSession({ userId, domainUrl, customerId, + intent, }: { priceId: Stripe.Price["id"]; userId: User["id"]; domainUrl: string; customerId: string; + intent: "trial" | "subscribe"; }): Promise { try { if (!stripe) { @@ -106,6 +108,17 @@ export async function createStripeCheckoutSession({ 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) { From 06feffcf1dfb9336af3ba8516246575afdbfe868 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Wed, 17 Apr 2024 17:48:58 +0300 Subject: [PATCH 05/12] dynamic CTA when getting a subscription --- app/components/subscription/no-subscription.tsx | 2 +- app/components/subscription/price-cta.tsx | 5 +++++ .../successful-subscription-modal.tsx | 17 ++++++++++++++--- app/routes/_layout+/settings.subscription.tsx | 9 +++++++-- app/utils/env.ts | 1 - app/utils/stripe.server.ts | 8 ++++++-- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/components/subscription/no-subscription.tsx b/app/components/subscription/no-subscription.tsx index 91c937e84..65896d6ef 100644 --- a/app/components/subscription/no-subscription.tsx +++ b/app/components/subscription/no-subscription.tsx @@ -1,5 +1,5 @@ -import { useUserData } from "~/hooks"; 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"; diff --git a/app/components/subscription/price-cta.tsx b/app/components/subscription/price-cta.tsx index ece1779b6..f4d2412be 100644 --- a/app/components/subscription/price-cta.tsx +++ b/app/components/subscription/price-cta.tsx @@ -27,6 +27,11 @@ export const PriceCta = ({ <>
+ diff --git a/app/components/subscription/successful-subscription-modal.tsx b/app/components/subscription/successful-subscription-modal.tsx index 004d0a9fd..9d275ca52 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/routes/_layout+/settings.subscription.tsx b/app/routes/_layout+/settings.subscription.tsx index e4818a7c5..7074b0cc1 100644 --- a/app/routes/_layout+/settings.subscription.tsx +++ b/app/routes/_layout+/settings.subscription.tsx @@ -119,9 +119,13 @@ export async function action({ context, request }: ActionFunctionArgs) { action: PermissionAction.update, }); - const { priceId, intent } = parseData( + const { priceId, intent, shelfTier } = parseData( await request.formData(), - z.object({ priceId: z.string(), intent: z.enum(["trial", "subscribe"]) }) + z.object({ + priceId: z.string(), + intent: z.enum(["trial", "subscribe"]), + shelfTier: z.enum(["tier_1", "tier_2"]), + }) ); const user = await db.user @@ -165,6 +169,7 @@ export async function action({ context, request }: ActionFunctionArgs) { domainUrl: getDomainUrl(request), customerId: customerId, intent, + shelfTier, }); return redirect(stripeRedirectUrl); 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 b56ce21e3..4043a1655 100644 --- a/app/utils/stripe.server.ts +++ b/app/utils/stripe.server.ts @@ -2,8 +2,8 @@ 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 { getOrganizationTierLimit } from "~/modules/tier/service.server"; 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"; @@ -65,12 +65,14 @@ export async function createStripeCheckoutSession({ 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) { @@ -104,7 +106,9 @@ 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, From 3fcdfd6c17a72e6448c8d96a1bc82b352e92f7dd Mon Sep 17 00:00:00 2001 From: Donkoko Date: Thu, 18 Apr 2024 16:46:45 +0300 Subject: [PATCH 06/12] added flag to user to control whether they had a free trial --- app/components/subscription/price-cta.tsx | 7 +++++-- .../migration.sql | 2 ++ app/database/schema.prisma | 1 + app/modules/user/types.ts | 1 + app/routes/_layout+/settings.subscription.tsx | 8 +++++++- 5 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 app/database/migrations/20240418133119_add_free_trial_flag_to_user/migration.sql diff --git a/app/components/subscription/price-cta.tsx b/app/components/subscription/price-cta.tsx index f4d2412be..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,6 +11,8 @@ export const PriceCta = ({ price: Price; subscription: Object | null; }) => { + const { usedFreeTrial } = useLoaderData(); + if (price.id === "free") return null; const isTeamSubscriptionColumn = @@ -36,7 +39,7 @@ export const PriceCta = ({ Upgrade to {price.product.name} - {isTeamSubscriptionColumn && !subscription && ( + {isTeamSubscriptionColumn && !subscription && !usedFreeTrial && ( ) : (