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') {
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);
}

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
) => {
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: {

View File

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

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 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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -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'),
});
}
}

View File

@@ -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<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,
modelClass: Subscription,
join: {
from: 'subscriptions.user_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();
}
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;

View File

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