From 4d616e92870e5a952c580d9fe2a1cf81c0ea549e Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 13 Apr 2024 10:17:48 +0200 Subject: [PATCH 1/8] Revert "feat(server): deprecated the subscription module." This reverts commit 44fc26b156b219a49cd95c4800a3ab2130e6a92e. --- .../src/api/controllers/Organization.ts | 6 +- .../api/controllers/Subscription/Licenses.ts | 250 ++++++++++++++++++ .../controllers/Subscription/PaymentMethod.ts | 31 +++ .../Subscription/PaymentViaLicense.ts | 125 +++++++++ .../src/api/controllers/Subscription/index.ts | 49 ++++ packages/server/src/api/index.ts | 6 + .../api/middleware/SubscriptionMiddleware.ts | 41 +++ ...091642_create_subscriptions_plans_table.js | 22 ++ .../20200823234134_create_plans_table.js | 30 +++ ...234434_create_subscription_plan_feature.js | 15 ++ ...6_create_subscription_plan_subscription.js | 22 ++ ...5339_create_subscription_licenses_table.js | 22 ++ 12 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/api/controllers/Subscription/Licenses.ts create mode 100644 packages/server/src/api/controllers/Subscription/PaymentMethod.ts create mode 100644 packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts create mode 100644 packages/server/src/api/controllers/Subscription/index.ts create mode 100644 packages/server/src/api/middleware/SubscriptionMiddleware.ts create mode 100644 packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js create mode 100644 packages/server/src/system/migrations/20200823234134_create_plans_table.js create mode 100644 packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js create mode 100644 packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js create mode 100644 packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js diff --git a/packages/server/src/api/controllers/Organization.ts b/packages/server/src/api/controllers/Organization.ts index 746e4f5be..c50f10fef 100644 --- a/packages/server/src/api/controllers/Organization.ts +++ b/packages/server/src/api/controllers/Organization.ts @@ -6,6 +6,7 @@ import { check, ValidationChain } from 'express-validator'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import JWTAuth from '@/api/middleware/jwtAuth'; import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; +import SubscriptionMiddleware from '@/api/middleware/SubscriptionMiddleware'; import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; import OrganizationService from '@/services/Organization/OrganizationService'; import { MONTHS, ACCEPTED_LOCALES } from '@/services/Organization/constants'; @@ -17,7 +18,7 @@ import BaseController from '@/api/controllers/BaseController'; @Service() export default class OrganizationController extends BaseController { @Inject() - private organizationService: OrganizationService; + organizationService: OrganizationService; /** * Router constructor. @@ -25,10 +26,13 @@ export default class OrganizationController extends BaseController { router() { const router = Router(); + // Should before build tenant database the user be authorized and + // most important than that, should be subscribed to any plan. router.use(JWTAuth); router.use(AttachCurrentTenantUser); router.use(TenancyMiddleware); + router.use('/build', SubscriptionMiddleware('main')); router.post( '/build', this.buildOrganizationValidationSchema, diff --git a/packages/server/src/api/controllers/Subscription/Licenses.ts b/packages/server/src/api/controllers/Subscription/Licenses.ts new file mode 100644 index 000000000..cf483f1a1 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/Licenses.ts @@ -0,0 +1,250 @@ +import { Service, Inject } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { check, oneOf, ValidationChain } from 'express-validator'; +import basicAuth from 'express-basic-auth'; +import config from '@/config'; +import { License } from '@/system/models'; +import { ServiceError } from '@/exceptions'; +import BaseController from '@/api/controllers/BaseController'; +import LicenseService from '@/services/Payment/License'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import { ILicensesFilter, ISendLicenseDTO } from '@/interfaces'; + +@Service() +export default class LicensesController extends BaseController { + @Inject() + licenseService: LicenseService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use( + basicAuth({ + users: { + [config.licensesAuth.user]: config.licensesAuth.password, + }, + challenge: true, + }) + ); + router.post( + '/generate', + this.generateLicenseSchema, + this.validationResult, + asyncMiddleware(this.generateLicense.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/disable/:licenseId', + this.validationResult, + asyncMiddleware(this.disableLicense.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/send', + this.sendLicenseSchemaValidation, + this.validationResult, + asyncMiddleware(this.sendLicense.bind(this)), + this.catchServiceErrors, + ); + router.delete( + '/:licenseId', + asyncMiddleware(this.deleteLicense.bind(this)), + this.catchServiceErrors, + ); + router.get('/', asyncMiddleware(this.listLicenses.bind(this))); + return router; + } + + /** + * Generate license validation schema. + */ + get generateLicenseSchema(): ValidationChain[] { + return [ + check('loop').exists().isNumeric().toInt(), + check('period').exists().isNumeric().toInt(), + check('period_interval') + .exists() + .isIn(['month', 'months', 'year', 'years', 'day', 'days']), + check('plan_slug').exists().trim().escape(), + ]; + } + + /** + * Specific license validation schema. + */ + get specificLicenseSchema(): ValidationChain[] { + return [ + oneOf( + [check('license_id').exists().isNumeric().toInt()], + [check('license_code').exists().isNumeric().toInt()] + ), + ]; + } + + /** + * Send license validation schema. + */ + get sendLicenseSchemaValidation(): ValidationChain[] { + return [ + check('period').exists().isNumeric(), + check('period_interval').exists().trim().escape(), + check('plan_slug').exists().trim().escape(), + oneOf([ + check('phone_number').exists().trim().escape(), + check('email').exists().trim().escape(), + ]), + ]; + } + + /** + * Generate licenses codes with given period in bulk. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async generateLicense(req: Request, res: Response, next: Function) { + const { loop = 10, period, periodInterval, planSlug } = this.matchedBodyData( + req + ); + + try { + await this.licenseService.generateLicenses( + loop, + period, + periodInterval, + planSlug + ); + return res.status(200).send({ + code: 100, + type: 'LICENSEES.GENERATED.SUCCESSFULLY', + message: 'The licenses have been generated successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Disable the given license on the storage. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async disableLicense(req: Request, res: Response, next: Function) { + const { licenseId } = req.params; + + try { + await this.licenseService.disableLicense(licenseId); + + return res.status(200).send({ license_id: licenseId }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given license code on the storage. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async deleteLicense(req: Request, res: Response, next: Function) { + const { licenseId } = req.params; + + try { + await this.licenseService.deleteLicense(licenseId); + + return res.status(200).send({ license_id: licenseId }); + } catch (error) { + next(error) + } + } + + /** + * Send license code in the given period to the customer via email or phone number + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async sendLicense(req: Request, res: Response, next: Function) { + const sendLicenseDTO: ISendLicenseDTO = this.matchedBodyData(req); + + try { + await this.licenseService.sendLicenseToCustomer(sendLicenseDTO); + + return res.status(200).send({ + status: 100, + code: 'LICENSE.CODE.SENT', + message: 'The license has been sent to the given customer.', + }); + } catch (error) { + next(error); + } + } + + /** + * Listing licenses. + * @param {Request} req + * @param {Response} res + */ + async listLicenses(req: Request, res: Response) { + const filter: ILicensesFilter = { + disabled: false, + used: false, + sent: false, + active: false, + ...req.query, + }; + const licenses = await License.query().onBuild((builder) => { + builder.modify('filter', filter); + builder.orderBy('createdAt', 'ASC'); + }); + return res.status(200).send({ licenses }); + } + + /** + * Catches all service errors. + */ + catchServiceErrors(error, req: Request, res: Response, next: NextFunction) { + if (error instanceof ServiceError) { + if (error.errorType === 'PLAN_NOT_FOUND') { + return res.status(400).send({ + errors: [{ + type: 'PLAN.NOT.FOUND', + code: 100, + message: 'The given plan not found.', + }], + }); + } + if (error.errorType === 'LICENSE_NOT_FOUND') { + return res.status(400).send({ + errors: [{ + type: 'LICENSE_NOT_FOUND', + code: 200, + message: 'The given license id not found.' + }], + }); + } + if (error.errorType === 'LICENSE_ALREADY_DISABLED') { + return res.status(400).send({ + errors: [{ + type: 'LICENSE.ALREADY.DISABLED', + code: 200, + message: 'License is already disabled.' + }], + }); + } + if (error.errorType === 'NO_AVALIABLE_LICENSE_CODE') { + return res.status(400).send({ + status: 110, + message: 'There is no licenses availiable right now with the given period and plan.', + code: 'NO.AVALIABLE.LICENSE.CODE', + }); + } + } + next(error); + } +} diff --git a/packages/server/src/api/controllers/Subscription/PaymentMethod.ts b/packages/server/src/api/controllers/Subscription/PaymentMethod.ts new file mode 100644 index 000000000..2c954c307 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/PaymentMethod.ts @@ -0,0 +1,31 @@ +import { Inject } from 'typedi'; +import { Request, Response } from 'express'; +import { Plan } from '@/system/models'; +import BaseController from '@/api/controllers/BaseController'; +import SubscriptionService from '@/services/Subscription/SubscriptionService'; + +export default class PaymentMethodController extends BaseController { + @Inject() + subscriptionService: SubscriptionService; + + /** + * Validate the given plan slug exists on the storage. + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * + * @return {Response|void} + */ + async validatePlanSlugExistance(req: Request, res: Response, next: Function) { + const { planSlug } = this.matchedBodyData(req); + const foundPlan = await Plan.query().where('slug', planSlug).first(); + + if (!foundPlan) { + return res.status(400).send({ + errors: [{ type: 'PLAN.SLUG.NOT.EXISTS', code: 110 }], + }); + } + next(); + } +} \ No newline at end of file diff --git a/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts b/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts new file mode 100644 index 000000000..7cf07c656 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts @@ -0,0 +1,125 @@ +import { Inject, Service } from 'typedi'; +import { NextFunction, Router, Request, Response } from 'express'; +import { check } from 'express-validator'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import PaymentMethodController from '@/api/controllers/Subscription/PaymentMethod'; +import { + NotAllowedChangeSubscriptionPlan, + NoPaymentModelWithPricedPlan, + PaymentAmountInvalidWithPlan, + PaymentInputInvalid, + VoucherCodeRequired, +} from '@/exceptions'; +import { ILicensePaymentModel } from '@/interfaces'; +import instance from 'tsyringe/dist/typings/dependency-container'; + +@Service() +export default class PaymentViaLicenseController extends PaymentMethodController { + @Inject('logger') + logger: any; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post( + '/payment', + this.paymentViaLicenseSchema, + this.validationResult, + asyncMiddleware(this.validatePlanSlugExistance.bind(this)), + asyncMiddleware(this.paymentViaLicense.bind(this)), + this.handleErrors, + ); + return router; + } + + /** + * Payment via license validation schema. + */ + get paymentViaLicenseSchema() { + return [ + check('plan_slug').exists().trim().escape(), + check('license_code').exists().trim().escape(), + ]; + } + + /** + * Handle the subscription payment via license code. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async paymentViaLicense(req: Request, res: Response, next: Function) { + const { planSlug, licenseCode } = this.matchedBodyData(req); + const { tenant } = req; + + try { + const licenseModel: ILicensePaymentModel = { licenseCode }; + + await this.subscriptionService.subscriptionViaLicense( + tenant.id, + planSlug, + licenseModel + ); + + return res.status(200).send({ + type: 'success', + code: 'PAYMENT.SUCCESSFULLY.MADE', + message: 'Payment via license has been made successfully.', + }); + } catch (exception) { + next(exception); + } + } + + /** + * Handle service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private handleErrors( + exception: Error, + req: Request, + res: Response, + next: NextFunction + ) { + const errorReasons = []; + + if (exception instanceof VoucherCodeRequired) { + errorReasons.push({ + type: 'VOUCHER_CODE_REQUIRED', + code: 100, + }); + } + if (exception instanceof NoPaymentModelWithPricedPlan) { + errorReasons.push({ + type: 'NO_PAYMENT_WITH_PRICED_PLAN', + code: 140, + }); + } + if (exception instanceof NotAllowedChangeSubscriptionPlan) { + errorReasons.push({ + type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE', + code: 120, + }); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + if (exception instanceof PaymentInputInvalid) { + return res.status(400).send({ + errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }], + }); + } + if (exception instanceof PaymentAmountInvalidWithPlan) { + return res.status(400).send({ + errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }], + }); + } + next(exception); + } +} diff --git a/packages/server/src/api/controllers/Subscription/index.ts b/packages/server/src/api/controllers/Subscription/index.ts new file mode 100644 index 000000000..6145e7551 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/index.ts @@ -0,0 +1,49 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { Container, Service, Inject } from 'typedi'; +import JWTAuth from '@/api/middleware/jwtAuth'; +import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; +import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; +import PaymentViaLicenseController from '@/api/controllers/Subscription/PaymentViaLicense'; +import SubscriptionService from '@/services/Subscription/SubscriptionService'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; + +@Service() +export default class SubscriptionController { + @Inject() + subscriptionService: SubscriptionService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use(JWTAuth); + router.use(AttachCurrentTenantUser); + router.use(TenancyMiddleware); + + router.use('/license', Container.get(PaymentViaLicenseController).router()); + router.get('/', asyncMiddleware(this.getSubscriptions.bind(this))); + + return router; + } + + /** + * Retrieve all subscriptions of the authenticated user's tenant. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async getSubscriptions(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + + try { + const subscriptions = await this.subscriptionService.getSubscriptions( + tenantId + ); + return res.status(200).send({ subscriptions }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index cc1206aee..f60de22d2 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -4,6 +4,7 @@ import { Container } from 'typedi'; // Middlewares import JWTAuth from '@/api/middleware/jwtAuth'; import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; +import SubscriptionMiddleware from '@/api/middleware/SubscriptionMiddleware'; import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; import EnsureTenantIsInitialized from '@/api/middleware/EnsureTenantIsInitialized'; import SettingsMiddleware from '@/api/middleware/SettingsMiddleware'; @@ -36,6 +37,8 @@ import Resources from './controllers/Resources'; import ExchangeRates from '@/api/controllers/ExchangeRates'; import Media from '@/api/controllers/Media'; import Ping from '@/api/controllers/Ping'; +import Subscription from '@/api/controllers/Subscription'; +import Licenses from '@/api/controllers/Subscription/Licenses'; import InventoryAdjustments from '@/api/controllers/Inventory/InventoryAdjustments'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; import Jobs from './controllers/Jobs'; @@ -70,6 +73,8 @@ export default () => { app.use('/auth', Container.get(Authentication).router()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); + app.use('/licenses', Container.get(Licenses).router()); + app.use('/subscription', Container.get(Subscription).router()); app.use('/organization', Container.get(Organization).router()); app.use('/ping', Container.get(Ping).router()); app.use('/jobs', Container.get(Jobs).router()); @@ -83,6 +88,7 @@ export default () => { dashboard.use(JWTAuth); dashboard.use(AttachCurrentTenantUser); dashboard.use(TenancyMiddleware); + dashboard.use(SubscriptionMiddleware('main')); dashboard.use(EnsureTenantIsInitialized); dashboard.use(SettingsMiddleware); dashboard.use(I18nAuthenticatedMiddlware); diff --git a/packages/server/src/api/middleware/SubscriptionMiddleware.ts b/packages/server/src/api/middleware/SubscriptionMiddleware.ts new file mode 100644 index 000000000..ce7d45258 --- /dev/null +++ b/packages/server/src/api/middleware/SubscriptionMiddleware.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from 'express'; +import { Container } from 'typedi'; + +export default (subscriptionSlug = 'main') => async ( + req: Request, + res: Response, + next: NextFunction +) => { + const { tenant, tenantId } = req; + const Logger = Container.get('logger'); + const { subscriptionRepository } = Container.get('repositories'); + + if (!tenant) { + throw new Error('Should load `TenancyMiddlware` before this middleware.'); + } + Logger.info('[subscription_middleware] trying get tenant main subscription.'); + const subscription = await subscriptionRepository.getBySlugInTenant( + subscriptionSlug, + tenantId + ); + // Validate in case there is no any already subscription. + if (!subscription) { + Logger.info('[subscription_middleware] tenant has no subscription.', { + tenantId, + }); + return res.boom.badRequest('Tenant has no subscription.', { + errors: [{ type: 'TENANT.HAS.NO.SUBSCRIPTION' }], + }); + } + // Validate in case the subscription is inactive. + else if (subscription.inactive()) { + Logger.info( + '[subscription_middleware] tenant main subscription is expired.', + { tenantId } + ); + return res.boom.badRequest(null, { + errors: [{ type: 'ORGANIZATION.SUBSCRIPTION.INACTIVE' }], + }); + } + next(); +}; diff --git a/packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js b/packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js new file mode 100644 index 000000000..09d890648 --- /dev/null +++ b/packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js @@ -0,0 +1,22 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscriptions_plans', table => { + table.increments(); + + table.string('name'); + table.string('description'); + table.decimal('price'); + table.string('currency', 3); + + table.integer('trial_period'); + table.string('trial_interval'); + + table.integer('invoice_period'); + table.string('invoice_interval'); + table.timestamps(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscriptions_plans') +}; diff --git a/packages/server/src/system/migrations/20200823234134_create_plans_table.js b/packages/server/src/system/migrations/20200823234134_create_plans_table.js new file mode 100644 index 000000000..2fc61a43a --- /dev/null +++ b/packages/server/src/system/migrations/20200823234134_create_plans_table.js @@ -0,0 +1,30 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plans', table => { + table.increments(); + table.string('slug'); + table.string('name'); + table.string('desc'); + table.boolean('active'); + + table.decimal('price').unsigned(); + table.string('currency', 3); + + table.decimal('trial_period').nullable(); + table.string('trial_interval').nullable(); + + table.decimal('invoice_period').nullable(); + table.string('invoice_interval').nullable(); + + table.integer('index').unsigned(); + table.timestamps(); + }).then(() => { + return knex.seed.run({ + specific: 'seed_subscriptions_plans.js', + }); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_plans') +}; diff --git a/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js b/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js new file mode 100644 index 000000000..43fea2798 --- /dev/null +++ b/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js @@ -0,0 +1,15 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plan_features', table => { + table.increments(); + table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); + table.string('slug'); + table.string('name'); + table.string('description'); + table.timestamps(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_plan_features'); +}; diff --git a/packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js b/packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js new file mode 100644 index 000000000..267be4614 --- /dev/null +++ b/packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js @@ -0,0 +1,22 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plan_subscriptions', table => { + table.increments('id'); + table.string('slug'); + + table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); + table.bigInteger('tenant_id').unsigned().index().references('id').inTable('tenants'); + + table.dateTime('starts_at').nullable(); + table.dateTime('ends_at').nullable(); + + table.dateTime('cancels_at').nullable(); + table.dateTime('canceled_at').nullable(); + + table.timestamps(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_plan_subscriptions'); +}; diff --git a/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js b/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js new file mode 100644 index 000000000..6babd6f03 --- /dev/null +++ b/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js @@ -0,0 +1,22 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_licenses', (table) => { + table.increments(); + + table.string('license_code').unique().index(); + table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); + + table.integer('license_period').unsigned(); + table.string('period_interval'); + + table.dateTime('sent_at').index(); + table.dateTime('disabled_at').index(); + table.dateTime('used_at').index(); + + table.timestamps(); + }) +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_licenses'); +}; From a39dcd00d568e9668d71987b16c9f30e7991332a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 13 Apr 2024 11:05:53 +0200 Subject: [PATCH 2/8] Revert "feat(server): deprecated the subscription module." This reverts commit 3b79ac66ae52cc596b7ee56dbfeea5cf010df1ad. --- .../NoPaymentModelWithPricedPlan.ts | 8 + .../NotAllowedChangeSubscriptionPlan.ts | 8 + .../PaymentAmountInvalidWithPlan.ts | 7 + .../src/exceptions/PaymentInputInvalid.ts | 3 + .../src/exceptions/VoucherCodeRequired.ts | 5 + packages/server/src/exceptions/index.ts | 12 +- .../src/jobs/MailNotificationSubscribeEnd.ts | 34 ++++ .../src/jobs/MailNotificationTrialEnd.ts | 34 ++++ .../src/jobs/SMSNotificationSubscribeEnd.ts | 28 +++ .../src/jobs/SMSNotificationTrialEnd.ts | 28 +++ packages/server/src/jobs/SendLicenseEmail.ts | 33 ++++ packages/server/src/jobs/SendLicensePhone.ts | 33 ++++ packages/server/src/loaders/jobs.ts | 29 +++ .../server/src/loaders/systemRepositories.ts | 2 + .../Organization/OrganizationService.ts | 1 + .../server/src/services/Payment/License.ts | 185 ++++++++++++++++++ .../services/Payment/LicenseMailMessages.ts | 26 +++ .../services/Payment/LicensePaymentMethod.ts | 67 +++++++ .../services/Payment/LicenseSMSMessages.ts | 17 ++ .../src/services/Payment/PaymentMethod.ts | 6 + packages/server/src/services/Payment/index.ts | 22 +++ .../src/services/Subscription/MailMessages.ts | 30 +++ .../src/services/Subscription/SMSMessages.ts | 40 ++++ .../src/services/Subscription/Subscription.ts | 80 ++++++++ .../Subscription/SubscriptionPeriod.ts | 41 ++++ .../Subscription/SubscriptionService.ts | 69 +++++++ .../Subscription/SubscriptionViaLicense.ts | 54 +++++ .../system/models/Subscriptions/License.ts | 129 ++++++++++++ .../src/system/models/Subscriptions/Plan.ts | 82 ++++++++ .../models/Subscriptions/PlanFeature.ts | 36 ++++ .../models/Subscriptions/PlanSubscription.ts | 164 ++++++++++++++++ packages/server/src/system/models/Tenant.ts | 32 ++- packages/server/src/system/models/index.ts | 8 + .../repositories/SubscriptionRepository.ts | 26 +++ .../server/src/system/repositories/index.ts | 7 +- packages/server/src/system/seeds/.gitkeep | 0 .../system/seeds/seed_subscriptions_plans.js | 66 +++++++ 37 files changed, 1445 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts create mode 100644 packages/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts create mode 100644 packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts create mode 100644 packages/server/src/exceptions/PaymentInputInvalid.ts create mode 100644 packages/server/src/exceptions/VoucherCodeRequired.ts create mode 100644 packages/server/src/jobs/MailNotificationSubscribeEnd.ts create mode 100644 packages/server/src/jobs/MailNotificationTrialEnd.ts create mode 100644 packages/server/src/jobs/SMSNotificationSubscribeEnd.ts create mode 100644 packages/server/src/jobs/SMSNotificationTrialEnd.ts create mode 100644 packages/server/src/jobs/SendLicenseEmail.ts create mode 100644 packages/server/src/jobs/SendLicensePhone.ts create mode 100644 packages/server/src/services/Payment/License.ts create mode 100644 packages/server/src/services/Payment/LicenseMailMessages.ts create mode 100644 packages/server/src/services/Payment/LicensePaymentMethod.ts create mode 100644 packages/server/src/services/Payment/LicenseSMSMessages.ts create mode 100644 packages/server/src/services/Payment/PaymentMethod.ts create mode 100644 packages/server/src/services/Payment/index.ts create mode 100644 packages/server/src/services/Subscription/MailMessages.ts create mode 100644 packages/server/src/services/Subscription/SMSMessages.ts create mode 100644 packages/server/src/services/Subscription/Subscription.ts create mode 100644 packages/server/src/services/Subscription/SubscriptionPeriod.ts create mode 100644 packages/server/src/services/Subscription/SubscriptionService.ts create mode 100644 packages/server/src/services/Subscription/SubscriptionViaLicense.ts create mode 100644 packages/server/src/system/models/Subscriptions/License.ts create mode 100644 packages/server/src/system/models/Subscriptions/Plan.ts create mode 100644 packages/server/src/system/models/Subscriptions/PlanFeature.ts create mode 100644 packages/server/src/system/models/Subscriptions/PlanSubscription.ts create mode 100644 packages/server/src/system/repositories/SubscriptionRepository.ts delete mode 100644 packages/server/src/system/seeds/.gitkeep create mode 100644 packages/server/src/system/seeds/seed_subscriptions_plans.js diff --git a/packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts b/packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts new file mode 100644 index 000000000..938ec8b4a --- /dev/null +++ b/packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts @@ -0,0 +1,8 @@ + + +export default class NoPaymentModelWithPricedPlan { + + constructor() { + + } +} \ No newline at end of file diff --git a/packages/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts b/packages/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts new file mode 100644 index 000000000..3c5380259 --- /dev/null +++ b/packages/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts @@ -0,0 +1,8 @@ + + +export default class NotAllowedChangeSubscriptionPlan { + + constructor() { + this.name = "NotAllowedChangeSubscriptionPlan"; + } +} \ No newline at end of file diff --git a/packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts b/packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts new file mode 100644 index 000000000..834e8cbe1 --- /dev/null +++ b/packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts @@ -0,0 +1,7 @@ + + +export default class PaymentAmountInvalidWithPlan{ + constructor() { + + } +} \ No newline at end of file diff --git a/packages/server/src/exceptions/PaymentInputInvalid.ts b/packages/server/src/exceptions/PaymentInputInvalid.ts new file mode 100644 index 000000000..2a024ff05 --- /dev/null +++ b/packages/server/src/exceptions/PaymentInputInvalid.ts @@ -0,0 +1,3 @@ +export default class PaymentInputInvalid { + constructor() {} +} diff --git a/packages/server/src/exceptions/VoucherCodeRequired.ts b/packages/server/src/exceptions/VoucherCodeRequired.ts new file mode 100644 index 000000000..a3bef6ff2 --- /dev/null +++ b/packages/server/src/exceptions/VoucherCodeRequired.ts @@ -0,0 +1,5 @@ +export default class VoucherCodeRequired { + constructor() { + this.name = 'VoucherCodeRequired'; + } +} diff --git a/packages/server/src/exceptions/index.ts b/packages/server/src/exceptions/index.ts index 53bb38897..a18746d02 100644 --- a/packages/server/src/exceptions/index.ts +++ b/packages/server/src/exceptions/index.ts @@ -1,15 +1,25 @@ +import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan'; import ServiceError from './ServiceError'; import ServiceErrors from './ServiceErrors'; +import NoPaymentModelWithPricedPlan from './NoPaymentModelWithPricedPlan'; +import PaymentInputInvalid from './PaymentInputInvalid'; +import PaymentAmountInvalidWithPlan from './PaymentAmountInvalidWithPlan'; import TenantAlreadyInitialized from './TenantAlreadyInitialized'; import TenantAlreadySeeded from './TenantAlreadySeeded'; import TenantDBAlreadyExists from './TenantDBAlreadyExists'; import TenantDatabaseNotBuilt from './TenantDatabaseNotBuilt'; +import VoucherCodeRequired from './VoucherCodeRequired'; export { + NotAllowedChangeSubscriptionPlan, + NoPaymentModelWithPricedPlan, + PaymentAmountInvalidWithPlan, ServiceError, ServiceErrors, + PaymentInputInvalid, TenantAlreadyInitialized, TenantAlreadySeeded, TenantDBAlreadyExists, TenantDatabaseNotBuilt, -}; + VoucherCodeRequired, +}; \ No newline at end of file diff --git a/packages/server/src/jobs/MailNotificationSubscribeEnd.ts b/packages/server/src/jobs/MailNotificationSubscribeEnd.ts new file mode 100644 index 000000000..a2b54778b --- /dev/null +++ b/packages/server/src/jobs/MailNotificationSubscribeEnd.ts @@ -0,0 +1,34 @@ +import Container from 'typedi'; +import SubscriptionService from '@/services/Subscription/Subscription'; + +export default class MailNotificationSubscribeEnd { + /** + * Job handler. + * @param {Job} job - + */ + handler(job) { + const { tenantId, phoneNumber, remainingDays } = job.attrs.data; + + const subscriptionService = Container.get(SubscriptionService); + const Logger = Container.get('logger'); + + Logger.info( + `Send mail notification subscription end soon - started: ${job.attrs.data}` + ); + + try { + subscriptionService.mailMessages.sendRemainingTrialPeriod( + phoneNumber, + remainingDays + ); + Logger.info( + `Send mail notification subscription end soon - finished: ${job.attrs.data}` + ); + } catch (error) { + Logger.info( + `Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}` + ); + done(e); + } + } +} diff --git a/packages/server/src/jobs/MailNotificationTrialEnd.ts b/packages/server/src/jobs/MailNotificationTrialEnd.ts new file mode 100644 index 000000000..82d8bd53c --- /dev/null +++ b/packages/server/src/jobs/MailNotificationTrialEnd.ts @@ -0,0 +1,34 @@ +import Container from 'typedi'; +import SubscriptionService from '@/services/Subscription/Subscription'; + +export default class MailNotificationTrialEnd { + /** + * + * @param {Job} job - + */ + handler(job) { + const { tenantId, phoneNumber, remainingDays } = job.attrs.data; + + const subscriptionService = Container.get(SubscriptionService); + const Logger = Container.get('logger'); + + Logger.info( + `Send mail notification subscription end soon - started: ${job.attrs.data}` + ); + + try { + subscriptionService.mailMessages.sendRemainingTrialPeriod( + phoneNumber, + remainingDays + ); + Logger.info( + `Send mail notification subscription end soon - finished: ${job.attrs.data}` + ); + } catch (error) { + Logger.info( + `Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}` + ); + done(e); + } + } +} diff --git a/packages/server/src/jobs/SMSNotificationSubscribeEnd.ts b/packages/server/src/jobs/SMSNotificationSubscribeEnd.ts new file mode 100644 index 000000000..d203c1d6b --- /dev/null +++ b/packages/server/src/jobs/SMSNotificationSubscribeEnd.ts @@ -0,0 +1,28 @@ +import Container from 'typedi'; +import SubscriptionService from '@/services/Subscription/Subscription'; + +export default class SMSNotificationSubscribeEnd { + + /** + * + * @param {Job}job + */ + handler(job) { + const { tenantId, phoneNumber, remainingDays } = job.attrs.data; + + const subscriptionService = Container.get(SubscriptionService); + const Logger = Container.get('logger'); + + Logger.info(`Send SMS notification subscription end soon - started: ${job.attrs.data}`); + + try { + subscriptionService.smsMessages.sendRemainingSubscriptionPeriod( + phoneNumber, remainingDays, + ); + Logger.info(`Send SMS notification subscription end soon - finished: ${job.attrs.data}`); + } catch(error) { + Logger.info(`Send SMS notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`); + done(e); + } + } +} \ No newline at end of file diff --git a/packages/server/src/jobs/SMSNotificationTrialEnd.ts b/packages/server/src/jobs/SMSNotificationTrialEnd.ts new file mode 100644 index 000000000..a3e5c5420 --- /dev/null +++ b/packages/server/src/jobs/SMSNotificationTrialEnd.ts @@ -0,0 +1,28 @@ +import Container from 'typedi'; +import SubscriptionService from '@/services/Subscription/Subscription'; + +export default class SMSNotificationTrialEnd { + + /** + * + * @param {Job}job + */ + handler(job) { + const { tenantId, phoneNumber, remainingDays } = job.attrs.data; + + const subscriptionService = Container.get(SubscriptionService); + const Logger = Container.get('logger'); + + Logger.info(`Send notification subscription end soon - started: ${job.attrs.data}`); + + try { + subscriptionService.smsMessages.sendRemainingTrialPeriod( + phoneNumber, remainingDays, + ); + Logger.info(`Send notification subscription end soon - finished: ${job.attrs.data}`); + } catch(error) { + Logger.info(`Send notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`); + done(e); + } + } +} \ No newline at end of file diff --git a/packages/server/src/jobs/SendLicenseEmail.ts b/packages/server/src/jobs/SendLicenseEmail.ts new file mode 100644 index 000000000..d28c07afe --- /dev/null +++ b/packages/server/src/jobs/SendLicenseEmail.ts @@ -0,0 +1,33 @@ +import { Container } from 'typedi'; +import LicenseService from '@/services/Payment/License'; + +export default class SendLicenseViaEmailJob { + /** + * Constructor method. + * @param agenda + */ + constructor(agenda) { + agenda.define( + 'send-license-via-email', + { priority: 'high', concurrency: 1, }, + this.handler, + ); + } + + public async handler(job, done: Function): Promise { + const Logger = Container.get('logger'); + const licenseService = Container.get(LicenseService); + const { email, licenseCode } = job.attrs.data; + + Logger.info(`[send_license_via_mail] started: ${job.attrs.data}`); + + try { + await licenseService.mailMessages.sendMailLicense(licenseCode, email); + Logger.info(`[send_license_via_mail] completed: ${job.attrs.data}`); + done(); + } catch(e) { + Logger.error(`[send_license_via_mail] ${job.attrs.data}, error: ${e}`); + done(e); + } + } +} diff --git a/packages/server/src/jobs/SendLicensePhone.ts b/packages/server/src/jobs/SendLicensePhone.ts new file mode 100644 index 000000000..adc5429fb --- /dev/null +++ b/packages/server/src/jobs/SendLicensePhone.ts @@ -0,0 +1,33 @@ +import { Container } from 'typedi'; +import LicenseService from '@/services/Payment/License'; + +export default class SendLicenseViaPhoneJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'send-license-via-phone', + { priority: 'high', concurrency: 1, }, + this.handler, + ); + } + + public async handler(job, done: Function): Promise { + const { phoneNumber, licenseCode } = job.attrs.data; + + const Logger = Container.get('logger'); + const licenseService = Container.get(LicenseService); + + Logger.debug(`Send license via phone number - started: ${job.attrs.data}`); + + try { + await licenseService.smsMessages.sendLicenseSMSMessage(phoneNumber, licenseCode); + Logger.debug(`Send license via phone number - completed: ${job.attrs.data}`); + done(); + } catch(e) { + Logger.error(`Send license via phone number: ${job.attrs.data}, error: ${e}`); + done(e); + } + } +} diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 58da23291..4ec446d0f 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -2,6 +2,12 @@ import Agenda from 'agenda'; import ResetPasswordMailJob from 'jobs/ResetPasswordMail'; import ComputeItemCost from 'jobs/ComputeItemCost'; import RewriteInvoicesJournalEntries from 'jobs/WriteInvoicesJEntries'; +import SendLicenseViaPhoneJob from 'jobs/SendLicensePhone'; +import SendLicenseViaEmailJob from 'jobs/SendLicenseEmail'; +import SendSMSNotificationSubscribeEnd from 'jobs/SMSNotificationSubscribeEnd'; +import SendSMSNotificationTrialEnd from 'jobs/SMSNotificationTrialEnd'; +import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd'; +import SendMailNotificationTrialEnd from 'jobs/MailNotificationTrialEnd'; import UserInviteMailJob from 'jobs/UserInviteMail'; import OrganizationSetupJob from 'jobs/OrganizationSetup'; import OrganizationUpgrade from 'jobs/OrganizationUpgrade'; @@ -16,6 +22,8 @@ import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDelet export default ({ agenda }: { agenda: Agenda }) => { new ResetPasswordMailJob(agenda); new UserInviteMailJob(agenda); + new SendLicenseViaEmailJob(agenda); + new SendLicenseViaPhoneJob(agenda); new ComputeItemCost(agenda); new RewriteInvoicesJournalEntries(agenda); new OrganizationSetupJob(agenda); @@ -31,4 +39,25 @@ export default ({ agenda }: { agenda: Agenda }) => { agenda.start().then(() => { agenda.every('1 hours', 'delete-expired-imported-files', {}); }); + // agenda.define( + // 'send-sms-notification-subscribe-end', + // { priority: 'nromal', concurrency: 1, }, + // new SendSMSNotificationSubscribeEnd().handler, + // ); + // agenda.define( + // 'send-sms-notification-trial-end', + // { priority: 'normal', concurrency: 1, }, + // new SendSMSNotificationTrialEnd().handler, + // ); + // agenda.define( + // 'send-mail-notification-subscribe-end', + // { priority: 'high', concurrency: 1, }, + // new SendMailNotificationSubscribeEnd().handler + // ); + // agenda.define( + // 'send-mail-notification-trial-end', + // { priority: 'high', concurrency: 1, }, + // new SendMailNotificationTrialEnd().handler + // ); + agenda.start(); }; diff --git a/packages/server/src/loaders/systemRepositories.ts b/packages/server/src/loaders/systemRepositories.ts index f162df8f3..4d84583ca 100644 --- a/packages/server/src/loaders/systemRepositories.ts +++ b/packages/server/src/loaders/systemRepositories.ts @@ -1,6 +1,7 @@ import Container from 'typedi'; import { SystemUserRepository, + SubscriptionRepository, TenantRepository, } from '@/system/repositories'; @@ -10,6 +11,7 @@ export default () => { return { systemUserRepository: new SystemUserRepository(knex, cache), + subscriptionRepository: new SubscriptionRepository(knex, cache), tenantRepository: new TenantRepository(knex, cache), }; } \ No newline at end of file diff --git a/packages/server/src/services/Organization/OrganizationService.ts b/packages/server/src/services/Organization/OrganizationService.ts index 453b6522c..06731b741 100644 --- a/packages/server/src/services/Organization/OrganizationService.ts +++ b/packages/server/src/services/Organization/OrganizationService.ts @@ -144,6 +144,7 @@ export default class OrganizationService { public async currentOrganization(tenantId: number): Promise { const tenant = await Tenant.query() .findById(tenantId) + .withGraphFetched('subscriptions') .withGraphFetched('metadata'); this.throwIfTenantNotExists(tenant); diff --git a/packages/server/src/services/Payment/License.ts b/packages/server/src/services/Payment/License.ts new file mode 100644 index 000000000..8e0fbc675 --- /dev/null +++ b/packages/server/src/services/Payment/License.ts @@ -0,0 +1,185 @@ +import { Service, Container, Inject } from 'typedi'; +import cryptoRandomString from 'crypto-random-string'; +import { times } from 'lodash'; +import { License, Plan } from '@/system/models'; +import { ILicense, ISendLicenseDTO } from '@/interfaces'; +import LicenseMailMessages from '@/services/Payment/LicenseMailMessages'; +import LicenseSMSMessages from '@/services/Payment/LicenseSMSMessages'; +import { ServiceError } from '@/exceptions'; + +const ERRORS = { + PLAN_NOT_FOUND: 'PLAN_NOT_FOUND', + LICENSE_NOT_FOUND: 'LICENSE_NOT_FOUND', + LICENSE_ALREADY_DISABLED: 'LICENSE_ALREADY_DISABLED', + NO_AVALIABLE_LICENSE_CODE: 'NO_AVALIABLE_LICENSE_CODE', +}; + +@Service() +export default class LicenseService { + @Inject() + smsMessages: LicenseSMSMessages; + + @Inject() + mailMessages: LicenseMailMessages; + + /** + * Validate the plan existance on the storage. + * @param {number} tenantId - + * @param {string} planSlug - Plan slug. + */ + private async getPlanOrThrowError(planSlug: string) { + const foundPlan = await Plan.query().where('slug', planSlug).first(); + + if (!foundPlan) { + throw new ServiceError(ERRORS.PLAN_NOT_FOUND); + } + return foundPlan; + } + + /** + * Valdiate the license existance on the storage. + * @param {number} licenseId - License id. + */ + private async getLicenseOrThrowError(licenseId: number) { + const foundLicense = await License.query().findById(licenseId); + + if (!foundLicense) { + throw new ServiceError(ERRORS.LICENSE_NOT_FOUND); + } + return foundLicense; + } + + /** + * Validates whether the license id is disabled. + * @param {ILicense} license + */ + private validateNotDisabledLicense(license: ILicense) { + if (license.disabledAt) { + throw new ServiceError(ERRORS.LICENSE_ALREADY_DISABLED); + } + } + + /** + * Generates the license code in the given period. + * @param {number} licensePeriod + * @return {Promise} + */ + public async generateLicense( + licensePeriod: number, + periodInterval: string = 'days', + planSlug: string + ): ILicense { + let licenseCode: string; + let repeat: boolean = true; + + // Retrieve plan or throw not found error. + const plan = await this.getPlanOrThrowError(planSlug); + + while (repeat) { + licenseCode = cryptoRandomString({ length: 10, type: 'numeric' }); + const foundLicenses = await License.query().where( + 'license_code', + licenseCode + ); + + if (foundLicenses.length === 0) { + repeat = false; + } + } + return License.query().insert({ + licenseCode, + licensePeriod, + periodInterval, + planId: plan.id, + }); + } + + /** + * Generates licenses. + * @param {number} loop + * @param {number} licensePeriod + * @param {string} periodInterval + * @param {number} planId + */ + public async generateLicenses( + loop = 1, + licensePeriod: number, + periodInterval: string = 'days', + planSlug: string + ) { + const asyncOpers: Promise[] = []; + + times(loop, () => { + const generateOper = this.generateLicense( + licensePeriod, + periodInterval, + planSlug + ); + asyncOpers.push(generateOper); + }); + return Promise.all(asyncOpers); + } + + /** + * Disables the given license id on the storage. + * @param {string} licenseSlug - License slug. + * @return {Promise} + */ + public async disableLicense(licenseId: number) { + const license = await this.getLicenseOrThrowError(licenseId); + + this.validateNotDisabledLicense(license); + + return License.markLicenseAsDisabled(license.id, 'id'); + } + + /** + * Deletes the given license id from the storage. + * @param licenseSlug {string} - License slug. + */ + public async deleteLicense(licenseSlug: string) { + const license = await this.getPlanOrThrowError(licenseSlug); + + return License.query().where('id', license.id).delete(); + } + + /** + * Sends license code to the given customer via SMS or mail message. + * @param {string} licenseCode - License code. + * @param {string} phoneNumber - Phone number. + * @param {string} email - Email address. + */ + public async sendLicenseToCustomer(sendLicense: ISendLicenseDTO) { + const agenda = Container.get('agenda'); + const { phoneNumber, email, period, periodInterval } = sendLicense; + + // Retreive plan details byt the given plan slug. + const plan = await this.getPlanOrThrowError(sendLicense.planSlug); + + const license = await License.query() + .modify('filterActiveLicense') + .where('license_period', period) + .where('period_interval', periodInterval) + .where('plan_id', plan.id) + .first(); + + if (!license) { + throw new ServiceError(ERRORS.NO_AVALIABLE_LICENSE_CODE) + } + // Mark the license as used. + await License.markLicenseAsSent(license.licenseCode); + + if (sendLicense.email) { + await agenda.schedule('1 second', 'send-license-via-email', { + licenseCode: license.licenseCode, + email, + }); + } + if (phoneNumber) { + await agenda.schedule('1 second', 'send-license-via-phone', { + licenseCode: license.licenseCode, + phoneNumber, + }); + } + } +} diff --git a/packages/server/src/services/Payment/LicenseMailMessages.ts b/packages/server/src/services/Payment/LicenseMailMessages.ts new file mode 100644 index 000000000..a9b144629 --- /dev/null +++ b/packages/server/src/services/Payment/LicenseMailMessages.ts @@ -0,0 +1,26 @@ +import { Container } from 'typedi'; +import Mail from '@/lib/Mail'; +import config from '@/config'; +export default class SubscriptionMailMessages { + /** + * Send license code to the given mail address. + * @param {string} licenseCode + * @param {email} email + */ + public async sendMailLicense(licenseCode: string, email: string) { + const Logger = Container.get('logger'); + + const mail = new Mail() + .setView('mail/LicenseReceive.html') + .setSubject('Bigcapital - License code') + .setTo(email) + .setData({ + licenseCode, + successEmail: config.customerSuccess.email, + successPhoneNumber: config.customerSuccess.phoneNumber, + }); + + await mail.send(); + Logger.info('[license_mail] sent successfully.'); + } +} \ No newline at end of file diff --git a/packages/server/src/services/Payment/LicensePaymentMethod.ts b/packages/server/src/services/Payment/LicensePaymentMethod.ts new file mode 100644 index 000000000..a8dbdf4ec --- /dev/null +++ b/packages/server/src/services/Payment/LicensePaymentMethod.ts @@ -0,0 +1,67 @@ +import { License } from '@/system/models'; +import PaymentMethod from '@/services/Payment/PaymentMethod'; +import { Plan } from '@/system/models'; +import { IPaymentMethod, ILicensePaymentModel } from '@/interfaces'; +import { + PaymentInputInvalid, + PaymentAmountInvalidWithPlan, + VoucherCodeRequired, +} from '@/exceptions'; + +export default class LicensePaymentMethod + extends PaymentMethod + implements IPaymentMethod +{ + /** + * Payment subscription of organization via license code. + * @param {ILicensePaymentModel} licensePaymentModel - + */ + public async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) { + this.validateLicensePaymentModel(licensePaymentModel); + + const license = await this.getLicenseOrThrowInvalid(licensePaymentModel); + this.validatePaymentAmountWithPlan(license, plan); + + // Mark the license code as used. + return License.markLicenseAsUsed(licensePaymentModel.licenseCode); + } + + /** + * Validates the license code activation on the storage. + * @param {ILicensePaymentModel} licensePaymentModel - + */ + private async getLicenseOrThrowInvalid( + licensePaymentModel: ILicensePaymentModel + ) { + const foundLicense = await License.query() + .modify('filterActiveLicense') + .where('license_code', licensePaymentModel.licenseCode) + .first(); + + if (!foundLicense) { + throw new PaymentInputInvalid(); + } + return foundLicense; + } + + /** + * Validates the payment amount with given plan price. + * @param {License} license + * @param {Plan} plan + */ + private validatePaymentAmountWithPlan(license: License, plan: Plan) { + if (license.planId !== plan.id) { + throw new PaymentAmountInvalidWithPlan(); + } + } + + /** + * Validate voucher payload. + * @param {ILicensePaymentModel} licenseModel - + */ + private validateLicensePaymentModel(licenseModel: ILicensePaymentModel) { + if (!licenseModel || !licenseModel.licenseCode) { + throw new VoucherCodeRequired(); + } + } +} diff --git a/packages/server/src/services/Payment/LicenseSMSMessages.ts b/packages/server/src/services/Payment/LicenseSMSMessages.ts new file mode 100644 index 000000000..aa884ccdf --- /dev/null +++ b/packages/server/src/services/Payment/LicenseSMSMessages.ts @@ -0,0 +1,17 @@ +import { Container, Inject } from 'typedi'; +import SMSClient from '@/services/SMSClient'; + +export default class SubscriptionSMSMessages { + @Inject('SMSClient') + smsClient: SMSClient; + + /** + * Sends license code to the given phone number via SMS message. + * @param {string} phoneNumber + * @param {string} licenseCode + */ + public async sendLicenseSMSMessage(phoneNumber: string, licenseCode: string) { + const message: string = `Your license card number: ${licenseCode}. If you need any help please contact us. Bigcapital.`; + return this.smsClient.sendMessage(phoneNumber, message); + } +} \ No newline at end of file diff --git a/packages/server/src/services/Payment/PaymentMethod.ts b/packages/server/src/services/Payment/PaymentMethod.ts new file mode 100644 index 000000000..a0c1072f0 --- /dev/null +++ b/packages/server/src/services/Payment/PaymentMethod.ts @@ -0,0 +1,6 @@ +import moment from 'moment'; +import { IPaymentModel } from '@/interfaces'; + +export default class PaymentMethod implements IPaymentModel { + +} \ No newline at end of file diff --git a/packages/server/src/services/Payment/index.ts b/packages/server/src/services/Payment/index.ts new file mode 100644 index 000000000..bec52feaf --- /dev/null +++ b/packages/server/src/services/Payment/index.ts @@ -0,0 +1,22 @@ +import { IPaymentMethod, IPaymentContext } from "interfaces"; +import { Plan } from '@/system/models'; + +export default class PaymentContext implements IPaymentContext{ + paymentMethod: IPaymentMethod; + + /** + * Constructor method. + * @param {IPaymentMethod} paymentMethod + */ + constructor(paymentMethod: IPaymentMethod) { + this.paymentMethod = paymentMethod; + } + + /** + * + * @param {} paymentModel + */ + makePayment(paymentModel: PaymentModel, plan: Plan) { + return this.paymentMethod.payment(paymentModel, plan); + } +} \ No newline at end of file diff --git a/packages/server/src/services/Subscription/MailMessages.ts b/packages/server/src/services/Subscription/MailMessages.ts new file mode 100644 index 000000000..4c50a5243 --- /dev/null +++ b/packages/server/src/services/Subscription/MailMessages.ts @@ -0,0 +1,30 @@ +import { Service } from "typedi"; + +@Service() +export default class SubscriptionMailMessages { + /** + * + * @param phoneNumber + * @param remainingDays + */ + public async sendRemainingSubscriptionPeriod(phoneNumber: string, remainingDays: number) { + const message: string = ` + Your remaining subscription is ${remainingDays} days, + please renew your subscription before expire. + `; + this.smsClient.sendMessage(phoneNumber, message); + } + + /** + * + * @param phoneNumber + * @param remainingDays + */ + public async sendRemainingTrialPeriod(phoneNumber: string, remainingDays: number) { + const message: string = ` + Your remaining free trial is ${remainingDays} days, + please subscription before ends, if you have any quation to contact us.`; + + this.smsClient.sendMessage(phoneNumber, message); + } +} \ No newline at end of file diff --git a/packages/server/src/services/Subscription/SMSMessages.ts b/packages/server/src/services/Subscription/SMSMessages.ts new file mode 100644 index 000000000..9cb7de273 --- /dev/null +++ b/packages/server/src/services/Subscription/SMSMessages.ts @@ -0,0 +1,40 @@ +import { Service, Inject } from 'typedi'; +import SMSClient from '@/services/SMSClient'; + +@Service() +export default class SubscriptionSMSMessages { + @Inject('SMSClient') + smsClient: SMSClient; + + /** + * Send remaining subscription period SMS message. + * @param {string} phoneNumber - + * @param {number} remainingDays - + */ + public async sendRemainingSubscriptionPeriod( + phoneNumber: string, + remainingDays: number + ): Promise { + const message: string = ` + Your remaining subscription is ${remainingDays} days, + please renew your subscription before expire. + `; + this.smsClient.sendMessage(phoneNumber, message); + } + + /** + * Send remaining trial period SMS message. + * @param {string} phoneNumber - + * @param {number} remainingDays - + */ + public async sendRemainingTrialPeriod( + phoneNumber: string, + remainingDays: number + ): Promise { + const message: string = ` + Your remaining free trial is ${remainingDays} days, + please subscription before ends, if you have any quation to contact us.`; + + this.smsClient.sendMessage(phoneNumber, message); + } +} diff --git a/packages/server/src/services/Subscription/Subscription.ts b/packages/server/src/services/Subscription/Subscription.ts new file mode 100644 index 000000000..4592a781f --- /dev/null +++ b/packages/server/src/services/Subscription/Subscription.ts @@ -0,0 +1,80 @@ +import { Inject } from 'typedi'; +import { Tenant, Plan } from '@/system/models'; +import { IPaymentContext } from '@/interfaces'; +import { NotAllowedChangeSubscriptionPlan } from '@/exceptions'; + +export default class Subscription { + paymentContext: IPaymentContext | null; + + @Inject('logger') + logger: any; + + /** + * Constructor method. + * @param {IPaymentContext} + */ + constructor(payment?: IPaymentContext) { + this.paymentContext = payment; + } + + /** + * Give the tenant a new subscription. + * @param {Tenant} tenant + * @param {Plan} plan + * @param {string} invoiceInterval + * @param {number} invoicePeriod + * @param {string} subscriptionSlug + */ + protected async newSubscribtion( + tenant, + plan, + invoiceInterval: string, + invoicePeriod: number, + subscriptionSlug: string = 'main' + ) { + const subscription = await tenant + .$relatedQuery('subscriptions') + .modify('subscriptionBySlug', subscriptionSlug) + .first(); + + // No allowed to re-new the the subscription while the subscription is active. + if (subscription && subscription.active()) { + throw new NotAllowedChangeSubscriptionPlan(); + + // In case there is already subscription associated to the given tenant renew it. + } else if (subscription && subscription.inactive()) { + await subscription.renew(invoiceInterval, invoicePeriod); + + // No stored past tenant subscriptions create new one. + } else { + await tenant.newSubscription( + plan.id, + invoiceInterval, + invoicePeriod, + subscriptionSlug + ); + } + } + + /** + * Subscripe to the given plan. + * @param {Plan} plan + * @throws {NotAllowedChangeSubscriptionPlan} + */ + public async subscribe( + tenant: Tenant, + plan: Plan, + paymentModel?: PaymentModel, + subscriptionSlug: string = 'main' + ) { + await this.paymentContext.makePayment(paymentModel, plan); + + return this.newSubscribtion( + tenant, + plan, + plan.invoiceInterval, + plan.invoicePeriod, + subscriptionSlug + ); + } +} diff --git a/packages/server/src/services/Subscription/SubscriptionPeriod.ts b/packages/server/src/services/Subscription/SubscriptionPeriod.ts new file mode 100644 index 000000000..c1d2e4a8b --- /dev/null +++ b/packages/server/src/services/Subscription/SubscriptionPeriod.ts @@ -0,0 +1,41 @@ +import moment from 'moment'; + +export default class SubscriptionPeriod { + start: Date; + end: Date; + interval: string; + count: number; + + /** + * Constructor method. + * @param {string} interval - + * @param {number} count - + * @param {Date} start - + */ + constructor(interval: string = 'month', count: number, start?: Date) { + this.interval = interval; + this.count = count; + this.start = start; + + if (!start) { + this.start = moment().toDate(); + } + this.end = moment(start).add(count, interval).toDate(); + } + + getStartDate() { + return this.start; + } + + getEndDate() { + return this.end; + } + + getInterval() { + return this.interval; + } + + getIntervalCount() { + return this.interval; + } +} \ No newline at end of file diff --git a/packages/server/src/services/Subscription/SubscriptionService.ts b/packages/server/src/services/Subscription/SubscriptionService.ts new file mode 100644 index 000000000..0e254066b --- /dev/null +++ b/packages/server/src/services/Subscription/SubscriptionService.ts @@ -0,0 +1,69 @@ +import { Service, Inject } from 'typedi'; +import { Plan, PlanSubscription, Tenant } from '@/system/models'; +import Subscription from '@/services/Subscription/Subscription'; +import LicensePaymentMethod from '@/services/Payment/LicensePaymentMethod'; +import PaymentContext from '@/services/Payment'; +import SubscriptionSMSMessages from '@/services/Subscription/SMSMessages'; +import SubscriptionMailMessages from '@/services/Subscription/MailMessages'; +import { ILicensePaymentModel } from '@/interfaces'; +import SubscriptionViaLicense from './SubscriptionViaLicense'; + +@Service() +export default class SubscriptionService { + @Inject() + smsMessages: SubscriptionSMSMessages; + + @Inject() + mailMessages: SubscriptionMailMessages; + + @Inject('logger') + logger: any; + + @Inject('repositories') + sysRepositories: any; + + /** + * Handles the payment process via license code and than subscribe to + * the given tenant. + * @param {number} tenantId + * @param {String} planSlug + * @param {string} licenseCode + * @return {Promise} + */ + public async subscriptionViaLicense( + tenantId: number, + planSlug: string, + paymentModel: ILicensePaymentModel, + subscriptionSlug: string = 'main' + ) { + // Retrieve plan details. + const plan = await Plan.query().findOne('slug', planSlug); + + // Retrieve tenant details. + const tenant = await Tenant.query().findById(tenantId); + + // License payment method. + const paymentViaLicense = new LicensePaymentMethod(); + + // Payment context. + const paymentContext = new PaymentContext(paymentViaLicense); + + // Subscription. + const subscription = new SubscriptionViaLicense(paymentContext); + + // Subscribe. + await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug); + } + + /** + * Retrieve all subscription of the given tenant. + * @param {number} tenantId + */ + public async getSubscriptions(tenantId: number) { + const subscriptions = await PlanSubscription.query().where( + 'tenant_id', + tenantId + ); + return subscriptions; + } +} diff --git a/packages/server/src/services/Subscription/SubscriptionViaLicense.ts b/packages/server/src/services/Subscription/SubscriptionViaLicense.ts new file mode 100644 index 000000000..629f30143 --- /dev/null +++ b/packages/server/src/services/Subscription/SubscriptionViaLicense.ts @@ -0,0 +1,54 @@ +import { License, Tenant, Plan } from '@/system/models'; +import Subscription from './Subscription'; +import { PaymentModel } from '@/interfaces'; + +export default class SubscriptionViaLicense extends Subscription { + /** + * Subscripe to the given plan. + * @param {Plan} plan + * @throws {NotAllowedChangeSubscriptionPlan} + */ + public async subscribe( + tenant: Tenant, + plan: Plan, + paymentModel?: PaymentModel, + subscriptionSlug: string = 'main' + ): Promise { + await this.paymentContext.makePayment(paymentModel, plan); + + return this.newSubscriptionFromLicense( + tenant, + plan, + paymentModel.licenseCode, + subscriptionSlug + ); + } + + /** + * New subscription from the given license. + * @param {Tanant} tenant + * @param {Plab} plan + * @param {string} licenseCode + * @param {string} subscriptionSlug + * @returns {Promise} + */ + private async newSubscriptionFromLicense( + tenant, + plan, + licenseCode: string, + subscriptionSlug: string = 'main' + ): Promise { + // License information. + const licenseInfo = await License.query().findOne( + 'licenseCode', + licenseCode + ); + return this.newSubscribtion( + tenant, + plan, + licenseInfo.periodInterval, + licenseInfo.licensePeriod, + subscriptionSlug + ); + } +} diff --git a/packages/server/src/system/models/Subscriptions/License.ts b/packages/server/src/system/models/Subscriptions/License.ts new file mode 100644 index 000000000..97bbc87a7 --- /dev/null +++ b/packages/server/src/system/models/Subscriptions/License.ts @@ -0,0 +1,129 @@ +import { Model, mixin } from 'objection'; +import moment from 'moment'; +import SystemModel from '@/system/models/SystemModel'; + +export default class License extends SystemModel { + /** + * Table name. + */ + static get tableName() { + return 'subscription_licenses'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + // Filters active licenses. + filterActiveLicense(query) { + query.where('disabled_at', null); + query.where('used_at', null); + }, + + // Find license by its code or id. + findByCodeOrId(query, id, code) { + if (id) { + query.where('id', id); + } + if (code) { + query.where('license_code', code); + } + }, + + // Filters licenses list. + filter(builder, licensesFilter) { + if (licensesFilter.active) { + builder.modify('filterActiveLicense'); + } + if (licensesFilter.disabled) { + builder.whereNot('disabled_at', null); + } + if (licensesFilter.used) { + builder.whereNot('used_at', null); + } + if (licensesFilter.sent) { + builder.whereNot('sent_at', null); + } + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Plan = require('system/models/Subscriptions/Plan'); + + return { + plan: { + relation: Model.BelongsToOneRelation, + modelClass: Plan.default, + join: { + from: 'subscription_licenses.planId', + to: 'subscriptions_plans.id', + }, + }, + }; + } + + /** + * Deletes the given license code from the storage. + * @param {string} licenseCode + * @return {Promise} + */ + static deleteLicense(licenseCode, viaAttribute = 'license_code') { + return this.query().where(viaAttribute, licenseCode).delete(); + } + + /** + * Marks the given license code as disabled on the storage. + * @param {string} licenseCode + * @return {Promise} + */ + static markLicenseAsDisabled(licenseCode, viaAttribute = 'license_code') { + return this.query().where(viaAttribute, licenseCode).patch({ + disabled_at: moment().toMySqlDateTime(), + }); + } + + /** + * Marks the given license code as sent on the storage. + * @param {string} licenseCode + */ + static markLicenseAsSent(licenseCode, viaAttribute = 'license_code') { + return this.query().where(viaAttribute, licenseCode).patch({ + sent_at: moment().toMySqlDateTime(), + }); + } + + /** + * Marks the given license code as used on the storage. + * @param {string} licenseCode + * @return {Promise} + */ + static markLicenseAsUsed(licenseCode, viaAttribute = 'license_code') { + return this.query().where(viaAttribute, licenseCode).patch({ + used_at: moment().toMySqlDateTime(), + }); + } + + /** + * + * @param {IIPlan} plan + * @return {boolean} + */ + isEqualPlanPeriod(plan) { + return ( + this.invoicePeriod === plan.invoiceInterval && + license.licensePeriod === license.periodInterval + ); + } +} diff --git a/packages/server/src/system/models/Subscriptions/Plan.ts b/packages/server/src/system/models/Subscriptions/Plan.ts new file mode 100644 index 000000000..2b3f6b057 --- /dev/null +++ b/packages/server/src/system/models/Subscriptions/Plan.ts @@ -0,0 +1,82 @@ +import { Model, mixin } from 'objection'; +import SystemModel from '@/system/models/SystemModel'; +import { PlanSubscription } from '..'; + +export default class Plan extends mixin(SystemModel) { + /** + * Table name. + */ + static get tableName() { + return 'subscription_plans'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Defined virtual attributes. + */ + static get virtualAttributes() { + return ['isFree', 'hasTrial']; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + getFeatureBySlug(builder, featureSlug) { + builder.where('slug', featureSlug); + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const PlanSubscription = require('system/models/Subscriptions/PlanSubscription'); + + return { + /** + * The plan may have many subscriptions. + */ + subscriptions: { + relation: Model.HasManyRelation, + modelClass: PlanSubscription.default, + join: { + from: 'subscription_plans.id', + to: 'subscription_plan_subscriptions.planId', + }, + } + }; + } + + /** + * Check if plan is free. + * @return {boolean} + */ + isFree() { + return this.price <= 0; + } + + /** + * Check if plan is paid. + * @return {boolean} + */ + isPaid() { + return !this.isFree(); + } + + /** + * Check if plan has trial. + * @return {boolean} + */ + hasTrial() { + return this.trialPeriod && this.trialInterval; + } +} diff --git a/packages/server/src/system/models/Subscriptions/PlanFeature.ts b/packages/server/src/system/models/Subscriptions/PlanFeature.ts new file mode 100644 index 000000000..178fe818e --- /dev/null +++ b/packages/server/src/system/models/Subscriptions/PlanFeature.ts @@ -0,0 +1,36 @@ +import { Model, mixin } from 'objection'; +import SystemModel from '@/system/models/SystemModel'; + +export default class PlanFeature extends mixin(SystemModel) { + /** + * Table name. + */ + static get tableName() { + return 'subscriptions.plan_features'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Plan = require('system/models/Subscriptions/Plan'); + + return { + plan: { + relation: Model.BelongsToOneRelation, + modelClass: Plan.default, + join: { + from: 'subscriptions.plan_features.planId', + to: 'subscriptions.plans.id', + }, + }, + }; + } +} diff --git a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts new file mode 100644 index 000000000..d77ee6418 --- /dev/null +++ b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts @@ -0,0 +1,164 @@ +import { Model, mixin } from 'objection'; +import SystemModel from '@/system/models/SystemModel'; +import moment from 'moment'; +import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; + +export default class PlanSubscription extends mixin(SystemModel) { + /** + * Table name. + */ + static get tableName() { + return 'subscription_plan_subscriptions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Defined virtual attributes. + */ + static get virtualAttributes() { + return ['active', 'inactive', 'ended', 'onTrial']; + } + + /** + * Modifiers queries. + */ + static get modifiers() { + return { + activeSubscriptions(builder) { + const dateFormat = 'YYYY-MM-DD HH:mm:ss'; + const now = moment().format(dateFormat); + + builder.where('ends_at', '>', now); + builder.where('trial_ends_at', '>', now); + }, + + inactiveSubscriptions() { + builder.modify('endedTrial'); + builder.modify('endedPeriod'); + }, + + subscriptionBySlug(builder, subscriptionSlug) { + builder.where('slug', subscriptionSlug); + }, + + endedTrial(builder) { + const dateFormat = 'YYYY-MM-DD HH:mm:ss'; + const endDate = moment().format(dateFormat); + + builder.where('ends_at', '<=', endDate); + }, + + endedPeriod(builder) { + const dateFormat = 'YYYY-MM-DD HH:mm:ss'; + const endDate = moment().format(dateFormat); + + builder.where('trial_ends_at', '<=', endDate); + }, + }; + } + + /** + * Relations mappings. + */ + static get relationMappings() { + const Tenant = require('system/models/Tenant'); + const Plan = require('system/models/Subscriptions/Plan'); + + return { + /** + * Plan subscription belongs to tenant. + */ + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant.default, + join: { + from: 'subscription_plan_subscriptions.tenantId', + to: 'tenants.id', + }, + }, + + /** + * Plan description belongs to plan. + */ + plan: { + relation: Model.BelongsToOneRelation, + modelClass: Plan.default, + join: { + from: 'subscription_plan_subscriptions.planId', + to: 'subscription_plans.id', + }, + }, + }; + } + + /** + * Check if subscription is active. + * @return {Boolean} + */ + active() { + return !this.ended() || this.onTrial(); + } + + /** + * Check if subscription is inactive. + * @return {Boolean} + */ + inactive() { + return !this.active(); + } + + /** + * Check if subscription period has ended. + * @return {Boolean} + */ + ended() { + return this.endsAt ? moment().isAfter(this.endsAt) : false; + } + + /** + * Check if subscription is currently on trial. + * @return {Boolean} + */ + onTrial() { + return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false; + } + + /** + * Set new period from the given details. + * @param {string} invoiceInterval + * @param {number} invoicePeriod + * @param {string} start + * + * @return {Object} + */ + static setNewPeriod(invoiceInterval, invoicePeriod, start) { + const period = new SubscriptionPeriod( + invoiceInterval, + invoicePeriod, + start, + ); + + const startsAt = period.getStartDate(); + const endsAt = period.getEndDate(); + + return { startsAt, endsAt }; + } + + /** + * Renews subscription period. + * @Promise + */ + renew(invoiceInterval, invoicePeriod) { + const { startsAt, endsAt } = PlanSubscription.setNewPeriod( + invoiceInterval, + invoicePeriod, + ); + return this.$query().update({ startsAt, endsAt }); + } +} diff --git a/packages/server/src/system/models/Tenant.ts b/packages/server/src/system/models/Tenant.ts index b3376a3a3..a40966797 100644 --- a/packages/server/src/system/models/Tenant.ts +++ b/packages/server/src/system/models/Tenant.ts @@ -1,8 +1,10 @@ import moment from 'moment'; import { Model } from 'objection'; import uniqid from 'uniqid'; +import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; import BaseModel from 'models/Model'; import TenantMetadata from './TenantMetadata'; +import PlanSubscription from './Subscriptions/PlanSubscription'; export default class Tenant extends BaseModel { upgradeJobId: string; @@ -57,13 +59,33 @@ export default class Tenant extends BaseModel { return !!this.upgradeJobId; } + /** + * Query modifiers. + */ + static modifiers() { + return { + subscriptions(builder) { + builder.withGraphFetched('subscriptions'); + }, + }; + } + /** * Relations mappings. */ static get relationMappings() { + const PlanSubscription = require('./Subscriptions/PlanSubscription'); const TenantMetadata = require('./TenantMetadata'); return { + subscriptions: { + relation: Model.HasManyRelation, + modelClass: PlanSubscription.default, + join: { + from: 'tenants.id', + to: 'subscription_plan_subscriptions.tenantId', + }, + }, metadata: { relation: Model.HasOneRelation, modelClass: TenantMetadata.default, @@ -125,9 +147,9 @@ export default class Tenant extends BaseModel { /** * Marks the given tenant as upgrading. - * @param {number} tenantId - * @param {string} upgradeJobId - * @returns + * @param {number} tenantId + * @param {string} upgradeJobId + * @returns */ static markAsUpgrading(tenantId, upgradeJobId) { return this.query().update({ upgradeJobId }).where({ id: tenantId }); @@ -135,8 +157,8 @@ export default class Tenant extends BaseModel { /** * Markes the given tenant as upgraded. - * @param {number} tenantId - * @returns + * @param {number} tenantId + * @returns */ static markAsUpgraded(tenantId) { return this.query().update({ upgradeJobId: null }).where({ id: tenantId }); diff --git a/packages/server/src/system/models/index.ts b/packages/server/src/system/models/index.ts index b6e3c1f45..8d8f41146 100644 --- a/packages/server/src/system/models/index.ts +++ b/packages/server/src/system/models/index.ts @@ -1,3 +1,7 @@ +import Plan from './Subscriptions/Plan'; +import PlanFeature from './Subscriptions/PlanFeature'; +import PlanSubscription from './Subscriptions/PlanSubscription'; +import License from './Subscriptions/License'; import Tenant from './Tenant'; import TenantMetadata from './TenantMetadata'; import SystemUser from './SystemUser'; @@ -7,6 +11,10 @@ import SystemPlaidItem from './SystemPlaidItem'; import { Import } from './Import'; export { + Plan, + PlanFeature, + PlanSubscription, + License, Tenant, TenantMetadata, SystemUser, diff --git a/packages/server/src/system/repositories/SubscriptionRepository.ts b/packages/server/src/system/repositories/SubscriptionRepository.ts new file mode 100644 index 000000000..44962b0b8 --- /dev/null +++ b/packages/server/src/system/repositories/SubscriptionRepository.ts @@ -0,0 +1,26 @@ +import SystemRepository from '@/system/repositories/SystemRepository'; +import { PlanSubscription } from '@/system/models'; + +export default class SubscriptionRepository extends SystemRepository { + /** + * Gets the repository's model. + */ + get model() { + return PlanSubscription.bindKnex(this.knex); + } + + /** + * Retrieve subscription from a given slug in specific tenant. + * @param {string} slug + * @param {number} tenantId + */ + getBySlugInTenant(slug: string, tenantId: number) { + const cacheKey = this.getCacheKey('getBySlugInTenant', slug, tenantId); + + return this.cache.get(cacheKey, () => { + return PlanSubscription.query() + .findOne('slug', slug) + .where('tenant_id', tenantId); + }); + } +} diff --git a/packages/server/src/system/repositories/index.ts b/packages/server/src/system/repositories/index.ts index 472f9867d..9fb001718 100644 --- a/packages/server/src/system/repositories/index.ts +++ b/packages/server/src/system/repositories/index.ts @@ -1,4 +1,9 @@ import SystemUserRepository from '@/system/repositories/SystemUserRepository'; +import SubscriptionRepository from '@/system/repositories/SubscriptionRepository'; import TenantRepository from '@/system/repositories/TenantRepository'; -export { SystemUserRepository, TenantRepository }; +export { + SystemUserRepository, + SubscriptionRepository, + TenantRepository, +}; \ No newline at end of file diff --git a/packages/server/src/system/seeds/.gitkeep b/packages/server/src/system/seeds/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/server/src/system/seeds/seed_subscriptions_plans.js b/packages/server/src/system/seeds/seed_subscriptions_plans.js new file mode 100644 index 000000000..0e69b94db --- /dev/null +++ b/packages/server/src/system/seeds/seed_subscriptions_plans.js @@ -0,0 +1,66 @@ + +exports.seed = (knex) => { + // Deletes ALL existing entries + return knex('subscription_plans').del() + .then(() => { + // Inserts seed entries + return knex('subscription_plans').insert([ + { + name: 'Essentials', + slug: 'essentials-monthly', + price: 100, + active: true, + currency: 'LYD', + trial_period: 7, + trial_interval: 'days', + }, + { + name: 'Essentials', + slug: 'essentials-yearly', + price: 1200, + active: true, + currency: 'LYD', + trial_period: 12, + trial_interval: 'months', + }, + { + name: 'Pro', + slug: 'pro-monthly', + price: 200, + active: true, + currency: 'LYD', + trial_period: 1, + trial_interval: 'months', + }, + { + name: 'Pro', + slug: 'pro-yearly', + price: 500, + active: true, + currency: 'LYD', + invoice_period: 12, + invoice_interval: 'month', + index: 2, + }, + { + name: 'Plus', + slug: 'plus-monthly', + price: 200, + active: true, + currency: 'LYD', + trial_period: 1, + trial_interval: 'months', + }, + { + name: 'Plus', + slug: 'plus-yearly', + price: 500, + active: true, + currency: 'LYD', + invoice_period: 12, + invoice_interval: 'month', + index: 2, + }, + ]); + }); +}; From 9807ac04b0a8351c6bf59069ed203a9f94fbc74c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 13 Apr 2024 15:18:59 +0200 Subject: [PATCH 3/8] Revert "feat(webapp): deprecate the subscription step in onboarding process" This reverts commit 0c1bf302e5b85e3b2ea62f9e8046419981c2a997. --- packages/server/src/api/index.ts | 2 -- packages/webapp/src/constants/registerWizard.tsx | 3 +++ .../webapp/src/containers/Setup/SetupRightSection.tsx | 10 ++++++++++ .../webapp/src/containers/Setup/SetupWizardContent.tsx | 2 ++ .../webapp/src/containers/Setup/WizardSetupSteps.tsx | 1 - packages/webapp/src/hooks/query/organization.tsx | 8 ++++++-- .../webapp/src/store/organizations/withSetupWizard.tsx | 3 +++ 7 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index f60de22d2..13de30877 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -38,7 +38,6 @@ import ExchangeRates from '@/api/controllers/ExchangeRates'; import Media from '@/api/controllers/Media'; import Ping from '@/api/controllers/Ping'; import Subscription from '@/api/controllers/Subscription'; -import Licenses from '@/api/controllers/Subscription/Licenses'; import InventoryAdjustments from '@/api/controllers/Inventory/InventoryAdjustments'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; import Jobs from './controllers/Jobs'; @@ -73,7 +72,6 @@ export default () => { app.use('/auth', Container.get(Authentication).router()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); - app.use('/licenses', Container.get(Licenses).router()); app.use('/subscription', Container.get(Subscription).router()); app.use('/organization', Container.get(Organization).router()); app.use('/ping', Container.get(Ping).router()); diff --git a/packages/webapp/src/constants/registerWizard.tsx b/packages/webapp/src/constants/registerWizard.tsx index e901d5202..f1d971d2e 100644 --- a/packages/webapp/src/constants/registerWizard.tsx +++ b/packages/webapp/src/constants/registerWizard.tsx @@ -2,6 +2,9 @@ import intl from 'react-intl-universal'; export const getSetupWizardSteps = () => [ + { + label: intl.get('setup.plan.plans'), + }, { label: intl.get('setup.plan.getting_started'), }, diff --git a/packages/webapp/src/containers/Setup/SetupRightSection.tsx b/packages/webapp/src/containers/Setup/SetupRightSection.tsx index c0ea49ef0..a4c880252 100644 --- a/packages/webapp/src/containers/Setup/SetupRightSection.tsx +++ b/packages/webapp/src/containers/Setup/SetupRightSection.tsx @@ -7,6 +7,7 @@ import SetupWizardContent from './SetupWizardContent'; import withOrganization from '@/containers/Organization/withOrganization'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; import withSetupWizard from '@/store/organizations/withSetupWizard'; +import withSubscriptions from '../Subscriptions/withSubscriptions'; import { compose } from '@/utils'; @@ -22,6 +23,9 @@ function SetupRightSection({ // #withSetupWizard setupStepId, setupStepIndex, + + // #withSubscriptions + isSubscriptionActive, }) { return (
@@ -53,6 +57,12 @@ export default compose( isOrganizationBuildRunning, }), ), + withSubscriptions( + ({ isSubscriptionActive }) => ({ + isSubscriptionActive, + }), + 'main', + ), withSetupWizard(({ setupStepId, setupStepIndex }) => ({ setupStepId, setupStepIndex, diff --git a/packages/webapp/src/containers/Setup/SetupWizardContent.tsx b/packages/webapp/src/containers/Setup/SetupWizardContent.tsx index 6e0a32b8c..57b162ccf 100644 --- a/packages/webapp/src/containers/Setup/SetupWizardContent.tsx +++ b/packages/webapp/src/containers/Setup/SetupWizardContent.tsx @@ -4,6 +4,7 @@ import React from 'react'; import SetupSteps from './SetupSteps'; import WizardSetupSteps from './WizardSetupSteps'; +import SetupSubscription from './SetupSubscription'; import SetupOrganizationPage from './SetupOrganizationPage'; import SetupInitializingForm from './SetupInitializingForm'; import SetupCongratsPage from './SetupCongratsPage'; @@ -18,6 +19,7 @@ export default function SetupWizardContent({ setupStepIndex, setupStepId }) {
+ diff --git a/packages/webapp/src/containers/Setup/WizardSetupSteps.tsx b/packages/webapp/src/containers/Setup/WizardSetupSteps.tsx index 0381d11d9..36b53a37e 100644 --- a/packages/webapp/src/containers/Setup/WizardSetupSteps.tsx +++ b/packages/webapp/src/containers/Setup/WizardSetupSteps.tsx @@ -25,7 +25,6 @@ export default function WizardSetupSteps({ currentStep = 1 }) { ))} diff --git a/packages/webapp/src/hooks/query/organization.tsx b/packages/webapp/src/hooks/query/organization.tsx index 3945de70f..aea3ab96c 100644 --- a/packages/webapp/src/hooks/query/organization.tsx +++ b/packages/webapp/src/hooks/query/organization.tsx @@ -1,11 +1,11 @@ // @ts-nocheck -import { omit } from 'lodash'; import { useMutation, useQueryClient } from 'react-query'; import { batch } from 'react-redux'; import t from './types'; import useApiRequest from '../useRequest'; import { useRequestQuery } from '../useQueryRequest'; -import { useSetOrganizations } from '../state'; +import { useSetOrganizations, useSetSubscriptions } from '../state'; +import { omit } from 'lodash'; /** * Retrieve organizations of the authenticated user. @@ -32,6 +32,7 @@ export function useOrganizations(props) { */ export function useCurrentOrganization(props) { const setOrganizations = useSetOrganizations(); + const setSubscriptions = useSetSubscriptions(); return useRequestQuery( [t.ORGANIZATION_CURRENT], @@ -43,6 +44,9 @@ export function useCurrentOrganization(props) { const organization = omit(data, ['subscriptions']); batch(() => { + // Sets subscriptions. + setSubscriptions(data.subscriptions); + // Sets organizations. setOrganizations([organization]); }); diff --git a/packages/webapp/src/store/organizations/withSetupWizard.tsx b/packages/webapp/src/store/organizations/withSetupWizard.tsx index 1a0b27f5b..7f39a2466 100644 --- a/packages/webapp/src/store/organizations/withSetupWizard.tsx +++ b/packages/webapp/src/store/organizations/withSetupWizard.tsx @@ -6,15 +6,18 @@ export default (mapState) => { const { isOrganizationSetupCompleted, isOrganizationReady, + isSubscriptionActive, isOrganizationBuildRunning } = props; const condits = { isCongratsStep: isOrganizationSetupCompleted, + isSubscriptionStep: !isSubscriptionActive, isInitializingStep: isOrganizationBuildRunning, isOrganizationStep: !isOrganizationReady && !isOrganizationBuildRunning, }; const scenarios = [ + { condition: condits.isSubscriptionStep, step: 'subscription' }, { condition: condits.isOrganizationStep, step: 'organization' }, { condition: condits.isInitializingStep, step: 'initializing' }, { condition: condits.isCongratsStep, step: 'congrats' }, From 693ae61141e2ad566dd09604d818f2de29027687 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 14 Apr 2024 10:33:29 +0200 Subject: [PATCH 4/8] feat: integrate LemonSqueezy to subscription payment --- .env.example | 6 ++ .../src/api/controllers/Subscription/index.ts | 51 +++++++-- packages/server/src/loaders/express.ts | 8 +- .../Subscription/LemonSqueezyService.ts | 37 +++++++ .../services/Subscription/LemonWebhooks.ts | 99 +++++++++++++++++ .../server/src/services/Subscription/utils.ts | 102 ++++++++++++++++++ packages/webapp/public/index.html | 1 + .../containers/Setup/SetupSubscription.tsx | 22 +++- .../SetupSubscriptionForm.tsx | 29 +++-- .../webapp/src/hooks/query/subscriptions.tsx | 33 ++++-- 10 files changed, 363 insertions(+), 25 deletions(-) create mode 100644 packages/server/src/services/Subscription/LemonSqueezyService.ts create mode 100644 packages/server/src/services/Subscription/LemonWebhooks.ts create mode 100644 packages/server/src/services/Subscription/utils.ts diff --git a/.env.example b/.env.example index 72882dd40..87ed7d226 100644 --- a/.env.example +++ b/.env.example @@ -95,3 +95,9 @@ PLAID_LINK_WEBHOOK= PLAID_SANDBOX_REDIRECT_URI= PLAID_DEVELOPMENT_REDIRECT_URI= + + +# https://docs.lemonsqueezy.com/guides/developer-guide/getting-started#create-an-api-key +LEMONSQUEEZY_API_KEY= +LEMONSQUEEZY_STORE_ID= +LEMONSQUEEZY_WEBHOOK_SECRET= diff --git a/packages/server/src/api/controllers/Subscription/index.ts b/packages/server/src/api/controllers/Subscription/index.ts index 6145e7551..02e00a6ba 100644 --- a/packages/server/src/api/controllers/Subscription/index.ts +++ b/packages/server/src/api/controllers/Subscription/index.ts @@ -1,16 +1,21 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { Container, Service, Inject } from 'typedi'; +import { Service, Inject } from 'typedi'; +import { body } from 'express-validator'; import JWTAuth from '@/api/middleware/jwtAuth'; import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; -import PaymentViaLicenseController from '@/api/controllers/Subscription/PaymentViaLicense'; import SubscriptionService from '@/services/Subscription/SubscriptionService'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import BaseController from '../BaseController'; +import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService'; @Service() -export default class SubscriptionController { +export default class SubscriptionController extends BaseController { @Inject() - subscriptionService: SubscriptionService; + private subscriptionService: SubscriptionService; + + @Inject() + private lemonSqueezyService: LemonSqueezyService; /** * Router constructor. @@ -22,7 +27,12 @@ export default class SubscriptionController { router.use(AttachCurrentTenantUser); router.use(TenancyMiddleware); - router.use('/license', Container.get(PaymentViaLicenseController).router()); + router.post( + '/lemon/checkout_url', + [body('variantId').exists().trim()], + this.validationResult, + this.getCheckoutUrl.bind(this) + ); router.get('/', asyncMiddleware(this.getSubscriptions.bind(this))); return router; @@ -34,7 +44,11 @@ export default class SubscriptionController { * @param {Response} res * @param {NextFunction} next */ - async getSubscriptions(req: Request, res: Response, next: NextFunction) { + private async getSubscriptions( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId } = req; try { @@ -46,4 +60,29 @@ export default class SubscriptionController { next(error); } } + + /** + * Retrieves the LemonSqueezy checkout url. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async getCheckoutUrl( + req: Request, + res: Response, + next: NextFunction + ) { + const { variantId } = this.matchedBodyData(req); + const { user } = req; + + try { + const checkout = await this.lemonSqueezyService.getCheckout( + variantId, + user + ); + return res.status(200).send(checkout); + } catch (error) { + next(error); + } + } } diff --git a/packages/server/src/loaders/express.ts b/packages/server/src/loaders/express.ts index 70b4fd309..92589f70f 100644 --- a/packages/server/src/loaders/express.ts +++ b/packages/server/src/loaders/express.ts @@ -36,7 +36,13 @@ export default ({ app }) => { // Boom response objects. app.use(boom()); - app.use(bodyParser.json()); + app.use( + bodyParser.json({ + verify: (req, res, buf) => { + req.rawBody = buf; + }, + }) + ); // Parses both json and urlencoded. app.use(json()); diff --git a/packages/server/src/services/Subscription/LemonSqueezyService.ts b/packages/server/src/services/Subscription/LemonSqueezyService.ts new file mode 100644 index 000000000..53b1cdcf3 --- /dev/null +++ b/packages/server/src/services/Subscription/LemonSqueezyService.ts @@ -0,0 +1,37 @@ +import { Service } from 'typedi'; +import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js'; +import { SystemUser } from '@/system/models'; +import { configureLemonSqueezy } from './utils'; + +@Service() +export class LemonSqueezyService { + /** + * Retrieves the LemonSqueezy checkout url. + * @param {number} variantId + * @param {SystemUser} user + */ + async getCheckout(variantId: number, user: SystemUser) { + configureLemonSqueezy(); + + return createCheckout(process.env.LEMONSQUEEZY_STORE_ID!, variantId, { + checkoutOptions: { + embed: true, + media: true, + logo: true, + }, + checkoutData: { + email: user.email, + custom: { + user_id: user.id + '', + tenant_id: user.tenantId + '', + }, + }, + productOptions: { + enabledVariants: [variantId], + redirectUrl: `http://localhost:4000/dashboard/billing/`, + receiptButtonText: 'Go to Dashboard', + receiptThankYouNote: 'Thank you for signing up to Lemon Stand!', + }, + }); + } +} diff --git a/packages/server/src/services/Subscription/LemonWebhooks.ts b/packages/server/src/services/Subscription/LemonWebhooks.ts new file mode 100644 index 000000000..68565ad92 --- /dev/null +++ b/packages/server/src/services/Subscription/LemonWebhooks.ts @@ -0,0 +1,99 @@ +import { getPrice } from '@lemonsqueezy/lemonsqueezy.js'; +import { ServiceError } from '@/exceptions'; +import { Service } from 'typedi'; +import { + compareSignatures, + configureLemonSqueezy, + createHmacSignature, + webhookHasData, + webhookHasMeta, +} from './utils'; +import { Plan } from '@/system/models'; + +@Service() +export class LemonWebhooks { + /** + * + * @param {string} rawBody + * @param {string} signature + * @returns + */ + public async handlePostWebhook( + rawData: any, + data: Record, + signature: string + ) { + configureLemonSqueezy(); + + if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) { + return new ServiceError('Lemon Squeezy Webhook Secret not set in .env'); + } + const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET; + const hmacSignature = createHmacSignature(secret, rawData); + + if (!compareSignatures(hmacSignature, signature)) { + console.log('invalid'); + return new Error('Invalid signature', { status: 400 }); + } + // Type guard to check if the object has a 'meta' property. + if (webhookHasMeta(data)) { + // Non-blocking call to process the webhook event. + void this.processWebhookEvent(data); + + return true; + } + return new Error('Data invalid', { status: 400 }); + } + + /** + * This action will process a webhook event in the database. + */ + async processWebhookEvent(eventBody) { + let processingError = ''; + const webhookEvent = eventBody.meta.event_name; + + if (!webhookHasMeta(eventBody)) { + processingError = "Event body is missing the 'meta' property."; + } else if (webhookHasData(eventBody)) { + if (webhookEvent.startsWith('subscription_payment_')) { + // Save subscription invoices; eventBody is a SubscriptionInvoice + // Not implemented. + } else if (webhookEvent.startsWith('subscription_')) { + // Save subscription events; obj is a Subscription + const attributes = eventBody.data.attributes; + const variantId = attributes.variant_id as string; + + // We assume that the Plan table is up to date. + const plan = await Plan.query().findOne('slug', 'essentials-yearly'); + + if (!plan) { + processingError = `Plan with variantId ${variantId} not found.`; + } else { + // Update the subscription in the database. + const priceId = attributes.first_subscription_item.price_id; + + // Get the price data from Lemon Squeezy. + const priceData = await getPrice(priceId); + + if (priceData.error) { + processingError = `Failed to get the price data for the subscription ${eventBody.data.id}.`; + } + + const isUsageBased = + attributes.first_subscription_item.is_usage_based; + const price = isUsageBased + ? priceData.data?.data.attributes.unit_price_decimal + : priceData.data?.data.attributes.unit_price; + + const newSubscription = {}; + } + } else if (webhookEvent.startsWith('order_')) { + // Save orders; eventBody is a "Order" + /* Not implemented */ + } else if (webhookEvent.startsWith('license_')) { + // Save license keys; eventBody is a "License key" + /* Not implemented */ + } + } + } +} diff --git a/packages/server/src/services/Subscription/utils.ts b/packages/server/src/services/Subscription/utils.ts new file mode 100644 index 000000000..b41e8ce5e --- /dev/null +++ b/packages/server/src/services/Subscription/utils.ts @@ -0,0 +1,102 @@ +import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js'; + +/** + * Ensures that required environment variables are set and sets up the Lemon + * Squeezy JS SDK. Throws an error if any environment variables are missing or + * if there's an error setting up the SDK. + */ +export function configureLemonSqueezy() { + const requiredVars = [ + 'LEMONSQUEEZY_API_KEY', + 'LEMONSQUEEZY_STORE_ID', + 'LEMONSQUEEZY_WEBHOOK_SECRET', + ]; + const missingVars = requiredVars.filter((varName) => !process.env[varName]); + + if (missingVars.length > 0) { + throw new Error( + `Missing required LEMONSQUEEZY env variables: ${missingVars.join( + ', ' + )}. Please, set them in your .env file.` + ); + } + lemonSqueezySetup({ + apiKey: process.env.LEMONSQUEEZY_API_KEY, + onError: (error) => { + console.log(error); + // console.log('LL', error.message); + // eslint-disable-next-line no-console -- allow logging + // console.error(error); + // throw new Error(`Lemon Squeezy API error: ${error.message}`); + }, + }); +} +/** + * Check if the value is an object. + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Typeguard to check if the object has a 'meta' property + * and that the 'meta' property has the correct shape. + */ +export function webhookHasMeta(obj: unknown): obj is { + meta: { + event_name: string; + custom_data: { + user_id: string; + }; + }; +} { + if ( + isObject(obj) && + isObject(obj.meta) && + typeof obj.meta.event_name === 'string' && + isObject(obj.meta.custom_data) && + typeof obj.meta.custom_data.user_id === 'string' + ) { + return true; + } + return false; +} + +/** + * Typeguard to check if the object has a 'data' property and the correct shape. + * + * @param obj - The object to check. + * @returns True if the object has a 'data' property. + */ +export function webhookHasData(obj: unknown): obj is { + data: { + attributes: Record & { + first_subscription_item: { + id: number; + price_id: number; + is_usage_based: boolean; + }; + }; + id: string; + }; +} { + return ( + isObject(obj) && + 'data' in obj && + isObject(obj.data) && + 'attributes' in obj.data + ); +} + +export function createHmacSignature(secretKey, body) { + return require('crypto') + .createHmac('sha256', secretKey) + .update(body) + .digest('hex'); +} + +export function compareSignatures(signature, comparison_signature) { + const source = Buffer.from(signature, 'utf8'); + const comparison = Buffer.from(comparison_signature, 'utf8'); + return require('crypto').timingSafeEqual(source, comparison); +} diff --git a/packages/webapp/public/index.html b/packages/webapp/public/index.html index b201cba67..1af330a07 100644 --- a/packages/webapp/public/index.html +++ b/packages/webapp/public/index.html @@ -51,5 +51,6 @@ href="https://cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css" type="text/css" /> + diff --git a/packages/webapp/src/containers/Setup/SetupSubscription.tsx b/packages/webapp/src/containers/Setup/SetupSubscription.tsx index 7b0d419b5..866ed0bcc 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription.tsx +++ b/packages/webapp/src/containers/Setup/SetupSubscription.tsx @@ -8,6 +8,7 @@ import '@/style/pages/Setup/Subscription.scss'; import SetupSubscriptionForm from './SetupSubscription/SetupSubscriptionForm'; import { getSubscriptionFormSchema } from './SubscriptionForm.schema'; import withSubscriptionPlansActions from '../Subscriptions/withSubscriptionPlansActions'; +import { useGetLemonSqueezyCheckout } from '@/hooks/query/subscriptions'; /** * Subscription step of wizard setup. @@ -20,14 +21,33 @@ function SetupSubscription({ initSubscriptionPlans(); }, [initSubscriptionPlans]); + React.useEffect(() => { + window.LemonSqueezy.Setup({ + eventHandler: (event) => { + // Do whatever you want with this event data + if (event.event === 'Checkout.Success') { + } + }, + }); + }, []); + // Initial values. const initialValues = { plan_slug: 'essentials', period: 'month', license_code: '', }; + const { mutateAsync: getLemonCheckout } = useGetLemonSqueezyCheckout(); + // Handle form submit. - const handleSubmit = (values) => {}; + const handleSubmit = (values) => { + getLemonCheckout({ variantId: '337977' }) + .then((res) => { + const checkoutUrl = res.data.data.attributes.url; + window.LemonSqueezy.Url.Open(checkoutUrl); + }) + .catch(() => {}); + }; // Retrieve momerized subscription form schema. const SubscriptionFormSchema = React.useMemo( diff --git a/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx b/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx index 1029bf0d4..52eed3f5f 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx +++ b/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx @@ -1,17 +1,28 @@ // @ts-nocheck -import React from 'react'; - +import { Form } from 'formik'; import SubscriptionPlansSection from './SubscriptionPlansSection'; import SubscriptionPeriodsSection from './SubscriptionPeriodsSection'; -import SubscriptionPaymentMethodsSection from './SubscriptionPaymentsMethodsSection'; +import { Button, Intent } from '@blueprintjs/core'; +import { T } from '@/components'; - -export default function SetupSubscriptionForm() { +function StepSubscriptionActions() { return ( -
- - - +
+
); } + +export default function SetupSubscriptionForm() { + return ( +
+
+ + + +
+
+ ); +} diff --git a/packages/webapp/src/hooks/query/subscriptions.tsx b/packages/webapp/src/hooks/query/subscriptions.tsx index 38d601ea5..c81e78cc6 100644 --- a/packages/webapp/src/hooks/query/subscriptions.tsx +++ b/packages/webapp/src/hooks/query/subscriptions.tsx @@ -1,8 +1,8 @@ // @ts-nocheck -import { useEffect } from "react" -import { useMutation, useQueryClient } from "react-query"; -import { useRequestQuery } from "../useQueryRequest"; -import useApiRequest from "../useRequest"; +import { useEffect } from 'react'; +import { useMutation, useQueryClient } from 'react-query'; +import { useRequestQuery } from '../useQueryRequest'; +import useApiRequest from '../useRequest'; import { useSetSubscriptions } from '../state/subscriptions'; import T from './types'; @@ -22,9 +22,9 @@ export const usePaymentByVoucher = (props) => { queryClient.invalidateQueries(T.ORGANIZATIONS); }, ...props, - } + }, ); -} +}; /** * Fetches the organization subscriptions. @@ -41,5 +41,22 @@ export const useOrganizationSubscriptions = (props) => { if (state.isSuccess) { setSubscriptions(state.data); } - }, [state.isSuccess, state.data, setSubscriptions]) -}; \ No newline at end of file + }, [state.isSuccess, state.data, setSubscriptions]); +}; + +/** + * Fetches the checkout url of the lemon squeezy. + */ +export const useGetLemonSqueezyCheckout = (props = {}) => { + const apiRequest = useApiRequest(); + + return useMutation( + (values) => + apiRequest + .post('subscription/lemon/checkout_url', values) + .then((res) => res.data), + { + ...props, + }, + ); +}; From a9748b23c08105e2398e19d9de48781137960768 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 14 Apr 2024 11:55:36 +0200 Subject: [PATCH 5/8] feat: listen LemonSqueezy webhooks --- .../api/controllers/Subscription/Licenses.ts | 250 ------------------ .../Subscription/PaymentViaLicense.ts | 125 --------- .../src/api/controllers/Webhooks/Webhooks.ts | 24 +- packages/server/src/jobs/SendLicenseEmail.ts | 33 --- packages/server/src/jobs/SendLicensePhone.ts | 33 --- packages/server/src/loaders/jobs.ts | 4 - .../server/src/services/Payment/License.ts | 185 ------------- .../services/Payment/LicenseMailMessages.ts | 26 -- .../services/Payment/LicensePaymentMethod.ts | 67 ----- .../services/Payment/LicenseSMSMessages.ts | 17 -- .../src/services/Payment/PaymentMethod.ts | 6 - packages/server/src/services/Payment/index.ts | 22 -- ...monWebhooks.ts => LemonSqueezyWebhooks.ts} | 36 ++- .../src/services/Subscription/Subscription.ts | 56 +--- .../Subscription/SubscriptionPeriod.ts | 2 +- .../Subscription/SubscriptionService.ts | 56 +--- .../Subscription/SubscriptionViaLicense.ts | 54 ---- ...234434_create_subscription_plan_feature.js | 15 -- ...5339_create_subscription_licenses_table.js | 22 -- .../models/Subscriptions/PlanFeature.ts | 36 --- packages/server/src/system/models/Tenant.ts | 50 +++- packages/server/src/system/models/index.ts | 2 - 22 files changed, 106 insertions(+), 1015 deletions(-) delete mode 100644 packages/server/src/api/controllers/Subscription/Licenses.ts delete mode 100644 packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts delete mode 100644 packages/server/src/jobs/SendLicenseEmail.ts delete mode 100644 packages/server/src/jobs/SendLicensePhone.ts delete mode 100644 packages/server/src/services/Payment/License.ts delete mode 100644 packages/server/src/services/Payment/LicenseMailMessages.ts delete mode 100644 packages/server/src/services/Payment/LicensePaymentMethod.ts delete mode 100644 packages/server/src/services/Payment/LicenseSMSMessages.ts delete mode 100644 packages/server/src/services/Payment/PaymentMethod.ts delete mode 100644 packages/server/src/services/Payment/index.ts rename packages/server/src/services/Subscription/{LemonWebhooks.ts => LemonSqueezyWebhooks.ts} (78%) delete mode 100644 packages/server/src/services/Subscription/SubscriptionViaLicense.ts delete mode 100644 packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js delete mode 100644 packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js delete mode 100644 packages/server/src/system/models/Subscriptions/PlanFeature.ts diff --git a/packages/server/src/api/controllers/Subscription/Licenses.ts b/packages/server/src/api/controllers/Subscription/Licenses.ts deleted file mode 100644 index cf483f1a1..000000000 --- a/packages/server/src/api/controllers/Subscription/Licenses.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { Service, Inject } from 'typedi'; -import { Router, Request, Response, NextFunction } from 'express'; -import { check, oneOf, ValidationChain } from 'express-validator'; -import basicAuth from 'express-basic-auth'; -import config from '@/config'; -import { License } from '@/system/models'; -import { ServiceError } from '@/exceptions'; -import BaseController from '@/api/controllers/BaseController'; -import LicenseService from '@/services/Payment/License'; -import asyncMiddleware from '@/api/middleware/asyncMiddleware'; -import { ILicensesFilter, ISendLicenseDTO } from '@/interfaces'; - -@Service() -export default class LicensesController extends BaseController { - @Inject() - licenseService: LicenseService; - - /** - * Router constructor. - */ - router() { - const router = Router(); - - router.use( - basicAuth({ - users: { - [config.licensesAuth.user]: config.licensesAuth.password, - }, - challenge: true, - }) - ); - router.post( - '/generate', - this.generateLicenseSchema, - this.validationResult, - asyncMiddleware(this.generateLicense.bind(this)), - this.catchServiceErrors, - ); - router.post( - '/disable/:licenseId', - this.validationResult, - asyncMiddleware(this.disableLicense.bind(this)), - this.catchServiceErrors, - ); - router.post( - '/send', - this.sendLicenseSchemaValidation, - this.validationResult, - asyncMiddleware(this.sendLicense.bind(this)), - this.catchServiceErrors, - ); - router.delete( - '/:licenseId', - asyncMiddleware(this.deleteLicense.bind(this)), - this.catchServiceErrors, - ); - router.get('/', asyncMiddleware(this.listLicenses.bind(this))); - return router; - } - - /** - * Generate license validation schema. - */ - get generateLicenseSchema(): ValidationChain[] { - return [ - check('loop').exists().isNumeric().toInt(), - check('period').exists().isNumeric().toInt(), - check('period_interval') - .exists() - .isIn(['month', 'months', 'year', 'years', 'day', 'days']), - check('plan_slug').exists().trim().escape(), - ]; - } - - /** - * Specific license validation schema. - */ - get specificLicenseSchema(): ValidationChain[] { - return [ - oneOf( - [check('license_id').exists().isNumeric().toInt()], - [check('license_code').exists().isNumeric().toInt()] - ), - ]; - } - - /** - * Send license validation schema. - */ - get sendLicenseSchemaValidation(): ValidationChain[] { - return [ - check('period').exists().isNumeric(), - check('period_interval').exists().trim().escape(), - check('plan_slug').exists().trim().escape(), - oneOf([ - check('phone_number').exists().trim().escape(), - check('email').exists().trim().escape(), - ]), - ]; - } - - /** - * Generate licenses codes with given period in bulk. - * @param {Request} req - * @param {Response} res - * @return {Response} - */ - async generateLicense(req: Request, res: Response, next: Function) { - const { loop = 10, period, periodInterval, planSlug } = this.matchedBodyData( - req - ); - - try { - await this.licenseService.generateLicenses( - loop, - period, - periodInterval, - planSlug - ); - return res.status(200).send({ - code: 100, - type: 'LICENSEES.GENERATED.SUCCESSFULLY', - message: 'The licenses have been generated successfully.', - }); - } catch (error) { - next(error); - } - } - - /** - * Disable the given license on the storage. - * @param {Request} req - * @param {Response} res - * @return {Response} - */ - async disableLicense(req: Request, res: Response, next: Function) { - const { licenseId } = req.params; - - try { - await this.licenseService.disableLicense(licenseId); - - return res.status(200).send({ license_id: licenseId }); - } catch (error) { - next(error); - } - } - - /** - * Deletes the given license code on the storage. - * @param {Request} req - * @param {Response} res - * @return {Response} - */ - async deleteLicense(req: Request, res: Response, next: Function) { - const { licenseId } = req.params; - - try { - await this.licenseService.deleteLicense(licenseId); - - return res.status(200).send({ license_id: licenseId }); - } catch (error) { - next(error) - } - } - - /** - * Send license code in the given period to the customer via email or phone number - * @param {Request} req - * @param {Response} res - * @return {Response} - */ - async sendLicense(req: Request, res: Response, next: Function) { - const sendLicenseDTO: ISendLicenseDTO = this.matchedBodyData(req); - - try { - await this.licenseService.sendLicenseToCustomer(sendLicenseDTO); - - return res.status(200).send({ - status: 100, - code: 'LICENSE.CODE.SENT', - message: 'The license has been sent to the given customer.', - }); - } catch (error) { - next(error); - } - } - - /** - * Listing licenses. - * @param {Request} req - * @param {Response} res - */ - async listLicenses(req: Request, res: Response) { - const filter: ILicensesFilter = { - disabled: false, - used: false, - sent: false, - active: false, - ...req.query, - }; - const licenses = await License.query().onBuild((builder) => { - builder.modify('filter', filter); - builder.orderBy('createdAt', 'ASC'); - }); - return res.status(200).send({ licenses }); - } - - /** - * Catches all service errors. - */ - catchServiceErrors(error, req: Request, res: Response, next: NextFunction) { - if (error instanceof ServiceError) { - if (error.errorType === 'PLAN_NOT_FOUND') { - return res.status(400).send({ - errors: [{ - type: 'PLAN.NOT.FOUND', - code: 100, - message: 'The given plan not found.', - }], - }); - } - if (error.errorType === 'LICENSE_NOT_FOUND') { - return res.status(400).send({ - errors: [{ - type: 'LICENSE_NOT_FOUND', - code: 200, - message: 'The given license id not found.' - }], - }); - } - if (error.errorType === 'LICENSE_ALREADY_DISABLED') { - return res.status(400).send({ - errors: [{ - type: 'LICENSE.ALREADY.DISABLED', - code: 200, - message: 'License is already disabled.' - }], - }); - } - if (error.errorType === 'NO_AVALIABLE_LICENSE_CODE') { - return res.status(400).send({ - status: 110, - message: 'There is no licenses availiable right now with the given period and plan.', - code: 'NO.AVALIABLE.LICENSE.CODE', - }); - } - } - next(error); - } -} diff --git a/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts b/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts deleted file mode 100644 index 7cf07c656..000000000 --- a/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Inject, Service } from 'typedi'; -import { NextFunction, Router, Request, Response } from 'express'; -import { check } from 'express-validator'; -import asyncMiddleware from '@/api/middleware/asyncMiddleware'; -import PaymentMethodController from '@/api/controllers/Subscription/PaymentMethod'; -import { - NotAllowedChangeSubscriptionPlan, - NoPaymentModelWithPricedPlan, - PaymentAmountInvalidWithPlan, - PaymentInputInvalid, - VoucherCodeRequired, -} from '@/exceptions'; -import { ILicensePaymentModel } from '@/interfaces'; -import instance from 'tsyringe/dist/typings/dependency-container'; - -@Service() -export default class PaymentViaLicenseController extends PaymentMethodController { - @Inject('logger') - logger: any; - - /** - * Router constructor. - */ - router() { - const router = Router(); - - router.post( - '/payment', - this.paymentViaLicenseSchema, - this.validationResult, - asyncMiddleware(this.validatePlanSlugExistance.bind(this)), - asyncMiddleware(this.paymentViaLicense.bind(this)), - this.handleErrors, - ); - return router; - } - - /** - * Payment via license validation schema. - */ - get paymentViaLicenseSchema() { - return [ - check('plan_slug').exists().trim().escape(), - check('license_code').exists().trim().escape(), - ]; - } - - /** - * Handle the subscription payment via license code. - * @param {Request} req - * @param {Response} res - * @return {Response} - */ - async paymentViaLicense(req: Request, res: Response, next: Function) { - const { planSlug, licenseCode } = this.matchedBodyData(req); - const { tenant } = req; - - try { - const licenseModel: ILicensePaymentModel = { licenseCode }; - - await this.subscriptionService.subscriptionViaLicense( - tenant.id, - planSlug, - licenseModel - ); - - return res.status(200).send({ - type: 'success', - code: 'PAYMENT.SUCCESSFULLY.MADE', - message: 'Payment via license has been made successfully.', - }); - } catch (exception) { - next(exception); - } - } - - /** - * Handle service errors. - * @param {Error} error - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - private handleErrors( - exception: Error, - req: Request, - res: Response, - next: NextFunction - ) { - const errorReasons = []; - - if (exception instanceof VoucherCodeRequired) { - errorReasons.push({ - type: 'VOUCHER_CODE_REQUIRED', - code: 100, - }); - } - if (exception instanceof NoPaymentModelWithPricedPlan) { - errorReasons.push({ - type: 'NO_PAYMENT_WITH_PRICED_PLAN', - code: 140, - }); - } - if (exception instanceof NotAllowedChangeSubscriptionPlan) { - errorReasons.push({ - type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE', - code: 120, - }); - } - if (errorReasons.length > 0) { - return res.status(400).send({ errors: errorReasons }); - } - if (exception instanceof PaymentInputInvalid) { - return res.status(400).send({ - errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }], - }); - } - if (exception instanceof PaymentAmountInvalidWithPlan) { - return res.status(400).send({ - errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }], - }); - } - next(exception); - } -} diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts index 955e382fb..662eff4ed 100644 --- a/packages/server/src/api/controllers/Webhooks/Webhooks.ts +++ b/packages/server/src/api/controllers/Webhooks/Webhooks.ts @@ -3,6 +3,7 @@ import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication'; import { Request, Response } from 'express'; import { Inject, Service } from 'typedi'; import BaseController from '../BaseController'; +import { LemonSqueezyWebhooks } from '@/services/Subscription/LemonSqueezyWebhooks'; import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware'; @Service() @@ -10,18 +11,39 @@ export class Webhooks extends BaseController { @Inject() private plaidApp: PlaidApplication; + @Inject() + private lemonWebhooksService: LemonSqueezyWebhooks; + /** * Router constructor. */ router() { const router = Router(); - router.use(PlaidWebhookTenantBootMiddleware); + router.use('/plaid', PlaidWebhookTenantBootMiddleware); router.post('/plaid', this.plaidWebhooks.bind(this)); + router.post('/lemon', this.lemonWebhooks.bind(this)); + return router; } + /** + * Listens to LemonSqueezy webhooks events. + * @param {Request} req + * @param {Response} res + * @returns {Response} + */ + public async lemonWebhooks(req: Request, res: Response) { + const data = req.body; + const signature = req.headers['x-signature'] ?? ''; + const rawBody = req.rawBody; + + await this.lemonWebhooksService.handlePostWebhook(rawBody, data, signature); + + return res.status(200).send(); + } + /** * Listens to Plaid webhooks. * @param {Request} req diff --git a/packages/server/src/jobs/SendLicenseEmail.ts b/packages/server/src/jobs/SendLicenseEmail.ts deleted file mode 100644 index d28c07afe..000000000 --- a/packages/server/src/jobs/SendLicenseEmail.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Container } from 'typedi'; -import LicenseService from '@/services/Payment/License'; - -export default class SendLicenseViaEmailJob { - /** - * Constructor method. - * @param agenda - */ - constructor(agenda) { - agenda.define( - 'send-license-via-email', - { priority: 'high', concurrency: 1, }, - this.handler, - ); - } - - public async handler(job, done: Function): Promise { - const Logger = Container.get('logger'); - const licenseService = Container.get(LicenseService); - const { email, licenseCode } = job.attrs.data; - - Logger.info(`[send_license_via_mail] started: ${job.attrs.data}`); - - try { - await licenseService.mailMessages.sendMailLicense(licenseCode, email); - Logger.info(`[send_license_via_mail] completed: ${job.attrs.data}`); - done(); - } catch(e) { - Logger.error(`[send_license_via_mail] ${job.attrs.data}, error: ${e}`); - done(e); - } - } -} diff --git a/packages/server/src/jobs/SendLicensePhone.ts b/packages/server/src/jobs/SendLicensePhone.ts deleted file mode 100644 index adc5429fb..000000000 --- a/packages/server/src/jobs/SendLicensePhone.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Container } from 'typedi'; -import LicenseService from '@/services/Payment/License'; - -export default class SendLicenseViaPhoneJob { - /** - * Constructor method. - */ - constructor(agenda) { - agenda.define( - 'send-license-via-phone', - { priority: 'high', concurrency: 1, }, - this.handler, - ); - } - - public async handler(job, done: Function): Promise { - const { phoneNumber, licenseCode } = job.attrs.data; - - const Logger = Container.get('logger'); - const licenseService = Container.get(LicenseService); - - Logger.debug(`Send license via phone number - started: ${job.attrs.data}`); - - try { - await licenseService.smsMessages.sendLicenseSMSMessage(phoneNumber, licenseCode); - Logger.debug(`Send license via phone number - completed: ${job.attrs.data}`); - done(); - } catch(e) { - Logger.error(`Send license via phone number: ${job.attrs.data}, error: ${e}`); - done(e); - } - } -} diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 4ec446d0f..db232656b 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -2,8 +2,6 @@ import Agenda from 'agenda'; import ResetPasswordMailJob from 'jobs/ResetPasswordMail'; import ComputeItemCost from 'jobs/ComputeItemCost'; import RewriteInvoicesJournalEntries from 'jobs/WriteInvoicesJEntries'; -import SendLicenseViaPhoneJob from 'jobs/SendLicensePhone'; -import SendLicenseViaEmailJob from 'jobs/SendLicenseEmail'; import SendSMSNotificationSubscribeEnd from 'jobs/SMSNotificationSubscribeEnd'; import SendSMSNotificationTrialEnd from 'jobs/SMSNotificationTrialEnd'; import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd'; @@ -22,8 +20,6 @@ import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDelet export default ({ agenda }: { agenda: Agenda }) => { new ResetPasswordMailJob(agenda); new UserInviteMailJob(agenda); - new SendLicenseViaEmailJob(agenda); - new SendLicenseViaPhoneJob(agenda); new ComputeItemCost(agenda); new RewriteInvoicesJournalEntries(agenda); new OrganizationSetupJob(agenda); diff --git a/packages/server/src/services/Payment/License.ts b/packages/server/src/services/Payment/License.ts deleted file mode 100644 index 8e0fbc675..000000000 --- a/packages/server/src/services/Payment/License.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Service, Container, Inject } from 'typedi'; -import cryptoRandomString from 'crypto-random-string'; -import { times } from 'lodash'; -import { License, Plan } from '@/system/models'; -import { ILicense, ISendLicenseDTO } from '@/interfaces'; -import LicenseMailMessages from '@/services/Payment/LicenseMailMessages'; -import LicenseSMSMessages from '@/services/Payment/LicenseSMSMessages'; -import { ServiceError } from '@/exceptions'; - -const ERRORS = { - PLAN_NOT_FOUND: 'PLAN_NOT_FOUND', - LICENSE_NOT_FOUND: 'LICENSE_NOT_FOUND', - LICENSE_ALREADY_DISABLED: 'LICENSE_ALREADY_DISABLED', - NO_AVALIABLE_LICENSE_CODE: 'NO_AVALIABLE_LICENSE_CODE', -}; - -@Service() -export default class LicenseService { - @Inject() - smsMessages: LicenseSMSMessages; - - @Inject() - mailMessages: LicenseMailMessages; - - /** - * Validate the plan existance on the storage. - * @param {number} tenantId - - * @param {string} planSlug - Plan slug. - */ - private async getPlanOrThrowError(planSlug: string) { - const foundPlan = await Plan.query().where('slug', planSlug).first(); - - if (!foundPlan) { - throw new ServiceError(ERRORS.PLAN_NOT_FOUND); - } - return foundPlan; - } - - /** - * Valdiate the license existance on the storage. - * @param {number} licenseId - License id. - */ - private async getLicenseOrThrowError(licenseId: number) { - const foundLicense = await License.query().findById(licenseId); - - if (!foundLicense) { - throw new ServiceError(ERRORS.LICENSE_NOT_FOUND); - } - return foundLicense; - } - - /** - * Validates whether the license id is disabled. - * @param {ILicense} license - */ - private validateNotDisabledLicense(license: ILicense) { - if (license.disabledAt) { - throw new ServiceError(ERRORS.LICENSE_ALREADY_DISABLED); - } - } - - /** - * Generates the license code in the given period. - * @param {number} licensePeriod - * @return {Promise} - */ - public async generateLicense( - licensePeriod: number, - periodInterval: string = 'days', - planSlug: string - ): ILicense { - let licenseCode: string; - let repeat: boolean = true; - - // Retrieve plan or throw not found error. - const plan = await this.getPlanOrThrowError(planSlug); - - while (repeat) { - licenseCode = cryptoRandomString({ length: 10, type: 'numeric' }); - const foundLicenses = await License.query().where( - 'license_code', - licenseCode - ); - - if (foundLicenses.length === 0) { - repeat = false; - } - } - return License.query().insert({ - licenseCode, - licensePeriod, - periodInterval, - planId: plan.id, - }); - } - - /** - * Generates licenses. - * @param {number} loop - * @param {number} licensePeriod - * @param {string} periodInterval - * @param {number} planId - */ - public async generateLicenses( - loop = 1, - licensePeriod: number, - periodInterval: string = 'days', - planSlug: string - ) { - const asyncOpers: Promise[] = []; - - times(loop, () => { - const generateOper = this.generateLicense( - licensePeriod, - periodInterval, - planSlug - ); - asyncOpers.push(generateOper); - }); - return Promise.all(asyncOpers); - } - - /** - * Disables the given license id on the storage. - * @param {string} licenseSlug - License slug. - * @return {Promise} - */ - public async disableLicense(licenseId: number) { - const license = await this.getLicenseOrThrowError(licenseId); - - this.validateNotDisabledLicense(license); - - return License.markLicenseAsDisabled(license.id, 'id'); - } - - /** - * Deletes the given license id from the storage. - * @param licenseSlug {string} - License slug. - */ - public async deleteLicense(licenseSlug: string) { - const license = await this.getPlanOrThrowError(licenseSlug); - - return License.query().where('id', license.id).delete(); - } - - /** - * Sends license code to the given customer via SMS or mail message. - * @param {string} licenseCode - License code. - * @param {string} phoneNumber - Phone number. - * @param {string} email - Email address. - */ - public async sendLicenseToCustomer(sendLicense: ISendLicenseDTO) { - const agenda = Container.get('agenda'); - const { phoneNumber, email, period, periodInterval } = sendLicense; - - // Retreive plan details byt the given plan slug. - const plan = await this.getPlanOrThrowError(sendLicense.planSlug); - - const license = await License.query() - .modify('filterActiveLicense') - .where('license_period', period) - .where('period_interval', periodInterval) - .where('plan_id', plan.id) - .first(); - - if (!license) { - throw new ServiceError(ERRORS.NO_AVALIABLE_LICENSE_CODE) - } - // Mark the license as used. - await License.markLicenseAsSent(license.licenseCode); - - if (sendLicense.email) { - await agenda.schedule('1 second', 'send-license-via-email', { - licenseCode: license.licenseCode, - email, - }); - } - if (phoneNumber) { - await agenda.schedule('1 second', 'send-license-via-phone', { - licenseCode: license.licenseCode, - phoneNumber, - }); - } - } -} diff --git a/packages/server/src/services/Payment/LicenseMailMessages.ts b/packages/server/src/services/Payment/LicenseMailMessages.ts deleted file mode 100644 index a9b144629..000000000 --- a/packages/server/src/services/Payment/LicenseMailMessages.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Container } from 'typedi'; -import Mail from '@/lib/Mail'; -import config from '@/config'; -export default class SubscriptionMailMessages { - /** - * Send license code to the given mail address. - * @param {string} licenseCode - * @param {email} email - */ - public async sendMailLicense(licenseCode: string, email: string) { - const Logger = Container.get('logger'); - - const mail = new Mail() - .setView('mail/LicenseReceive.html') - .setSubject('Bigcapital - License code') - .setTo(email) - .setData({ - licenseCode, - successEmail: config.customerSuccess.email, - successPhoneNumber: config.customerSuccess.phoneNumber, - }); - - await mail.send(); - Logger.info('[license_mail] sent successfully.'); - } -} \ No newline at end of file diff --git a/packages/server/src/services/Payment/LicensePaymentMethod.ts b/packages/server/src/services/Payment/LicensePaymentMethod.ts deleted file mode 100644 index a8dbdf4ec..000000000 --- a/packages/server/src/services/Payment/LicensePaymentMethod.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { License } from '@/system/models'; -import PaymentMethod from '@/services/Payment/PaymentMethod'; -import { Plan } from '@/system/models'; -import { IPaymentMethod, ILicensePaymentModel } from '@/interfaces'; -import { - PaymentInputInvalid, - PaymentAmountInvalidWithPlan, - VoucherCodeRequired, -} from '@/exceptions'; - -export default class LicensePaymentMethod - extends PaymentMethod - implements IPaymentMethod -{ - /** - * Payment subscription of organization via license code. - * @param {ILicensePaymentModel} licensePaymentModel - - */ - public async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) { - this.validateLicensePaymentModel(licensePaymentModel); - - const license = await this.getLicenseOrThrowInvalid(licensePaymentModel); - this.validatePaymentAmountWithPlan(license, plan); - - // Mark the license code as used. - return License.markLicenseAsUsed(licensePaymentModel.licenseCode); - } - - /** - * Validates the license code activation on the storage. - * @param {ILicensePaymentModel} licensePaymentModel - - */ - private async getLicenseOrThrowInvalid( - licensePaymentModel: ILicensePaymentModel - ) { - const foundLicense = await License.query() - .modify('filterActiveLicense') - .where('license_code', licensePaymentModel.licenseCode) - .first(); - - if (!foundLicense) { - throw new PaymentInputInvalid(); - } - return foundLicense; - } - - /** - * Validates the payment amount with given plan price. - * @param {License} license - * @param {Plan} plan - */ - private validatePaymentAmountWithPlan(license: License, plan: Plan) { - if (license.planId !== plan.id) { - throw new PaymentAmountInvalidWithPlan(); - } - } - - /** - * Validate voucher payload. - * @param {ILicensePaymentModel} licenseModel - - */ - private validateLicensePaymentModel(licenseModel: ILicensePaymentModel) { - if (!licenseModel || !licenseModel.licenseCode) { - throw new VoucherCodeRequired(); - } - } -} diff --git a/packages/server/src/services/Payment/LicenseSMSMessages.ts b/packages/server/src/services/Payment/LicenseSMSMessages.ts deleted file mode 100644 index aa884ccdf..000000000 --- a/packages/server/src/services/Payment/LicenseSMSMessages.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Container, Inject } from 'typedi'; -import SMSClient from '@/services/SMSClient'; - -export default class SubscriptionSMSMessages { - @Inject('SMSClient') - smsClient: SMSClient; - - /** - * Sends license code to the given phone number via SMS message. - * @param {string} phoneNumber - * @param {string} licenseCode - */ - public async sendLicenseSMSMessage(phoneNumber: string, licenseCode: string) { - const message: string = `Your license card number: ${licenseCode}. If you need any help please contact us. Bigcapital.`; - return this.smsClient.sendMessage(phoneNumber, message); - } -} \ No newline at end of file diff --git a/packages/server/src/services/Payment/PaymentMethod.ts b/packages/server/src/services/Payment/PaymentMethod.ts deleted file mode 100644 index a0c1072f0..000000000 --- a/packages/server/src/services/Payment/PaymentMethod.ts +++ /dev/null @@ -1,6 +0,0 @@ -import moment from 'moment'; -import { IPaymentModel } from '@/interfaces'; - -export default class PaymentMethod implements IPaymentModel { - -} \ No newline at end of file diff --git a/packages/server/src/services/Payment/index.ts b/packages/server/src/services/Payment/index.ts deleted file mode 100644 index bec52feaf..000000000 --- a/packages/server/src/services/Payment/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IPaymentMethod, IPaymentContext } from "interfaces"; -import { Plan } from '@/system/models'; - -export default class PaymentContext implements IPaymentContext{ - paymentMethod: IPaymentMethod; - - /** - * Constructor method. - * @param {IPaymentMethod} paymentMethod - */ - constructor(paymentMethod: IPaymentMethod) { - this.paymentMethod = paymentMethod; - } - - /** - * - * @param {} paymentModel - */ - makePayment(paymentModel: PaymentModel, plan: Plan) { - return this.paymentMethod.payment(paymentModel, plan); - } -} \ No newline at end of file diff --git a/packages/server/src/services/Subscription/LemonWebhooks.ts b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts similarity index 78% rename from packages/server/src/services/Subscription/LemonWebhooks.ts rename to packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts index 68565ad92..49899ec5b 100644 --- a/packages/server/src/services/Subscription/LemonWebhooks.ts +++ b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts @@ -1,6 +1,6 @@ import { getPrice } from '@lemonsqueezy/lemonsqueezy.js'; import { ServiceError } from '@/exceptions'; -import { Service } from 'typedi'; +import { Inject, Service } from 'typedi'; import { compareSignatures, configureLemonSqueezy, @@ -9,40 +9,41 @@ import { webhookHasMeta, } from './utils'; import { Plan } from '@/system/models'; +import { Subscription } from './Subscription'; @Service() -export class LemonWebhooks { +export class LemonSqueezyWebhooks { + @Inject() + private subscriptionService: Subscription; + /** - * + * handle the LemonSqueezy webhooks. * @param {string} rawBody * @param {string} signature - * @returns + * @returns {Promise} */ public async handlePostWebhook( rawData: any, data: Record, signature: string - ) { + ): Promise { configureLemonSqueezy(); if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) { - return new ServiceError('Lemon Squeezy Webhook Secret not set in .env'); + throw new ServiceError('Lemon Squeezy Webhook Secret not set in .env'); } const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET; const hmacSignature = createHmacSignature(secret, rawData); if (!compareSignatures(hmacSignature, signature)) { - console.log('invalid'); - return new Error('Invalid signature', { status: 400 }); + throw new Error('Invalid signature'); } // Type guard to check if the object has a 'meta' property. if (webhookHasMeta(data)) { // Non-blocking call to process the webhook event. void this.processWebhookEvent(data); - - return true; } - return new Error('Data invalid', { status: 400 }); + throw new Error('Data invalid'); } /** @@ -52,6 +53,9 @@ export class LemonWebhooks { let processingError = ''; const webhookEvent = eventBody.meta.event_name; + const userId = eventBody.meta.custom_data?.user_id; + const tenantId = eventBody.meta.custom_data?.tenant_id; + if (!webhookHasMeta(eventBody)) { processingError = "Event body is missing the 'meta' property."; } else if (webhookHasData(eventBody)) { @@ -78,14 +82,20 @@ export class LemonWebhooks { if (priceData.error) { processingError = `Failed to get the price data for the subscription ${eventBody.data.id}.`; } - const isUsageBased = attributes.first_subscription_item.is_usage_based; const price = isUsageBased ? priceData.data?.data.attributes.unit_price_decimal : priceData.data?.data.attributes.unit_price; - const newSubscription = {}; + if (webhookEvent === 'subscription_created') { + await this.subscriptionService.newSubscribtion( + tenantId, + 'pro-yearly', + 'year', + 1 + ); + } } } else if (webhookEvent.startsWith('order_')) { // Save orders; eventBody is a "Order" diff --git a/packages/server/src/services/Subscription/Subscription.ts b/packages/server/src/services/Subscription/Subscription.ts index 4592a781f..6832f7127 100644 --- a/packages/server/src/services/Subscription/Subscription.ts +++ b/packages/server/src/services/Subscription/Subscription.ts @@ -1,37 +1,27 @@ -import { Inject } from 'typedi'; -import { Tenant, Plan } from '@/system/models'; -import { IPaymentContext } from '@/interfaces'; +import { Service } from 'typedi'; import { NotAllowedChangeSubscriptionPlan } from '@/exceptions'; +import { Plan, Tenant } from '@/system/models'; -export default class Subscription { - paymentContext: IPaymentContext | null; - - @Inject('logger') - logger: any; - - /** - * Constructor method. - * @param {IPaymentContext} - */ - constructor(payment?: IPaymentContext) { - this.paymentContext = payment; - } - +@Service() +export class Subscription { /** * Give the tenant a new subscription. - * @param {Tenant} tenant - * @param {Plan} plan + * @param {number} tenantId - Tenant id. + * @param {string} planSlug - Plan slug. * @param {string} invoiceInterval * @param {number} invoicePeriod * @param {string} subscriptionSlug */ - protected async newSubscribtion( - tenant, - plan, + public async newSubscribtion( + tenantId: number, + planSlug: string, invoiceInterval: string, invoicePeriod: number, subscriptionSlug: string = 'main' ) { + const tenant = await Tenant.query().findById(tenantId).throwIfNotFound(); + const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound(); + const subscription = await tenant .$relatedQuery('subscriptions') .modify('subscriptionBySlug', subscriptionSlug) @@ -55,26 +45,4 @@ export default class Subscription { ); } } - - /** - * Subscripe to the given plan. - * @param {Plan} plan - * @throws {NotAllowedChangeSubscriptionPlan} - */ - public async subscribe( - tenant: Tenant, - plan: Plan, - paymentModel?: PaymentModel, - subscriptionSlug: string = 'main' - ) { - await this.paymentContext.makePayment(paymentModel, plan); - - return this.newSubscribtion( - tenant, - plan, - plan.invoiceInterval, - plan.invoicePeriod, - subscriptionSlug - ); - } } diff --git a/packages/server/src/services/Subscription/SubscriptionPeriod.ts b/packages/server/src/services/Subscription/SubscriptionPeriod.ts index c1d2e4a8b..2deb6b073 100644 --- a/packages/server/src/services/Subscription/SubscriptionPeriod.ts +++ b/packages/server/src/services/Subscription/SubscriptionPeriod.ts @@ -38,4 +38,4 @@ export default class SubscriptionPeriod { getIntervalCount() { return this.interval; } -} \ No newline at end of file +} diff --git a/packages/server/src/services/Subscription/SubscriptionService.ts b/packages/server/src/services/Subscription/SubscriptionService.ts index 0e254066b..8e70c55d8 100644 --- a/packages/server/src/services/Subscription/SubscriptionService.ts +++ b/packages/server/src/services/Subscription/SubscriptionService.ts @@ -1,60 +1,8 @@ -import { Service, Inject } from 'typedi'; -import { Plan, PlanSubscription, Tenant } from '@/system/models'; -import Subscription from '@/services/Subscription/Subscription'; -import LicensePaymentMethod from '@/services/Payment/LicensePaymentMethod'; -import PaymentContext from '@/services/Payment'; -import SubscriptionSMSMessages from '@/services/Subscription/SMSMessages'; -import SubscriptionMailMessages from '@/services/Subscription/MailMessages'; -import { ILicensePaymentModel } from '@/interfaces'; -import SubscriptionViaLicense from './SubscriptionViaLicense'; +import { Service } from 'typedi'; +import { PlanSubscription } from '@/system/models'; @Service() export default class SubscriptionService { - @Inject() - smsMessages: SubscriptionSMSMessages; - - @Inject() - mailMessages: SubscriptionMailMessages; - - @Inject('logger') - logger: any; - - @Inject('repositories') - sysRepositories: any; - - /** - * Handles the payment process via license code and than subscribe to - * the given tenant. - * @param {number} tenantId - * @param {String} planSlug - * @param {string} licenseCode - * @return {Promise} - */ - public async subscriptionViaLicense( - tenantId: number, - planSlug: string, - paymentModel: ILicensePaymentModel, - subscriptionSlug: string = 'main' - ) { - // Retrieve plan details. - const plan = await Plan.query().findOne('slug', planSlug); - - // Retrieve tenant details. - const tenant = await Tenant.query().findById(tenantId); - - // License payment method. - const paymentViaLicense = new LicensePaymentMethod(); - - // Payment context. - const paymentContext = new PaymentContext(paymentViaLicense); - - // Subscription. - const subscription = new SubscriptionViaLicense(paymentContext); - - // Subscribe. - await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug); - } - /** * Retrieve all subscription of the given tenant. * @param {number} tenantId diff --git a/packages/server/src/services/Subscription/SubscriptionViaLicense.ts b/packages/server/src/services/Subscription/SubscriptionViaLicense.ts deleted file mode 100644 index 629f30143..000000000 --- a/packages/server/src/services/Subscription/SubscriptionViaLicense.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { License, Tenant, Plan } from '@/system/models'; -import Subscription from './Subscription'; -import { PaymentModel } from '@/interfaces'; - -export default class SubscriptionViaLicense extends Subscription { - /** - * Subscripe to the given plan. - * @param {Plan} plan - * @throws {NotAllowedChangeSubscriptionPlan} - */ - public async subscribe( - tenant: Tenant, - plan: Plan, - paymentModel?: PaymentModel, - subscriptionSlug: string = 'main' - ): Promise { - await this.paymentContext.makePayment(paymentModel, plan); - - return this.newSubscriptionFromLicense( - tenant, - plan, - paymentModel.licenseCode, - subscriptionSlug - ); - } - - /** - * New subscription from the given license. - * @param {Tanant} tenant - * @param {Plab} plan - * @param {string} licenseCode - * @param {string} subscriptionSlug - * @returns {Promise} - */ - private async newSubscriptionFromLicense( - tenant, - plan, - licenseCode: string, - subscriptionSlug: string = 'main' - ): Promise { - // License information. - const licenseInfo = await License.query().findOne( - 'licenseCode', - licenseCode - ); - return this.newSubscribtion( - tenant, - plan, - licenseInfo.periodInterval, - licenseInfo.licensePeriod, - subscriptionSlug - ); - } -} diff --git a/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js b/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js deleted file mode 100644 index 43fea2798..000000000 --- a/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js +++ /dev/null @@ -1,15 +0,0 @@ - -exports.up = function(knex) { - return knex.schema.createTable('subscription_plan_features', table => { - table.increments(); - table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); - table.string('slug'); - table.string('name'); - table.string('description'); - table.timestamps(); - }); -}; - -exports.down = function(knex) { - return knex.schema.dropTableIfExists('subscription_plan_features'); -}; diff --git a/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js b/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js deleted file mode 100644 index 6babd6f03..000000000 --- a/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js +++ /dev/null @@ -1,22 +0,0 @@ - -exports.up = function(knex) { - return knex.schema.createTable('subscription_licenses', (table) => { - table.increments(); - - table.string('license_code').unique().index(); - table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); - - table.integer('license_period').unsigned(); - table.string('period_interval'); - - table.dateTime('sent_at').index(); - table.dateTime('disabled_at').index(); - table.dateTime('used_at').index(); - - table.timestamps(); - }) -}; - -exports.down = function(knex) { - return knex.schema.dropTableIfExists('subscription_licenses'); -}; diff --git a/packages/server/src/system/models/Subscriptions/PlanFeature.ts b/packages/server/src/system/models/Subscriptions/PlanFeature.ts deleted file mode 100644 index 178fe818e..000000000 --- a/packages/server/src/system/models/Subscriptions/PlanFeature.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Model, mixin } from 'objection'; -import SystemModel from '@/system/models/SystemModel'; - -export default class PlanFeature extends mixin(SystemModel) { - /** - * Table name. - */ - static get tableName() { - return 'subscriptions.plan_features'; - } - - /** - * Timestamps columns. - */ - static get timestamps() { - return ['createdAt', 'updatedAt']; - } - - /** - * Relationship mapping. - */ - static get relationMappings() { - const Plan = require('system/models/Subscriptions/Plan'); - - return { - plan: { - relation: Model.BelongsToOneRelation, - modelClass: Plan.default, - join: { - from: 'subscriptions.plan_features.planId', - to: 'subscriptions.plans.id', - }, - }, - }; - } -} diff --git a/packages/server/src/system/models/Tenant.ts b/packages/server/src/system/models/Tenant.ts index a40966797..b715adc8c 100644 --- a/packages/server/src/system/models/Tenant.ts +++ b/packages/server/src/system/models/Tenant.ts @@ -147,9 +147,9 @@ export default class Tenant extends BaseModel { /** * Marks the given tenant as upgrading. - * @param {number} tenantId - * @param {string} upgradeJobId - * @returns + * @param {number} tenantId + * @param {string} upgradeJobId + * @returns */ static markAsUpgrading(tenantId, upgradeJobId) { return this.query().update({ upgradeJobId }).where({ id: tenantId }); @@ -157,8 +157,8 @@ export default class Tenant extends BaseModel { /** * Markes the given tenant as upgraded. - * @param {number} tenantId - * @returns + * @param {number} tenantId + * @returns */ static markAsUpgraded(tenantId) { return this.query().update({ upgradeJobId: null }).where({ id: tenantId }); @@ -185,4 +185,44 @@ export default class Tenant extends BaseModel { saveMetadata(metadata) { return Tenant.saveMetadata(this.id, metadata); } + + /** + * + * @param {*} planId + * @param {*} invoiceInterval + * @param {*} invoicePeriod + * @param {*} subscriptionSlug + * @returns + */ + public newSubscription(planId, invoiceInterval, invoicePeriod, subscriptionSlug) { + return Tenant.newSubscription( + this.id, + planId, + invoiceInterval, + invoicePeriod, + subscriptionSlug + ); + ); + } + + /** + * Records a new subscription for the associated tenant. + */ + static newSubscription( + tenantId: number, + planId: number, + invoiceInterval: string, + invoicePeriod: number, + subscriptionSlug: string + ) { + const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod); + + return PlanSubscription.query().insert({ + tenantId, + slug: subscriptionSlug, + planId, + startsAt: period.getStartDate(), + endsAt: period.getEndDate(), + }); + } } diff --git a/packages/server/src/system/models/index.ts b/packages/server/src/system/models/index.ts index 8d8f41146..4c21f755c 100644 --- a/packages/server/src/system/models/index.ts +++ b/packages/server/src/system/models/index.ts @@ -1,5 +1,4 @@ import Plan from './Subscriptions/Plan'; -import PlanFeature from './Subscriptions/PlanFeature'; import PlanSubscription from './Subscriptions/PlanSubscription'; import License from './Subscriptions/License'; import Tenant from './Tenant'; @@ -12,7 +11,6 @@ import { Import } from './Import'; export { Plan, - PlanFeature, PlanSubscription, License, Tenant, From e486333c96fbf4e4b2e33d27892e904cff31d38e Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 14 Apr 2024 12:44:02 +0200 Subject: [PATCH 6/8] feat: sweep up the Lemon Squeezy integration --- .../src/api/controllers/Webhooks/Webhooks.ts | 2 +- .../api/middleware/SubscriptionMiddleware.ts | 62 ++++----- packages/server/src/config/index.ts | 11 +- .../NoPaymentModelWithPricedPlan.ts | 8 -- .../PaymentAmountInvalidWithPlan.ts | 7 - .../src/exceptions/PaymentInputInvalid.ts | 3 - .../src/exceptions/VoucherCodeRequired.ts | 5 - packages/server/src/exceptions/index.ts | 10 +- .../src/jobs/MailNotificationSubscribeEnd.ts | 34 ----- .../src/jobs/MailNotificationTrialEnd.ts | 34 ----- .../src/jobs/SMSNotificationSubscribeEnd.ts | 28 ---- .../src/jobs/SMSNotificationTrialEnd.ts | 28 ---- packages/server/src/loaders/jobs.ts | 24 ---- .../src/services/Subscription/MailMessages.ts | 30 ---- .../src/services/Subscription/SMSMessages.ts | 40 ------ .../server/src/services/Subscription/utils.ts | 6 +- .../system/models/Subscriptions/License.ts | 129 ------------------ 17 files changed, 39 insertions(+), 422 deletions(-) delete mode 100644 packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts delete mode 100644 packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts delete mode 100644 packages/server/src/exceptions/PaymentInputInvalid.ts delete mode 100644 packages/server/src/exceptions/VoucherCodeRequired.ts delete mode 100644 packages/server/src/jobs/MailNotificationSubscribeEnd.ts delete mode 100644 packages/server/src/jobs/MailNotificationTrialEnd.ts delete mode 100644 packages/server/src/jobs/SMSNotificationSubscribeEnd.ts delete mode 100644 packages/server/src/jobs/SMSNotificationTrialEnd.ts delete mode 100644 packages/server/src/services/Subscription/MailMessages.ts delete mode 100644 packages/server/src/services/Subscription/SMSMessages.ts delete mode 100644 packages/server/src/system/models/Subscriptions/License.ts diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts index 662eff4ed..4ff3acc76 100644 --- a/packages/server/src/api/controllers/Webhooks/Webhooks.ts +++ b/packages/server/src/api/controllers/Webhooks/Webhooks.ts @@ -29,7 +29,7 @@ export class Webhooks extends BaseController { } /** - * Listens to LemonSqueezy webhooks events. + * Listens to Lemon Squeezy webhooks events. * @param {Request} req * @param {Response} res * @returns {Response} diff --git a/packages/server/src/api/middleware/SubscriptionMiddleware.ts b/packages/server/src/api/middleware/SubscriptionMiddleware.ts index ce7d45258..6d840c666 100644 --- a/packages/server/src/api/middleware/SubscriptionMiddleware.ts +++ b/packages/server/src/api/middleware/SubscriptionMiddleware.ts @@ -1,41 +1,29 @@ -import { Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; +import { Request, Response, NextFunction } from 'express'; -export default (subscriptionSlug = 'main') => async ( - req: Request, - res: Response, - next: NextFunction -) => { - const { tenant, tenantId } = req; - const Logger = Container.get('logger'); - const { subscriptionRepository } = Container.get('repositories'); +export default (subscriptionSlug = 'main') => + async (req: Request, res: Response, next: NextFunction) => { + const { tenant, tenantId } = req; + const { subscriptionRepository } = Container.get('repositories'); - if (!tenant) { - throw new Error('Should load `TenancyMiddlware` before this middleware.'); - } - Logger.info('[subscription_middleware] trying get tenant main subscription.'); - const subscription = await subscriptionRepository.getBySlugInTenant( - subscriptionSlug, - tenantId - ); - // Validate in case there is no any already subscription. - if (!subscription) { - Logger.info('[subscription_middleware] tenant has no subscription.', { - tenantId, - }); - return res.boom.badRequest('Tenant has no subscription.', { - errors: [{ type: 'TENANT.HAS.NO.SUBSCRIPTION' }], - }); - } - // Validate in case the subscription is inactive. - else if (subscription.inactive()) { - Logger.info( - '[subscription_middleware] tenant main subscription is expired.', - { tenantId } + if (!tenant) { + throw new Error('Should load `TenancyMiddlware` before this middleware.'); + } + const subscription = await subscriptionRepository.getBySlugInTenant( + subscriptionSlug, + tenantId ); - return res.boom.badRequest(null, { - errors: [{ type: 'ORGANIZATION.SUBSCRIPTION.INACTIVE' }], - }); - } - next(); -}; + // Validate in case there is no any already subscription. + if (!subscription) { + return res.boom.badRequest('Tenant has no subscription.', { + errors: [{ type: 'TENANT.HAS.NO.SUBSCRIPTION' }], + }); + } + // Validate in case the subscription is inactive. + else if (subscription.inactive()) { + return res.boom.badRequest(null, { + errors: [{ type: 'ORGANIZATION.SUBSCRIPTION.INACTIVE' }], + }); + } + next(); + }; diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 12938038f..175056d7a 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -190,6 +190,15 @@ module.exports = { secretSandbox: process.env.PLAID_SECRET_SANDBOX, redirectSandBox: process.env.PLAID_SANDBOX_REDIRECT_URI, redirectDevelopment: process.env.PLAID_DEVELOPMENT_REDIRECT_URI, - linkWebhook: process.env.PLAID_LINK_WEBHOOK + linkWebhook: process.env.PLAID_LINK_WEBHOOK, + }, + + /** + * Lemon Squeezy. + */ + lemonSqueezy: { + key: process.env.LEMONSQUEEZY_API_KEY, + storeId: process.env.LEMONSQUEEZY_STORE_ID, + webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET, }, }; diff --git a/packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts b/packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts deleted file mode 100644 index 938ec8b4a..000000000 --- a/packages/server/src/exceptions/NoPaymentModelWithPricedPlan.ts +++ /dev/null @@ -1,8 +0,0 @@ - - -export default class NoPaymentModelWithPricedPlan { - - constructor() { - - } -} \ No newline at end of file diff --git a/packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts b/packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts deleted file mode 100644 index 834e8cbe1..000000000 --- a/packages/server/src/exceptions/PaymentAmountInvalidWithPlan.ts +++ /dev/null @@ -1,7 +0,0 @@ - - -export default class PaymentAmountInvalidWithPlan{ - constructor() { - - } -} \ No newline at end of file diff --git a/packages/server/src/exceptions/PaymentInputInvalid.ts b/packages/server/src/exceptions/PaymentInputInvalid.ts deleted file mode 100644 index 2a024ff05..000000000 --- a/packages/server/src/exceptions/PaymentInputInvalid.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default class PaymentInputInvalid { - constructor() {} -} diff --git a/packages/server/src/exceptions/VoucherCodeRequired.ts b/packages/server/src/exceptions/VoucherCodeRequired.ts deleted file mode 100644 index a3bef6ff2..000000000 --- a/packages/server/src/exceptions/VoucherCodeRequired.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default class VoucherCodeRequired { - constructor() { - this.name = 'VoucherCodeRequired'; - } -} diff --git a/packages/server/src/exceptions/index.ts b/packages/server/src/exceptions/index.ts index a18746d02..f04873ba5 100644 --- a/packages/server/src/exceptions/index.ts +++ b/packages/server/src/exceptions/index.ts @@ -1,25 +1,17 @@ import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan'; import ServiceError from './ServiceError'; import ServiceErrors from './ServiceErrors'; -import NoPaymentModelWithPricedPlan from './NoPaymentModelWithPricedPlan'; -import PaymentInputInvalid from './PaymentInputInvalid'; -import PaymentAmountInvalidWithPlan from './PaymentAmountInvalidWithPlan'; import TenantAlreadyInitialized from './TenantAlreadyInitialized'; import TenantAlreadySeeded from './TenantAlreadySeeded'; import TenantDBAlreadyExists from './TenantDBAlreadyExists'; import TenantDatabaseNotBuilt from './TenantDatabaseNotBuilt'; -import VoucherCodeRequired from './VoucherCodeRequired'; export { NotAllowedChangeSubscriptionPlan, - NoPaymentModelWithPricedPlan, - PaymentAmountInvalidWithPlan, ServiceError, ServiceErrors, - PaymentInputInvalid, TenantAlreadyInitialized, TenantAlreadySeeded, TenantDBAlreadyExists, TenantDatabaseNotBuilt, - VoucherCodeRequired, -}; \ No newline at end of file +}; diff --git a/packages/server/src/jobs/MailNotificationSubscribeEnd.ts b/packages/server/src/jobs/MailNotificationSubscribeEnd.ts deleted file mode 100644 index a2b54778b..000000000 --- a/packages/server/src/jobs/MailNotificationSubscribeEnd.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Container from 'typedi'; -import SubscriptionService from '@/services/Subscription/Subscription'; - -export default class MailNotificationSubscribeEnd { - /** - * Job handler. - * @param {Job} job - - */ - handler(job) { - const { tenantId, phoneNumber, remainingDays } = job.attrs.data; - - const subscriptionService = Container.get(SubscriptionService); - const Logger = Container.get('logger'); - - Logger.info( - `Send mail notification subscription end soon - started: ${job.attrs.data}` - ); - - try { - subscriptionService.mailMessages.sendRemainingTrialPeriod( - phoneNumber, - remainingDays - ); - Logger.info( - `Send mail notification subscription end soon - finished: ${job.attrs.data}` - ); - } catch (error) { - Logger.info( - `Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}` - ); - done(e); - } - } -} diff --git a/packages/server/src/jobs/MailNotificationTrialEnd.ts b/packages/server/src/jobs/MailNotificationTrialEnd.ts deleted file mode 100644 index 82d8bd53c..000000000 --- a/packages/server/src/jobs/MailNotificationTrialEnd.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Container from 'typedi'; -import SubscriptionService from '@/services/Subscription/Subscription'; - -export default class MailNotificationTrialEnd { - /** - * - * @param {Job} job - - */ - handler(job) { - const { tenantId, phoneNumber, remainingDays } = job.attrs.data; - - const subscriptionService = Container.get(SubscriptionService); - const Logger = Container.get('logger'); - - Logger.info( - `Send mail notification subscription end soon - started: ${job.attrs.data}` - ); - - try { - subscriptionService.mailMessages.sendRemainingTrialPeriod( - phoneNumber, - remainingDays - ); - Logger.info( - `Send mail notification subscription end soon - finished: ${job.attrs.data}` - ); - } catch (error) { - Logger.info( - `Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}` - ); - done(e); - } - } -} diff --git a/packages/server/src/jobs/SMSNotificationSubscribeEnd.ts b/packages/server/src/jobs/SMSNotificationSubscribeEnd.ts deleted file mode 100644 index d203c1d6b..000000000 --- a/packages/server/src/jobs/SMSNotificationSubscribeEnd.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Container from 'typedi'; -import SubscriptionService from '@/services/Subscription/Subscription'; - -export default class SMSNotificationSubscribeEnd { - - /** - * - * @param {Job}job - */ - handler(job) { - const { tenantId, phoneNumber, remainingDays } = job.attrs.data; - - const subscriptionService = Container.get(SubscriptionService); - const Logger = Container.get('logger'); - - Logger.info(`Send SMS notification subscription end soon - started: ${job.attrs.data}`); - - try { - subscriptionService.smsMessages.sendRemainingSubscriptionPeriod( - phoneNumber, remainingDays, - ); - Logger.info(`Send SMS notification subscription end soon - finished: ${job.attrs.data}`); - } catch(error) { - Logger.info(`Send SMS notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`); - done(e); - } - } -} \ No newline at end of file diff --git a/packages/server/src/jobs/SMSNotificationTrialEnd.ts b/packages/server/src/jobs/SMSNotificationTrialEnd.ts deleted file mode 100644 index a3e5c5420..000000000 --- a/packages/server/src/jobs/SMSNotificationTrialEnd.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Container from 'typedi'; -import SubscriptionService from '@/services/Subscription/Subscription'; - -export default class SMSNotificationTrialEnd { - - /** - * - * @param {Job}job - */ - handler(job) { - const { tenantId, phoneNumber, remainingDays } = job.attrs.data; - - const subscriptionService = Container.get(SubscriptionService); - const Logger = Container.get('logger'); - - Logger.info(`Send notification subscription end soon - started: ${job.attrs.data}`); - - try { - subscriptionService.smsMessages.sendRemainingTrialPeriod( - phoneNumber, remainingDays, - ); - Logger.info(`Send notification subscription end soon - finished: ${job.attrs.data}`); - } catch(error) { - Logger.info(`Send notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`); - done(e); - } - } -} \ No newline at end of file diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index db232656b..075005bf1 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -2,10 +2,6 @@ import Agenda from 'agenda'; import ResetPasswordMailJob from 'jobs/ResetPasswordMail'; import ComputeItemCost from 'jobs/ComputeItemCost'; import RewriteInvoicesJournalEntries from 'jobs/WriteInvoicesJEntries'; -import SendSMSNotificationSubscribeEnd from 'jobs/SMSNotificationSubscribeEnd'; -import SendSMSNotificationTrialEnd from 'jobs/SMSNotificationTrialEnd'; -import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd'; -import SendMailNotificationTrialEnd from 'jobs/MailNotificationTrialEnd'; import UserInviteMailJob from 'jobs/UserInviteMail'; import OrganizationSetupJob from 'jobs/OrganizationSetup'; import OrganizationUpgrade from 'jobs/OrganizationUpgrade'; @@ -35,25 +31,5 @@ export default ({ agenda }: { agenda: Agenda }) => { agenda.start().then(() => { agenda.every('1 hours', 'delete-expired-imported-files', {}); }); - // agenda.define( - // 'send-sms-notification-subscribe-end', - // { priority: 'nromal', concurrency: 1, }, - // new SendSMSNotificationSubscribeEnd().handler, - // ); - // agenda.define( - // 'send-sms-notification-trial-end', - // { priority: 'normal', concurrency: 1, }, - // new SendSMSNotificationTrialEnd().handler, - // ); - // agenda.define( - // 'send-mail-notification-subscribe-end', - // { priority: 'high', concurrency: 1, }, - // new SendMailNotificationSubscribeEnd().handler - // ); - // agenda.define( - // 'send-mail-notification-trial-end', - // { priority: 'high', concurrency: 1, }, - // new SendMailNotificationTrialEnd().handler - // ); agenda.start(); }; diff --git a/packages/server/src/services/Subscription/MailMessages.ts b/packages/server/src/services/Subscription/MailMessages.ts deleted file mode 100644 index 4c50a5243..000000000 --- a/packages/server/src/services/Subscription/MailMessages.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Service } from "typedi"; - -@Service() -export default class SubscriptionMailMessages { - /** - * - * @param phoneNumber - * @param remainingDays - */ - public async sendRemainingSubscriptionPeriod(phoneNumber: string, remainingDays: number) { - const message: string = ` - Your remaining subscription is ${remainingDays} days, - please renew your subscription before expire. - `; - this.smsClient.sendMessage(phoneNumber, message); - } - - /** - * - * @param phoneNumber - * @param remainingDays - */ - public async sendRemainingTrialPeriod(phoneNumber: string, remainingDays: number) { - const message: string = ` - Your remaining free trial is ${remainingDays} days, - please subscription before ends, if you have any quation to contact us.`; - - this.smsClient.sendMessage(phoneNumber, message); - } -} \ No newline at end of file diff --git a/packages/server/src/services/Subscription/SMSMessages.ts b/packages/server/src/services/Subscription/SMSMessages.ts deleted file mode 100644 index 9cb7de273..000000000 --- a/packages/server/src/services/Subscription/SMSMessages.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Service, Inject } from 'typedi'; -import SMSClient from '@/services/SMSClient'; - -@Service() -export default class SubscriptionSMSMessages { - @Inject('SMSClient') - smsClient: SMSClient; - - /** - * Send remaining subscription period SMS message. - * @param {string} phoneNumber - - * @param {number} remainingDays - - */ - public async sendRemainingSubscriptionPeriod( - phoneNumber: string, - remainingDays: number - ): Promise { - const message: string = ` - Your remaining subscription is ${remainingDays} days, - please renew your subscription before expire. - `; - this.smsClient.sendMessage(phoneNumber, message); - } - - /** - * Send remaining trial period SMS message. - * @param {string} phoneNumber - - * @param {number} remainingDays - - */ - public async sendRemainingTrialPeriod( - phoneNumber: string, - remainingDays: number - ): Promise { - const message: string = ` - Your remaining free trial is ${remainingDays} days, - please subscription before ends, if you have any quation to contact us.`; - - this.smsClient.sendMessage(phoneNumber, message); - } -} diff --git a/packages/server/src/services/Subscription/utils.ts b/packages/server/src/services/Subscription/utils.ts index b41e8ce5e..56dc7e4b4 100644 --- a/packages/server/src/services/Subscription/utils.ts +++ b/packages/server/src/services/Subscription/utils.ts @@ -23,11 +23,9 @@ export function configureLemonSqueezy() { lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY, onError: (error) => { - console.log(error); - // console.log('LL', error.message); // eslint-disable-next-line no-console -- allow logging - // console.error(error); - // throw new Error(`Lemon Squeezy API error: ${error.message}`); + console.error(error); + throw new Error(`Lemon Squeezy API error: ${error.message}`); }, }); } diff --git a/packages/server/src/system/models/Subscriptions/License.ts b/packages/server/src/system/models/Subscriptions/License.ts deleted file mode 100644 index 97bbc87a7..000000000 --- a/packages/server/src/system/models/Subscriptions/License.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Model, mixin } from 'objection'; -import moment from 'moment'; -import SystemModel from '@/system/models/SystemModel'; - -export default class License extends SystemModel { - /** - * Table name. - */ - static get tableName() { - return 'subscription_licenses'; - } - - /** - * Timestamps columns. - */ - get timestamps() { - return ['createdAt', 'updatedAt']; - } - - /** - * Model modifiers. - */ - static get modifiers() { - return { - // Filters active licenses. - filterActiveLicense(query) { - query.where('disabled_at', null); - query.where('used_at', null); - }, - - // Find license by its code or id. - findByCodeOrId(query, id, code) { - if (id) { - query.where('id', id); - } - if (code) { - query.where('license_code', code); - } - }, - - // Filters licenses list. - filter(builder, licensesFilter) { - if (licensesFilter.active) { - builder.modify('filterActiveLicense'); - } - if (licensesFilter.disabled) { - builder.whereNot('disabled_at', null); - } - if (licensesFilter.used) { - builder.whereNot('used_at', null); - } - if (licensesFilter.sent) { - builder.whereNot('sent_at', null); - } - }, - }; - } - - /** - * Relationship mapping. - */ - static get relationMappings() { - const Plan = require('system/models/Subscriptions/Plan'); - - return { - plan: { - relation: Model.BelongsToOneRelation, - modelClass: Plan.default, - join: { - from: 'subscription_licenses.planId', - to: 'subscriptions_plans.id', - }, - }, - }; - } - - /** - * Deletes the given license code from the storage. - * @param {string} licenseCode - * @return {Promise} - */ - static deleteLicense(licenseCode, viaAttribute = 'license_code') { - return this.query().where(viaAttribute, licenseCode).delete(); - } - - /** - * Marks the given license code as disabled on the storage. - * @param {string} licenseCode - * @return {Promise} - */ - static markLicenseAsDisabled(licenseCode, viaAttribute = 'license_code') { - return this.query().where(viaAttribute, licenseCode).patch({ - disabled_at: moment().toMySqlDateTime(), - }); - } - - /** - * Marks the given license code as sent on the storage. - * @param {string} licenseCode - */ - static markLicenseAsSent(licenseCode, viaAttribute = 'license_code') { - return this.query().where(viaAttribute, licenseCode).patch({ - sent_at: moment().toMySqlDateTime(), - }); - } - - /** - * Marks the given license code as used on the storage. - * @param {string} licenseCode - * @return {Promise} - */ - static markLicenseAsUsed(licenseCode, viaAttribute = 'license_code') { - return this.query().where(viaAttribute, licenseCode).patch({ - used_at: moment().toMySqlDateTime(), - }); - } - - /** - * - * @param {IIPlan} plan - * @return {boolean} - */ - isEqualPlanPeriod(plan) { - return ( - this.invoicePeriod === plan.invoiceInterval && - license.licensePeriod === license.periodInterval - ); - } -} From 9321db2a3a462bc7a03ad71e1878361019e0d6fe Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 14 Apr 2024 12:58:53 +0200 Subject: [PATCH 7/8] feat: sweep up lemon squeezy webhooks. --- packages/server/src/loaders/jobs.ts | 1 - .../services/Subscription/LemonSqueezyWebhooks.ts | 14 ++++++++------ packages/server/src/system/models/index.ts | 2 -- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 075005bf1..58da23291 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -31,5 +31,4 @@ export default ({ agenda }: { agenda: Agenda }) => { agenda.start().then(() => { agenda.every('1 hours', 'delete-expired-imported-files', {}); }); - agenda.start(); }; diff --git a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts index 49899ec5b..4301b924a 100644 --- a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts +++ b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts @@ -1,5 +1,5 @@ import { getPrice } from '@lemonsqueezy/lemonsqueezy.js'; -import { ServiceError } from '@/exceptions'; +import config from '@/config'; import { Inject, Service } from 'typedi'; import { compareSignatures, @@ -29,10 +29,10 @@ export class LemonSqueezyWebhooks { ): Promise { configureLemonSqueezy(); - if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) { - throw new ServiceError('Lemon Squeezy Webhook Secret not set in .env'); + if (!config.lemonSqueezy.webhookSecret) { + throw new Error('Lemon Squeezy Webhook Secret not set in .env'); } - const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET; + const secret = config.lemonSqueezy.webhookSecret; const hmacSignature = createHmacSignature(secret, rawData); if (!compareSignatures(hmacSignature, signature)) { @@ -42,14 +42,15 @@ export class LemonSqueezyWebhooks { if (webhookHasMeta(data)) { // Non-blocking call to process the webhook event. void this.processWebhookEvent(data); + } else { + throw new Error('Data invalid'); } - throw new Error('Data invalid'); } /** * This action will process a webhook event in the database. */ - async processWebhookEvent(eventBody) { + private async processWebhookEvent(eventBody) { let processingError = ''; const webhookEvent = eventBody.meta.event_name; @@ -88,6 +89,7 @@ export class LemonSqueezyWebhooks { ? priceData.data?.data.attributes.unit_price_decimal : priceData.data?.data.attributes.unit_price; + // Create a new subscription of the tenant. if (webhookEvent === 'subscription_created') { await this.subscriptionService.newSubscribtion( tenantId, diff --git a/packages/server/src/system/models/index.ts b/packages/server/src/system/models/index.ts index 4c21f755c..e96fdd8ff 100644 --- a/packages/server/src/system/models/index.ts +++ b/packages/server/src/system/models/index.ts @@ -1,6 +1,5 @@ import Plan from './Subscriptions/Plan'; import PlanSubscription from './Subscriptions/PlanSubscription'; -import License from './Subscriptions/License'; import Tenant from './Tenant'; import TenantMetadata from './TenantMetadata'; import SystemUser from './SystemUser'; @@ -12,7 +11,6 @@ import { Import } from './Import'; export { Plan, PlanSubscription, - License, Tenant, TenantMetadata, SystemUser, From c191c4bd260e1fb6d4cc54bfa08624871620c84a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 15 Apr 2024 14:49:27 +0200 Subject: [PATCH 8/8] feat: remove other payment methods --- .../controllers/Subscription/PaymentMethod.ts | 31 ------- .../Subscription/SubscriptionController.ts | 88 ++++++++++++++++++ .../src/api/controllers/Subscription/index.ts | 89 +------------------ packages/server/src/api/index.ts | 6 +- .../Subscription/LemonSqueezyWebhooks.ts | 13 +-- .../webapp/src/hooks/query/organization.tsx | 2 +- 6 files changed, 100 insertions(+), 129 deletions(-) delete mode 100644 packages/server/src/api/controllers/Subscription/PaymentMethod.ts create mode 100644 packages/server/src/api/controllers/Subscription/SubscriptionController.ts diff --git a/packages/server/src/api/controllers/Subscription/PaymentMethod.ts b/packages/server/src/api/controllers/Subscription/PaymentMethod.ts deleted file mode 100644 index 2c954c307..000000000 --- a/packages/server/src/api/controllers/Subscription/PaymentMethod.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Inject } from 'typedi'; -import { Request, Response } from 'express'; -import { Plan } from '@/system/models'; -import BaseController from '@/api/controllers/BaseController'; -import SubscriptionService from '@/services/Subscription/SubscriptionService'; - -export default class PaymentMethodController extends BaseController { - @Inject() - subscriptionService: SubscriptionService; - - /** - * Validate the given plan slug exists on the storage. - * - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - * - * @return {Response|void} - */ - async validatePlanSlugExistance(req: Request, res: Response, next: Function) { - const { planSlug } = this.matchedBodyData(req); - const foundPlan = await Plan.query().where('slug', planSlug).first(); - - if (!foundPlan) { - return res.status(400).send({ - errors: [{ type: 'PLAN.SLUG.NOT.EXISTS', code: 110 }], - }); - } - next(); - } -} \ No newline at end of file diff --git a/packages/server/src/api/controllers/Subscription/SubscriptionController.ts b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts new file mode 100644 index 000000000..7f394a666 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts @@ -0,0 +1,88 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { Service, Inject } from 'typedi'; +import { body } from 'express-validator'; +import JWTAuth from '@/api/middleware/jwtAuth'; +import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; +import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; +import SubscriptionService from '@/services/Subscription/SubscriptionService'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import BaseController from '../BaseController'; +import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService'; + +@Service() +export class SubscriptionController extends BaseController { + @Inject() + private subscriptionService: SubscriptionService; + + @Inject() + private lemonSqueezyService: LemonSqueezyService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use(JWTAuth); + router.use(AttachCurrentTenantUser); + router.use(TenancyMiddleware); + + router.post( + '/lemon/checkout_url', + [body('variantId').exists().trim()], + this.validationResult, + this.getCheckoutUrl.bind(this) + ); + router.get('/', asyncMiddleware(this.getSubscriptions.bind(this))); + + return router; + } + + /** + * Retrieve all subscriptions of the authenticated user's tenant. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async getSubscriptions( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + + try { + const subscriptions = await this.subscriptionService.getSubscriptions( + tenantId + ); + return res.status(200).send({ subscriptions }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the LemonSqueezy checkout url. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async getCheckoutUrl( + req: Request, + res: Response, + next: NextFunction + ) { + const { variantId } = this.matchedBodyData(req); + const { user } = req; + + try { + const checkout = await this.lemonSqueezyService.getCheckout( + variantId, + user + ); + return res.status(200).send(checkout); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Subscription/index.ts b/packages/server/src/api/controllers/Subscription/index.ts index 02e00a6ba..67ff37623 100644 --- a/packages/server/src/api/controllers/Subscription/index.ts +++ b/packages/server/src/api/controllers/Subscription/index.ts @@ -1,88 +1 @@ -import { Router, Request, Response, NextFunction } from 'express'; -import { Service, Inject } from 'typedi'; -import { body } from 'express-validator'; -import JWTAuth from '@/api/middleware/jwtAuth'; -import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; -import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; -import SubscriptionService from '@/services/Subscription/SubscriptionService'; -import asyncMiddleware from '@/api/middleware/asyncMiddleware'; -import BaseController from '../BaseController'; -import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService'; - -@Service() -export default class SubscriptionController extends BaseController { - @Inject() - private subscriptionService: SubscriptionService; - - @Inject() - private lemonSqueezyService: LemonSqueezyService; - - /** - * Router constructor. - */ - router() { - const router = Router(); - - router.use(JWTAuth); - router.use(AttachCurrentTenantUser); - router.use(TenancyMiddleware); - - router.post( - '/lemon/checkout_url', - [body('variantId').exists().trim()], - this.validationResult, - this.getCheckoutUrl.bind(this) - ); - router.get('/', asyncMiddleware(this.getSubscriptions.bind(this))); - - return router; - } - - /** - * Retrieve all subscriptions of the authenticated user's tenant. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - private async getSubscriptions( - req: Request, - res: Response, - next: NextFunction - ) { - const { tenantId } = req; - - try { - const subscriptions = await this.subscriptionService.getSubscriptions( - tenantId - ); - return res.status(200).send({ subscriptions }); - } catch (error) { - next(error); - } - } - - /** - * Retrieves the LemonSqueezy checkout url. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - private async getCheckoutUrl( - req: Request, - res: Response, - next: NextFunction - ) { - const { variantId } = this.matchedBodyData(req); - const { user } = req; - - try { - const checkout = await this.lemonSqueezyService.getCheckout( - variantId, - user - ); - return res.status(200).send(checkout); - } catch (error) { - next(error); - } - } -} +export * from './SubscriptionController'; \ No newline at end of file diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 13de30877..623e5a0f7 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -37,7 +37,7 @@ import Resources from './controllers/Resources'; import ExchangeRates from '@/api/controllers/ExchangeRates'; import Media from '@/api/controllers/Media'; import Ping from '@/api/controllers/Ping'; -import Subscription from '@/api/controllers/Subscription'; +import { SubscriptionController } from '@/api/controllers/Subscription'; import InventoryAdjustments from '@/api/controllers/Inventory/InventoryAdjustments'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; import Jobs from './controllers/Jobs'; @@ -72,7 +72,7 @@ export default () => { app.use('/auth', Container.get(Authentication).router()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); - app.use('/subscription', Container.get(Subscription).router()); + app.use('/subscription', Container.get(SubscriptionController).router()); app.use('/organization', Container.get(Organization).router()); app.use('/ping', Container.get(Ping).router()); app.use('/jobs', Container.get(Jobs).router()); @@ -140,12 +140,10 @@ export default () => { dashboard.use('/warehouses', Container.get(WarehousesController).router()); dashboard.use('/projects', Container.get(ProjectsController).router()); dashboard.use('/tax-rates', Container.get(TaxRatesController).router()); - dashboard.use('/import', Container.get(ImportController).router()); dashboard.use('/', Container.get(ProjectTasksController).router()); dashboard.use('/', Container.get(ProjectTimesController).router()); - dashboard.use('/', Container.get(WarehousesItemController).router()); dashboard.use('/dashboard', Container.get(DashboardController).router()); diff --git a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts index 4301b924a..ff21c1871 100644 --- a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts +++ b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts @@ -49,16 +49,17 @@ export class LemonSqueezyWebhooks { /** * This action will process a webhook event in the database. + * @param {unknown} eventBody - + * @returns {Promise} */ - private async processWebhookEvent(eventBody) { - let processingError = ''; + private async processWebhookEvent(eventBody): Promise { const webhookEvent = eventBody.meta.event_name; const userId = eventBody.meta.custom_data?.user_id; const tenantId = eventBody.meta.custom_data?.tenant_id; if (!webhookHasMeta(eventBody)) { - processingError = "Event body is missing the 'meta' property."; + throw new Error("Event body is missing the 'meta' property."); } else if (webhookHasData(eventBody)) { if (webhookEvent.startsWith('subscription_payment_')) { // Save subscription invoices; eventBody is a SubscriptionInvoice @@ -72,7 +73,7 @@ export class LemonSqueezyWebhooks { const plan = await Plan.query().findOne('slug', 'essentials-yearly'); if (!plan) { - processingError = `Plan with variantId ${variantId} not found.`; + throw new Error(`Plan with variantId ${variantId} not found.`); } else { // Update the subscription in the database. const priceId = attributes.first_subscription_item.price_id; @@ -81,7 +82,9 @@ export class LemonSqueezyWebhooks { const priceData = await getPrice(priceId); if (priceData.error) { - processingError = `Failed to get the price data for the subscription ${eventBody.data.id}.`; + throw new Error( + `Failed to get the price data for the subscription ${eventBody.data.id}.` + ); } const isUsageBased = attributes.first_subscription_item.is_usage_based; diff --git a/packages/webapp/src/hooks/query/organization.tsx b/packages/webapp/src/hooks/query/organization.tsx index aea3ab96c..3a68efa89 100644 --- a/packages/webapp/src/hooks/query/organization.tsx +++ b/packages/webapp/src/hooks/query/organization.tsx @@ -1,11 +1,11 @@ // @ts-nocheck import { useMutation, useQueryClient } from 'react-query'; import { batch } from 'react-redux'; +import { omit } from 'lodash'; import t from './types'; import useApiRequest from '../useRequest'; import { useRequestQuery } from '../useQueryRequest'; import { useSetOrganizations, useSetSubscriptions } from '../state'; -import { omit } from 'lodash'; /** * Retrieve organizations of the authenticated user.