diff --git a/backend/api/migrations/0075_organisation_stripe_customer_id_and_more.py b/backend/api/migrations/0075_organisation_stripe_customer_id_and_more.py new file mode 100644 index 000000000..e3090d98d --- /dev/null +++ b/backend/api/migrations/0075_organisation_stripe_customer_id_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2024-07-30 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0074_correct_set_index_values'), + ] + + operations = [ + migrations.AddField( + model_name='organisation', + name='stripe_customer_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='organisation', + name='stripe_subscription_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='activatedphaselicense', + name='seats', + field=models.IntegerField(null=True), + ), + migrations.AlterField( + model_name='activatedphaselicense', + name='tokens', + field=models.IntegerField(null=True), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index a8a085592..5f58eeedb 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -92,6 +92,8 @@ class Organisation(models.Model): choices=PLAN_TIERS, default=FREE_PLAN, ) + stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) + stripe_subscription_id = models.CharField(max_length=255, blank=True, null=True) list_display = ("name", "identity_key", "id") def __str__(self): @@ -107,8 +109,8 @@ class ActivatedPhaseLicense(models.Model): choices=Organisation.PLAN_TIERS, default=Organisation.ENTERPRISE_PLAN, ) - seats = models.IntegerField() - tokens = models.IntegerField() + seats = models.IntegerField(null=True) + tokens = models.IntegerField(null=True) metadata = models.JSONField() environment = models.CharField(max_length=255) license_type = models.CharField(max_length=255) diff --git a/backend/api/utils/organisations.py b/backend/api/utils/organisations.py new file mode 100644 index 000000000..444d2226f --- /dev/null +++ b/backend/api/utils/organisations.py @@ -0,0 +1,15 @@ +from api.models import OrganisationMember, OrganisationMemberInvite +from django.utils import timezone + + +def get_organisation_seats(organisation): + seats = ( + OrganisationMember.objects.filter( + organisation=organisation, deleted_at=None + ).count() + + OrganisationMemberInvite.objects.filter( + organisation=organisation, valid=True, expires_at__gte=timezone.now() + ).count() + ) + + return seats diff --git a/backend/backend/graphene/mutations/organisation.py b/backend/backend/graphene/mutations/organisation.py index ac0111fd4..a68aae107 100644 --- a/backend/backend/graphene/mutations/organisation.py +++ b/backend/backend/graphene/mutations/organisation.py @@ -48,6 +48,11 @@ def mutate( wrapped_recovery=wrapped_recovery, ) + if settings.APP_HOST == "cloud": + from ee.billing.stripe import create_stripe_customer + + create_stripe_customer(org, owner.email) + if settings.PHASE_LICENSE: from ee.license.utils import activate_license @@ -202,6 +207,11 @@ def mutate( invite.valid = False invite.save() + if settings.APP_HOST == "cloud": + from ee.billing.stripe import update_stripe_subscription_seats + + update_stripe_subscription_seats(org) + try: send_user_joined_email(invite, org_member) except Exception as e: @@ -228,6 +238,11 @@ def mutate(cls, root, info, member_id): if user_is_admin(info.context.user.userId, org_member.organisation.id): org_member.delete() + if settings.APP_HOST == "cloud": + from ee.billing.stripe import update_stripe_subscription_seats + + update_stripe_subscription_seats(org_member.organisation) + return DeleteOrganisationMemberMutation(ok=True) else: raise GraphQLError("You don't have permission to perform that action") diff --git a/backend/backend/schema.py b/backend/backend/schema.py index 2806aa6ec..b3056d2bd 100644 --- a/backend/backend/schema.py +++ b/backend/backend/schema.py @@ -4,6 +4,11 @@ from api.utils.syncing.vault.main import VaultMountType from api.utils.syncing.gitlab.main import GitLabGroupType, GitLabProjectType from api.utils.syncing.railway.main import RailwayEnvironmentType, RailwayProjectType +from ee.billing.graphene.queries.stripe import ( + StripeCheckoutDetails, + resolve_stripe_checkout_details, +) +from ee.billing.graphene.mutations.stripe import CreateProUpgradeCheckoutSession from .graphene.mutations.lockbox import CreateLockboxMutation from .graphene.queries.syncing import ( resolve_aws_secret_manager_secrets, @@ -259,6 +264,10 @@ class Query(graphene.ObjectType): test_nomad_creds = graphene.Field(graphene.Boolean, credential_id=graphene.ID()) + stripe_checkout_details = graphene.Field( + StripeCheckoutDetails, stripe_session_id=graphene.String(required=True) + ) + # -------------------------------------------------------------------- resolve_server_public_key = resolve_server_public_key @@ -673,6 +682,8 @@ def resolve_app_activity_chart(root, info, app_id, period=TimeRange.DAY): return time_series_logs + resolve_stripe_checkout_details = resolve_stripe_checkout_details + class Mutation(graphene.ObjectType): create_organisation = CreateOrganisationMutation.Field() @@ -751,5 +762,8 @@ class Mutation(graphene.ObjectType): # Lockbox create_lockbox = CreateLockboxMutation.Field() + # Billing + create_pro_upgrade_checkout_session = CreateProUpgradeCheckoutSession.Field() + schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/backend/backend/settings.py b/backend/backend/settings.py index fab04a99b..de75f484c 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -288,3 +288,17 @@ } PHASE_LICENSE = check_license(os.getenv("PHASE_LICENSE_OFFLINE")) + + +STRIPE = {} +try: + STRIPE["secret_key"] = os.getenv("STRIPE_SECRET_KEY") + STRIPE["public_key"] = os.getenv("STRIPE_PUBLIC_KEY") + STRIPE["webhook_secret"] = os.getenv("STRIPE_WEBHOOK_SECRET") + STRIPE["prices"] = { + "free": os.getenv("STRIPE_FREE"), + "pro_monthly": os.getenv("STRIPE_PRO_MONTHLY"), + "pro_yearly": os.getenv("STRIPE_PRO_YEARLY"), + } +except: + pass diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 6d4b470b8..67a73f010 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -8,6 +8,7 @@ from api.views.auth import logout_view, health_check, github_callback, secrets_tokens from api.views.kms import kms + CLOUD_HOSTED = settings.APP_HOST == "cloud" urlpatterns = [ @@ -24,8 +25,12 @@ path("lockbox/", LockboxView.as_view()), ] -if not CLOUD_HOSTED: +if CLOUD_HOSTED: + from ee.billing.webhooks.stripe import stripe_webhook + urlpatterns.append(path("kms/", kms)) + urlpatterns.append(path("stripe/webhook/", stripe_webhook, name="stripe-webhook")) + try: if settings.ADMIN_ENABLED: diff --git a/backend/ee/billing/graphene/mutations/stripe.py b/backend/ee/billing/graphene/mutations/stripe.py new file mode 100644 index 000000000..e1110a356 --- /dev/null +++ b/backend/ee/billing/graphene/mutations/stripe.py @@ -0,0 +1,55 @@ +from api.models import Organisation +from api.utils.organisations import get_organisation_seats +import stripe +from django.conf import settings +from graphene import Mutation, ID, String +from graphql import GraphQLError + + +class CreateProUpgradeCheckoutSession(Mutation): + class Arguments: + organisation_id = ID(required=True) + billing_period = String() + + client_secret = String() + + def mutate(self, info, organisation_id, billing_period): + + try: + stripe.api_key = settings.STRIPE["secret_key"] + + organisation = Organisation.objects.get(id=organisation_id) + seats = get_organisation_seats(organisation) + + # Ensure the organisation has a Stripe customer ID + if not organisation.stripe_customer_id: + raise GraphQLError("Organisation must have a Stripe customer ID.") + + price = ( + settings.STRIPE["prices"]["pro_monthly"] + if billing_period == "monthly" + else settings.STRIPE["prices"]["pro_yearly"] + ) + + # Create the checkout session + session = stripe.checkout.Session.create( + mode="subscription", + ui_mode="embedded", + line_items=[ + { + "price": price, + "quantity": seats, + }, + ], + customer=organisation.stripe_customer_id, + payment_method_types=["card"], + return_url=f"{settings.OAUTH_REDIRECT_URI}/{organisation.name}/settings?stripe_session_id={{CHECKOUT_SESSION_ID}}", + ) + return CreateProUpgradeCheckoutSession(client_secret=session.client_secret) + + except Organisation.DoesNotExist: + raise GraphQLError("Organisation not found.") + except Exception as e: + raise GraphQLError( + f"Something went wrong during checkout. Please try again." + ) diff --git a/backend/ee/billing/graphene/queries/stripe.py b/backend/ee/billing/graphene/queries/stripe.py new file mode 100644 index 000000000..b0ee4eefe --- /dev/null +++ b/backend/ee/billing/graphene/queries/stripe.py @@ -0,0 +1,42 @@ +import graphene +from graphene import ObjectType, String, Field +import stripe +from django.conf import settings + + +class StripeCheckoutDetails(graphene.ObjectType): + payment_status = graphene.String() + customer_email = graphene.String() + billing_start_date = graphene.String() + billing_end_date = graphene.String() + subscription_id = graphene.String() + plan_name = graphene.String() + + +def resolve_stripe_checkout_details(self, info, stripe_session_id): + stripe.api_key = settings.STRIPE["secret_key"] + + try: + session = stripe.checkout.Session.retrieve(stripe_session_id) + + subscription_id = session.get("subscription") + if subscription_id: + subscription = stripe.Subscription.retrieve(subscription_id) + plan_name = subscription["items"]["data"][0]["plan"]["nickname"] + billing_start_date = subscription["current_period_start"] + billing_end_date = subscription["current_period_end"] + else: + plan_name = None + billing_start_date = None + billing_end_date = None + + return StripeCheckoutDetails( + payment_status=session.payment_status, + customer_email=session.customer_details.email, + billing_start_date=str(billing_start_date), + billing_end_date=str(billing_end_date), + subscription_id=subscription_id, + plan_name=plan_name, + ) + except stripe.error.StripeError as e: + return None diff --git a/backend/ee/billing/stripe.py b/backend/ee/billing/stripe.py new file mode 100644 index 000000000..c4d30104c --- /dev/null +++ b/backend/ee/billing/stripe.py @@ -0,0 +1,77 @@ +from api.models import Organisation +from backend.api.notifier import notify_slack +from api.utils.organisations import get_organisation_seats +import stripe +from django.conf import settings + + +def create_stripe_customer(organisation, email): + stripe.api_key = settings.STRIPE["secret_key"] + + stripe_customer = stripe.Customer.create( + name=organisation.name, + email=email, + ) + organisation.stripe_customer_id = stripe_customer.id + subscription = stripe.Subscription.create( + customer=stripe_customer.id, + items=[ + { + "price": settings.STRIPE["prices"]["free"], + } + ], + ) + organisation.stripe_subscription_id = subscription.id + organisation.save() + + +def update_stripe_subscription_seats(organisation): + stripe.api_key = settings.STRIPE["secret_key"] + + if not organisation.stripe_subscription_id: + raise ValueError("Organisation must have a Stripe subscription ID.") + + try: + new_seat_count = get_organisation_seats(organisation) + + # Retrieve the subscription + subscription = stripe.Subscription.retrieve(organisation.stripe_subscription_id) + + if not subscription["items"]["data"]: + raise ValueError("No items found in the subscription.") + + # Assume we're updating the first item in the subscription + item_id = subscription["items"]["data"][0]["id"] + + # Modify the subscription with the new seat count + updated_subscription = stripe.Subscription.modify( + organisation.stripe_subscription_id, + items=[ + { + "id": item_id, + "quantity": new_seat_count, + } + ], + proration_behavior='always_invoice' + ) + return updated_subscription + + except Exception as ex: + print("Failed to update Stripe seat count:", ex) + try: + notify_slack( + f"Failed to update Stripe seat count for organisation {organisation.id}: {ex}" + ) + except: + pass + pass + + +def map_stripe_plan_to_tier(stripe_plan_id): + if ( + stripe_plan_id == settings.STRIPE["prices"]["pro_monthly"] + or stripe_plan_id == settings.STRIPE["prices"]["pro_yearly"] + ): + return Organisation.PRO_PLAN + elif stripe_plan_id == settings.STRIPE["prices"]["free"]: + return Organisation.FREE_PLAN diff --git a/backend/ee/billing/webhooks/stripe.py b/backend/ee/billing/webhooks/stripe.py new file mode 100644 index 000000000..800521231 --- /dev/null +++ b/backend/ee/billing/webhooks/stripe.py @@ -0,0 +1,82 @@ +from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse +from ee.billing.stripe import map_stripe_plan_to_tier +import stripe +from api.models import Organisation + +from django.conf import settings + + +def handle_subscription_updated(event): + subscription = event["data"]["object"] + + try: + organisation = Organisation.objects.get( + stripe_customer_id=subscription["customer"] + ) + + # Update the plan and subscription ID + organisation.plan = map_stripe_plan_to_tier( + subscription["items"]["data"][0]["price"]["id"] + ) + organisation.stripe_subscription_id = subscription["id"] + organisation.save() + + except Organisation.DoesNotExist: + return JsonResponse({"error": "Organisation not found"}, status=404) + + +def handle_subscription_deleted(event): + subscription = event["data"]["object"] + + try: + organisation = Organisation.objects.get( + stripe_customer_id=subscription["customer"] + ) + + pro_price_ids = [ + settings.STRIPE["prices"]["pro_monthly"], + settings.STRIPE["prices"]["pro_yearly"], + ] + if subscription["items"]["data"][0]["price"]["id"] in pro_price_ids: + active_subscriptions = stripe.Subscription.list( + customer=organisation.stripe_customer_id, status="active" + ) + + has_active_pro_subscription = any( + item["price"]["id"] in pro_price_ids + for sub in active_subscriptions["data"] + for item in sub["items"]["data"] + ) + + if not has_active_pro_subscription: + organisation.plan = Organisation.FREE_PLAN + organisation.save() + + except Organisation.DoesNotExist: + return JsonResponse({"error": "Organisation not found"}, status=404) + + +@csrf_exempt +def stripe_webhook(request): + payload = request.body + sig_header = request.META["HTTP_STRIPE_SIGNATURE"] + event = None + + try: + stripe.api_key = settings.STRIPE["secret_key"] + event = stripe.Webhook.construct_event( + payload, sig_header, settings.STRIPE["webhook_secret"] + ) + except ValueError: + return JsonResponse({"error": "Invalid payload"}, status=400) + except stripe.error.SignatureVerificationError: + return JsonResponse({"error": "Invalid signature"}, status=400) + + # Route events to the appropriate handler + if event["type"] == "customer.subscription.updated": + handle_subscription_updated(event) + elif event["type"] == "customer.subscription.deleted": + handle_subscription_deleted(event) + + return JsonResponse({"status": "success"}, status=200) diff --git a/backend/requirements.txt b/backend/requirements.txt index bc23b1b03..315972c6e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -33,6 +33,7 @@ idna==3.4 incremental==22.10.0 jmespath==1.0.1 oauthlib==3.2.2 +packaging==24.1 promise==2.3 psycopg2-binary==2.9.6 pyasn1==0.4.8 @@ -54,10 +55,11 @@ s3transfer==0.8.2 service-identity==21.1.0 six==1.16.0 sqlparse==0.5.0 +stripe==10.5.0 text-unidecode==1.3 tomli==2.0.1 Twisted==23.10.0 txaio==23.1.1 -typing_extensions==4.5.0 +typing_extensions==4.6.0 urllib3==1.26.18 zope.interface==5.5.2 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a9541b933..18cc55cb7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -41,6 +41,7 @@ ARG NEXT_PUBLIC_APP_HOST=BAKED_NEXT_PUBLIC_APP_HOST ARG NEXT_PUBLIC_POSTHOG_KEY=BAKED_NEXT_PUBLIC_POSTHOG_KEY ARG NEXT_PUBLIC_POSTHOG_HOST=BAKED_NEXT_PUBLIC_POSTHOG_HOST ARG NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID=BAKED_NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID +ARG NEXT_PUBLIC_STRIPE_PUBLIC_KEY=BAKED_NEXT_PUBLIC_STRIPE_PUBLIC_KEY RUN yarn build # ---- Release ---- diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 596ee623c..468167962 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -16,6 +16,7 @@ const documents = { "mutation AddMemberToApp($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n addAppMember(memberId: $memberId, appId: $appId, envKeys: $envKeys) {\n app {\n id\n }\n }\n}": types.AddMemberToAppDocument, "mutation RemoveMemberFromApp($memberId: ID!, $appId: ID!) {\n removeAppMember(memberId: $memberId, appId: $appId) {\n app {\n id\n }\n }\n}": types.RemoveMemberFromAppDocument, "mutation UpdateEnvScope($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}": types.UpdateEnvScopeDocument, + "mutation InitStripeProUpgradeCheckout($organisationId: ID!, $billingPeriod: String!) {\n createProUpgradeCheckoutSession(\n organisationId: $organisationId\n billingPeriod: $billingPeriod\n ) {\n clientSecret\n }\n}": types.InitStripeProUpgradeCheckoutDocument, "mutation CreateApplication($id: ID!, $organisationId: ID!, $name: String!, $identityKey: String!, $appToken: String!, $appSeed: String!, $wrappedKeyShare: String!, $appVersion: Int!) {\n createApp(\n id: $id\n organisationId: $organisationId\n name: $name\n identityKey: $identityKey\n appToken: $appToken\n appSeed: $appSeed\n wrappedKeyShare: $wrappedKeyShare\n appVersion: $appVersion\n ) {\n app {\n id\n name\n identityKey\n }\n }\n}": types.CreateApplicationDocument, "mutation CreateOrg($id: ID!, $name: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n createOrganisation(\n id: $id\n name: $name\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n organisation {\n id\n name\n memberId\n }\n }\n}": types.CreateOrgDocument, "mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n ok\n }\n}": types.DeleteApplicationDocument, @@ -63,6 +64,7 @@ const documents = { "mutation CreateNewUserToken($orgId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createUserToken(\n orgId: $orgId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n ok\n }\n}": types.CreateNewUserTokenDocument, "mutation RevokeUserToken($tokenId: ID!) {\n deleteUserToken(tokenId: $tokenId) {\n ok\n }\n}": types.RevokeUserTokenDocument, "query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role\n }\n}": types.GetAppMembersDocument, + "query GetCheckoutDetails($stripeSessionId: String!) {\n stripeCheckoutDetails(stripeSessionId: $stripeSessionId) {\n paymentStatus\n customerEmail\n billingStartDate\n billingEndDate\n subscriptionId\n planName\n }\n}": types.GetCheckoutDetailsDocument, "query GetAppActivityChart($appId: ID!, $period: TimeRange) {\n appActivityChart(appId: $appId, period: $period) {\n index\n date\n data\n }\n}": types.GetAppActivityChartDocument, "query GetAppDetail($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n identityKey\n createdAt\n appToken\n appSeed\n appVersion\n sseEnabled\n }\n}": types.GetAppDetailDocument, "query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n logs(appId: $appId, start: $start, end: $end) {\n kms {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n }\n kmsLogsCount(appId: $appId)\n}": types.GetAppKmsLogsDocument, @@ -128,6 +130,10 @@ export function graphql(source: "mutation RemoveMemberFromApp($memberId: ID!, $a * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "mutation UpdateEnvScope($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}"): (typeof documents)["mutation UpdateEnvScope($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation InitStripeProUpgradeCheckout($organisationId: ID!, $billingPeriod: String!) {\n createProUpgradeCheckoutSession(\n organisationId: $organisationId\n billingPeriod: $billingPeriod\n ) {\n clientSecret\n }\n}"): (typeof documents)["mutation InitStripeProUpgradeCheckout($organisationId: ID!, $billingPeriod: String!) {\n createProUpgradeCheckoutSession(\n organisationId: $organisationId\n billingPeriod: $billingPeriod\n ) {\n clientSecret\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -316,6 +322,10 @@ export function graphql(source: "mutation RevokeUserToken($tokenId: ID!) {\n de * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role\n }\n}"): (typeof documents)["query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetCheckoutDetails($stripeSessionId: String!) {\n stripeCheckoutDetails(stripeSessionId: $stripeSessionId) {\n paymentStatus\n customerEmail\n billingStartDate\n billingEndDate\n subscriptionId\n planName\n }\n}"): (typeof documents)["query GetCheckoutDetails($stripeSessionId: String!) {\n stripeCheckoutDetails(stripeSessionId: $stripeSessionId) {\n paymentStatus\n customerEmail\n billingStartDate\n billingEndDate\n subscriptionId\n planName\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index 3a8a0f9bb..58217e61b 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -60,9 +60,9 @@ export type ActivatedPhaseLicenseType = { metadata: Scalars['JSONString']['output']; organisation: OrganisationType; plan: ApiActivatedPhaseLicensePlanChoices; - seats: Scalars['Int']['output']; + seats?: Maybe; signatureDate: Scalars['Date']['output']; - tokens: Scalars['Int']['output']; + tokens?: Maybe; }; export type AddAppMemberMutation = { @@ -255,6 +255,11 @@ export type CreatePersonalSecretMutation = { override?: Maybe; }; +export type CreateProUpgradeCheckoutSession = { + __typename?: 'CreateProUpgradeCheckoutSession'; + clientSecret?: Maybe; +}; + export type CreateProviderCredentials = { __typename?: 'CreateProviderCredentials'; credential?: Maybe; @@ -560,6 +565,7 @@ export type Mutation = { createOrganisation?: Maybe; createOrganisationMember?: Maybe; createOverride?: Maybe; + createProUpgradeCheckoutSession?: Maybe; createProviderCredentials?: Maybe; createRailwaySync?: Maybe; createSecret?: Maybe; @@ -719,6 +725,12 @@ export type MutationCreateOverrideArgs = { }; +export type MutationCreateProUpgradeCheckoutSessionArgs = { + billingPeriod?: InputMaybe; + organisationId: Scalars['ID']['input']; +}; + + export type MutationCreateProviderCredentialsArgs = { credentials?: InputMaybe; name?: InputMaybe; @@ -1096,6 +1108,7 @@ export type Query = { serviceTokens?: Maybe>>; services?: Maybe>>; sseEnabled?: Maybe; + stripeCheckoutDetails?: Maybe; syncs?: Maybe>>; testNomadCreds?: Maybe; testVaultCreds?: Maybe; @@ -1262,6 +1275,11 @@ export type QuerySseEnabledArgs = { }; +export type QueryStripeCheckoutDetailsArgs = { + stripeSessionId: Scalars['String']['input']; +}; + + export type QuerySyncsArgs = { appId?: InputMaybe; envId?: InputMaybe; @@ -1432,6 +1450,16 @@ export type ServiceType = { resourceType?: Maybe; }; +export type StripeCheckoutDetails = { + __typename?: 'StripeCheckoutDetails'; + billingEndDate?: Maybe; + billingStartDate?: Maybe; + customerEmail?: Maybe; + paymentStatus?: Maybe; + planName?: Maybe; + subscriptionId?: Maybe; +}; + export type SwapEnvironmentOrderMutation = { __typename?: 'SwapEnvironmentOrderMutation'; ok?: Maybe; @@ -1520,6 +1548,14 @@ export type UpdateEnvScopeMutationVariables = Exact<{ export type UpdateEnvScopeMutation = { __typename?: 'Mutation', updateMemberEnvironmentScope?: { __typename?: 'UpdateMemberEnvScopeMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; +export type InitStripeProUpgradeCheckoutMutationVariables = Exact<{ + organisationId: Scalars['ID']['input']; + billingPeriod: Scalars['String']['input']; +}>; + + +export type InitStripeProUpgradeCheckoutMutation = { __typename?: 'Mutation', createProUpgradeCheckoutSession?: { __typename?: 'CreateProUpgradeCheckoutSession', clientSecret?: string | null } | null }; + export type CreateApplicationMutationVariables = Exact<{ id: Scalars['ID']['input']; organisationId: Scalars['ID']['input']; @@ -1946,6 +1982,13 @@ export type GetAppMembersQueryVariables = Exact<{ export type GetAppMembersQuery = { __typename?: 'Query', appUsers?: Array<{ __typename?: 'OrganisationMemberType', id: string, identityKey?: string | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, role: ApiOrganisationMemberRoleChoices } | null> | null }; +export type GetCheckoutDetailsQueryVariables = Exact<{ + stripeSessionId: Scalars['String']['input']; +}>; + + +export type GetCheckoutDetailsQuery = { __typename?: 'Query', stripeCheckoutDetails?: { __typename?: 'StripeCheckoutDetails', paymentStatus?: string | null, customerEmail?: string | null, billingStartDate?: string | null, billingEndDate?: string | null, subscriptionId?: string | null, planName?: string | null } | null }; + export type GetAppActivityChartQueryVariables = Exact<{ appId: Scalars['ID']['input']; period?: InputMaybe; @@ -2022,7 +2065,7 @@ export type GetOrgLicenseQueryVariables = Exact<{ }>; -export type GetOrgLicenseQuery = { __typename?: 'Query', organisationLicense?: { __typename?: 'ActivatedPhaseLicenseType', id: string, customerName: string, issuedAt: any, expiresAt: any, activatedAt: any, plan: ApiActivatedPhaseLicensePlanChoices, seats: number, tokens: number } | null }; +export type GetOrgLicenseQuery = { __typename?: 'Query', organisationLicense?: { __typename?: 'ActivatedPhaseLicenseType', id: string, customerName: string, issuedAt: any, expiresAt: any, activatedAt: any, plan: ApiActivatedPhaseLicensePlanChoices, seats?: number | null, tokens?: number | null } | null }; export type GetOrganisationMembersQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; @@ -2212,6 +2255,7 @@ export type GetUserTokensQuery = { __typename?: 'Query', userTokens?: Array<{ __ export const AddMemberToAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddMemberToApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addAppMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"envKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const RemoveMemberFromAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveMemberFromApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeAppMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateEnvScopeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateEnvScope"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberEnvironmentScope"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"envKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const InitStripeProUpgradeCheckoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InitStripeProUpgradeCheckout"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingPeriod"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createProUpgradeCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"billingPeriod"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingPeriod"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode; export const CreateApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appSeed"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appVersion"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"appToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"appSeed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appSeed"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"appVersion"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appVersion"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateOrgDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrg"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrganisation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"memberId"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; @@ -2259,6 +2303,7 @@ export const CreateNewVaultSyncDocument = {"kind":"Document","definitions":[{"ki export const CreateNewUserTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewUserToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUserToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const RevokeUserTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeUserToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUserToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const GetAppMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppMembers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; +export const GetCheckoutDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCheckoutDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stripeSessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeCheckoutDetails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"stripeSessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripeSessionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"paymentStatus"}},{"kind":"Field","name":{"kind":"Name","value":"customerEmail"}},{"kind":"Field","name":{"kind":"Name","value":"billingStartDate"}},{"kind":"Field","name":{"kind":"Name","value":"billingEndDate"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}},{"kind":"Field","name":{"kind":"Name","value":"planName"}}]}}]}}]} as unknown as DocumentNode; export const GetAppActivityChartDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppActivityChart"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"period"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"TimeRange"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appActivityChart"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"period"},"value":{"kind":"Variable","name":{"kind":"Name","value":"period"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"data"}}]}}]}}]} as unknown as DocumentNode; export const GetAppDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"appToken"}},{"kind":"Field","name":{"kind":"Name","value":"appSeed"}},{"kind":"Field","name":{"kind":"Name","value":"appVersion"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}}]}}]}}]} as unknown as DocumentNode; export const GetAppKmsLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppKmsLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"start"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"end"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"start"},"value":{"kind":"Variable","name":{"kind":"Name","value":"start"}}},{"kind":"Argument","name":{"kind":"Name","value":"end"},"value":{"kind":"Variable","name":{"kind":"Name","value":"end"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kms"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"phaseNode"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"city"}},{"kind":"Field","name":{"kind":"Name","value":"phSize"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"kmsLogsCount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]}]}}]} as unknown as DocumentNode; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index ebfb5a8a6..a3eba6091 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -38,6 +38,7 @@ type Query { railwayProjects(credentialId: ID): [RailwayProjectType] testVaultCreds(credentialId: ID): Boolean testNomadCreds(credentialId: ID): Boolean + stripeCheckoutDetails(stripeSessionId: String!): StripeCheckoutDetails } type OrganisationType { @@ -141,8 +142,8 @@ type ActivatedPhaseLicenseType { customerName: String! organisation: OrganisationType! plan: ApiActivatedPhaseLicensePlanChoices! - seats: Int! - tokens: Int! + seats: Int + tokens: Int metadata: JSONString! environment: String! licenseType: String! @@ -597,6 +598,15 @@ type RailwayServiceType { name: String! } +type StripeCheckoutDetails { + paymentStatus: String + customerEmail: String + billingStartDate: String + billingEndDate: String + subscriptionId: String + planName: String +} + type Mutation { createOrganisation(id: ID!, identityKey: String!, name: String!, wrappedKeyring: String!, wrappedRecovery: String!): CreateOrganisationMutation inviteOrganisationMember(apps: [String], email: String!, orgId: ID!, role: String): InviteOrganisationMemberMutation @@ -646,6 +656,7 @@ type Mutation { createOverride(overrideData: PersonalSecretInput): CreatePersonalSecretMutation removeOverride(secretId: ID): DeletePersonalSecretMutation createLockbox(input: LockboxInput): CreateLockboxMutation + createProUpgradeCheckoutSession(billingPeriod: String, organisationId: ID!): CreateProUpgradeCheckoutSession } type CreateOrganisationMutation { @@ -892,4 +903,8 @@ input LockboxInput { data: JSONString allowedViews: Int expiry: BigInt +} + +type CreateProUpgradeCheckoutSession { + clientSecret: String } \ No newline at end of file diff --git a/frontend/app/[team]/layout.tsx b/frontend/app/[team]/layout.tsx index 4e3d0c57b..3ce9ab003 100644 --- a/frontend/app/[team]/layout.tsx +++ b/frontend/app/[team]/layout.tsx @@ -57,7 +57,9 @@ export default function RootLayout({ {showNav && }
-
{children}
+
+ {children} +
) diff --git a/frontend/app/[team]/members/page.tsx b/frontend/app/[team]/members/page.tsx index 82ad97b87..cfd3a207c 100644 --- a/frontend/app/[team]/members/page.tsx +++ b/frontend/app/[team]/members/page.tsx @@ -10,6 +10,7 @@ import DeleteOrgInvite from '@/graphql/mutations/organisation/deleteInvite.gql' import RemoveMember from '@/graphql/mutations/organisation/deleteOrgMember.gql' import UpdateMemberRole from '@/graphql/mutations/organisation/updateOrgMemberRole.gql' import AddMemberToApp from '@/graphql/mutations/apps/addAppMember.gql' +import { GetOrganisationPlan } from '@/graphql/queries/organisation/getOrganisationPlan.gql' import { useLazyQuery, useMutation, useQuery } from '@apollo/client' import { Fragment, useContext, useEffect, useState } from 'react' import { @@ -18,6 +19,8 @@ import { AppType, ApiOrganisationMemberRoleChoices, EnvironmentType, + ApiActivatedPhaseLicensePlanChoices, + ApiOrganisationPlanChoices, } from '@/apollo/graphql' import { Button } from '@/components/common/Button' import { organisationContext } from '@/contexts/organisationContext' @@ -37,6 +40,8 @@ import { Alert } from '@/components/common/Alert' import { Input } from '@/components/common/Input' import CopyButton from '@/components/common/CopyButton' import { getInviteLink, unwrapEnvSecretsForUser, wrapEnvSecretsForUser } from '@/utils/crypto' +import { isCloudHosted } from '@/utils/appConfig' +import { UpsellDialog } from '@/components/settings/organisation/UpsellDialog' const handleCopy = (val: string) => { copyToClipBoard(val) @@ -220,6 +225,20 @@ const RoleSelector = (props: { member: OrganisationMemberType }) => { const InviteDialog = (props: { organisationId: string }) => { const { organisationId } = props + const { activeOrganisation } = useContext(organisationContext) + + const FREE_SEAT_LIMIT = 5 + + const { data } = useQuery(GetOrganisationPlan, { + variables: { organisationId }, + fetchPolicy: 'cache-and-network', + }) + + const upsell = + isCloudHosted() && + activeOrganisation?.plan === ApiOrganisationPlanChoices.Fr && + data.organisationPlan.userCount === FREE_SEAT_LIMIT + const [createInvite, { error, loading: mutationLoading }] = useMutation(InviteMember) const [isOpen, setIsOpen] = useState(false) @@ -275,6 +294,17 @@ const InviteDialog = (props: { organisationId: string }) => { setInviteLink(getInviteLink(data?.inviteOrganisationMember.invite.id)) } + if (upsell) + return ( + + Add a member + + } + /> + ) + return ( <>
@@ -642,7 +672,7 @@ export default function Members({ params }: { params: { team: string } }) { } return ( -
+

{params.team} Members

diff --git a/frontend/app/[team]/settings/page.tsx b/frontend/app/[team]/settings/page.tsx index 17b55e9f1..60f7093f8 100644 --- a/frontend/app/[team]/settings/page.tsx +++ b/frontend/app/[team]/settings/page.tsx @@ -13,6 +13,7 @@ import clsx from 'clsx' import { useSession } from 'next-auth/react' import { Fragment, useContext } from 'react' import { FaMoon, FaSun } from 'react-icons/fa' +import Spinner from '@/components/common/Spinner' export default function Settings({ params }: { params: { team: string } }) { const { activeOrganisation } = useContext(organisationContext) @@ -26,8 +27,15 @@ export default function Settings({ params }: { params: { team: string } }) { ...[{ name: 'Account' }, { name: 'App' }], ] + if (!activeOrganisation) + return ( +
+ {' '} +
+ ) + return ( -
+

Settings

diff --git a/frontend/app/[team]/tokens/page.tsx b/frontend/app/[team]/tokens/page.tsx index a7838a55e..484e794ef 100644 --- a/frontend/app/[team]/tokens/page.tsx +++ b/frontend/app/[team]/tokens/page.tsx @@ -179,7 +179,7 @@ export default function UserTokens({ params }: { params: { team: string } }) { return (
-
+

Personal Access Tokens

diff --git a/frontend/components/apps/NewAppDialog.tsx b/frontend/components/apps/NewAppDialog.tsx index 1c7cdcdc6..c59af63d7 100644 --- a/frontend/components/apps/NewAppDialog.tsx +++ b/frontend/components/apps/NewAppDialog.tsx @@ -41,6 +41,7 @@ import { encryptAppSeed, getWrappedKeyShare, } from '@/utils/crypto' +import { UpsellDialog } from '../settings/organisation/UpsellDialog' const FREE_APP_LIMIT = 3 const PRO_APP_LIMIT = 10 @@ -397,6 +398,20 @@ export default function NewAppDialog(props: { appCount: number; organisation: Or } } + if (!allowNewApp() && !createSuccess) + return ( +
+ + + Create an App + + } + /> +
+ ) + return ( <>
))} - - {!allowNewApp() && !createSuccess && ( -
-

{planDisplay()?.description}

- {isCloudHosted() ? ( - - ) : ( -
- Please contact us at{' '} - - info@phase.dev - {' '} - to request an upgrade. -
- )} -
- )}
diff --git a/frontend/components/common/GenericDialog.tsx b/frontend/components/common/GenericDialog.tsx index 903b35e38..eb2b4c0af 100644 --- a/frontend/components/common/GenericDialog.tsx +++ b/frontend/components/common/GenericDialog.tsx @@ -2,6 +2,7 @@ import { useState, useImperativeHandle, forwardRef, ReactNode, Fragment } from ' import { Dialog, Transition } from '@headlessui/react' import { FaTimes } from 'react-icons/fa' import { Button } from './Button' +import clsx from 'clsx' interface GenericDialogProps { title: string @@ -9,10 +10,11 @@ interface GenericDialogProps { children: ReactNode buttonVariant: 'primary' | 'secondary' | 'danger' | 'outline' buttonContent: ReactNode + size?: 'lg' | 'md' | 'sm' } const GenericDialog = forwardRef( - ({ title, onClose, children, buttonVariant, buttonContent }: GenericDialogProps, ref) => { + ({ title, onClose, children, buttonVariant, buttonContent, size }: GenericDialogProps, ref) => { const [isOpen, setIsOpen] = useState(false) const closeModal = () => { @@ -28,6 +30,14 @@ const GenericDialog = forwardRef( closeModal, })) + const sizeVariants = { + lg: 'max-w-4xl', + md: 'max-w-2xl', + sm: 'max-w-lg', + } + + const sizeClass = size ? sizeVariants[size] : sizeVariants['md'] + return ( <>
@@ -61,7 +71,12 @@ const GenericDialog = forwardRef( leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - +

{title} diff --git a/frontend/components/environments/CreateEnvironmentDialog.tsx b/frontend/components/environments/CreateEnvironmentDialog.tsx index 0ea0aed10..9f72e47ac 100644 --- a/frontend/components/environments/CreateEnvironmentDialog.tsx +++ b/frontend/components/environments/CreateEnvironmentDialog.tsx @@ -15,6 +15,7 @@ import { UpgradeRequestForm } from '../forms/UpgradeRequestForm' import Spinner from '../common/Spinner' import { isCloudHosted } from '@/utils/appConfig' import { Alert } from '../common/Alert' +import { UpsellDialog } from '../settings/organisation/UpsellDialog' export const CreateEnvironmentDialog = (props: { appId: string }) => { const { activeOrganisation: organisation } = useContext(organisationContext) @@ -102,6 +103,19 @@ export const CreateEnvironmentDialog = (props: { appId: string }) => {

) + if (!allowNewEnv) + return ( + + New Environment + + } + buttonVariant="outline" + /> + ) + return ( {
} > - {allowNewEnv ? ( -
-
-

Create a new Environment in this App

-
- - - All Organisation Admins will have accesss to this Environment. - - - - -
- -
-
- ) : ( -
-

{planDisplay()?.description}

- {isCloudHosted() ? ( - - ) : ( -
- Please contact us at{' '} - - info@phase.dev - {' '} - to request an upgrade. -
- )} +
+
+

Create a new Environment in this App

+
+ + + All Organisation Admins will have accesss to this Environment. + + + + +
+
- )} +
) } diff --git a/frontend/components/environments/ManageEnvironmentDialog.tsx b/frontend/components/environments/ManageEnvironmentDialog.tsx index 388c2016a..440e924da 100644 --- a/frontend/components/environments/ManageEnvironmentDialog.tsx +++ b/frontend/components/environments/ManageEnvironmentDialog.tsx @@ -20,6 +20,7 @@ import Link from 'next/link' import { organisationContext } from '@/contexts/organisationContext' import { isCloudHosted } from '@/utils/appConfig' import { UpgradeRequestForm } from '../forms/UpgradeRequestForm' +import { UpsellDialog } from '../settings/organisation/UpsellDialog' const RenameEnvironment = (props: { environment: EnvironmentType }) => { const { activeOrganisation: organisation } = useContext(organisationContext) @@ -106,6 +107,21 @@ const DeleteEnvironment = (props: { environment: EnvironmentType }) => { toast.success('Environment deleted!') closeModal() } + + if (!allowDelete) + return ( +
+ + Delete + + } + buttonVariant="danger" + /> +
+ ) return (
@@ -148,7 +164,7 @@ const DeleteEnvironment = (props: { environment: EnvironmentType }) => {

- {allowDelete ? `Delete ${props.environment.name}` : planDisplay.dialogTitle} + Delete {props.environment.name}

- {allowDelete ? ( -
-

- Are you sure you want to delete this environment? +

+

+ Are you sure you want to delete this environment? +

+ + Deleting this Environment will permanently delete all Secrets and Integrations + associated with it. This action cannot be undone! + +
+

+ Type{' '} + + {props.environment.name} + {' '} + to confirm.

- - Deleting this Environment will permanently delete all Secrets and - Integrations associated with it. This action cannot be undone! - -
-

- Type{' '} - - {props.environment.name} - {' '} - to confirm. -

- -
-
- - -
+
- ) : ( -
-

{planDisplay.description}

- {isCloudHosted() ? ( - - ) : ( -
- Please contact us at{' '} - - info@phase.dev - {' '} - to request an upgrade. -
- )} +
+ +
- )} +
diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index aac3907d0..a8ce6bc6f 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -19,6 +19,7 @@ import { Fragment, useContext } from 'react' import { OrganisationType } from '@/apollo/graphql' import { Menu, Transition } from '@headlessui/react' import { Button } from '../common/Button' +import { PlanLabel } from '../settings/organisation/PlanLabel' export type SidebarLinkT = { name: string @@ -61,11 +62,21 @@ const Sidebar = () => { <> - {activeOrganisation?.name} +
+
+ +
+ + {activeOrganisation?.name}{' '} + +
{ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - + {showOrgsMenu ? (
{organisations?.map((org: OrganisationType) => ( @@ -88,11 +99,18 @@ const Sidebar = () => { title={`Switch to ${org.name}`} className={`${ active - ? 'hover:text-emerald-500 dark:text-white dark:hover:text-emerald-500' - : 'text-gray-900 dark:text-white dark:hover:text-emerald-500' - } group flex w-full gap-2 items-center justify-between rounded-md px-2 py-2 text-base font-medium`} + ? 'hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-neutral-100 dark:hover:bg-neutral-700' + : 'text-zinc-700 dark:text-zinc-300 dark:hover:text-emerald-500' + } group flex w-full gap-2 items-center justify-between px-2 py-2 border-b border-neutral-500/20`} > - {org.name} +
+
+ +
+ + {org.name} + +
diff --git a/frontend/components/settings/organisation/PlanInfo.tsx b/frontend/components/settings/organisation/PlanInfo.tsx index 83ca1bee3..8029a01c1 100644 --- a/frontend/components/settings/organisation/PlanInfo.tsx +++ b/frontend/components/settings/organisation/PlanInfo.tsx @@ -2,22 +2,13 @@ import ProgressBar from '@/components/common/ProgressBar' import { organisationContext } from '@/contexts/organisationContext' import { GetOrganisationPlan } from '@/graphql/queries/organisation/getOrganisationPlan.gql' import { GetOrgLicense } from '@/graphql/queries/organisation/getOrganisationLicense.gql' -import { GetLicenseData } from '@/graphql/queries/organisation/getLicense.gql' import { useQuery } from '@apollo/client' import { ReactNode, useContext } from 'react' import { PlanLabel } from './PlanLabel' import Spinner from '@/components/common/Spinner' import { calculatePercentage } from '@/utils/dataUnits' import { Button } from '@/components/common/Button' -import { - FaCheckCircle, - FaCube, - FaCubes, - FaProjectDiagram, - FaTimesCircle, - FaUser, - FaUsersCog, -} from 'react-icons/fa' +import { FaCheckCircle, FaCube, FaCubes, FaTimesCircle, FaUser, FaUsersCog } from 'react-icons/fa' import Link from 'next/link' import GenericDialog from '@/components/common/GenericDialog' import { UpgradeRequestForm } from '@/components/forms/UpgradeRequestForm' @@ -27,6 +18,10 @@ import { LogoWordMark } from '@/components/common/LogoWordMark' import { License } from './License' import { BsListColumnsReverse } from 'react-icons/bs' import { FaKey } from 'react-icons/fa6' +import { ProUpgradeDialog } from '@/ee/billing/ProUpgradeDialog' +import { useSearchParams } from 'next/navigation' +import { PostCheckoutScreen } from '@/ee/billing/PostCheckoutScreen' +import { UpsellDialog } from './UpsellDialog' const plansInfo = { FR: { @@ -122,6 +117,8 @@ const PlanFeatureItem = (props: { export const PlanInfo = () => { const { activeOrganisation } = useContext(organisationContext) + const searchParams = useSearchParams() + const planInfo = activeOrganisation ? plansInfo[activeOrganisation.plan] : undefined const { loading, data } = useQuery(GetOrganisationPlan, { @@ -136,8 +133,6 @@ export const PlanInfo = () => { fetchPolicy: 'cache-and-network', }) - //const { data: licenseData } = useQuery(GetLicenseData) - const license = (): ActivatedPhaseLicenseType | null => licenseData?.organisationLicense || null const appQuotaUsage = data @@ -180,27 +175,7 @@ export const PlanInfo = () => {
Compare plans
- {}} - > -
-
Request an upgrade to your account.
- {isCloudHosted() ? ( - {}} /> - ) : ( -
- Please contact us at{' '} - - info@phase.dev - {' '} - to request an upgrade. -
- )} -
-
+
)}
@@ -294,6 +269,10 @@ export const PlanInfo = () => {
+ + {searchParams?.get('stripe_session_id') && ( + + )}
) } diff --git a/frontend/components/settings/organisation/PlanLabel.tsx b/frontend/components/settings/organisation/PlanLabel.tsx index d825f1926..bf80f9737 100644 --- a/frontend/components/settings/organisation/PlanLabel.tsx +++ b/frontend/components/settings/organisation/PlanLabel.tsx @@ -4,11 +4,11 @@ import clsx from 'clsx' export const PlanLabel = (props: { plan: ApiOrganisationPlanChoices }) => { const planStyle = () => { if (props.plan === ApiOrganisationPlanChoices.Fr) - return 'ring-neutral-500/40 bg-neutral-500/40 text-black dark:bg-zinc-800 dark:text-neutral-500' + return 'ring-neutral-500/40 bg-neutral-500/40 text-zinc-900 dark:bg-zinc-800 dark:text-neutral-500' if (props.plan === ApiOrganisationPlanChoices.Pr) - return 'ring-emerald-400/10 bg-emerald-400 text-black dark:bg-zinc-800 dark:text-emerald-400' + return 'ring-emerald-400/10 bg-emerald-400 text-zinc-900 dark:bg-emerald-400/10 dark:text-emerald-400' if (props.plan === ApiOrganisationPlanChoices.En) - return 'ring-amber-400/10 bg-amber-400 text-black dark:bg-zinc-800 dark:text-amber-400' + return 'ring-amber-400/10 bg-amber-400 text-zinc-900 dark:bg-amber-400/10 dark:text-amber-400' } const planDisplay = () => { @@ -20,7 +20,7 @@ export const PlanLabel = (props: { plan: ApiOrganisationPlanChoices }) => { return ( diff --git a/frontend/components/settings/organisation/UpsellDialog.tsx b/frontend/components/settings/organisation/UpsellDialog.tsx new file mode 100644 index 000000000..0c39f4d8e --- /dev/null +++ b/frontend/components/settings/organisation/UpsellDialog.tsx @@ -0,0 +1,73 @@ +import { ApiOrganisationPlanChoices } from '@/apollo/graphql' +import GenericDialog from '@/components/common/GenericDialog' +import { UpgradeRequestForm } from '@/components/forms/UpgradeRequestForm' +import { organisationContext } from '@/contexts/organisationContext' +import { ProUpgradeDialog } from '@/ee/billing/ProUpgradeDialog' +import { GetOrganisationPlan } from '@/graphql/queries/organisation/getOrganisationPlan.gql' +import { isCloudHosted } from '@/utils/appConfig' +import { useQuery } from '@apollo/client' +import { ReactNode, useContext } from 'react' + +export const UpsellDialog = ({ + title, + buttonLabel, + buttonVariant, +}: { + title?: string + buttonLabel?: ReactNode + buttonVariant?: 'primary' | 'secondary' | 'outline' | 'danger' +}) => { + const { activeOrganisation } = useContext(organisationContext) + + const { data, loading } = useQuery(GetOrganisationPlan, { + variables: { organisationId: activeOrganisation?.id }, + skip: !activeOrganisation, + fetchPolicy: 'cache-and-network', + }) + + if (!activeOrganisation || loading) return <> + + return ( + {}} + > +
+
+ Get access to all the features in Phase{' '} + {activeOrganisation.plan === ApiOrganisationPlanChoices.Fr ? 'Pro' : 'Enterprise'} +
+ {isCloudHosted() ? ( + activeOrganisation.plan === ApiOrganisationPlanChoices.Pr ? ( + {}} /> + ) : ( + + ) + ) : ( +
+ Please contact us at{' '} + + info@phase.dev + {' '} + or get in touch via{' '} + + Slack + {' '} + to request an upgrade. +
+ )} +
+
+ ) +} diff --git a/frontend/ee/billing/PostCheckoutScreen.tsx b/frontend/ee/billing/PostCheckoutScreen.tsx new file mode 100644 index 000000000..3226c9a98 --- /dev/null +++ b/frontend/ee/billing/PostCheckoutScreen.tsx @@ -0,0 +1,91 @@ +import { useQuery, gql } from '@apollo/client' +import { GetCheckoutDetails } from '@/graphql/queries/billing/getCheckoutDetails.gql' +import { Button } from '@/components/common/Button' +import { Dialog, Transition } from '@headlessui/react' +import { useState, Fragment } from 'react' +import { FaCheckCircle, FaTimes, FaTimesCircle } from 'react-icons/fa' +import { relativeTimeFromDates } from '@/utils/time' + +export const PostCheckoutScreen = ({ stripeSessionId }: { stripeSessionId: string }) => { + const { loading, error, data } = useQuery(GetCheckoutDetails, { + variables: { stripeSessionId }, + }) + + const [isOpen, setIsOpen] = useState(true) + + const closeModal = () => { + setIsOpen(false) + } + + if (loading) return

Loading...

+ + const { + paymentStatus, + customerEmail, + billingStartDate, + billingEndDate, + subscriptionId, + planName, + } = data?.stripeCheckoutDetails + + return ( + + + +
+ + +
+
+ + + +

+ Payment {paymentStatus === 'paid' ? 'Success' : 'Failed'} +

+ +
+
+
+ {paymentStatus === 'paid' ? ( + + ) : ( + + )} +
+ +

+ Your subscription of Phase Pro is now active! +

+
+ +

+ Renewal date: {new Date(billingEndDate * 1000).toLocaleDateString()} +

+
+
+
+
+
+
+
+ ) +} diff --git a/frontend/ee/billing/ProUpgradeDialog.tsx b/frontend/ee/billing/ProUpgradeDialog.tsx new file mode 100644 index 000000000..69b123006 --- /dev/null +++ b/frontend/ee/billing/ProUpgradeDialog.tsx @@ -0,0 +1,142 @@ +import { useSession } from 'next-auth/react' +import { useCallback, useContext, useState } from 'react' +import { toast } from 'react-toastify' +import { InitStripeProUpgradeCheckout } from '@/graphql/mutations/billing/initProUpgradeCheckout.gql' +import { useMutation } from '@apollo/client' +import { loadStripe } from '@stripe/stripe-js' +import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js' +import { organisationContext } from '@/contexts/organisationContext' +import { LogoWordMark } from '@/components/common/LogoWordMark' +import { PlanLabel } from '@/components/settings/organisation/PlanLabel' +import { ApiOrganisationPlanChoices } from '@/apollo/graphql' +import { FaArrowRight, FaCheckCircle, FaUser } from 'react-icons/fa' +import { Button } from '@/components/common/Button' +import { FaCartShopping } from 'react-icons/fa6' +import { ToggleSwitch } from '@/components/common/ToggleSwitch' + +type BillingPeriods = 'monthly' | 'yearly' + +type PriceOption = { name: BillingPeriods; unitPrice: number; monthlyPrice: number } + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!) + +const UpgradeForm = (props: { onSuccess: Function; billingPeriod: BillingPeriods }) => { + const { billingPeriod } = props + + const [createCheckoutSession] = useMutation(InitStripeProUpgradeCheckout) + const { activeOrganisation } = useContext(organisationContext) + + const fetchClientSecret = useCallback(async () => { + // Create a Checkout Session + const { data } = await createCheckoutSession({ + variables: { organisationId: activeOrganisation!.id, billingPeriod }, + }) + const clientSecret = data.createProUpgradeCheckoutSession.clientSecret + + return clientSecret + }, []) + + const options = { fetchClientSecret } + + return ( +
+ + + +
+ ) +} + +const prices: PriceOption[] = [ + { + name: 'monthly', + unitPrice: 18, + monthlyPrice: 18, + }, + { + name: 'yearly', + unitPrice: 192, + monthlyPrice: 16, + }, +] + +export const ProUpgradeDialog = (props: { userCount: number }) => { + const [checkoutPreview, setCheckoutPreview] = useState('yearly') + const [billingPeriod, setBillingPeriod] = useState(null) + + const toggleCheckoutPreview = () => { + if (checkoutPreview === 'yearly') setCheckoutPreview('monthly') + else setCheckoutPreview('yearly') + } + + const priceToPreview = prices.find((price) => price.name === checkoutPreview) + + const CheckoutPreview = ({ price }: { price: PriceOption }) => ( +
+
+
+ ${price.monthlyPrice} + /mo per user +
+
+
Billed
+
+
Monthly
+ +
Yearly
+
+
+
+ +
+
+ Unit Price: + ${price.unitPrice} +
+
+ Number of Users: + {props.userCount} +
+
+
+ Total: + ${price.unitPrice * props.userCount} +
+
+ +
+ +
+
+ ) + + if (billingPeriod === null) + return ( +
+
+ {' '} + +
+ +
{priceToPreview && }
+
+ ) + return ( +
+ console.log('Upgrade successful!')} + /> +
+ +
+
+ ) +} diff --git a/frontend/graphql/mutations/billing/initProUpgradeCheckout.gql b/frontend/graphql/mutations/billing/initProUpgradeCheckout.gql new file mode 100644 index 000000000..b48c31b8c --- /dev/null +++ b/frontend/graphql/mutations/billing/initProUpgradeCheckout.gql @@ -0,0 +1,5 @@ +mutation InitStripeProUpgradeCheckout($organisationId: ID!, $billingPeriod: String!) { + createProUpgradeCheckoutSession(organisationId: $organisationId, billingPeriod: $billingPeriod) { + clientSecret + } +} diff --git a/frontend/graphql/queries/billing/getCheckoutDetails.gql b/frontend/graphql/queries/billing/getCheckoutDetails.gql new file mode 100644 index 000000000..5d389a1eb --- /dev/null +++ b/frontend/graphql/queries/billing/getCheckoutDetails.gql @@ -0,0 +1,10 @@ +query GetCheckoutDetails($stripeSessionId: String!) { + stripeCheckoutDetails(stripeSessionId: $stripeSessionId) { + paymentStatus + customerEmail + billingStartDate + billingEndDate + subscriptionId + planName + } +} diff --git a/frontend/next.config.js b/frontend/next.config.js index dfa846efe..b52f4c2c7 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,14 +1,14 @@ /** @type {import('next').NextConfig} */ const ContentSecurityPolicy = ` default-src 'self'; - script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://session.phase.dev; + script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://session.phase.dev https://checkout.stripe.com https://js.stripe.com https://*.js.stripe.com; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; - connect-src 'self' data: http://127.0.0.1:* https://*.phase.dev https://phase.statuspage.io/api/v2/status.json; + connect-src 'self' data: http://127.0.0.1:* https://*.phase.dev https://phase.statuspage.io/api/v2/status.json https://checkout.stripe.com https://api.stripe.com; font-src 'self'; - frame-src 'self'; - img-src 'self' https://lh3.googleusercontent.com https://avatars.githubusercontent.com https://secure.gravatar.com https://gitlab.com; + frame-src 'self' https://checkout.stripe.com https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com; + img-src 'self' https://lh3.googleusercontent.com https://avatars.githubusercontent.com https://secure.gravatar.com https://gitlab.com https://*.stripe.com; manifest-src 'self'; media-src 'self'; worker-src 'none'; @@ -69,7 +69,7 @@ const nextConfig = { }, ] }, - output: "standalone", + output: 'standalone', experimental: { esmExternals: 'loose', }, diff --git a/frontend/package.json b/frontend/package.json index 16208fb78..a11e621ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "@apollo/client": "^3.8.10", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.1", + "@stripe/react-stripe-js": "^2.7.3", + "@stripe/stripe-js": "^4.1.0", "@tailwindcss/typography": "^0.5.10", "@types/node": "20.11.7", "@types/react": "^18.2.48", diff --git a/frontend/scripts/replace-variable.sh b/frontend/scripts/replace-variable.sh index a5df79d06..db19390b5 100644 --- a/frontend/scripts/replace-variable.sh +++ b/frontend/scripts/replace-variable.sh @@ -5,7 +5,7 @@ MANDATORY_VARS=("NEXT_PUBLIC_BACKEND_API_BASE" "NEXT_PUBLIC_NEXTAUTH_PROVIDERS") # Define a list of optional environment variables (no check needed) -OPTIONAL_VARS=("APP_HOST" "NEXT_PUBLIC_POSTHOG_KEY" "NEXT_PUBLIC_POSTHOG_HOST") +OPTIONAL_VARS=("APP_HOST" "NEXT_PUBLIC_POSTHOG_KEY" "NEXT_PUBLIC_POSTHOG_HOST" "NEXT_PUBLIC_STRIPE_PUBLIC_KEY") # Infer NEXT_PUBLIC_APP_HOST from APP_HOST if not already set if [ -z "$NEXT_PUBLIC_APP_HOST" ] && [ ! -z "$APP_HOST" ]; then @@ -17,6 +17,11 @@ if [ -z "$NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID" ] && [ ! -z "$GITHUB_INTEGRA export NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID="$GITHUB_INTEGRATION_CLIENT_ID" fi +# Infer NEXT_PUBLIC_STRIPE_PUBLIC_KEY from STRIPE_PUBLIC_KEY if not already set +if [ -z "$NEXT_PUBLIC_STRIPE_PUBLIC_KEY" ] && [ ! -z "$STRIPE_PUBLIC_KEY" ]; then + export NEXT_PUBLIC_STRIPE_PUBLIC_KEY="$STRIPE_PUBLIC_KEY" +fi + # Check if each mandatory variable is set for VAR in "${MANDATORY_VARS[@]}"; do if [ -z "${!VAR}" ]; then @@ -29,7 +34,7 @@ done ALL_VARS=("${MANDATORY_VARS[@]}" "${OPTIONAL_VARS[@]}") # Add NEXT_PUBLIC_APP_HOST and NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID to the list for replacement -ALL_VARS+=("NEXT_PUBLIC_APP_HOST" "NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID") +ALL_VARS+=("NEXT_PUBLIC_APP_HOST" "NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID" "NEXT_PUBLIC_STRIPE_PUBLIC_KEY") # Find and replace BAKED values with real values find /app/public /app/.next -type f -name "*.js" | xargs grep -i -l "BAKED_" | diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 21b853833..d5ae5a304 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2147,6 +2147,18 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@stripe/react-stripe-js@^2.7.3": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-2.7.3.tgz#9cbbc2adc24076850885b059dda86fdb45b9f64a" + integrity sha512-05t6oY7cmAJt7asknmeoI4z4GnutgKRZ7dcdTWCkeYclONzIRMuMTiyjBMQ/q3I2sdNizSl25YZ8G6Lg4nN1aw== + dependencies: + prop-types "^15.7.2" + +"@stripe/stripe-js@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-4.1.0.tgz#11a54478df28b7a2d146251f645fb26e9efc9bfd" + integrity sha512-HhstGRUz/4JdbZpb26OcOf8Qb/cFR02arvHvgz4sPFLSnI6ZNHC53Jc6JP/FGNwxtrF719YyUnK0gGy4oyhucQ== + "@swc/counter@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"