Merge branch 'main' into spotify-app
This commit is contained in:
@@ -59,6 +59,7 @@
|
||||
"nodemailer": "6.7.0",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"objection": "^3.0.0",
|
||||
"paddle-sdk": "^3.3.0",
|
||||
"pg": "^8.7.1",
|
||||
"stripe": "^11.13.0",
|
||||
"winston": "^3.7.1"
|
||||
|
@@ -40,13 +40,15 @@ app.use(
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(express.urlencoded({
|
||||
extended: false,
|
||||
limit: appConfig.requestBodySizeLimit,
|
||||
verify(req, res, buf) {
|
||||
(req as IRequest).rawBody = buf;
|
||||
},
|
||||
}));
|
||||
app.use(
|
||||
express.urlencoded({
|
||||
extended: true,
|
||||
limit: appConfig.requestBodySizeLimit,
|
||||
verify(req, res, buf) {
|
||||
(req as IRequest).rawBody = buf;
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(cors(corsOptions));
|
||||
app.use('/', router);
|
||||
|
||||
|
@@ -39,10 +39,9 @@ type AppConfig = {
|
||||
smtpPassword: string;
|
||||
fromEmail: string;
|
||||
isCloud: boolean;
|
||||
stripeSecretKey: string;
|
||||
stripeSigningSecret: string;
|
||||
stripeStarterPriceKey: string;
|
||||
stripeGrowthPriceKey: string;
|
||||
paddleVendorId: number;
|
||||
paddleVendorAuthCode: string;
|
||||
paddlePublicKey: string;
|
||||
licenseKey: string;
|
||||
sentryDsn: string;
|
||||
};
|
||||
@@ -111,10 +110,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,
|
||||
stripeSigningSecret: process.env.STRIPE_SIGNING_SECRET,
|
||||
stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY,
|
||||
stripeGrowthPriceKey: process.env.STRIPE_GROWTH_PRICE_KEY,
|
||||
paddleVendorId: Number(process.env.PADDLE_VENDOR_ID),
|
||||
paddleVendorAuthCode: process.env.PADDLE_VENDOR_AUTH_CODE,
|
||||
paddlePublicKey: process.env.PADDLE_PUBLIC_KEY,
|
||||
licenseKey: process.env.LICENSE_KEY,
|
||||
sentryDsn: process.env.SENTRY_DSN,
|
||||
};
|
||||
|
14
packages/backend/src/controllers/paddle/webhooks.ee.ts
Normal file
14
packages/backend/src/controllers/paddle/webhooks.ee.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Response } from 'express';
|
||||
import { IRequest } from '@automatisch/types';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
|
||||
export default async (request: IRequest, response: Response) => {
|
||||
const isVerified = Billing.paddleClient.verifyWebhookData(request.body);
|
||||
|
||||
if (!isVerified) {
|
||||
return response.sendStatus(401);
|
||||
}
|
||||
|
||||
// TODO: Handle Paddle webhooks
|
||||
return response.sendStatus(200);
|
||||
};
|
@@ -1,27 +0,0 @@
|
||||
import { Response } from 'express';
|
||||
import { IRequest } from '@automatisch/types';
|
||||
|
||||
import * as Sentry from '../../helpers/sentry.ee';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
import appConfig from '../../config/app';
|
||||
import logger from '../../helpers/logger';
|
||||
|
||||
export default async (request: IRequest, response: Response) => {
|
||||
const signature = request.headers['stripe-signature'];
|
||||
|
||||
try {
|
||||
const event = Billing.stripe.webhooks.constructEvent(
|
||||
request.rawBody,
|
||||
signature,
|
||||
appConfig.stripeSigningSecret
|
||||
);
|
||||
|
||||
await Billing.handleWebhooks(event);
|
||||
return response.sendStatus(200);
|
||||
} catch (error) {
|
||||
logger.error(`Webhook Error: ${error.message}`);
|
||||
|
||||
Sentry.captureException(error);
|
||||
return response.sendStatus(400);
|
||||
}
|
||||
};
|
@@ -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('users', (table) => {
|
||||
table.date('trial_expiry_date');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.dropColumn('trial_expiry_date');
|
||||
});
|
||||
}
|
@@ -1,6 +1,4 @@
|
||||
import User from '../../models/user';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
import appConfig from '../../config/app';
|
||||
|
||||
type Params = {
|
||||
input: {
|
||||
@@ -26,10 +24,6 @@ const createUser = async (_parent: unknown, params: Params) => {
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
if (appConfig.isCloud) {
|
||||
await Billing.createSubscription(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
|
10
packages/backend/src/graphql/queries/get-paddle-info.ee.ts
Normal file
10
packages/backend/src/graphql/queries/get-paddle-info.ee.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import appConfig from '../../config/app';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
|
||||
const getPaddleInfo = async () => {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
return Billing.paddleInfo;
|
||||
};
|
||||
|
||||
export default getPaddleInfo;
|
10
packages/backend/src/graphql/queries/get-payment-plans.ee.ts
Normal file
10
packages/backend/src/graphql/queries/get-payment-plans.ee.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import appConfig from '../../config/app';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
|
||||
const getPaymentPlans = async () => {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
return Billing.paddlePlans;
|
||||
};
|
||||
|
||||
export default getPaymentPlans;
|
@@ -1,16 +0,0 @@
|
||||
import appConfig from '../../config/app';
|
||||
import Context from '../../types/express/context';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
|
||||
const getPaymentPortalUrl = async (
|
||||
_parent: unknown,
|
||||
_params: unknown,
|
||||
context: Context
|
||||
) => {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
const url = Billing.createPaymentPortalUrl(context.currentUser);
|
||||
return { url };
|
||||
};
|
||||
|
||||
export default getPaymentPortalUrl;
|
@@ -12,7 +12,8 @@ import getDynamicData from './queries/get-dynamic-data';
|
||||
import getDynamicFields from './queries/get-dynamic-fields';
|
||||
import getCurrentUser from './queries/get-current-user';
|
||||
import getUsageData from './queries/get-usage-data.ee';
|
||||
import getPaymentPortalUrl from './queries/get-payment-portal-url.ee';
|
||||
import getPaymentPlans from './queries/get-payment-plans.ee';
|
||||
import getPaddleInfo from './queries/get-paddle-info.ee';
|
||||
import getAutomatischInfo from './queries/get-automatisch-info';
|
||||
import healthcheck from './queries/healthcheck';
|
||||
|
||||
@@ -31,7 +32,8 @@ const queryResolvers = {
|
||||
getDynamicFields,
|
||||
getCurrentUser,
|
||||
getUsageData,
|
||||
getPaymentPortalUrl,
|
||||
getPaymentPlans,
|
||||
getPaddleInfo,
|
||||
getAutomatischInfo,
|
||||
healthcheck,
|
||||
};
|
||||
|
@@ -35,7 +35,8 @@ type Query {
|
||||
): [SubstepArgument]
|
||||
getCurrentUser: User
|
||||
getUsageData: GetUsageData
|
||||
getPaymentPortalUrl: GetPaymentPortalUrl
|
||||
getPaymentPlans: [PaymentPlan]
|
||||
getPaddleInfo: GetPaddleInfo
|
||||
getAutomatischInfo: GetAutomatischInfo
|
||||
healthcheck: AppHealth
|
||||
}
|
||||
@@ -477,8 +478,16 @@ type GetUsageData {
|
||||
nextResetAt: String
|
||||
}
|
||||
|
||||
type GetPaymentPortalUrl {
|
||||
url: String
|
||||
type GetPaddleInfo {
|
||||
sandbox: Boolean
|
||||
vendorId: Int
|
||||
}
|
||||
|
||||
type PaymentPlan {
|
||||
name: String
|
||||
limit: String
|
||||
price: String
|
||||
productId: String
|
||||
}
|
||||
|
||||
schema {
|
||||
|
@@ -1,100 +1,16 @@
|
||||
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';
|
||||
import handleWebhooks from './webhooks.ee';
|
||||
import paddleClient from './paddle.ee';
|
||||
import paddlePlans from './plans.ee';
|
||||
|
||||
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/settings/billing',
|
||||
});
|
||||
|
||||
return userSession.url;
|
||||
const paddleInfo = {
|
||||
sandbox: appConfig.isDev ? true : false,
|
||||
vendorId: appConfig.paddleVendorId,
|
||||
};
|
||||
|
||||
const billing = {
|
||||
createSubscription,
|
||||
createPaymentPortalUrl,
|
||||
handleWebhooks,
|
||||
stripe,
|
||||
plans,
|
||||
paddleClient,
|
||||
paddlePlans,
|
||||
paddleInfo,
|
||||
};
|
||||
|
||||
export default billing;
|
||||
|
10
packages/backend/src/helpers/billing/paddle.ee.ts
Normal file
10
packages/backend/src/helpers/billing/paddle.ee.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import PaddleSDK from 'paddle-sdk';
|
||||
import appConfig from '../../config/app';
|
||||
|
||||
const paddleClient = new PaddleSDK(
|
||||
appConfig.paddleVendorId.toString(),
|
||||
appConfig.paddleVendorAuthCode,
|
||||
appConfig.paddlePublicKey
|
||||
);
|
||||
|
||||
export default paddleClient;
|
16
packages/backend/src/helpers/billing/plans.ee.ts
Normal file
16
packages/backend/src/helpers/billing/plans.ee.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const plans = [
|
||||
{
|
||||
name: '10k - monthly',
|
||||
limit: '10,000',
|
||||
price: '€20',
|
||||
productId: '47384',
|
||||
},
|
||||
{
|
||||
name: '30k - monthly',
|
||||
limit: '30,000',
|
||||
price: '€50',
|
||||
productId: '47419',
|
||||
},
|
||||
];
|
||||
|
||||
export default plans;
|
@@ -1,42 +0,0 @@
|
||||
import Stripe from 'stripe';
|
||||
import PaymentPlan from '../../models/payment-plan.ee';
|
||||
import Billing from './index.ee';
|
||||
|
||||
const handleWebhooks = async (event: Stripe.Event) => {
|
||||
const trackedWebhookTypes = [
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
];
|
||||
|
||||
if (!trackedWebhookTypes.includes(event.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updatePaymentPlan(event);
|
||||
};
|
||||
|
||||
const updatePaymentPlan = async (event: Stripe.Event) => {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const priceKey = subscription.items.data[0].plan.id;
|
||||
const plan = Billing.plans.find((plan) => plan.price === priceKey);
|
||||
|
||||
const paymentPlan = await PaymentPlan.query().findOne({
|
||||
stripe_customer_id: subscription.customer,
|
||||
});
|
||||
|
||||
await paymentPlan.$query().patchAndFetch({
|
||||
name: plan.name,
|
||||
taskCount: plan.taskCount,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
});
|
||||
|
||||
const user = await paymentPlan.$relatedQuery('user');
|
||||
const usageData = await user.$relatedQuery('usageData');
|
||||
|
||||
await usageData.$query().patchAndFetch({
|
||||
nextResetAt: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
export default handleWebhooks;
|
@@ -1,4 +1,6 @@
|
||||
import { QueryContext, ModelOptions } from 'objection';
|
||||
import { DateTime } from 'luxon';
|
||||
import appConfig from '../config/app';
|
||||
import Base from './base';
|
||||
import Connection from './connection';
|
||||
import Flow from './flow';
|
||||
@@ -17,6 +19,7 @@ class User extends Base {
|
||||
role: string;
|
||||
resetPasswordToken: string;
|
||||
resetPasswordTokenSentAt: string;
|
||||
trialExpiryDate: string;
|
||||
connections?: Connection[];
|
||||
flows?: Flow[];
|
||||
steps?: Step[];
|
||||
@@ -133,9 +136,17 @@ class User extends Base {
|
||||
this.password = await bcrypt.hash(this.password, 10);
|
||||
}
|
||||
|
||||
async startTrialPeriod() {
|
||||
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toFormat('D');
|
||||
}
|
||||
|
||||
async $beforeInsert(queryContext: QueryContext) {
|
||||
await super.$beforeInsert(queryContext);
|
||||
await this.generateHash();
|
||||
|
||||
if (appConfig.isCloud) {
|
||||
await this.startTrialPeriod();
|
||||
}
|
||||
}
|
||||
|
||||
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import graphQLInstance from '../helpers/graphql-instance';
|
||||
import webhooksRouter from './webhooks';
|
||||
import stripeRouter from './stripe.ee';
|
||||
import paddleRouter from './paddle.ee';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/graphql', graphQLInstance);
|
||||
router.use('/webhooks', webhooksRouter);
|
||||
router.use('/stripe', stripeRouter);
|
||||
router.use('/paddle', paddleRouter);
|
||||
|
||||
export default router;
|
||||
|
19
packages/backend/src/routes/paddle.ee.ts
Normal file
19
packages/backend/src/routes/paddle.ee.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Response, Router, NextFunction, RequestHandler } from 'express';
|
||||
import { IRequest } from '@automatisch/types';
|
||||
import webhooksHandler from '../controllers/paddle/webhooks.ee';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const exposeError =
|
||||
(handler: RequestHandler) =>
|
||||
async (req: IRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await handler(req, res, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
router.post('/webhooks', exposeError(webhooksHandler));
|
||||
|
||||
export default router;
|
@@ -1,23 +0,0 @@
|
||||
import express, { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { IRequest } from '@automatisch/types';
|
||||
import appConfig from '../config/app';
|
||||
import stripeWebhooksAction from '../controllers/stripe/webhooks.ee';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer();
|
||||
|
||||
router.use(upload.none());
|
||||
|
||||
router.use(
|
||||
express.text({
|
||||
limit: appConfig.requestBodySizeLimit,
|
||||
verify(req, res, buf) {
|
||||
(req as IRequest).rawBody = buf;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/webhooks', stripeWebhooksAction);
|
||||
|
||||
export default router;
|
Reference in New Issue
Block a user