feat: Initial payment implementation
This commit is contained in:
@@ -58,6 +58,7 @@
|
|||||||
"oauth-1.0a": "^2.2.6",
|
"oauth-1.0a": "^2.2.6",
|
||||||
"objection": "^3.0.0",
|
"objection": "^3.0.0",
|
||||||
"pg": "^8.7.1",
|
"pg": "^8.7.1",
|
||||||
|
"stripe": "^11.13.0",
|
||||||
"winston": "^3.7.1"
|
"winston": "^3.7.1"
|
||||||
},
|
},
|
||||||
"contributors": [
|
"contributors": [
|
||||||
|
@@ -39,6 +39,9 @@ type AppConfig = {
|
|||||||
smtpPassword: string;
|
smtpPassword: string;
|
||||||
fromEmail: string;
|
fromEmail: string;
|
||||||
isCloud: boolean;
|
isCloud: boolean;
|
||||||
|
stripeSecretKey: string;
|
||||||
|
stripeStarterPriceKey: string;
|
||||||
|
stripeGrowthPriceKey: string;
|
||||||
licenseKey: string;
|
licenseKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +109,9 @@ const appConfig: AppConfig = {
|
|||||||
smtpPassword: process.env.SMTP_PASSWORD,
|
smtpPassword: process.env.SMTP_PASSWORD,
|
||||||
fromEmail: process.env.FROM_EMAIL,
|
fromEmail: process.env.FROM_EMAIL,
|
||||||
isCloud: process.env.AUTOMATISCH_CLOUD === 'true',
|
isCloud: process.env.AUTOMATISCH_CLOUD === 'true',
|
||||||
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||||
|
stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY,
|
||||||
|
stripeGrowthPriceKey: process.env.STRIPE_GROWTH_PRICE_KEY,
|
||||||
licenseKey: process.env.LICENSE_KEY,
|
licenseKey: process.env.LICENSE_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -0,0 +1,24 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import appConfig from '../../config/app';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!appConfig.isCloud) return;
|
||||||
|
|
||||||
|
return knex.schema.createTable('payment_plans', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.string('name').notNullable();
|
||||||
|
table.integer('task_count').notNullable();
|
||||||
|
table.uuid('user_id').references('id').inTable('users');
|
||||||
|
table.string('stripe_customer_id');
|
||||||
|
table.string('stripe_subscription_id');
|
||||||
|
table.timestamp('current_period_started_at').nullable();
|
||||||
|
table.timestamp('current_period_ends_at').nullable();
|
||||||
|
table.timestamp('deleted_at').nullable();
|
||||||
|
table.timestamps(true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (!appConfig.isCloud) return;
|
||||||
|
return knex.schema.dropTable('payment_plans');
|
||||||
|
}
|
@@ -0,0 +1,20 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import appConfig from '../../config/app';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!appConfig.isCloud) return;
|
||||||
|
|
||||||
|
return knex.schema.createTable('usage_data', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.uuid('user_id').references('id').inTable('users');
|
||||||
|
table.string('consumed_task_count').notNullable();
|
||||||
|
table.timestamp('next_reset_at').nullable();
|
||||||
|
table.timestamp('deleted_at').nullable();
|
||||||
|
table.timestamps(true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (!appConfig.isCloud) return;
|
||||||
|
return knex.schema.dropTable('usage_data');
|
||||||
|
}
|
@@ -1,15 +1,17 @@
|
|||||||
import User from '../../models/user';
|
import User from '../../models/user';
|
||||||
|
import Billing from '../../helpers/billing/index.ee';
|
||||||
|
import appConfig from '../../config/app';
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
input: {
|
input: {
|
||||||
|
fullName: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
fullName: string;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createUser = async (_parent: unknown, params: Params) => {
|
const createUser = async (_parent: unknown, params: Params) => {
|
||||||
const { email, password, fullName } = params.input;
|
const { fullName, email, password } = params.input;
|
||||||
|
|
||||||
const existingUser = await User.query().findOne({ email });
|
const existingUser = await User.query().findOne({ email });
|
||||||
|
|
||||||
@@ -18,12 +20,16 @@ const createUser = async (_parent: unknown, params: Params) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.query().insert({
|
const user = await User.query().insert({
|
||||||
|
fullName,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
fullName,
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (appConfig.isCloud) {
|
||||||
|
Billing.createSubscription(user);
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
96
packages/backend/src/helpers/billing/index.ee.ts
Normal file
96
packages/backend/src/helpers/billing/index.ee.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import Stripe from 'stripe';
|
||||||
|
import User from '../../models/user';
|
||||||
|
import PaymentPlan from '../../models/payment-plan.ee';
|
||||||
|
import UsageData from '../../models/usage-data.ee';
|
||||||
|
import appConfig from '../../config/app';
|
||||||
|
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
price: appConfig.stripeStarterPriceKey,
|
||||||
|
name: 'Starter',
|
||||||
|
taskCount: 1000,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
price: appConfig.stripeGrowthPriceKey,
|
||||||
|
name: 'Growth',
|
||||||
|
taskCount: 10000,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stripe = new Stripe(appConfig.stripeSecretKey, {
|
||||||
|
apiVersion: '2022-11-15',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStripeCustomer = async (user: User) => {
|
||||||
|
const params: Stripe.CustomerCreateParams = {
|
||||||
|
email: user.email,
|
||||||
|
name: user.fullName,
|
||||||
|
description: `User ID: ${user.id}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await stripe.customers.create(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPlan = plans.find((plan) => plan.default);
|
||||||
|
|
||||||
|
const createStripeSubscription = async (
|
||||||
|
user: User,
|
||||||
|
stripeCustomer: Stripe.Customer
|
||||||
|
) => {
|
||||||
|
const params: Stripe.SubscriptionCreateParams = {
|
||||||
|
customer: stripeCustomer.id,
|
||||||
|
items: [{ price: defaultPlan.price }],
|
||||||
|
};
|
||||||
|
|
||||||
|
return await stripe.subscriptions.create(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSubscription = async (user: User) => {
|
||||||
|
const stripeCustomer = await createStripeCustomer(user);
|
||||||
|
const stripeSubscription = await createStripeSubscription(
|
||||||
|
user,
|
||||||
|
stripeCustomer
|
||||||
|
);
|
||||||
|
|
||||||
|
await PaymentPlan.query().insert({
|
||||||
|
name: defaultPlan.name,
|
||||||
|
taskCount: defaultPlan.taskCount,
|
||||||
|
userId: user.id,
|
||||||
|
stripeCustomerId: stripeCustomer.id,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
currentPeriodStartedAt: new Date(
|
||||||
|
stripeSubscription.current_period_start * 1000
|
||||||
|
).toISOString(),
|
||||||
|
currentPeriodEndsAt: new Date(
|
||||||
|
stripeSubscription.current_period_end * 1000
|
||||||
|
).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await UsageData.query().insert({
|
||||||
|
userId: user.id,
|
||||||
|
consumedTaskCount: 0,
|
||||||
|
nextResetAt: new Date(
|
||||||
|
stripeSubscription.current_period_end * 1000
|
||||||
|
).toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPaymentPortalUrl = async (user: User) => {
|
||||||
|
const paymentPlan = await user.$relatedQuery('paymentPlan');
|
||||||
|
|
||||||
|
const userSession = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: paymentPlan.stripeCustomerId,
|
||||||
|
return_url: 'https://cloud.automatisch.io',
|
||||||
|
});
|
||||||
|
|
||||||
|
return userSession.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const billing = {
|
||||||
|
createSubscription,
|
||||||
|
createPaymentPortalUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default billing;
|
52
packages/backend/src/models/payment-plan.ee.ts
Normal file
52
packages/backend/src/models/payment-plan.ee.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Base from './base';
|
||||||
|
import User from './user';
|
||||||
|
|
||||||
|
class PaymentPlan extends Base {
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
taskCount: number;
|
||||||
|
userId!: string;
|
||||||
|
stripeCustomerId!: string;
|
||||||
|
stripeSubscriptionId!: string;
|
||||||
|
currentPeriodStartedAt!: string;
|
||||||
|
currentPeriodEndsAt!: string;
|
||||||
|
|
||||||
|
static tableName = 'payment_plans';
|
||||||
|
|
||||||
|
static jsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: [
|
||||||
|
'name',
|
||||||
|
'taskCount',
|
||||||
|
'userId',
|
||||||
|
'stripeCustomerId',
|
||||||
|
'stripeSubscriptionId',
|
||||||
|
'currentPeriodStartedAt',
|
||||||
|
'currentPeriodEndsAt',
|
||||||
|
],
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
taskCount: { type: 'integer' },
|
||||||
|
userId: { type: 'string', format: 'uuid' },
|
||||||
|
stripeCustomerId: { type: 'string' },
|
||||||
|
stripeSubscriptionId: { type: 'string' },
|
||||||
|
currentPeriodStartedAt: { type: 'string' },
|
||||||
|
currentPeriodEndsAt: { type: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
user: {
|
||||||
|
relation: Base.BelongsToOneRelation,
|
||||||
|
modelClass: User,
|
||||||
|
join: {
|
||||||
|
from: 'payment_plans.user_id',
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PaymentPlan;
|
36
packages/backend/src/models/usage-data.ee.ts
Normal file
36
packages/backend/src/models/usage-data.ee.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Base from './base';
|
||||||
|
import User from './user';
|
||||||
|
|
||||||
|
class UsageData extends Base {
|
||||||
|
id!: string;
|
||||||
|
userId!: string;
|
||||||
|
consumedTaskCount!: number;
|
||||||
|
nextResetAt!: string;
|
||||||
|
|
||||||
|
static tableName = 'usage_data';
|
||||||
|
|
||||||
|
static jsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['userId', 'consumedTaskCount', 'nextResetAt'],
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
userId: { type: 'string', format: 'uuid' },
|
||||||
|
consumedTaskCount: { type: 'integer' },
|
||||||
|
nextResetAt: { type: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
user: {
|
||||||
|
relation: Base.BelongsToOneRelation,
|
||||||
|
modelClass: User,
|
||||||
|
join: {
|
||||||
|
from: 'payment_plans.user_id',
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsageData;
|
@@ -6,6 +6,7 @@ import Step from './step';
|
|||||||
import Execution from './execution';
|
import Execution from './execution';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import PaymentPlan from './payment-plan.ee';
|
||||||
|
|
||||||
class User extends Base {
|
class User extends Base {
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -19,6 +20,7 @@ class User extends Base {
|
|||||||
flows?: Flow[];
|
flows?: Flow[];
|
||||||
steps?: Step[];
|
steps?: Step[];
|
||||||
executions?: Execution[];
|
executions?: Execution[];
|
||||||
|
paymentPlan?: PaymentPlan;
|
||||||
|
|
||||||
static tableName = 'users';
|
static tableName = 'users';
|
||||||
|
|
||||||
@@ -76,6 +78,14 @@ 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
login(password: string) {
|
login(password: string) {
|
||||||
|
15
yarn.lock
15
yarn.lock
@@ -4009,6 +4009,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.10.tgz#616f16e9d3a2a3d618136b1be244315d95bd7cab"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.10.tgz#616f16e9d3a2a3d618136b1be244315d95bd7cab"
|
||||||
integrity sha512-S/3xB4KzyFxYGCppyDt68yzBU9ysL88lSdIah4D6cptdcltc4NCPCAMc0+PCpg/lLIyC7IPvj2Z52OJWeIUkog==
|
integrity sha512-S/3xB4KzyFxYGCppyDt68yzBU9ysL88lSdIah4D6cptdcltc4NCPCAMc0+PCpg/lLIyC7IPvj2Z52OJWeIUkog==
|
||||||
|
|
||||||
|
"@types/node@>=8.1.0":
|
||||||
|
version "18.14.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.5.tgz#4a13a6445862159303fc38586598a9396fc408b3"
|
||||||
|
integrity sha512-CRT4tMK/DHYhw1fcCEBwME9CSaZNclxfzVMe7GsO6ULSwsttbj70wSiX6rZdIjGblu93sTJxLdhNIT85KKI7Qw==
|
||||||
|
|
||||||
"@types/node@^12.0.0":
|
"@types/node@^12.0.0":
|
||||||
version "12.20.42"
|
version "12.20.42"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.42.tgz#2f021733232c2130c26f9eabbdd3bfd881774733"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.42.tgz#2f021733232c2130c26f9eabbdd3bfd881774733"
|
||||||
@@ -14379,7 +14384,7 @@ qqjs@^0.3.11:
|
|||||||
tmp "^0.1.0"
|
tmp "^0.1.0"
|
||||||
write-json-file "^4.1.1"
|
write-json-file "^4.1.1"
|
||||||
|
|
||||||
qs@6.11.0, qs@^6.9.4:
|
qs@6.11.0, qs@^6.11.0, qs@^6.9.4:
|
||||||
version "6.11.0"
|
version "6.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
||||||
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
|
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
|
||||||
@@ -16190,6 +16195,14 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||||
|
|
||||||
|
stripe@^11.13.0:
|
||||||
|
version "11.13.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/stripe/-/stripe-11.13.0.tgz#c930f618b3a111a7618cd582cd8c7a7e54aaaf9e"
|
||||||
|
integrity sha512-Jx0nDbdvRsTtDSX5OFQ+4rLmYIftoiOE9HAXWIgyhAz1QjRFI3UIiJ/kCyhkdJBoHu019O5Ya6EmQ5Zf635XDw==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" ">=8.1.0"
|
||||||
|
qs "^6.11.0"
|
||||||
|
|
||||||
strnum@^1.0.5:
|
strnum@^1.0.5:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db"
|
resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db"
|
||||||
|
Reference in New Issue
Block a user