From 6018047f2647b6514ed4640d62b30b90f3a79e3a Mon Sep 17 00:00:00 2001 From: Leo Phan Date: Fri, 4 Dec 2020 18:36:46 +0700 Subject: [PATCH] #23 & #28 | fix subscription --- api/email-templates/invoicePaymentFailed.mjml | 26 +++ .../invoicePaymentSuccess.mjml | 26 +++ api/email-templates/trialWillEnd.mjml | 26 +++ api/graphql/resolvers/stripe.resolver.js | 11 -- api/graphql/root.resolver.js | 3 +- api/graphql/root.schema.js | 3 +- api/graphql/schemas/stripe.schema.js | 9 - api/graphql/schemas/user-plan.schema.js | 2 + api/jobs/main.js | 9 + .../20201126173501_create-user-plans.js | 5 + ...20201126180852_create-user-permissions.js} | 3 + api/package.json | 1 + api/repository/user.repository.js | 15 +- api/repository/user_permission.repository.js | 20 ++- api/repository/user_plans.repository.js | 30 +++- api/scripts/create-products.js | 124 ++++++++----- api/server.js | 3 + .../authentication/register.service.js | 30 ++-- api/services/stripe/subcription.service.js | 48 +++-- api/services/stripe/webhooks.servive.js | 31 ++++ api/services/user/plans-user.service.js | 165 ++++++++++++++++-- api/utils/format-date-db.js | 9 + app/src/containers/Profile/PlanSetting.jsx | 28 +-- app/src/containers/Stripe/index.jsx | 6 +- app/src/queries/stripe/createSubcription.js | 7 - app/src/queries/userPlans/getUserPlan.js | 2 + 26 files changed, 507 insertions(+), 135 deletions(-) create mode 100644 api/email-templates/invoicePaymentFailed.mjml create mode 100644 api/email-templates/invoicePaymentSuccess.mjml create mode 100644 api/email-templates/trialWillEnd.mjml delete mode 100644 api/graphql/resolvers/stripe.resolver.js delete mode 100644 api/graphql/schemas/stripe.schema.js create mode 100644 api/jobs/main.js rename api/migrations/{20201116120852_create-user-permissions.js => 20201126180852_create-user-permissions.js} (80%) create mode 100644 api/services/stripe/webhooks.servive.js create mode 100644 api/utils/format-date-db.js delete mode 100644 app/src/queries/stripe/createSubcription.js diff --git a/api/email-templates/invoicePaymentFailed.mjml b/api/email-templates/invoicePaymentFailed.mjml new file mode 100644 index 0000000..4ce73c0 --- /dev/null +++ b/api/email-templates/invoicePaymentFailed.mjml @@ -0,0 +1,26 @@ + + + + + + + + + + + Invoice payment failed + + + + Hi {{name}}! Your invoice payment failed. Your plan expire on {{date}}. + + + Please check your card and payment again to extend your plan. + + + + + diff --git a/api/email-templates/invoicePaymentSuccess.mjml b/api/email-templates/invoicePaymentSuccess.mjml new file mode 100644 index 0000000..ee42c27 --- /dev/null +++ b/api/email-templates/invoicePaymentSuccess.mjml @@ -0,0 +1,26 @@ + + + + + + + + + + + Invoice payment successfully + + + + Hi {{name}}! Your invoice payment successfully. Your plan expire on {{date}}. + + + You can check your invoice at this link. + + + + + diff --git a/api/email-templates/trialWillEnd.mjml b/api/email-templates/trialWillEnd.mjml new file mode 100644 index 0000000..3d3329b --- /dev/null +++ b/api/email-templates/trialWillEnd.mjml @@ -0,0 +1,26 @@ + + + + + + + + + + + Trial will end + + + + Hi {{name}}! Your trial will expire on {{date}}. We will automatically charge your money when the trial ends. + + + Please go to your plan detail if you want to change or unsubscribe subscription. + + + + + diff --git a/api/graphql/resolvers/stripe.resolver.js b/api/graphql/resolvers/stripe.resolver.js deleted file mode 100644 index 05c785f..0000000 --- a/api/graphql/resolvers/stripe.resolver.js +++ /dev/null @@ -1,11 +0,0 @@ -import { createNewSubcription } from '~/services/stripe/subcription.service'; - -const resolvers = { - Mutation: { - createSubcription(_, { token }, { user }) { - return createNewSubcription(token, user); - }, - }, -}; - -export default resolvers; diff --git a/api/graphql/root.resolver.js b/api/graphql/root.resolver.js index 4e60b0c..8593c71 100644 --- a/api/graphql/root.resolver.js +++ b/api/graphql/root.resolver.js @@ -1,5 +1,4 @@ import userResolves from './resolvers/user.resolver'; import userPlanResolves from './resolvers/user-plan.resolver'; -import stripeResolves from './resolvers/stripe.resolver'; -export default [userResolves, userPlanResolves, stripeResolves]; +export default [userResolves, userPlanResolves]; diff --git a/api/graphql/root.schema.js b/api/graphql/root.schema.js index 583542b..6551413 100644 --- a/api/graphql/root.schema.js +++ b/api/graphql/root.schema.js @@ -1,7 +1,6 @@ import Apollo from 'apollo-server-express'; import { UserSchema } from './schemas/user.schema'; import { UserPlanSchema } from './schemas/user-plan.schema'; -import { StripeSchema } from './schemas/stripe.schema'; const { gql } = Apollo; const rootSchema = gql` @@ -21,4 +20,4 @@ const rootSchema = gql` } `; -export default [rootSchema, UserSchema, UserPlanSchema, StripeSchema]; +export default [rootSchema, UserSchema, UserPlanSchema]; diff --git a/api/graphql/schemas/stripe.schema.js b/api/graphql/schemas/stripe.schema.js deleted file mode 100644 index c49d254..0000000 --- a/api/graphql/schemas/stripe.schema.js +++ /dev/null @@ -1,9 +0,0 @@ -import pkg from 'apollo-server-express'; - -const { gql } = pkg; - -export const StripeSchema = gql` - extend type Mutation { - createSubcription(token: String!): Boolean! - } -`; diff --git a/api/graphql/schemas/user-plan.schema.js b/api/graphql/schemas/user-plan.schema.js index c3a35e8..6582759 100644 --- a/api/graphql/schemas/user-plan.schema.js +++ b/api/graphql/schemas/user-plan.schema.js @@ -13,6 +13,8 @@ export const UserPlanSchema = gql` amount: Float! productType: String! priceType: String! + expiredAt: Date + deletedAt: Date } extend type Query { diff --git a/api/jobs/main.js b/api/jobs/main.js new file mode 100644 index 0000000..7020721 --- /dev/null +++ b/api/jobs/main.js @@ -0,0 +1,9 @@ +function main() { + Promise.all([ + // todo + ]).then(() => { + process.exit(0); + }).catch(() => process.exit(0)); +} + +main(); diff --git a/api/migrations/20201126173501_create-user-plans.js b/api/migrations/20201126173501_create-user-plans.js index 57f9a6a..e166528 100644 --- a/api/migrations/20201126173501_create-user-plans.js +++ b/api/migrations/20201126173501_create-user-plans.js @@ -5,12 +5,17 @@ export function up(knex) { t.integer('product_id').unsigned().notNullable(); t.integer('price_id').unsigned().notNullable(); t.string('subcription_id').notNullable(); + t.string('customer_id').notNullable(); + t.boolean('is_trial').defaultTo(false); + t.dateTime('expired_at'); + t.boolean('is_active').defaultTo(true); t.dateTime('created_at') .notNullable() .defaultTo(knex.raw('CURRENT_TIMESTAMP')); t.dateTime('updated_at') .notNullable() .defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')); + t.dateTime('deleted_at'); t.foreign('user_id').references('id').inTable('users'); t.foreign('product_id').references('id').inTable('products'); t.foreign('price_id').references('id').inTable('prices'); diff --git a/api/migrations/20201116120852_create-user-permissions.js b/api/migrations/20201126180852_create-user-permissions.js similarity index 80% rename from api/migrations/20201116120852_create-user-permissions.js rename to api/migrations/20201126180852_create-user-permissions.js index 485b962..610a047 100644 --- a/api/migrations/20201116120852_create-user-permissions.js +++ b/api/migrations/20201126180852_create-user-permissions.js @@ -3,13 +3,16 @@ export function up(knex) { t.increments('id'); t.integer('user_id').unsigned().notNullable(); t.string('permission'); + t.integer('user_plan_id').unsigned(); t.dateTime('created_at') .notNullable() .defaultTo(knex.raw('CURRENT_TIMESTAMP')); t.dateTime('updated_at') .notNullable() .defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')); + t.dateTime('deleted_at'); t.foreign('user_id').references('id').inTable('users'); + t.foreign('user_plan_id').references('id').inTable('user_plans'); }); } diff --git a/api/package.json b/api/package.json index a18875a..28b86d9 100644 --- a/api/package.json +++ b/api/package.json @@ -22,6 +22,7 @@ "apollo-server-express": "^2.19.0", "axios": "^0.21.0", "bcryptjs": "^2.4.3", + "body-parser": "^1.19.0", "bunyan": "^1.8.14", "cors": "^2.8.5", "crypto": "^1.0.1", diff --git a/api/repository/user.repository.js b/api/repository/user.repository.js index 867cd71..da116f4 100644 --- a/api/repository/user.repository.js +++ b/api/repository/user.repository.js @@ -3,6 +3,8 @@ import database from '~/config/database.config'; import { userTokenColumns } from './user_tokens.repository'; import { TABLES } from '~/constants/table-name.constant'; import { insertUserPlan } from './user_plans.repository'; +import { insertMultiPermission } from './user_permission.repository'; +import { PERMISSION_PLAN } from '~/constants/billing.constant'; const TABLE = TABLES.users; @@ -30,20 +32,29 @@ export async function findUser({ id, email, provider_id, provider, deleted_at = return database(TABLE).where(condition).first(); } -export async function createUser(userData, userPlanData = null) { +export async function createUser(userData, userPlanData = null, planType = null) { let t; try { t = await database.transaction(); const [userId] = await database(TABLE).transacting(t).insert(userData); if (userPlanData) { - await insertUserPlan( + const [userPlanId] = await insertUserPlan( { ...userPlanData, user_id: userId, }, t, ); + + if (planType && PERMISSION_PLAN[planType]) { + const userPermissionData = PERMISSION_PLAN[planType].map((permission) => ({ + user_id: userId, + user_plan_id: userPlanId, + permission, + })); + await insertMultiPermission(userPermissionData, t); + } } await t.commit(); diff --git a/api/repository/user_permission.repository.js b/api/repository/user_permission.repository.js index 0dda3dc..1015491 100644 --- a/api/repository/user_permission.repository.js +++ b/api/repository/user_permission.repository.js @@ -1,16 +1,32 @@ import database from '~/config/database.config'; import { TABLES } from '~/constants/table-name.constant'; +import formatDateDB from '~/utils/format-date-db'; const TABLE = TABLES.userPermissions; export const userPermissionColumns = { id: 'user_permissions.id', userId: 'user_permissions.user_id', + userPlanId: 'user_permissions.user_plan_id', permission: 'user_permissions.permission', createAt: 'user_permissions.created_at', updatedAt: 'user_permissions.updated_at', + deletedAt: 'user_permissions.deleted_at', }; -export async function insertMultiPermission(data) { - return database(TABLE).insert(data); +export async function insertMultiPermission(data, transaction = null) { + const query = database(TABLE).insert(data); + if (!transaction) { + return query; + } + return query.transacting(transaction); +} + +export function deletePermissionByUserPlanId(userPlanId, dateDeleted = null) { + const deleteAt = dateDeleted || formatDateDB(); + return database(TABLE).where({ [userPermissionColumns.userPlanId]: userPlanId }).update({ deleted_at: deleteAt }); +} + +export function deletePermissionByUserPlanIds(userPlanIds) { + return database(TABLE).whereIn({ [userPermissionColumns.userPlanId]: userPlanIds }).update({ deleted_at: formatDateDB() }); } diff --git a/api/repository/user_plans.repository.js b/api/repository/user_plans.repository.js index d0bc09c..a871b8d 100644 --- a/api/repository/user_plans.repository.js +++ b/api/repository/user_plans.repository.js @@ -1,5 +1,6 @@ import database from '~/config/database.config'; import { TABLES } from '~/constants/table-name.constant'; +import formatDateDB from '~/utils/format-date-db'; import { priceColumns } from './prices.repository'; import { productColumns } from './products.repository'; @@ -11,8 +12,13 @@ export const userPlanColumns = { productId: 'user_plans.product_id', priceId: 'user_plans.price_id', subcriptionId: 'user_plans.subcription_id', + customerId: 'user_plans.customer_id', + isTrial: 'user_plans.is_trial', + expiredAt: 'user_plans.expired_at', + isActive: 'user_plans.is_active', createAt: 'user_plans.created_at', updatedAt: 'user_plans.updated_at', + deletedAt: 'user_plans.deleted_at', }; export function insertUserPlan(data, transaction = null) { @@ -24,7 +30,24 @@ export function insertUserPlan(data, transaction = null) { } export function getUserPlanById(id) { - return database(TABLE).where({ id }).first(); + return database(TABLE) + .where({ id, [userPlanColumns.isActive]: true }) + .where(userPlanColumns.expiredAt, '>=', formatDateDB()) + .first(); +} + +export function getUserPlanExpired() { + return database(TABLE) + .whereNotNull([userPlanColumns.deletedAt]) + .where(userPlanColumns.expiredAt, '<', formatDateDB()); +} + +export function getUserPlanByCustomerId(customerId) { + return database(TABLE) + .leftJoin(TABLES.prices, userPlanColumns.priceId, priceColumns.id) + .select(userPlanColumns, `${priceColumns.type} as priceType`) + .where({ [userPlanColumns.customerId]: customerId, [userPlanColumns.isActive]: true }) + .first(); } export function updateUserPlanById(id, data) { @@ -36,10 +59,11 @@ export function getUserPlanByUserId(userId) { .leftJoin(TABLES.products, userPlanColumns.productId, productColumns.id) .leftJoin(TABLES.prices, userPlanColumns.priceId, priceColumns.id) .select(userPlanColumns, productColumns.name, `${productColumns.type} as productType`, priceColumns.amount, `${priceColumns.type} as priceType`) - .where({ user_id: userId }) + .where({ [userPlanColumns.userId]: userId, [userPlanColumns.isActive]: true }) + .where(userPlanColumns.expiredAt, '>=', formatDateDB()) .first(); } export function deleteUserPlanById(id) { - return database(TABLE).where({ id }).del(); + return database(TABLE).where({ id }).update({ [userPlanColumns.deletedAt]: formatDateDB() }); } diff --git a/api/scripts/create-products.js b/api/scripts/create-products.js index 1496d32..dd47255 100644 --- a/api/scripts/create-products.js +++ b/api/scripts/create-products.js @@ -8,54 +8,98 @@ const products = [ { name: 'Professional', amount: 295 }, ]; -async function createProductItem(item) { - const product = await stripe.products.create({ name: item.name }); - if (!product) { - return false; - } +async function createProductItem(item, dataStripe) { + const productStripe = dataStripe.find((pro) => pro.name === item.name); + + let productData = null; + let priceData = []; + if (productStripe) { + productData = { + name: item.name, + type: item.name.toLowerCase(), + stripe_id: productStripe.id, + }; + priceData = [ + { + amount: item.amount, + type: 'monthly', + stripe_id: productStripe.prices.find((pri) => pri.type === 'month').id, + }, + { + amount: item.amount * 9, + type: 'yearly', + stripe_id: productStripe.prices.find((pri) => pri.type === 'year').id, + }, + ]; + } else { + const product = await stripe.products.create({ name: item.name }); + if (!product) { + return false; + } + + const [priceMonth, priceYear] = await Promise.all([ + stripe.prices.create({ + unit_amount: item.amount * 100, + currency: 'usd', + recurring: { interval: 'month' }, + product: product.id, + }), + stripe.prices.create({ + unit_amount: item.amount * 9 * 100, + currency: 'usd', + recurring: { interval: 'year' }, + product: product.id, + }), + ]); - const [priceMonth, priceYear] = await Promise.all([ - stripe.prices.create({ - unit_amount: item.amount * 100, - currency: 'usd', - recurring: { interval: 'month' }, - product: product.id, - }), - stripe.prices.create({ - unit_amount: item.amount * 9 * 100, - currency: 'usd', - recurring: { interval: 'year' }, - product: product.id, - }), - ]); + if (!priceMonth || !priceYear) { + return false; + } - if (!priceMonth || !priceYear) { - return false; + productData = { + name: item.name, + type: item.name.toLowerCase(), + stripe_id: product.id, + }; + + priceData = [ + { + amount: item.amount, + type: 'monthly', + stripe_id: priceMonth.id, + }, + { + amount: item.amount * 9, + type: 'yearly', + stripe_id: priceYear.id, + }, + ]; } - const productData = { - name: item.name, - type: item.name.toLowerCase(), - stripe_id: product.id, - }; + if (productData && priceData.length) { + return insertProduct(productData, priceData); + } - const priceData = [ - { - amount: item.amount, - type: 'monthly', - stripe_id: priceMonth.id, - }, - { - amount: item.amount * 9, - type: 'yearly', - stripe_id: priceYear.id, - }, - ]; + return true; +} - return insertProduct(productData, priceData); +async function getProductAndPriceStripe(pro) { + const { data: dataPrices } = await stripe.prices.list({ product: pro.id }); + const prices = dataPrices?.map((pri) => ({ id: pri.id, type: pri.recurring.interval })); + return { + id: pro.id, + name: pro.name, + prices, + }; } async function run() { + const { data: stripeProducts } = await stripe.products.list(); + let data = []; + if (stripeProducts.length) { + data = await Promise.all(stripeProducts.map((pro) => getProductAndPriceStripe(pro))); + } + const productTypes = products.map((product) => product.name.toLowerCase()); const listProducts = await findProductInType(productTypes); let newProducts = products; @@ -64,7 +108,7 @@ async function run() { newProducts = products.filter((product) => !typesExist.includes(product.name.toLowerCase())); } if (newProducts.length > 0) { - return Promise.all(newProducts.map((productItem) => createProductItem(productItem))); + return Promise.all(newProducts.map((productItem) => createProductItem(productItem, data))); } return true; } diff --git a/api/server.js b/api/server.js index 3987403..739f536 100644 --- a/api/server.js +++ b/api/server.js @@ -3,6 +3,7 @@ import dotenv from 'dotenv'; import express from 'express'; import morgan from 'morgan'; import cors from 'cors'; +import bodyParser from 'body-parser'; import Apollo from 'apollo-server-express'; import Sentry from '@sentry/node'; @@ -11,6 +12,7 @@ import accessLogStream from './middlewares/logger.middleware'; import RootSchema from './graphql/root.schema'; import RootResolver from './graphql/root.resolver'; import getUserLogined from './services/authentication/get-user-logined.service'; +import stripeHooks from './services/stripe/webhooks.servive'; dotenv.config(); @@ -29,6 +31,7 @@ const corsOptions = { app.get('/', (req, res) => { res.send('Hello World!'); }); + app.post('/stripe-hooks', bodyParser.raw({ type: 'application/json' }), stripeHooks); const serverGraph = new Apollo.ApolloServer({ schema: Apollo.makeExecutableSchema({ diff --git a/api/services/authentication/register.service.js b/api/services/authentication/register.service.js index 6268257..9b8ba1f 100644 --- a/api/services/authentication/register.service.js +++ b/api/services/authentication/register.service.js @@ -1,4 +1,5 @@ -import pkg from 'apollo-server-express'; +import { ValidationError, ApolloError } from 'apollo-server-express'; +import dayjs from 'dayjs'; import { findUser, createUser } from '~/repository/user.repository'; import { createToken } from '~/repository/user_tokens.repository'; @@ -11,11 +12,8 @@ import logger from '~/utils/logger'; import { sign } from '~/helpers/jwt.helper'; import { findProductAndPriceByType } from '~/repository/products.repository'; import { createNewSubcription } from '~/services/stripe/subcription.service'; -import { addMultiPermissions } from '~/services/user/permission.service'; -import { PERMISSION_PLAN } from '~/constants/billing.constant'; import { SEND_MAIL_TYPE } from '~/constants/send-mail-type.constant'; - -const { ValidationError, ApolloError } = pkg; +import formatDateDB from '~/utils/format-date-db'; async function registerUser(email, password, name, paymentMethodToken, planName, billingType) { const validateResult = registerValidation({ email, password, name }); @@ -49,15 +47,19 @@ async function registerUser(email, password, name, paymentMethodToken, planName, return new ApolloError('Can not find any plan'); } - const subscriptionId = await createNewSubcription(paymentMethodToken, email, name, product.price_stripe_id); + const { subcription_id, customer_id } = await createNewSubcription(paymentMethodToken, email, name, product.price_stripe_id, true); - if (subscriptionId) { + if (subcription_id && customer_id) { const userPlanData = { product_id: product.id, price_id: product.price_id, - subcription_id: subscriptionId, + customer_id, + subcription_id, + is_trial: true, + expired_at: formatDateDB(dayjs().add(14, 'd')), }; - newUserId = await createUser(userData, userPlanData); + + newUserId = await createUser(userData, userPlanData, product.type); } } else { newUserId = await createUser(userData); @@ -72,17 +74,13 @@ async function registerUser(email, password, name, paymentMethodToken, planName, }, }); - const pms = [ + await Promise.all([ sendMail(email, 'Confirm your email address', template), createToken(newUserId, tokenVerifyEmail, SEND_MAIL_TYPE.VERIFY_EMAIL), - ]; - if (planName && PERMISSION_PLAN[planName]) { - pms.push(addMultiPermissions(newUserId, PERMISSION_PLAN[planName])); - } - - await Promise.all(pms); + ]); const token = sign({ email, name }); + return { token }; } catch (error) { logger.error(error); diff --git a/api/services/stripe/subcription.service.js b/api/services/stripe/subcription.service.js index ea7c0ee..69112be 100644 --- a/api/services/stripe/subcription.service.js +++ b/api/services/stripe/subcription.service.js @@ -11,10 +11,14 @@ const { ApolloError } = Apollo; * Create new subcription * * @param {string} token + * @param {string} email + * @param {string} name + * @param {string} priceId + * @param {boolean} isTrial * * @returns {Promise} */ -export async function createNewSubcription(token, email, name, price_id) { +export async function createNewSubcription(token, email, name, priceId, isTrial = false) { if (!token) { throw new ApolloError('Invalid token'); } @@ -26,26 +30,39 @@ export async function createNewSubcription(token, email, name, price_id) { source: token, }); - const result = await stripe.subscriptions.create({ + const dataSubcription = { customer: customer.id, - items: [{ price: price_id }], - trial_end: dayjs().add(14, 'day').unix(), - }); + items: [{ price: priceId }], + }; + if (isTrial) { + dataSubcription.trial_end = dayjs().add(14, 'd').unix(); + } + + const result = await stripe.subscriptions.create(dataSubcription); - return result.id; + return { + customer_id: customer.id, + subcription_id: result.id, + }; } catch (error) { logger.error(error); - throw new ApolloError('Something went wrong!'); + throw new ApolloError('Payment failed! Please check your card.'); } } +/** + * Update subcription + * + * @param {string} subId + * @param {string} priceId + * + * @returns {Promise} + */ export async function updateSubcription(subId, priceId) { try { const subscription = await stripe.subscriptions.retrieve(subId); await stripe.subscriptions.update(subId, { - cancel_at_period_end: false, - proration_behavior: 'create_prorations', items: [{ id: subscription.items.data[0].id, price: priceId, @@ -55,13 +72,20 @@ export async function updateSubcription(subId, priceId) { return true; } catch (error) { logger.error(error); - throw new ApolloError('Something went wrong!'); + throw new ApolloError('Payment failed! Please check your card.'); } } -export async function cancelSubcription(subId) { +/** + * Cancel subcription + * + * @param {string} customerId + * + * @returns {Promise} + */ +export async function cancelSubcription(customerId) { try { - await stripe.subscriptions.del(subId); + await stripe.customers.del(customerId); return true; } catch (error) { diff --git a/api/services/stripe/webhooks.servive.js b/api/services/stripe/webhooks.servive.js new file mode 100644 index 0000000..6303b77 --- /dev/null +++ b/api/services/stripe/webhooks.servive.js @@ -0,0 +1,31 @@ +import logger from '~/utils/logger'; +import { invoicePaymentFailed, invoicePaymentSuccess, trialWillEnd } from '../user/plans-user.service'; + +async function webhookStripe(req, res) { + let event; + try { + event = JSON.parse(req.body); + } catch (err) { + logger.error(err); + res.status(400).send(`Webhook Error: ${err.message}`); + } + + console.log(event.type); + switch (event.type) { + case 'invoice.payment_succeeded': + invoicePaymentSuccess(event.data.object); + break; + case 'invoice.payment_failed': + invoicePaymentFailed(event.data.object); + break; + case 'customer.subscription.trial_will_end': + trialWillEnd(event.data.object); + break; + default: + console.log(`Unhandled event type ${event.type}`); + } + + res.json({ received: true }); +} + +export default webhookStripe; diff --git a/api/services/user/plans-user.service.js b/api/services/user/plans-user.service.js index 5af9c38..5bff170 100644 --- a/api/services/user/plans-user.service.js +++ b/api/services/user/plans-user.service.js @@ -1,8 +1,10 @@ -import Apollo from 'apollo-server-express'; +import { ApolloError } from 'apollo-server-express'; +import dayjs from 'dayjs'; import logger from '~/utils/logger'; import { deleteUserPlanById, + getUserPlanByCustomerId, getUserPlanById, getUserPlanByUserId, insertUserPlan, @@ -10,9 +12,13 @@ import { } from '~/repository/user_plans.repository'; import { cancelSubcription, createNewSubcription, updateSubcription } from '~/services/stripe/subcription.service'; import { findProductAndPriceByType } from '~/repository/products.repository'; +import { deletePermissionByUserPlanId, insertMultiPermission } from '~/repository/user_permission.repository'; import { findUser } from '~/repository/user.repository'; +import formatDateDB from '~/utils/format-date-db'; +import { PERMISSION_PLAN } from '~/constants/billing.constant'; +import compileEmailTemplate from '~/helpers/compile-email-template'; +import sendMail from '~/libs/mail'; -const { ApolloError } = Apollo; export function getUserPlan(userId) { return getUserPlanByUserId(userId); } @@ -29,14 +35,31 @@ export async function createUserPlan(userId, paymentMethodToken, planName, billi return new ApolloError('Can not find any plan'); } - const subscriptionId = await createNewSubcription(paymentMethodToken, user.email, user.name, product.price_stripe_id); - const dataUserPlan = { - user_id: userId, - product_id: product.id, - price_id: product.price_id, - subcription_id: subscriptionId, - }; - await insertUserPlan(dataUserPlan); + const userPlan = await getUserPlanByUserId(userId); + if (userPlan) { + await updateUserPlanById(userPlan.id, { is_active: false }); + await deletePermissionByUserPlanId(userPlan.id); + } + + const { subcription_id, customer_id } = await createNewSubcription(paymentMethodToken, user.email, user.name, product.price_stripe_id); + if (subcription_id && customer_id) { + const dataUserPlan = { + user_id: userId, + product_id: product.id, + price_id: product.price_id, + customer_id, + subcription_id, + expired_at: formatDateDB(dayjs().add(1, product.price_type === 'yearly' ? 'y' : 'M')), + }; + const userPlanId = await insertUserPlan(dataUserPlan); + + const userPermissionData = PERMISSION_PLAN[product.type].map((permission) => ({ + user_id: userId, + user_plan_id: userPlanId, + permission, + })); + await insertMultiPermission(userPermissionData); + } return true; } catch (error) { @@ -45,9 +68,9 @@ export async function createUserPlan(userId, paymentMethodToken, planName, billi } } -export async function updateUserPlan(usePlanId, planName, billingType) { +export async function updateUserPlan(userPlanId, planName, billingType) { try { - const userPlan = await getUserPlanById(usePlanId); + const userPlan = await getUserPlanById(userPlanId); if (!userPlan) { return new ApolloError('Can not find any user plan'); } @@ -63,7 +86,20 @@ export async function updateUserPlan(usePlanId, planName, billingType) { product_id: product.id, price_id: product.price_id, }; - await updateUserPlanById(usePlanId, dataUserPlan); + if (!userPlan.is_trial) { + dataUserPlan.expired_at = formatDateDB(dayjs().add(1, product.price_type === 'yearly' ? 'y' : 'M')); + } + + await updateUserPlanById(userPlanId, dataUserPlan); + + const userPermissionData = PERMISSION_PLAN[product.type].map((permission) => ({ + user_id: userPlan.user_id, + user_plan_id: userPlanId, + permission, + })); + + await deletePermissionByUserPlanId(userPlanId); + await insertMultiPermission(userPermissionData); return true; } catch (error) { @@ -79,9 +115,108 @@ export async function deleteUserPlan(id) { return new ApolloError('Can not find any user plan'); } - await cancelSubcription(userPlan.subcription_id); - await deleteUserPlanById(id); + await cancelSubcription(userPlan.customer_id); + await Promise.all([ + deleteUserPlanById(userPlan.id), + deletePermissionByUserPlanId(userPlan.id, userPlan.expired_at), + ]); + + return true; + } catch (error) { + logger.error(error); + throw new ApolloError('Something went wrong!'); + } +} + +export async function invoicePaymentSuccess(data) { + try { + const userPlan = await getUserPlanByCustomerId(data.customer); + if (!userPlan) { + throw new ApolloError('Can not find any user plan'); + } + + const expiredAt = formatDateDB(dayjs().add(1, userPlan.priceType === 'yearly' ? 'y' : 'M')); + const dataUserPlan = { + expired_at: expiredAt, + deleted_at: null, + }; + + await updateUserPlanById(userPlan.id, dataUserPlan); + + const user = await findUser({ id: userPlan.userId }); + if (user) { + const template = await compileEmailTemplate({ + fileName: 'invoicePaymentSuccess.mjml', + data: { + link: data.hosted_invoice_url, + name: user.name, + date: expiredAt, + }, + }); + + sendMail(user.email, 'Invoice payment successfully', template); + } + + return true; + } catch (error) { + logger.error(error); + throw new ApolloError('Something went wrong!'); + } +} + +export async function invoicePaymentFailed(data) { + try { + const userPlan = await getUserPlanByCustomerId(data.customer); + if (!userPlan) { + throw new ApolloError('Can not find any user plan'); + } + + const expiredAt = formatDateDB(dayjs(userPlan.expiredAt).add(10, 'd')); + const dataUserPlan = { + expired_at: expiredAt, + deleted_at: formatDateDB(), + }; + + await updateUserPlanById(userPlan.id, dataUserPlan); + + const user = await findUser({ id: userPlan.userId }); + if (user) { + const template = await compileEmailTemplate({ + fileName: 'invoicePaymentFailed.mjml', + data: { + name: user.name, + date: expiredAt, + }, + }); + + sendMail(user.email, 'Invoice payment failed', template); + } + return true; + } catch (error) { + logger.error(error); + throw new ApolloError('Something went wrong!'); + } +} + +export async function trialWillEnd(data) { + try { + const userPlan = await getUserPlanByCustomerId(data.customer); + if (!userPlan) { + throw new ApolloError('Can not find any user plan'); + } + + const user = await findUser({ id: userPlan.userId }); + if (user) { + const template = await compileEmailTemplate({ + fileName: 'trialWillEnd.mjml', + data: { + name: user.name, + date: formatDateDB(dayjs(data.trial_end * 1000)), + }, + }); + sendMail(user.email, 'Trial will end', template); + } return true; } catch (error) { logger.error(error); diff --git a/api/utils/format-date-db.js b/api/utils/format-date-db.js new file mode 100644 index 0000000..7e756aa --- /dev/null +++ b/api/utils/format-date-db.js @@ -0,0 +1,9 @@ +import dayjs from 'dayjs'; + +export default function formatDateDB(date = null) { + let dateFormat = dayjs(); + if (date) { + dateFormat = dayjs(date); + } + return dateFormat.format('YYYY-MM-DD HH:mm:ss'); +} diff --git a/app/src/containers/Profile/PlanSetting.jsx b/app/src/containers/Profile/PlanSetting.jsx index d9296d5..1c61ef2 100644 --- a/app/src/containers/Profile/PlanSetting.jsx +++ b/app/src/containers/Profile/PlanSetting.jsx @@ -11,6 +11,7 @@ import updateUserPlanQuery from '@/queries/userPlans/updateUserPlan'; import createUserPlanQuery from '@/queries/userPlans/createUserPlan'; import getUserPlanQuery from '@/queries/userPlans/getUserPlan'; import { setUserPlan } from '@/features/auth/userPlan'; +import dayjs from 'dayjs'; const plans = [ { @@ -66,9 +67,7 @@ const PlanSetting = ({ isActive }) => { await deleteUserPlanMutation({ variables: { userPlanId: currentPlan.id } }); - setIsYearly(false); - setSelectedPlan(''); - dispatch(setUserPlan({})); + fetchUserPlan(); } async function createPaymentMethodSuccess(token) { @@ -144,9 +143,9 @@ const PlanSetting = ({ isActive }) => { )} {checkIsCurrentPlan(plan.id) ? ( - + ) : ( - + )} ))} @@ -159,12 +158,16 @@ const PlanSetting = ({ isActive }) => { {planChanged && ( checkIsCurrentPlan(planChanged.id) ? (
- + {currentPlan.deletedAt ? ( +

Plan will expire on {dayjs(currentPlan.expiredAt).format('YYYY-MM-DD HH:mm')}

+ ) : ( + + )}
) : (
@@ -183,11 +186,12 @@ const PlanSetting = ({ isActive }) => {
- {isEmpty(currentPlan) ? ( + {(isEmpty(currentPlan) || (currentPlan && currentPlan.deletedAt)) ? ( ) : (