diff --git a/packages/backend/src/controllers/paddle/webhooks.ee.ts b/packages/backend/src/controllers/paddle/webhooks.ee.ts index 41ed90da..bd8ebb2d 100644 --- a/packages/backend/src/controllers/paddle/webhooks.ee.ts +++ b/packages/backend/src/controllers/paddle/webhooks.ee.ts @@ -12,6 +12,10 @@ export default async (request: IRequest, response: Response) => { if (request.body.alert_name === 'subscription_created') { await Billing.webhooks.handleSubscriptionCreated(request); + } else if (request.body.alert_name === 'subscription_updated') { + await Billing.webhooks.handleSubscriptionUpdated(request); + } else if (request.body.alert_name === 'subscription_cancelled') { + await Billing.webhooks.handleSubscriptionCancelled(request); } else if (request.body.alert_name === 'subscription_payment_succeeded') { await Billing.webhooks.handleSubscriptionPaymentSucceeded(request); } diff --git a/packages/backend/src/db/migrations/20230402183738_add_subscription_id_in_usage_data.ts b/packages/backend/src/db/migrations/20230402183738_add_subscription_id_in_usage_data.ts new file mode 100644 index 00000000..e2018caf --- /dev/null +++ b/packages/backend/src/db/migrations/20230402183738_add_subscription_id_in_usage_data.ts @@ -0,0 +1,18 @@ +import { Knex } from 'knex'; +import appConfig from '../../config/app'; + +export async function up(knex: Knex): Promise { + if (!appConfig.isCloud) return; + + return knex.schema.table('usage_data', (table) => { + table.uuid('subscription_id').references('id').inTable('subscriptions'); + }); +} + +export async function down(knex: Knex): Promise { + if (!appConfig.isCloud) return; + + return knex.schema.table('usage_data', (table) => { + table.dropColumn('subscription_id'); + }); +} diff --git a/packages/backend/src/graphql/queries/get-billing-and-usage.ee.ts b/packages/backend/src/graphql/queries/get-billing-and-usage.ee.ts index ae6ed957..7c786b96 100644 --- a/packages/backend/src/graphql/queries/get-billing-and-usage.ee.ts +++ b/packages/backend/src/graphql/queries/get-billing-and-usage.ee.ts @@ -13,7 +13,7 @@ const getBillingAndUsage = async ( context: Context ) => { const persistedSubscription = await context.currentUser.$relatedQuery( - 'subscription' + 'currentSubscription' ); const subscription = persistedSubscription @@ -39,8 +39,8 @@ const paidSubscription = (subscription: Subscription): TSubscription => { title: currentPlan.limit, action: { type: 'link', - text: 'Change plan', - src: '/settings/billing/change-plan', + text: 'Cancel plan', + src: subscription.cancelUrl, }, }, nextBillAmount: { diff --git a/packages/backend/src/graphql/queries/get-invoices.ee.ts b/packages/backend/src/graphql/queries/get-invoices.ee.ts index c745a4c6..49bad7c5 100644 --- a/packages/backend/src/graphql/queries/get-invoices.ee.ts +++ b/packages/backend/src/graphql/queries/get-invoices.ee.ts @@ -6,7 +6,7 @@ const getInvoices = async ( _params: unknown, context: Context ) => { - const subscription = await context.currentUser.$relatedQuery('subscription'); + const subscription = await context.currentUser.$relatedQuery('currentSubscription'); if (!subscription) { return; diff --git a/packages/backend/src/graphql/queries/get-trial-status.ee.ts b/packages/backend/src/graphql/queries/get-trial-status.ee.ts new file mode 100644 index 00000000..58a8f936 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-trial-status.ee.ts @@ -0,0 +1,19 @@ +import appConfig from '../../config/app'; +import Context from '../../types/express/context'; + +const getTrialStatus = async ( + _parent: unknown, + _params: unknown, + context: Context +) => { + if (!appConfig.isCloud) return; + + const inTrial = await context.currentUser.inTrial(); + if (!inTrial) return; + + return { + expireAt: context.currentUser.trialExpiryDate, + }; +}; + +export default getTrialStatus; diff --git a/packages/backend/src/graphql/queries/get-usage-data.ee.ts b/packages/backend/src/graphql/queries/get-usage-data.ee.ts index a5dfc69b..40f604a3 100644 --- a/packages/backend/src/graphql/queries/get-usage-data.ee.ts +++ b/packages/backend/src/graphql/queries/get-usage-data.ee.ts @@ -1,6 +1,7 @@ import appConfig from '../../config/app'; import Context from '../../types/express/context'; +// TODO: remove as getBillingAndUsageData query has been introduced const getUsageData = async ( _parent: unknown, _params: unknown, @@ -9,18 +10,20 @@ const getUsageData = async ( if (!appConfig.isCloud) return; const usageData = await context.currentUser - .$relatedQuery('usageData') + .$relatedQuery('currentUsageData') .throwIfNotFound(); - const paymentPlan = await context.currentUser - .$relatedQuery('paymentPlan') + const subscription = await usageData + .$relatedQuery('subscription') .throwIfNotFound(); + const plan = subscription.plan; + const computedUsageData = { - name: paymentPlan.name, - allowedTaskCount: paymentPlan.taskCount, + name: plan.name, + allowedTaskCount: plan.quota, consumedTaskCount: usageData.consumedTaskCount, - remainingTaskCount: paymentPlan.taskCount - usageData.consumedTaskCount, + remainingTaskCount: plan.quota - usageData.consumedTaskCount, nextResetAt: usageData.nextResetAt, }; diff --git a/packages/backend/src/graphql/query-resolvers.ts b/packages/backend/src/graphql/query-resolvers.ts index b7263799..71e44020 100644 --- a/packages/backend/src/graphql/query-resolvers.ts +++ b/packages/backend/src/graphql/query-resolvers.ts @@ -17,6 +17,7 @@ import getPaddleInfo from './queries/get-paddle-info.ee'; import getBillingAndUsage from './queries/get-billing-and-usage.ee'; import getInvoices from './queries/get-invoices.ee'; import getAutomatischInfo from './queries/get-automatisch-info'; +import getTrialStatus from './queries/get-trial-status.ee'; import healthcheck from './queries/healthcheck'; const queryResolvers = { @@ -39,6 +40,7 @@ const queryResolvers = { getBillingAndUsage, getInvoices, getAutomatischInfo, + getTrialStatus, healthcheck, }; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 458abe2e..2ffe0fcd 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -40,6 +40,7 @@ type Query { getBillingAndUsage: GetBillingAndUsage getInvoices: [Invoice] getAutomatischInfo: GetAutomatischInfo + getTrialStatus: GetTrialStatus healthcheck: AppHealth } @@ -475,6 +476,10 @@ type GetAutomatischInfo { isCloud: Boolean } +type GetTrialStatus { + expireAt: String +} + type GetBillingAndUsage { subscription: Subscription usage: Usage diff --git a/packages/backend/src/helpers/billing/paddle.ee.ts b/packages/backend/src/helpers/billing/paddle.ee.ts index 40594016..f558713d 100644 --- a/packages/backend/src/helpers/billing/paddle.ee.ts +++ b/packages/backend/src/helpers/billing/paddle.ee.ts @@ -24,6 +24,7 @@ const getSubscription = async (subscriptionId: number) => { }; const getInvoices = async (subscriptionId: number) => { + // TODO: iterate over previous subscriptions and include their invoices const data = { vendor_id: appConfig.paddleVendorId, vendor_auth_code: appConfig.paddleVendorAuthCode, diff --git a/packages/backend/src/helpers/billing/plans.ee.ts b/packages/backend/src/helpers/billing/plans.ee.ts index 98bdcac9..45b2fea5 100644 --- a/packages/backend/src/helpers/billing/plans.ee.ts +++ b/packages/backend/src/helpers/billing/plans.ee.ts @@ -2,15 +2,14 @@ const plans = [ { name: '10k - monthly', limit: '10,000', + quota: 10000, price: '€20', productId: '47384', - }, - { - name: '30k - monthly', - limit: '30,000', - price: '€50', - productId: '47419', - }, + } ]; +export function getPlanById(id: string) { + return plans.find((plan) => plan.productId === id); +} + export default plans; diff --git a/packages/backend/src/helpers/billing/webhooks.ee.ts b/packages/backend/src/helpers/billing/webhooks.ee.ts index 46930a8e..056c754f 100644 --- a/packages/backend/src/helpers/billing/webhooks.ee.ts +++ b/packages/backend/src/helpers/billing/webhooks.ee.ts @@ -3,7 +3,30 @@ import Subscription from '../../models/subscription.ee'; import Billing from './index.ee'; const handleSubscriptionCreated = async (request: IRequest) => { - await Subscription.query().insertAndFetch(formatSubscription(request)); + const subscription = await Subscription.query().insertAndFetch(formatSubscription(request)); + await subscription.$relatedQuery('usageData').insert(formatUsageData(request)); +}; + +const handleSubscriptionUpdated = async (request: IRequest) => { + await Subscription + .query() + .findOne({ + paddle_subscription_id: request.body.subscription_id, + }) + .patch(formatSubscription(request)); +}; + +const handleSubscriptionCancelled = async (request: IRequest) => { + const subscription = await Subscription + .query() + .findOne({ + paddle_subscription_id: request.body.subscription_id, + }) + .patchAndFetch(formatSubscription(request)); + + if (request.body.status === 'deleted') { + await subscription.$query().delete(); + } }; const handleSubscriptionPaymentSucceeded = async (request: IRequest) => { @@ -22,6 +45,8 @@ const handleSubscriptionPaymentSucceeded = async (request: IRequest) => { nextBillDate: remoteSubscription.next_payment.date, lastBillDate: remoteSubscription.last_payment.date, }); + + await subscription.$relatedQuery('usageData').insert(formatUsageData(request)); }; const formatSubscription = (request: IRequest) => { @@ -37,8 +62,18 @@ const formatSubscription = (request: IRequest) => { }; }; +const formatUsageData = (request: IRequest) => { + return { + userId: JSON.parse(request.body.passthrough).id, + consumedTaskCount: 0, + nextResetAt: request.body.next_bill_date, + }; +}; + const webhooks = { handleSubscriptionCreated, + handleSubscriptionUpdated, + handleSubscriptionCancelled, handleSubscriptionPaymentSucceeded, }; diff --git a/packages/backend/src/models/execution-step.ts b/packages/backend/src/models/execution-step.ts index 6b52d15d..b26a3766 100644 --- a/packages/backend/src/models/execution-step.ts +++ b/packages/backend/src/models/execution-step.ts @@ -67,7 +67,7 @@ class ExecutionStep extends Base { if (!execution.testRun && !this.isFailed) { const flow = await execution.$relatedQuery('flow'); const user = await flow.$relatedQuery('user'); - const usageData = await user.$relatedQuery('usageData'); + const usageData = await user.$relatedQuery('currentUsageData'); await usageData.increaseConsumedTaskCountByOne(); } diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index 6eaab50f..ed57c1f3 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -136,7 +136,7 @@ class Flow extends Base { if (!appConfig.isCloud) return; const user = await this.$relatedQuery('user'); - const usageData = await user.$relatedQuery('usageData'); + const usageData = await user.$relatedQuery('currentUsageData'); const hasExceeded = await usageData.checkIfLimitExceeded(); diff --git a/packages/backend/src/models/subscription.ee.ts b/packages/backend/src/models/subscription.ee.ts index 5cadc24b..216e5e52 100644 --- a/packages/backend/src/models/subscription.ee.ts +++ b/packages/backend/src/models/subscription.ee.ts @@ -1,5 +1,7 @@ import Base from './base'; import User from './user'; +import UsageData from './usage-data.ee'; +import { getPlanById } from '../helpers/billing/plans.ee'; class Subscription extends Base { id!: string; @@ -12,6 +14,8 @@ class Subscription extends Base { nextBillAmount!: string; nextBillDate!: string; lastBillDate?: string; + usageData?: UsageData[]; + currentUsageData?: UsageData; static tableName = 'subscriptions'; @@ -51,7 +55,31 @@ class Subscription extends Base { to: 'users.id', }, }, + usageData: { + relation: Base.HasManyRelation, + modelClass: UsageData, + join: { + from: 'subscriptions.id', + to: 'usage_data.subscription_id', + }, + }, + currentUsageData: { + relation: Base.HasOneRelation, + modelClass: UsageData, + join: { + from: 'subscriptions.id', + to: 'usage_data.subscription_id', + }, + }, }); + + get plan() { + return getPlanById(this.paddlePlanId); + } + + get isActive() { + return this.status === 'active' || this.status === 'past_due'; + } } export default Subscription; diff --git a/packages/backend/src/models/usage-data.ee.ts b/packages/backend/src/models/usage-data.ee.ts index 2e508f12..047d0bf1 100644 --- a/packages/backend/src/models/usage-data.ee.ts +++ b/packages/backend/src/models/usage-data.ee.ts @@ -1,14 +1,17 @@ import { raw } from 'objection'; import Base from './base'; import User from './user'; -import PaymentPlan from './payment-plan.ee'; +import Subscription from './subscription.ee'; +import { getPlanById } from '../helpers/billing/plans.ee'; class UsageData extends Base { id!: string; userId!: string; + subscriptionId?: string; consumedTaskCount!: number; nextResetAt!: string; - paymentPlan?: PaymentPlan; + subscription?: Subscription; + user?: User; static tableName = 'usage_data'; @@ -19,6 +22,7 @@ class UsageData extends Base { properties: { id: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' }, + subscriptionId: { type: 'string', format: 'uuid' }, consumedTaskCount: { type: 'integer' }, nextResetAt: { type: 'string' }, }, @@ -33,24 +37,38 @@ class UsageData extends Base { to: 'users.id', }, }, - paymentPlan: { + subscription: { relation: Base.BelongsToOneRelation, - modelClass: PaymentPlan, + modelClass: Subscription, join: { - from: 'usage_data.user_id', - to: 'payment_plans.user_id', + from: 'usage_data.subscription_id', + to: 'subscriptions.id', }, }, }); async checkIfLimitExceeded() { - const paymentPlan = await this.$relatedQuery('paymentPlan'); + const user = await this.$relatedQuery('user'); - return this.consumedTaskCount >= paymentPlan.taskCount; + if (await user.inTrial()) { + return false; + } + + const subscription = await this.$relatedQuery('subscription'); + + if (!subscription.isActive) { + return true; + } + + const plan = subscription.plan; + + return this.consumedTaskCount >= plan.quota; } async increaseConsumedTaskCountByOne() { - return await this.$query().patch({ consumedTaskCount: raw('consumed_task_count + 1') }); + return await this.$query().patch({ + consumedTaskCount: raw('consumed_task_count + 1'), + }); } } diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index 3815e693..f83b6c01 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -1,15 +1,14 @@ import { QueryContext, ModelOptions } from 'objection'; +import bcrypt from 'bcrypt'; +import crypto from 'crypto'; import { DateTime } from 'luxon'; import appConfig from '../config/app'; import Base from './base'; +import ExtendedQueryBuilder from './query-builder'; import Connection from './connection'; import Flow from './flow'; import Step from './step'; import Execution from './execution'; -import ExecutionStep from './execution-step'; -import bcrypt from 'bcrypt'; -import crypto from 'crypto'; -import PaymentPlan from './payment-plan.ee'; import UsageData from './usage-data.ee'; import Subscription from './subscription.ee'; @@ -26,9 +25,10 @@ class User extends Base { flows?: Flow[]; steps?: Step[]; executions?: Execution[]; - paymentPlan?: PaymentPlan; - usageData?: UsageData; - subscription?: Subscription; + usageData?: UsageData[]; + currentUsageData?: UsageData; + subscriptions?: Subscription[]; + currentSubscription?: Subscription; static tableName = 'users'; @@ -86,29 +86,43 @@ class User extends Base { to: 'executions.flow_id', }, }, - paymentPlan: { - relation: Base.HasOneRelation, - modelClass: PaymentPlan, - join: { - from: 'payment_plans.user_id', - to: 'users.id', - }, - }, usageData: { - relation: Base.HasOneRelation, + relation: Base.HasManyRelation, modelClass: UsageData, join: { from: 'usage_data.user_id', to: 'users.id', }, }, - subscription: { + currentUsageData: { + relation: Base.HasOneRelation, + modelClass: UsageData, + join: { + from: 'usage_data.user_id', + to: 'users.id', + }, + filter(builder: ExtendedQueryBuilder) { + builder.orderBy('created_at', 'desc').first(); + }, + }, + subscriptions: { + relation: Base.HasManyRelation, + modelClass: Subscription, + join: { + from: 'subscriptions.user_id', + to: 'users.id', + }, + }, + currentSubscription: { relation: Base.HasOneRelation, modelClass: Subscription, join: { from: 'subscriptions.user_id', to: 'users.id', }, + filter(builder: ExtendedQueryBuilder) { + builder.orderBy('created_at', 'desc').first(); + }, }, }); @@ -151,6 +165,29 @@ class User extends Base { this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); } + async inTrial() { + if (!appConfig.isCloud) { + return false; + } + + if (!this.trialExpiryDate) { + return false; + } + + const subscription = await this.$relatedQuery('currentSubscription'); + + if (subscription?.isActive) { + return false; + } + + const expiryDate = DateTime.fromJSDate( + this.trialExpiryDate as unknown as Date + ); + const now = DateTime.now(); + + return now < expiryDate; + } + async $beforeInsert(queryContext: QueryContext) { await super.$beforeInsert(queryContext); await this.generateHash(); @@ -167,6 +204,18 @@ class User extends Base { await this.generateHash(); } } + + async $afterInsert(queryContext: QueryContext) { + await super.$afterInsert(queryContext); + + if (appConfig.isCloud) { + await this.$relatedQuery('usageData').insert({ + userId: this.id, + consumedTaskCount: 0, + nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(), + }); + } + } } export default User; diff --git a/packages/backend/src/routes/paddle.ee.ts b/packages/backend/src/routes/paddle.ee.ts index 46e96b65..3f6aa004 100644 --- a/packages/backend/src/routes/paddle.ee.ts +++ b/packages/backend/src/routes/paddle.ee.ts @@ -6,13 +6,13 @@ const router = Router(); const exposeError = (handler: RequestHandler) => - async (req: IRequest, res: Response, next: NextFunction) => { - try { - await handler(req, res, next); - } catch (err) { - next(err); - } - }; + async (req: IRequest, res: Response, next: NextFunction) => { + try { + await handler(req, res, next); + } catch (err) { + next(err); + } + }; router.post('/webhooks', exposeError(webhooksHandler)); diff --git a/packages/web/src/components/UsageDataInformation/index.ee.tsx b/packages/web/src/components/UsageDataInformation/index.ee.tsx index b2162001..4cd47a2f 100644 --- a/packages/web/src/components/UsageDataInformation/index.ee.tsx +++ b/packages/web/src/components/UsageDataInformation/index.ee.tsx @@ -191,7 +191,8 @@ export default function UsageDataInformation() { - + }