diff --git a/packages/backend/package.json b/packages/backend/package.json index 889199e5..f0875737 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -58,6 +58,7 @@ "oauth-1.0a": "^2.2.6", "objection": "^3.0.0", "pg": "^8.7.1", + "stripe": "^11.13.0", "winston": "^3.7.1" }, "contributors": [ diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index d705d8d7..c3814b7c 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -39,6 +39,9 @@ type AppConfig = { smtpPassword: string; fromEmail: string; isCloud: boolean; + stripeSecretKey: string; + stripeStarterPriceKey: string; + stripeGrowthPriceKey: string; licenseKey: string; }; @@ -106,6 +109,9 @@ const appConfig: AppConfig = { smtpPassword: process.env.SMTP_PASSWORD, fromEmail: process.env.FROM_EMAIL, 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, }; diff --git a/packages/backend/src/db/migrations/20230303134548_create_payment_plans.ee.ts b/packages/backend/src/db/migrations/20230303134548_create_payment_plans.ee.ts new file mode 100644 index 00000000..068b35a8 --- /dev/null +++ b/packages/backend/src/db/migrations/20230303134548_create_payment_plans.ee.ts @@ -0,0 +1,24 @@ +import { Knex } from 'knex'; +import appConfig from '../../config/app'; + +export async function up(knex: Knex): Promise { + 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 { + if (!appConfig.isCloud) return; + return knex.schema.dropTable('payment_plans'); +} diff --git a/packages/backend/src/db/migrations/20230303180902_create_usage_data.ee.ts b/packages/backend/src/db/migrations/20230303180902_create_usage_data.ee.ts new file mode 100644 index 00000000..31bfbb7e --- /dev/null +++ b/packages/backend/src/db/migrations/20230303180902_create_usage_data.ee.ts @@ -0,0 +1,20 @@ +import { Knex } from 'knex'; +import appConfig from '../../config/app'; + +export async function up(knex: Knex): Promise { + 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 { + if (!appConfig.isCloud) return; + return knex.schema.dropTable('usage_data'); +} diff --git a/packages/backend/src/graphql/mutations/create-user.ee.ts b/packages/backend/src/graphql/mutations/create-user.ee.ts index 4a92faa6..a96828f1 100644 --- a/packages/backend/src/graphql/mutations/create-user.ee.ts +++ b/packages/backend/src/graphql/mutations/create-user.ee.ts @@ -1,15 +1,17 @@ import User from '../../models/user'; +import Billing from '../../helpers/billing/index.ee'; +import appConfig from '../../config/app'; type Params = { input: { + fullName: string; email: string; password: string; - fullName: string; }; }; 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 }); @@ -18,12 +20,16 @@ const createUser = async (_parent: unknown, params: Params) => { } const user = await User.query().insert({ + fullName, email, password, - fullName, role: 'user', }); + if (appConfig.isCloud) { + Billing.createSubscription(user); + } + return user; }; diff --git a/packages/backend/src/helpers/billing/index.ee.ts b/packages/backend/src/helpers/billing/index.ee.ts new file mode 100644 index 00000000..24e72f2a --- /dev/null +++ b/packages/backend/src/helpers/billing/index.ee.ts @@ -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; diff --git a/packages/backend/src/models/payment-plan.ee.ts b/packages/backend/src/models/payment-plan.ee.ts new file mode 100644 index 00000000..fdec5ed9 --- /dev/null +++ b/packages/backend/src/models/payment-plan.ee.ts @@ -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; diff --git a/packages/backend/src/models/usage-data.ee.ts b/packages/backend/src/models/usage-data.ee.ts new file mode 100644 index 00000000..50f8ea8a --- /dev/null +++ b/packages/backend/src/models/usage-data.ee.ts @@ -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; diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index c5cc9e7c..32ca83e5 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -6,6 +6,7 @@ import Step from './step'; import Execution from './execution'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; +import PaymentPlan from './payment-plan.ee'; class User extends Base { id!: string; @@ -19,6 +20,7 @@ class User extends Base { flows?: Flow[]; steps?: Step[]; executions?: Execution[]; + paymentPlan?: PaymentPlan; static tableName = 'users'; @@ -76,6 +78,14 @@ class User extends Base { to: 'executions.flow_id', }, }, + paymentPlan: { + relation: Base.HasOneRelation, + modelClass: PaymentPlan, + join: { + from: 'payment_plans.user_id', + to: 'users.id', + }, + }, }); login(password: string) { diff --git a/yarn.lock b/yarn.lock index 1a2f1955..742a7a0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4009,6 +4009,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.10.tgz#616f16e9d3a2a3d618136b1be244315d95bd7cab" 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": version "12.20.42" 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" 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" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" 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" 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: version "1.0.5" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db"