From a39dcd00d568e9668d71987b16c9f30e7991332a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 13 Apr 2024 11:05:53 +0200 Subject: [PATCH] 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, + }, + ]); + }); +};