Merge pull request #1040 from automatisch/usage-data

feat: add subscription update and cancel logics
This commit is contained in:
Ömer Faruk Aydın
2023-04-08 00:34:28 +03:00
committed by GitHub
18 changed files with 237 additions and 55 deletions

View File

@@ -12,6 +12,10 @@ export default async (request: IRequest, response: Response) => {
if (request.body.alert_name === 'subscription_created') { if (request.body.alert_name === 'subscription_created') {
await Billing.webhooks.handleSubscriptionCreated(request); 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') { } else if (request.body.alert_name === 'subscription_payment_succeeded') {
await Billing.webhooks.handleSubscriptionPaymentSucceeded(request); await Billing.webhooks.handleSubscriptionPaymentSucceeded(request);
} }

View File

@@ -0,0 +1,18 @@
import { Knex } from 'knex';
import appConfig from '../../config/app';
export async function up(knex: Knex): Promise<void> {
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<void> {
if (!appConfig.isCloud) return;
return knex.schema.table('usage_data', (table) => {
table.dropColumn('subscription_id');
});
}

View File

@@ -13,7 +13,7 @@ const getBillingAndUsage = async (
context: Context context: Context
) => { ) => {
const persistedSubscription = await context.currentUser.$relatedQuery( const persistedSubscription = await context.currentUser.$relatedQuery(
'subscription' 'currentSubscription'
); );
const subscription = persistedSubscription const subscription = persistedSubscription
@@ -39,8 +39,8 @@ const paidSubscription = (subscription: Subscription): TSubscription => {
title: currentPlan.limit, title: currentPlan.limit,
action: { action: {
type: 'link', type: 'link',
text: 'Change plan', text: 'Cancel plan',
src: '/settings/billing/change-plan', src: subscription.cancelUrl,
}, },
}, },
nextBillAmount: { nextBillAmount: {

View File

@@ -6,7 +6,7 @@ const getInvoices = async (
_params: unknown, _params: unknown,
context: Context context: Context
) => { ) => {
const subscription = await context.currentUser.$relatedQuery('subscription'); const subscription = await context.currentUser.$relatedQuery('currentSubscription');
if (!subscription) { if (!subscription) {
return; return;

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
import appConfig from '../../config/app'; import appConfig from '../../config/app';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
// TODO: remove as getBillingAndUsageData query has been introduced
const getUsageData = async ( const getUsageData = async (
_parent: unknown, _parent: unknown,
_params: unknown, _params: unknown,
@@ -9,18 +10,20 @@ const getUsageData = async (
if (!appConfig.isCloud) return; if (!appConfig.isCloud) return;
const usageData = await context.currentUser const usageData = await context.currentUser
.$relatedQuery('usageData') .$relatedQuery('currentUsageData')
.throwIfNotFound(); .throwIfNotFound();
const paymentPlan = await context.currentUser const subscription = await usageData
.$relatedQuery('paymentPlan') .$relatedQuery('subscription')
.throwIfNotFound(); .throwIfNotFound();
const plan = subscription.plan;
const computedUsageData = { const computedUsageData = {
name: paymentPlan.name, name: plan.name,
allowedTaskCount: paymentPlan.taskCount, allowedTaskCount: plan.quota,
consumedTaskCount: usageData.consumedTaskCount, consumedTaskCount: usageData.consumedTaskCount,
remainingTaskCount: paymentPlan.taskCount - usageData.consumedTaskCount, remainingTaskCount: plan.quota - usageData.consumedTaskCount,
nextResetAt: usageData.nextResetAt, nextResetAt: usageData.nextResetAt,
}; };

View File

@@ -17,6 +17,7 @@ import getPaddleInfo from './queries/get-paddle-info.ee';
import getBillingAndUsage from './queries/get-billing-and-usage.ee'; import getBillingAndUsage from './queries/get-billing-and-usage.ee';
import getInvoices from './queries/get-invoices.ee'; import getInvoices from './queries/get-invoices.ee';
import getAutomatischInfo from './queries/get-automatisch-info'; import getAutomatischInfo from './queries/get-automatisch-info';
import getTrialStatus from './queries/get-trial-status.ee';
import healthcheck from './queries/healthcheck'; import healthcheck from './queries/healthcheck';
const queryResolvers = { const queryResolvers = {
@@ -39,6 +40,7 @@ const queryResolvers = {
getBillingAndUsage, getBillingAndUsage,
getInvoices, getInvoices,
getAutomatischInfo, getAutomatischInfo,
getTrialStatus,
healthcheck, healthcheck,
}; };

View File

@@ -40,6 +40,7 @@ type Query {
getBillingAndUsage: GetBillingAndUsage getBillingAndUsage: GetBillingAndUsage
getInvoices: [Invoice] getInvoices: [Invoice]
getAutomatischInfo: GetAutomatischInfo getAutomatischInfo: GetAutomatischInfo
getTrialStatus: GetTrialStatus
healthcheck: AppHealth healthcheck: AppHealth
} }
@@ -475,6 +476,10 @@ type GetAutomatischInfo {
isCloud: Boolean isCloud: Boolean
} }
type GetTrialStatus {
expireAt: String
}
type GetBillingAndUsage { type GetBillingAndUsage {
subscription: Subscription subscription: Subscription
usage: Usage usage: Usage

View File

@@ -24,6 +24,7 @@ const getSubscription = async (subscriptionId: number) => {
}; };
const getInvoices = async (subscriptionId: number) => { const getInvoices = async (subscriptionId: number) => {
// TODO: iterate over previous subscriptions and include their invoices
const data = { const data = {
vendor_id: appConfig.paddleVendorId, vendor_id: appConfig.paddleVendorId,
vendor_auth_code: appConfig.paddleVendorAuthCode, vendor_auth_code: appConfig.paddleVendorAuthCode,

View File

@@ -2,15 +2,14 @@ const plans = [
{ {
name: '10k - monthly', name: '10k - monthly',
limit: '10,000', limit: '10,000',
quota: 10000,
price: '€20', price: '€20',
productId: '47384', 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; export default plans;

View File

@@ -3,7 +3,30 @@ import Subscription from '../../models/subscription.ee';
import Billing from './index.ee'; import Billing from './index.ee';
const handleSubscriptionCreated = async (request: IRequest) => { 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) => { const handleSubscriptionPaymentSucceeded = async (request: IRequest) => {
@@ -22,6 +45,8 @@ const handleSubscriptionPaymentSucceeded = async (request: IRequest) => {
nextBillDate: remoteSubscription.next_payment.date, nextBillDate: remoteSubscription.next_payment.date,
lastBillDate: remoteSubscription.last_payment.date, lastBillDate: remoteSubscription.last_payment.date,
}); });
await subscription.$relatedQuery('usageData').insert(formatUsageData(request));
}; };
const formatSubscription = (request: IRequest) => { 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 = { const webhooks = {
handleSubscriptionCreated, handleSubscriptionCreated,
handleSubscriptionUpdated,
handleSubscriptionCancelled,
handleSubscriptionPaymentSucceeded, handleSubscriptionPaymentSucceeded,
}; };

View File

@@ -67,7 +67,7 @@ class ExecutionStep extends Base {
if (!execution.testRun && !this.isFailed) { if (!execution.testRun && !this.isFailed) {
const flow = await execution.$relatedQuery('flow'); const flow = await execution.$relatedQuery('flow');
const user = await flow.$relatedQuery('user'); const user = await flow.$relatedQuery('user');
const usageData = await user.$relatedQuery('usageData'); const usageData = await user.$relatedQuery('currentUsageData');
await usageData.increaseConsumedTaskCountByOne(); await usageData.increaseConsumedTaskCountByOne();
} }

View File

@@ -136,7 +136,7 @@ class Flow extends Base {
if (!appConfig.isCloud) return; if (!appConfig.isCloud) return;
const user = await this.$relatedQuery('user'); const user = await this.$relatedQuery('user');
const usageData = await user.$relatedQuery('usageData'); const usageData = await user.$relatedQuery('currentUsageData');
const hasExceeded = await usageData.checkIfLimitExceeded(); const hasExceeded = await usageData.checkIfLimitExceeded();

View File

@@ -1,5 +1,7 @@
import Base from './base'; import Base from './base';
import User from './user'; import User from './user';
import UsageData from './usage-data.ee';
import { getPlanById } from '../helpers/billing/plans.ee';
class Subscription extends Base { class Subscription extends Base {
id!: string; id!: string;
@@ -12,6 +14,8 @@ class Subscription extends Base {
nextBillAmount!: string; nextBillAmount!: string;
nextBillDate!: string; nextBillDate!: string;
lastBillDate?: string; lastBillDate?: string;
usageData?: UsageData[];
currentUsageData?: UsageData;
static tableName = 'subscriptions'; static tableName = 'subscriptions';
@@ -51,7 +55,31 @@ class Subscription extends Base {
to: 'users.id', 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; export default Subscription;

View File

@@ -1,14 +1,17 @@
import { raw } from 'objection'; import { raw } from 'objection';
import Base from './base'; import Base from './base';
import User from './user'; 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 { class UsageData extends Base {
id!: string; id!: string;
userId!: string; userId!: string;
subscriptionId?: string;
consumedTaskCount!: number; consumedTaskCount!: number;
nextResetAt!: string; nextResetAt!: string;
paymentPlan?: PaymentPlan; subscription?: Subscription;
user?: User;
static tableName = 'usage_data'; static tableName = 'usage_data';
@@ -19,6 +22,7 @@ class UsageData extends Base {
properties: { properties: {
id: { type: 'string', format: 'uuid' }, id: { type: 'string', format: 'uuid' },
userId: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' },
subscriptionId: { type: 'string', format: 'uuid' },
consumedTaskCount: { type: 'integer' }, consumedTaskCount: { type: 'integer' },
nextResetAt: { type: 'string' }, nextResetAt: { type: 'string' },
}, },
@@ -33,24 +37,38 @@ class UsageData extends Base {
to: 'users.id', to: 'users.id',
}, },
}, },
paymentPlan: { subscription: {
relation: Base.BelongsToOneRelation, relation: Base.BelongsToOneRelation,
modelClass: PaymentPlan, modelClass: Subscription,
join: { join: {
from: 'usage_data.user_id', from: 'usage_data.subscription_id',
to: 'payment_plans.user_id', to: 'subscriptions.id',
}, },
}, },
}); });
async checkIfLimitExceeded() { 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() { async increaseConsumedTaskCountByOne() {
return await this.$query().patch({ consumedTaskCount: raw('consumed_task_count + 1') }); return await this.$query().patch({
consumedTaskCount: raw('consumed_task_count + 1'),
});
} }
} }

View File

@@ -1,15 +1,14 @@
import { QueryContext, ModelOptions } from 'objection'; import { QueryContext, ModelOptions } from 'objection';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import appConfig from '../config/app'; import appConfig from '../config/app';
import Base from './base'; import Base from './base';
import ExtendedQueryBuilder from './query-builder';
import Connection from './connection'; import Connection from './connection';
import Flow from './flow'; import Flow from './flow';
import Step from './step'; import Step from './step';
import Execution from './execution'; 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 UsageData from './usage-data.ee';
import Subscription from './subscription.ee'; import Subscription from './subscription.ee';
@@ -26,9 +25,10 @@ class User extends Base {
flows?: Flow[]; flows?: Flow[];
steps?: Step[]; steps?: Step[];
executions?: Execution[]; executions?: Execution[];
paymentPlan?: PaymentPlan; usageData?: UsageData[];
usageData?: UsageData; currentUsageData?: UsageData;
subscription?: Subscription; subscriptions?: Subscription[];
currentSubscription?: Subscription;
static tableName = 'users'; static tableName = 'users';
@@ -86,29 +86,43 @@ class User extends Base {
to: 'executions.flow_id', to: 'executions.flow_id',
}, },
}, },
paymentPlan: {
relation: Base.HasOneRelation,
modelClass: PaymentPlan,
join: {
from: 'payment_plans.user_id',
to: 'users.id',
},
},
usageData: { usageData: {
relation: Base.HasOneRelation, relation: Base.HasManyRelation,
modelClass: UsageData, modelClass: UsageData,
join: { join: {
from: 'usage_data.user_id', from: 'usage_data.user_id',
to: 'users.id', to: 'users.id',
}, },
}, },
subscription: { currentUsageData: {
relation: Base.HasOneRelation,
modelClass: UsageData,
join: {
from: 'usage_data.user_id',
to: 'users.id',
},
filter(builder: ExtendedQueryBuilder<UsageData>) {
builder.orderBy('created_at', 'desc').first();
},
},
subscriptions: {
relation: Base.HasManyRelation,
modelClass: Subscription,
join: {
from: 'subscriptions.user_id',
to: 'users.id',
},
},
currentSubscription: {
relation: Base.HasOneRelation, relation: Base.HasOneRelation,
modelClass: Subscription, modelClass: Subscription,
join: { join: {
from: 'subscriptions.user_id', from: 'subscriptions.user_id',
to: 'users.id', to: 'users.id',
}, },
filter(builder: ExtendedQueryBuilder<Subscription>) {
builder.orderBy('created_at', 'desc').first();
},
}, },
}); });
@@ -151,6 +165,29 @@ class User extends Base {
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); 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) { async $beforeInsert(queryContext: QueryContext) {
await super.$beforeInsert(queryContext); await super.$beforeInsert(queryContext);
await this.generateHash(); await this.generateHash();
@@ -167,6 +204,18 @@ class User extends Base {
await this.generateHash(); 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; export default User;

View File

@@ -6,13 +6,13 @@ const router = Router();
const exposeError = const exposeError =
(handler: RequestHandler) => (handler: RequestHandler) =>
async (req: IRequest, res: Response, next: NextFunction) => { async (req: IRequest, res: Response, next: NextFunction) => {
try { try {
await handler(req, res, next); await handler(req, res, next);
} catch (err) { } catch (err) {
next(err); next(err);
} }
}; };
router.post('/webhooks', exposeError(webhooksHandler)); router.post('/webhooks', exposeError(webhooksHandler));

View File

@@ -191,7 +191,8 @@ export default function UsageDataInformation() {
<Divider sx={{ mt: 2 }} /> <Divider sx={{ mt: 2 }} />
</Box> </Box>
<Button {/* free plan has `null` status so that we can show the upgrade button */}
{billingAndUsageData?.subscription?.status === null && <Button
component={Link} component={Link}
to={URLS.SETTINGS_PLAN_UPGRADE} to={URLS.SETTINGS_PLAN_UPGRADE}
size="small" size="small"
@@ -199,7 +200,7 @@ export default function UsageDataInformation() {
sx={{ mt: 2, alignSelf: 'flex-end' }} sx={{ mt: 2, alignSelf: 'flex-end' }}
> >
{formatMessage('usageDataInformation.upgrade')} {formatMessage('usageDataInformation.upgrade')}
</Button> </Button>}
</CardContent> </CardContent>
</Card> </Card>
</React.Fragment> </React.Fragment>