From f1b6cd9974cc1f33c7ec50303545c85ffd21ae14 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 17 Jul 2025 23:46:16 -0300 Subject: [PATCH 1/3] Add card declined alert --- frontend/src/hooks/api/subscriptions/types.ts | 1 + .../components/NavBar/Navbar.tsx | 65 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 87a02231f..0893d3bd1 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -53,4 +53,5 @@ export type SubscriptionPlan = { secretScanning: boolean; enterpriseSecretSyncs: boolean; enterpriseAppConnections: boolean; + cardDeclined?: boolean; }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index a46578726..8af8a24da 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -8,6 +8,7 @@ import { faCaretDown, faCheck, faEnvelope, + faExclamationTriangle, faInfo, faInfoCircle, faSignOut, @@ -109,6 +110,9 @@ export const Navbar = () => { const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const [showAdminsModal, setShowAdminsModal] = useState(false); + const [showCardDeclinedModal, setShowCardDeclinedModal] = useState( + subscription?.cardDeclined || false + ); const { data: orgs } = useGetOrganizations(); const navigate = useNavigate(); @@ -195,8 +199,23 @@ export const Navbar = () => {
{currentOrg?.name}
-
- {getPlan(subscription)} +
+
+ {getPlan(subscription)} +
+ {subscription.cardDeclined && ( + +
+ +
+
+ )}
@@ -394,6 +413,48 @@ export const Navbar = () => { + + + + Your payment method has been declined + + } + > +
+
+
+

+ Your payment method was declined and your subscription may be at risk. Please + update your payment information to continue using premium features. +

+
+
+
+ + + + +
+
+
+
+
+
From 8666f328e2f2b78b08222a3bf2e5b51c3a38b398 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Wed, 30 Jul 2025 11:58:35 -0300 Subject: [PATCH 2/3] Add declined payment reason and force refresh on billing page load --- backend/src/ee/routes/v1/license-router.ts | 9 +++++++- .../ee/services/license/license-service.ts | 13 ++++++++++- .../src/ee/services/license/license-types.ts | 1 + .../SubscriptionContext.tsx | 4 ++-- .../src/hooks/api/subscriptions/queries.tsx | 4 ++-- frontend/src/hooks/api/subscriptions/types.ts | 1 + .../components/NavBar/Navbar.tsx | 22 ++++++++++++------- .../BillingCloudTab/PreviewSection.tsx | 12 +++++++++- 8 files changed, 51 insertions(+), 15 deletions(-) diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index 0a59fa7b5..17923975d 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -43,6 +43,12 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ organizationId: z.string().trim() }), + querystring: z.object({ + refreshCache: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), response: { 200: z.object({ plan: z.any() }) } @@ -54,7 +60,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - orgId: req.params.organizationId + orgId: req.params.organizationId, + refreshCache: req.query.refreshCache }); return { plan }; } diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 7e784d9ad..bb208ab2e 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -295,8 +295,19 @@ export const licenseServiceFactory = ({ return data; }; - const getOrgPlan = async ({ orgId, actor, actorId, actorOrgId, actorAuthMethod, projectId }: TOrgPlanDTO) => { + const getOrgPlan = async ({ + orgId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + refreshCache + }: TOrgPlanDTO) => { await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (refreshCache) { + await refreshPlan(orgId); + } const plan = await getPlan(orgId, projectId); return plan; }; diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index a3412f574..ea94f6172 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -84,6 +84,7 @@ export type TOrgPlansTableDTO = { export type TOrgPlanDTO = { projectId?: string; + refreshCache?: boolean; } & TOrgPermission; export type TStartOrgTrialDTO = { diff --git a/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx b/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx index de52e5f18..95da6bb01 100644 --- a/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx +++ b/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx @@ -3,7 +3,7 @@ import { useRouteContext } from "@tanstack/react-router"; import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries"; -export const useSubscription = () => { +export const useSubscription = (refreshCache?: boolean) => { const organizationId = useRouteContext({ from: "/_authenticate/_inject-org-details", select: (el) => el.organizationId @@ -11,7 +11,7 @@ export const useSubscription = () => { const { data: subscription } = useSuspenseQuery({ queryKey: subscriptionQueryKeys.getOrgSubsription(organizationId), - queryFn: () => fetchOrgSubscription(organizationId), + queryFn: () => fetchOrgSubscription(organizationId, refreshCache), staleTime: Infinity }); diff --git a/frontend/src/hooks/api/subscriptions/queries.tsx b/frontend/src/hooks/api/subscriptions/queries.tsx index 99b1f4486..f545565eb 100644 --- a/frontend/src/hooks/api/subscriptions/queries.tsx +++ b/frontend/src/hooks/api/subscriptions/queries.tsx @@ -10,9 +10,9 @@ export const subscriptionQueryKeys = { getOrgSubsription: (orgID: string) => ["plan", { orgID }] as const }; -export const fetchOrgSubscription = async (orgID: string) => { +export const fetchOrgSubscription = async (orgID: string, refreshCache: boolean = false) => { const { data } = await apiRequest.get<{ plan: SubscriptionPlan }>( - `/api/v1/organizations/${orgID}/plan` + `/api/v1/organizations/${orgID}/plan${refreshCache ? "?refreshCache=true" : ""}` ); return data.plan; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 0893d3bd1..6042df67d 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -54,4 +54,5 @@ export type SubscriptionPlan = { enterpriseSecretSyncs: boolean; enterpriseAppConnections: boolean; cardDeclined?: boolean; + cardDeclinedReason?: string; }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 8af8a24da..96da887b1 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; import { faCircleQuestion, faUserCircle } from "@fortawesome/free-regular-svg-icons"; import { @@ -110,9 +110,14 @@ export const Navbar = () => { const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const [showAdminsModal, setShowAdminsModal] = useState(false); - const [showCardDeclinedModal, setShowCardDeclinedModal] = useState( - subscription?.cardDeclined || false - ); + const [showCardDeclinedModal, setShowCardDeclinedModal] = useState(false); + + useEffect(() => { + if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) { + sessionStorage.setItem("paymentFailed", "true"); + setShowCardDeclinedModal(true); + } + }, [subscription]); const { data: orgs } = useGetOrganizations(); const navigate = useNavigate(); @@ -205,7 +210,7 @@ export const Navbar = () => {
{subscription.cardDeclined && (
@@ -418,7 +423,7 @@ export const Navbar = () => { title={
- Your payment method has been declined + Your payment could not be processed.
} > @@ -426,8 +431,9 @@ export const Navbar = () => {

- Your payment method was declined and your subscription may be at risk. Please - update your payment information to continue using premium features. + We were unable to process your last payment + {subscription.cardDeclinedReason ? `: ${subscription.cardDeclinedReason}` : ""}. + Please update your payment information to continue using premium features.

diff --git a/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx index eaa90450e..6ab0429ee 100644 --- a/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx +++ b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx @@ -1,5 +1,7 @@ +import { useEffect } from "react"; import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQueryClient } from "@tanstack/react-query"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; @@ -15,13 +17,15 @@ import { useGetOrgPlanBillingInfo, useGetOrgTrialUrl } from "@app/hooks/api"; +import { subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries"; import { usePopUp } from "@app/hooks/usePopUp"; import { ManagePlansModal } from "./ManagePlansModal"; export const PreviewSection = () => { const { currentOrg } = useOrganization(); - const { subscription } = useSubscription(); + const { subscription } = useSubscription(true); + const queryClient = useQueryClient(); const { data, isPending } = useGetOrgPlanBillingInfo(currentOrg?.id ?? ""); const getOrgTrialUrl = useGetOrgTrialUrl(); const createCustomerPortalSession = useCreateCustomerPortalSession(); @@ -37,6 +41,12 @@ export const PreviewSection = () => { return formattedTotal; }; + useEffect(() => { + queryClient.invalidateQueries({ + queryKey: subscriptionQueryKeys.getOrgSubsription(currentOrg?.id ?? "") + }); + }, []); + const formatDate = (date: number) => { const createdDate = new Date(date * 1000); const day: number = createdDate.getDate(); From 0c2ffc75f6a472260d9cf39ddc087580c8718c65 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Wed, 10 Sep 2025 12:36:38 -0300 Subject: [PATCH 3/3] Improve self-hosted refresh logic --- .../src/ee/services/license/license-service.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 23d8b0646..ca1615ce8 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -99,6 +99,17 @@ export const licenseServiceFactory = ({ const workspacesUsed = await projectDAL.countOfOrgProjects(null); currentPlan.workspacesUsed = workspacesUsed; + const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null); + if (usedIdentitySeats !== currentPlan.identitiesUsed) { + const usedSeats = await licenseDAL.countOfOrgMembers(null); + await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, { + usedSeats, + usedIdentitySeats + }); + currentPlan.identitiesUsed = usedIdentitySeats; + currentPlan.membersUsed = usedSeats; + } + onPremFeatures = currentPlan; logger.info("Successfully synchronized license key features"); } catch (error) { @@ -226,10 +237,13 @@ export const licenseServiceFactory = ({ }; const refreshPlan = async (orgId: string) => { + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); if (instanceType === InstanceType.Cloud) { - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); await getPlan(orgId); } + if (instanceType === InstanceType.EnterpriseOnPrem) { + await syncLicenseKeyOnPremFeatures(true); + } }; const generateOrgCustomerId = async (orgName: string, email?: string | null) => {