Merge pull request #1040 from automatisch/usage-data
feat: add subscription update and cancel logics
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
@@ -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');
|
||||
});
|
||||
}
|
@@ -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: {
|
||||
|
@@ -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;
|
||||
|
19
packages/backend/src/graphql/queries/get-trial-status.ee.ts
Normal file
19
packages/backend/src/graphql/queries/get-trial-status.ee.ts
Normal 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;
|
@@ -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,
|
||||
};
|
||||
|
||||
|
@@ -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,
|
||||
};
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
};
|
||||
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user