From 294c0eb15fe7d377022084eac485a5291f0236a4 Mon Sep 17 00:00:00 2001
From: harsh mahajan
Date: Mon, 24 Aug 2026 11:44:57 +0530
Subject: [PATCH 1/2] fix(billing): gate the SOC-2 request behind eligible
plans
The SOC-2 card rendered for every cloud organization owner, so Free and
Pro users could fill in the whole request form and only learn it was
unavailable from a generic "please try again later" error. BAA, the card
directly above it, has always been gated on `supportedAddons.baa`.
No SOC-2 entitlement exists on the plan model, so infer it: the Scale
group is the lowest one that advertises SOC-2 (the Enterprise column on
the pricing page), and contract plans sit above it. Ineligible
organizations now get a short explanation and an upgrade CTA instead of
a form that cannot succeed, and the modal is no longer mounted for them.
Also send the request as multipart form data, the shape the support
wizard uses. SOC-2 was the last caller posting JSON to growth's
/support: the wizard moved to FormData in e446f7ff8 and the BAA request
modal, the other JSON caller, went away in bf2166bd3, so if that path
has rotted the request was failing on every plan including Enterprise.
The billing plan now rides along in metaFields so support can see the
tier on the ticket.
---
src/lib/stores/billing.ts | 13 +++
.../settings/Soc2.svelte | 55 ++++++++---
.../settings/Soc2Modal.svelte | 91 ++++++++++---------
3 files changed, 103 insertions(+), 56 deletions(-)
diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts
index d2b42db269..f0c0aec770 100644
--- a/src/lib/stores/billing.ts
+++ b/src/lib/stores/billing.ts
@@ -134,6 +134,19 @@ export function isStarterPlan(
return planHasGroup(billingPlan, BillingPlanGroup.Starter);
}
+export function planSupportsSoc2(
+ billingPlanOrId: string | Models.BillingPlan | null | undefined
+): boolean {
+ const billingPlan = makeBillingPlan(billingPlanOrId);
+ if (!billingPlan) {
+ return false;
+ }
+
+ // no SOC-2 entitlement exists on the plan model, so infer it: Scale is the
+ // lowest group that advertises SOC-2, and contract plans sit above it.
+ return planHasGroup(billingPlan, BillingPlanGroup.Scale) || billingPlan.selfService === false;
+}
+
export function canUpgrade(
billingPlanOrId: string | Models.BillingPlan | null | undefined
): boolean {
diff --git a/src/routes/(console)/organization-[organization]/settings/Soc2.svelte b/src/routes/(console)/organization-[organization]/settings/Soc2.svelte
index 167a802613..8a63463e42 100644
--- a/src/routes/(console)/organization-[organization]/settings/Soc2.svelte
+++ b/src/routes/(console)/organization-[organization]/settings/Soc2.svelte
@@ -2,11 +2,22 @@
import { Box, CardGrid } from '$lib/components';
import { Button } from '$lib/elements/forms';
import Soc2Modal from './Soc2Modal.svelte';
- import type { Models } from '@appwrite.io/console';
+ import { getBasePlanFromGroup, getChangePlanUrl, planSupportsSoc2 } from '$lib/stores/billing';
+ import { currentPlan, organization } from '$lib/stores/organization';
+ import { BillingPlanGroup, type Models } from '@appwrite.io/console';
- let show = false;
- export let locale: Models.Locale;
- export let countryList: Models.CountryList;
+ let {
+ locale,
+ countryList
+ }: {
+ locale: Models.Locale;
+ countryList: Models.CountryList;
+ } = $props();
+
+ let show = $state(false);
+
+ const supportsSoc2 = $derived(planSupportsSoc2($currentPlan));
+ const upgradePlanName = $derived(getBasePlanFromGroup(BillingPlanGroup.Scale)?.name);
@@ -22,16 +33,34 @@
compliance with trust service criteria such as security, availability, processing
integrity, confidentiality, and privacy.
-
+ {#if supportsSoc2}
+
+ {:else}
+
+ SOC-2 is not available on your current plan.{upgradePlanName
+ ? ` Upgrade to ${upgradePlanName} to request a SOC-2 report for your organization.`
+ : ''}
+
+ {#if upgradePlanName}
+
+ {/if}
+ {/if}
-
+{#if supportsSoc2}
+
+{/if}
diff --git a/src/routes/(console)/organization-[organization]/settings/Soc2Modal.svelte b/src/routes/(console)/organization-[organization]/settings/Soc2Modal.svelte
index c4b3b645d1..3a311861b4 100644
--- a/src/routes/(console)/organization-[organization]/settings/Soc2Modal.svelte
+++ b/src/routes/(console)/organization-[organization]/settings/Soc2Modal.svelte
@@ -7,16 +7,22 @@
import { organization } from '$lib/stores/organization';
import { user } from '$lib/stores/user';
import { VARS } from '$lib/system';
- import { onMount } from 'svelte';
+ import { untrack } from 'svelte';
import type { Models } from '@appwrite.io/console';
- export let show = false;
- export let locale: Models.Locale;
- export let countryList: Models.CountryList;
+ let {
+ show = $bindable(false),
+ locale,
+ countryList
+ }: {
+ show?: boolean;
+ locale: Models.Locale;
+ countryList: Models.CountryList;
+ } = $props();
- let email = '';
- let employees: string = null;
- let employeesOptions = [
+ let email = $state($user?.email ?? '');
+ let employees = $state(null);
+ const employeesOptions = [
{
value: '1-5',
label: '1-5'
@@ -35,50 +41,49 @@
}
];
- let country = '';
- let countryOptions = [];
-
- let role = '';
- let website = '';
-
- let error: string;
-
- onMount(async () => {
- if (locale.countryCode) {
- country = locale.countryCode;
- }
- countryOptions = countryList.countries.map((country) => {
+ let country = $state(untrack(() => locale?.countryCode ?? ''));
+ const countryOptions = $derived(
+ (countryList?.countries ?? []).map((country) => {
return {
value: country.code,
label: country.name
};
- });
- email = $user.email;
- });
+ })
+ );
+
+ let role = $state('');
+ let website = $state('');
+
+ let error = $state(null);
async function handleSubmit() {
+ const formData = new FormData();
+ formData.append('subject', 'SOC-2 Request');
+ formData.append('email', email);
+ formData.append('firstName', ($user?.name ?? '').slice(0, 40));
+ formData.append(
+ 'message',
+ `SOC-2 request for ${$organization?.name ?? ''} (${$organization?.$id ?? ''})`
+ );
+ formData.append('tags[]', 'cloud');
+ formData.append(
+ 'metaFields',
+ JSON.stringify({
+ category: 'SOC-2',
+ userName: $user?.name ?? '',
+ orgId: $organization?.$id ?? '',
+ userId: $user?.$id ?? '',
+ billingPlan: $organization?.billingPlanId ?? '',
+ employees: employees,
+ country: country,
+ role: role,
+ website: website
+ })
+ );
+
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/support`, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- subject: 'SOC-2 Request',
- email: email,
- firstName: ($user?.name ?? '').slice(0, 40),
- message: `SOC-2 request for ${$organization?.name ?? ''} (${$organization?.$id ?? ''})`,
- tags: ['cloud'],
- metaFields: {
- category: 'SOC-2',
- userName: $user?.name ?? '',
- orgId: $organization?.$id ?? '',
- userId: $user?.$id ?? '',
- employees: employees,
- country: country,
- role: role,
- website: website
- }
- })
+ body: formData
});
trackEvent(Submit.RequestSoc2);
if (response.status !== 200) {
From 96ac9f8f49283e2674e99efa1088bb963fa3555b Mon Sep 17 00:00:00 2001
From: harsh mahajan
Date: Mon, 24 Aug 2026 12:21:27 +0530
Subject: [PATCH 2/2] fix(billing): key SOC-2 eligibility off selfService, not
plan group
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
D5's answer to the customer in the support thread was explicit: Pro
doesn't include SOC-2, "you need the custom plan" — not Scale. The
previous gate OR'd in `planHasGroup(Scale)`, which was inferred from
planComparisonBox's marketing copy rather than any real entitlement,
and would have wrongly let a self-service Scale org through.
Checked plans.php in the cloud repo: every self-service plan (Pro,
Scale) sets `selfService: true`, and every negotiated Custom/Enterprise
contract plan sets it `false` — `supportedAddons` doesn't distinguish
them for anything SOC-2-shaped (it only carries the paid, self-service
BAA/premiumGeoDB addons). `selfService` is already a real field the
API returns per plan today, so gating on `selfService === false` alone
needs no backend change and matches support's stated policy exactly,
instead of guessing at a group boundary.
Also swapped the ineligible-state CTA from the self-service change-plan
wizard to the existing Enterprise contact-sales link, since there's no
self-service upgrade path onto a plan that actually unlocks this.
---
src/lib/stores/billing.ts | 7 ++---
.../settings/Soc2.svelte | 27 +++++++++----------
2 files changed, 16 insertions(+), 18 deletions(-)
diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts
index f0c0aec770..f413955175 100644
--- a/src/lib/stores/billing.ts
+++ b/src/lib/stores/billing.ts
@@ -142,9 +142,10 @@ export function planSupportsSoc2(
return false;
}
- // no SOC-2 entitlement exists on the plan model, so infer it: Scale is the
- // lowest group that advertises SOC-2, and contract plans sit above it.
- return planHasGroup(billingPlan, BillingPlanGroup.Scale) || billingPlan.selfService === false;
+ // no SOC-2 entitlement exists on the plan model, so key off a real API field
+ // instead of guessing: SOC-2 is only offered on negotiated/custom contracts,
+ // never on a self-service plan (including self-service Scale).
+ return billingPlan.selfService === false;
}
export function canUpgrade(
diff --git a/src/routes/(console)/organization-[organization]/settings/Soc2.svelte b/src/routes/(console)/organization-[organization]/settings/Soc2.svelte
index 8a63463e42..7571515c6f 100644
--- a/src/routes/(console)/organization-[organization]/settings/Soc2.svelte
+++ b/src/routes/(console)/organization-[organization]/settings/Soc2.svelte
@@ -2,9 +2,9 @@
import { Box, CardGrid } from '$lib/components';
import { Button } from '$lib/elements/forms';
import Soc2Modal from './Soc2Modal.svelte';
- import { getBasePlanFromGroup, getChangePlanUrl, planSupportsSoc2 } from '$lib/stores/billing';
- import { currentPlan, organization } from '$lib/stores/organization';
- import { BillingPlanGroup, type Models } from '@appwrite.io/console';
+ import { planSupportsSoc2 } from '$lib/stores/billing';
+ import { currentPlan } from '$lib/stores/organization';
+ import type { Models } from '@appwrite.io/console';
let {
locale,
@@ -17,7 +17,6 @@
let show = $state(false);
const supportsSoc2 = $derived(planSupportsSoc2($currentPlan));
- const upgradePlanName = $derived(getBasePlanFromGroup(BillingPlanGroup.Scale)?.name);
@@ -44,18 +43,16 @@
{:else}
- SOC-2 is not available on your current plan.{upgradePlanName
- ? ` Upgrade to ${upgradePlanName} to request a SOC-2 report for your organization.`
- : ''}
+ SOC-2 reports are only available on an Enterprise contract. Contact our sales
+ team to discuss upgrading your organization.