From 0c1bf0e40cfc028dc69ee484a7df0ae500cb52e2 Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 3 Dec 2024 10:27:16 +0530 Subject: [PATCH] feat: allow resuming cancelled subscription, misc UI improvements --- backend/backend/schema.py | 2 + .../ee/billing/graphene/mutations/stripe.py | 64 ++++++++++++++-- frontend/apollo/gql.ts | 5 ++ frontend/apollo/graphql.ts | 36 ++++++--- frontend/apollo/schema.graphql | 5 +- .../organisation/StripeBillingInfo.tsx | 75 +++++++++++++++---- .../billing/resumeProSubscription.gql | 8 ++ 7 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 frontend/graphql/mutations/billing/resumeProSubscription.gql diff --git a/backend/backend/schema.py b/backend/backend/schema.py index a7d83c3f..38e29956 100644 --- a/backend/backend/schema.py +++ b/backend/backend/schema.py @@ -33,6 +33,7 @@ CreateProUpgradeCheckoutSession, CreateSetupIntentMutation, DeletePaymentMethodMutation, + ResumeSubscriptionMutation, SetDefaultPaymentMethodMutation, ) from .graphene.mutations.lockbox import CreateLockboxMutation @@ -893,6 +894,7 @@ class Mutation(graphene.ObjectType): create_pro_upgrade_checkout_session = CreateProUpgradeCheckoutSession.Field() delete_payment_method = DeletePaymentMethodMutation.Field() cancel_subscription = CancelSubscriptionMutation.Field() + resume_subscription = ResumeSubscriptionMutation.Field() create_setup_intent = CreateSetupIntentMutation.Field() set_default_payment_method = SetDefaultPaymentMethodMutation.Field() diff --git a/backend/ee/billing/graphene/mutations/stripe.py b/backend/ee/billing/graphene/mutations/stripe.py index a707f5bb..bf88cb6a 100644 --- a/backend/ee/billing/graphene/mutations/stripe.py +++ b/backend/ee/billing/graphene/mutations/stripe.py @@ -7,7 +7,7 @@ from graphql import GraphQLError -class CancelSubscriptionResponse(ObjectType): +class UpdateSubscriptionResponse(ObjectType): success = Boolean() message = String() canceled_at = String() @@ -101,7 +101,7 @@ class Arguments: organisation_id = ID() subscription_id = String(required=True) - Output = CancelSubscriptionResponse + Output = UpdateSubscriptionResponse def mutate(self, info, organisation_id, subscription_id): stripe.api_key = settings.STRIPE["secret_key"] @@ -125,21 +125,75 @@ def mutate(self, info, organisation_id, subscription_id): subscription_id, cancel_at_period_end=True ) - return CancelSubscriptionResponse( + return UpdateSubscriptionResponse( success=True, message="Subscription set to cancel at the end of the current billing cycle.", canceled_at=None, # The subscription is not yet canceled status=updated_subscription["status"], ) except stripe.error.InvalidRequestError as e: - return CancelSubscriptionResponse( + return UpdateSubscriptionResponse( success=False, message=f"Error: {str(e)}", canceled_at=None, status=None, ) except Exception as e: - return CancelSubscriptionResponse( + return UpdateSubscriptionResponse( + success=False, + message=f"An unexpected error occurred: {str(e)}", + canceled_at=None, + status=None, + ) + + +class ResumeSubscriptionMutation(Mutation): + class Arguments: + organisation_id = ID() + subscription_id = String(required=True) + + Output = UpdateSubscriptionResponse # Reuse the response class for consistency + + def mutate(self, info, organisation_id, subscription_id): + stripe.api_key = settings.STRIPE["secret_key"] + + try: + org = Organisation.objects.get(id=organisation_id) + + if not user_has_permission(info.context.user, "update", "Billing", org): + raise GraphQLError( + "You don't have the permissions required to update Billing information in this Organisation." + ) + + if org.stripe_subscription_id != subscription_id: + raise GraphQLError("The subscription ID provided is not valid.") + + # Retrieve the subscription + subscription = stripe.Subscription.retrieve(subscription_id) + + if not subscription.get("cancel_at_period_end"): + raise GraphQLError("The subscription is not marked for cancellation.") + + # Resume the subscription by updating cancel_at_period_end to False + updated_subscription = stripe.Subscription.modify( + subscription_id, cancel_at_period_end=False + ) + + return UpdateSubscriptionResponse( + success=True, + message="Subscription resumed successfully.", + canceled_at=None, # Reset canceled_at since the subscription is active + status=updated_subscription["status"], + ) + except stripe.error.InvalidRequestError as e: + return UpdateSubscriptionResponse( + success=False, + message=f"Error: {str(e)}", + canceled_at=None, + status=None, + ) + except Exception as e: + return UpdateSubscriptionResponse( success=False, message=f"An unexpected error occurred: {str(e)}", canceled_at=None, diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index ee4eff04..0c58d0e6 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -23,6 +23,7 @@ const documents = { "mutation CreateStripeSetupIntentOp($organisationId: ID!) {\n createSetupIntent(organisationId: $organisationId) {\n clientSecret\n }\n}": types.CreateStripeSetupIntentOpDocument, "mutation DeleteStripePaymentMethod($organisationId: ID!, $paymentMethodId: String!) {\n deletePaymentMethod(\n organisationId: $organisationId\n paymentMethodId: $paymentMethodId\n ) {\n ok\n }\n}": types.DeleteStripePaymentMethodDocument, "mutation InitStripeProUpgradeCheckout($organisationId: ID!, $billingPeriod: String!) {\n createProUpgradeCheckoutSession(\n organisationId: $organisationId\n billingPeriod: $billingPeriod\n ) {\n clientSecret\n }\n}": types.InitStripeProUpgradeCheckoutDocument, + "mutation ResumeStripeSubscription($organisationId: ID!, $subscriptionId: String!) {\n resumeSubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n ) {\n success\n message\n canceledAt\n status\n }\n}": types.ResumeStripeSubscriptionDocument, "mutation SetDefaultStripePaymentMethodOp($organisationId: ID!, $paymentMethodId: String!) {\n setDefaultPaymentMethod(\n organisationId: $organisationId\n paymentMethodId: $paymentMethodId\n ) {\n ok\n }\n}": types.SetDefaultStripePaymentMethodOpDocument, "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, @@ -179,6 +180,10 @@ export function graphql(source: "mutation DeleteStripePaymentMethod($organisatio * 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. + */ +export function graphql(source: "mutation ResumeStripeSubscription($organisationId: ID!, $subscriptionId: String!) {\n resumeSubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n ) {\n success\n message\n canceledAt\n status\n }\n}"): (typeof documents)["mutation ResumeStripeSubscription($organisationId: ID!, $subscriptionId: String!) {\n resumeSubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n ) {\n success\n message\n canceledAt\n status\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 2d65d7d6..9862af43 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -167,14 +167,6 @@ export type BulkEditSecretMutation = { secrets?: Maybe>>; }; -export type CancelSubscriptionResponse = { - __typename?: 'CancelSubscriptionResponse'; - canceledAt?: Maybe; - message?: Maybe; - status?: Maybe; - success?: Maybe; -}; - export type ChartDataPointType = { __typename?: 'ChartDataPointType'; data?: Maybe; @@ -607,7 +599,7 @@ export enum MemberType { export type Mutation = { __typename?: 'Mutation'; addAppMember?: Maybe; - cancelSubscription?: Maybe; + cancelSubscription?: Maybe; createApp?: Maybe; createAwsSecretSync?: Maybe; createCloudflarePagesSync?: Maybe; @@ -660,6 +652,7 @@ export type Mutation = { removeAppMember?: Maybe; removeOverride?: Maybe; renameEnvironment?: Maybe; + resumeSubscription?: Maybe; rotateAppKeys?: Maybe; setDefaultPaymentMethod?: Maybe; swapEnvironmentOrder?: Maybe; @@ -1059,6 +1052,12 @@ export type MutationRenameEnvironmentArgs = { }; +export type MutationResumeSubscriptionArgs = { + organisationId?: InputMaybe; + subscriptionId: Scalars['String']['input']; +}; + + export type MutationRotateAppKeysArgs = { appToken: Scalars['String']['input']; id: Scalars['ID']['input']; @@ -1852,6 +1851,14 @@ export type UpdateServiceAccountMutation = { serviceAccount?: Maybe; }; +export type UpdateSubscriptionResponse = { + __typename?: 'UpdateSubscriptionResponse'; + canceledAt?: Maybe; + message?: Maybe; + status?: Maybe; + success?: Maybe; +}; + export type UpdateSyncAuthentication = { __typename?: 'UpdateSyncAuthentication'; sync?: Maybe; @@ -1945,7 +1952,7 @@ export type CancelStripeSubscriptionMutationVariables = Exact<{ }>; -export type CancelStripeSubscriptionMutation = { __typename?: 'Mutation', cancelSubscription?: { __typename?: 'CancelSubscriptionResponse', success?: boolean | null } | null }; +export type CancelStripeSubscriptionMutation = { __typename?: 'Mutation', cancelSubscription?: { __typename?: 'UpdateSubscriptionResponse', success?: boolean | null } | null }; export type CreateStripeSetupIntentOpMutationVariables = Exact<{ organisationId: Scalars['ID']['input']; @@ -1970,6 +1977,14 @@ export type InitStripeProUpgradeCheckoutMutationVariables = Exact<{ export type InitStripeProUpgradeCheckoutMutation = { __typename?: 'Mutation', createProUpgradeCheckoutSession?: { __typename?: 'CreateProUpgradeCheckoutSession', clientSecret?: string | null } | null }; +export type ResumeStripeSubscriptionMutationVariables = Exact<{ + organisationId: Scalars['ID']['input']; + subscriptionId: Scalars['String']['input']; +}>; + + +export type ResumeStripeSubscriptionMutation = { __typename?: 'Mutation', resumeSubscription?: { __typename?: 'UpdateSubscriptionResponse', success?: boolean | null, message?: string | null, canceledAt?: string | null, status?: string | null } | null }; + export type SetDefaultStripePaymentMethodOpMutationVariables = Exact<{ organisationId: Scalars['ID']['input']; paymentMethodId: Scalars['String']['input']; @@ -2805,6 +2820,7 @@ export const CancelStripeSubscriptionDocument = {"kind":"Document","definitions" export const CreateStripeSetupIntentOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStripeSetupIntentOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSetupIntent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode; export const DeleteStripePaymentMethodDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteStripePaymentMethod"},"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":"paymentMethodId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletePaymentMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"paymentMethodId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} 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 ResumeStripeSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResumeStripeSubscription"},"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":"subscriptionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resumeSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"subscriptionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"subscriptionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"canceledAt"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const SetDefaultStripePaymentMethodOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetDefaultStripePaymentMethodOp"},"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":"paymentMethodId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setDefaultPaymentMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"paymentMethodId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} 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; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index f88e89f8..ebe9b09a 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -738,7 +738,8 @@ type Mutation { createLockbox(input: LockboxInput): CreateLockboxMutation createProUpgradeCheckoutSession(billingPeriod: String, organisationId: ID!): CreateProUpgradeCheckoutSession deletePaymentMethod(organisationId: ID, paymentMethodId: String): DeletePaymentMethodMutation - cancelSubscription(organisationId: ID, subscriptionId: String!): CancelSubscriptionResponse + cancelSubscription(organisationId: ID, subscriptionId: String!): UpdateSubscriptionResponse + resumeSubscription(organisationId: ID, subscriptionId: String!): UpdateSubscriptionResponse createSetupIntent(organisationId: ID): CreateSetupIntentMutation setDefaultPaymentMethod( organisationId: ID @@ -1066,7 +1067,7 @@ type DeletePaymentMethodMutation { ok: Boolean } -type CancelSubscriptionResponse { +type UpdateSubscriptionResponse { success: Boolean message: String canceledAt: String diff --git a/frontend/components/settings/organisation/StripeBillingInfo.tsx b/frontend/components/settings/organisation/StripeBillingInfo.tsx index 70ad66a9..c68b175e 100644 --- a/frontend/components/settings/organisation/StripeBillingInfo.tsx +++ b/frontend/components/settings/organisation/StripeBillingInfo.tsx @@ -6,12 +6,13 @@ import { organisationContext } from '@/contexts/organisationContext' import { GetSubscriptionDetails } from '@/graphql/queries/billing/getSubscriptionDetails.gql' import { DeleteStripePaymentMethod } from '@/graphql/mutations/billing/deletePaymentMethod.gql' import { CancelStripeSubscription } from '@/graphql/mutations/billing/cancelProSubscription.gql' +import { ResumeStripeSubscription } from '@/graphql/mutations/billing/resumeProSubscription.gql' import { SetDefaultStripePaymentMethodOp } from '@/graphql/mutations/billing/setDefaultPaymentMethod.gql' import { GetOrganisations } from '@/graphql/queries/getOrganisations.gql' import { relativeTimeFromDates } from '@/utils/time' import { useLazyQuery, useMutation, useQuery } from '@apollo/client' import { useContext, useRef } from 'react' -import { FaCheckCircle, FaCreditCard, FaTimes, FaTrash } from 'react-icons/fa' +import { FaCheckCircle, FaCreditCard, FaPlay, FaTimes, FaTrash } from 'react-icons/fa' import { SiAmericanexpress, SiDinersclub, @@ -23,6 +24,7 @@ import { import { AddPaymentMethodDialog } from './AddPaymentMethodForm' import { toast } from 'react-toastify' import clsx from 'clsx' +import { Alert } from '@/components/common/Alert' const BrandIcon = ({ brand }: { brand?: string }) => { switch (brand) { @@ -131,6 +133,8 @@ const ManagePaymentMethodsDialog = () => { ) : [] + const allowDelete = subscriptionData?.paymentMethods!.length! > 1 + const [getSubscriptionDetails] = useLazyQuery(GetSubscriptionDetails) const [setDefaultPaymentMethod, { loading: setDefaultPending }] = useMutation( SetDefaultStripePaymentMethodOp @@ -210,9 +214,11 @@ const ManagePaymentMethodsDialog = () => { )} -
- -
+ {allowDelete && ( +
+ +
+ )} ) @@ -307,9 +313,27 @@ export const StripeBillingInfo = () => { skip: !activeOrganisation, }) + const [resumeSubscription, { loading: resumeIsPending }] = useMutation(ResumeStripeSubscription) + const subscriptionData: StripeSubscriptionDetails | undefined = data?.stripeSubscriptionDetails ?? undefined + const handleResumeSubscription = async () => { + if (subscriptionData?.cancelAtPeriodEnd) { + await resumeSubscription({ + variables: { + organisationId: activeOrganisation?.id, + subscriptionId: subscriptionData.subscriptionId, + }, + refetchQueries: [ + { query: GetSubscriptionDetails, variables: { organisationId: activeOrganisation?.id } }, + { query: GetOrganisations }, + ], + }) + toast.success('Resumed subscription') + } + } + const defaultPaymentMethod = subscriptionData?.paymentMethods!.length === 1 ? subscriptionData?.paymentMethods[0] @@ -331,9 +355,19 @@ export const StripeBillingInfo = () => { subscriptionData.cancelAtPeriodEnd ? 'border-t-amber-500' : 'border-t-emerald-500' )} > -
- {subscriptionData.planName}{' '} - ({subscriptionData.status}) +
+
+ {subscriptionData.planName}{' '} + + ({subscriptionData.cancelAtPeriodEnd ? 'Cancelled' : subscriptionData.status}) + +
+ {subscriptionData.cancelAtPeriodEnd && ( + + Your subscription will end{' '} + {relativeTimeFromDates(new Date(subscriptionData.cancelAt! * 1000))}{' '} + + )}
Current billing cycle:{' '} @@ -343,19 +377,32 @@ export const StripeBillingInfo = () => {
-
- {!subscriptionData.cancelAtPeriodEnd && - `Next payment ${relativeTimeFromDates(new Date(subscriptionData.renewalDate! * 1000))}`} +
+ {!subscriptionData.cancelAtPeriodEnd + ? `Next payment ${relativeTimeFromDates(new Date(subscriptionData.renewalDate! * 1000))}` + : `Ends ${relativeTimeFromDates(new Date(subscriptionData.cancelAt! * 1000))}`} - {subscriptionData.cancelAtPeriodEnd && - `Cancels ${relativeTimeFromDates(new Date(subscriptionData.cancelAt! * 1000))}`} - {defaultPaymentMethod && ` on card ending in ${defaultPaymentMethod.last4}`} + {!subscriptionData.cancelAtPeriodEnd && ( +
+ {defaultPaymentMethod && ` on card ending in ${defaultPaymentMethod.last4}`} + {defaultPaymentMethod && } +
+ )}
- {!subscriptionData.cancelAtPeriodEnd && ( + {!subscriptionData.cancelAtPeriodEnd ? ( + ) : ( + )}
diff --git a/frontend/graphql/mutations/billing/resumeProSubscription.gql b/frontend/graphql/mutations/billing/resumeProSubscription.gql new file mode 100644 index 00000000..7cc0b8af --- /dev/null +++ b/frontend/graphql/mutations/billing/resumeProSubscription.gql @@ -0,0 +1,8 @@ +mutation ResumeStripeSubscription($organisationId: ID!, $subscriptionId: String!) { + resumeSubscription(organisationId: $organisationId, subscriptionId: $subscriptionId) { + success + message + canceledAt + status + } +}