From e2727f971ffe93ba99195e7f7893b7e62b2a0e9c Mon Sep 17 00:00:00 2001 From: Igor Lesnenko Date: Fri, 26 May 2023 19:49:10 +0400 Subject: [PATCH 1/4] fix: do not cancel stripe subscription in case of failed payment --- .../mutations/helpers/upgradeToTeamTierOld.ts | 46 +++++++++++++------ .../private/mutations/stripeFailPayment.ts | 14 +++++- packages/server/utils/stripe/StripeManager.ts | 11 +++++ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/server/graphql/mutations/helpers/upgradeToTeamTierOld.ts b/packages/server/graphql/mutations/helpers/upgradeToTeamTierOld.ts index 6aee6abf69d..c43cb027fb0 100644 --- a/packages/server/graphql/mutations/helpers/upgradeToTeamTierOld.ts +++ b/packages/server/graphql/mutations/helpers/upgradeToTeamTierOld.ts @@ -43,22 +43,38 @@ const upgradeToTeamTierOld = async ( } } + await r({ + updatedOrg: r + .table('Organization') + .get(orgId) + .update({ + ...subscriptionFields, + creditCard: await getCCFromCustomer(customer), + tier: 'team', + stripeId: customer.id, + tierLimitExceededAt: null, + scheduledLockAt: null, + lockedAt: null, + updatedAt: now + }) + }).run() + + // If subscription already exists and has open invoices, try to process them + if (stripeSubscriptionId) { + const invoices = (await manager.listSubscriptionOpenInvoices(stripeSubscriptionId)).data + + if (invoices.length) { + for (const invoice of invoices) { + const invoiceResult = await manager.payInvoice(invoice.id) + // Unlock teams only if all invoices are paid + if (invoiceResult.status !== 'paid') { + throw new Error('Unable to process payment') + } + } + } + } + await Promise.all([ - r({ - updatedOrg: r - .table('Organization') - .get(orgId) - .update({ - ...subscriptionFields, - creditCard: await getCCFromCustomer(customer), - tier: 'team', - stripeId: customer.id, - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: now - }) - }).run(), updateTeamByOrgId( { isPaid: true, diff --git a/packages/server/graphql/private/mutations/stripeFailPayment.ts b/packages/server/graphql/private/mutations/stripeFailPayment.ts index 2ce63ecc769..7bfa28e1a0b 100644 --- a/packages/server/graphql/private/mutations/stripeFailPayment.ts +++ b/packages/server/graphql/private/mutations/stripeFailPayment.ts @@ -6,6 +6,7 @@ import {isSuperUser} from '../../../utils/authorization' import publish from '../../../utils/publish' import {getStripeManager} from '../../../utils/stripe' import {MutationResolvers} from '../resolverTypes' +import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' export type StripeFailPaymentPayloadSource = | { @@ -56,10 +57,19 @@ const stripeFailPayment: MutationResolvers['stripeFailPayment'] = async ( } const {creditCard, stripeSubscriptionId} = org - if (paid || stripeSubscriptionId !== subscription) return {orgId} + if (paid || stripeSubscriptionId !== subscription || stripeSubscriptionId === null) return {orgId} // RESOLUTION - await terminateSubscription(orgId) + const subscriptionObject = await manager.retrieveSubscription(stripeSubscriptionId) + + if (subscriptionObject.status === 'incomplete') { + // Terminate subscription if it is the first failed payment + await terminateSubscription(orgId) + } else { + // Keep subscription, but disable teams + await updateTeamByOrgId({isPaid: false}, orgId) + } + const billingLeaderUserIds = (await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) diff --git a/packages/server/utils/stripe/StripeManager.ts b/packages/server/utils/stripe/StripeManager.ts index 58bb849ceed..c5e92759ff4 100644 --- a/packages/server/utils/stripe/StripeManager.ts +++ b/packages/server/utils/stripe/StripeManager.ts @@ -104,6 +104,17 @@ export default class StripeManager { return allSubscriptionItems.data[0] } + async listSubscriptionOpenInvoices(subscriptionId: string) { + return this.stripe.invoices.list({ + subscription: subscriptionId, + status: 'open' + }) + } + + async payInvoice(invoiceId: string) { + return this.stripe.invoices.pay(invoiceId) + } + async listLineItems(invoiceId: string, options: Stripe.InvoiceLineItemListParams) { return this.stripe.invoices.listLineItems(invoiceId, options) } From 0f8badc7f307c30ea1539be6962f996fe1531bc1 Mon Sep 17 00:00:00 2001 From: Igor Lesnenko Date: Mon, 29 May 2023 15:14:22 +0400 Subject: [PATCH 2/4] Handle canceled state too --- .../server/graphql/private/mutations/stripeFailPayment.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/graphql/private/mutations/stripeFailPayment.ts b/packages/server/graphql/private/mutations/stripeFailPayment.ts index 7bfa28e1a0b..3c5e640e4d8 100644 --- a/packages/server/graphql/private/mutations/stripeFailPayment.ts +++ b/packages/server/graphql/private/mutations/stripeFailPayment.ts @@ -62,8 +62,8 @@ const stripeFailPayment: MutationResolvers['stripeFailPayment'] = async ( // RESOLUTION const subscriptionObject = await manager.retrieveSubscription(stripeSubscriptionId) - if (subscriptionObject.status === 'incomplete') { - // Terminate subscription if it is the first failed payment + if (subscriptionObject.status === 'incomplete' || subscriptionObject.status === 'canceled') { + // Terminate subscription if it is the first failed payment, or it is already canceled await terminateSubscription(orgId) } else { // Keep subscription, but disable teams From af3e8cb8eaa1a1d3eb1117bcd163b611dcd209c9 Mon Sep 17 00:00:00 2001 From: Igor Lesnenko Date: Fri, 2 Jun 2023 15:51:22 +0400 Subject: [PATCH 3/4] Add more comments --- .../server/graphql/private/mutations/stripeFailPayment.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/graphql/private/mutations/stripeFailPayment.ts b/packages/server/graphql/private/mutations/stripeFailPayment.ts index 3c5e640e4d8..b7e1c0a9848 100644 --- a/packages/server/graphql/private/mutations/stripeFailPayment.ts +++ b/packages/server/graphql/private/mutations/stripeFailPayment.ts @@ -63,7 +63,9 @@ const stripeFailPayment: MutationResolvers['stripeFailPayment'] = async ( const subscriptionObject = await manager.retrieveSubscription(stripeSubscriptionId) if (subscriptionObject.status === 'incomplete' || subscriptionObject.status === 'canceled') { - // Terminate subscription if it is the first failed payment, or it is already canceled + // Terminate subscription if the first payment fails or if it is already canceled + // After 23 hours subscription updates to incomplete_expired and the invoice becomes void. + // Not to handle this particular case in 23 hours, we do it now await terminateSubscription(orgId) } else { // Keep subscription, but disable teams From c59a7db0093f0fdebb5c63dae85bcf0c25752b9f Mon Sep 17 00:00:00 2001 From: Igor Lesnenko Date: Fri, 9 Jun 2023 15:40:52 +0400 Subject: [PATCH 4/4] Take isPaid from org team when doing moveToOrg --- .../server/graphql/mutations/moveTeamToOrg.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/server/graphql/mutations/moveTeamToOrg.ts b/packages/server/graphql/mutations/moveTeamToOrg.ts index 4810d04e983..0764c2582a1 100644 --- a/packages/server/graphql/mutations/moveTeamToOrg.ts +++ b/packages/server/graphql/mutations/moveTeamToOrg.ts @@ -12,6 +12,7 @@ import {getUserId, isSuperUser} from '../../utils/authorization' import standardError from '../../utils/standardError' import {DataLoaderWorker, GQLContext} from '../graphql' import isValid from '../isValid' +import getKysely from '../../postgres/getKysely' const moveToOrg = async ( teamId: string, @@ -20,12 +21,21 @@ const moveToOrg = async ( dataLoader: DataLoaderWorker ) => { const r = await getRethink() + const pg = getKysely() + // AUTH const su = isSuperUser(authToken) // VALIDATION - const [org, teams] = await Promise.all([ + const [org, teams, isPaidResult] = await Promise.all([ r.table('Organization').get(orgId).run(), - getTeamsByIds([teamId]) + getTeamsByIds([teamId]), + pg + .selectFrom('Team') + .select('isPaid') + .where('orgId', '=', orgId) + .where('isArchived', '!=', true) + .limit(1) + .executeTakeFirst() ]) const team = teams[0] if (!team) { @@ -70,7 +80,7 @@ const moveToOrg = async ( // RESOLUTION const updates = { orgId, - isPaid: Boolean(org.stripeSubscriptionId), + isPaid: !!isPaidResult?.isPaid, tier: org.tier, updatedAt: new Date() }