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/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/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 new file mode 100644 index 000000000..67ff37623 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/index.ts @@ -0,0 +1 @@ +export * from './SubscriptionController'; \ No newline at end of file diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts index 955e382fb..4ff3acc76 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 Lemon Squeezy 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/api/index.ts b/packages/server/src/api/index.ts index cc1206aee..623e5a0f7 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,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 { SubscriptionController } from '@/api/controllers/Subscription'; import InventoryAdjustments from '@/api/controllers/Inventory/InventoryAdjustments'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; import Jobs from './controllers/Jobs'; @@ -70,6 +72,7 @@ export default () => { app.use('/auth', Container.get(Authentication).router()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); + 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()); @@ -83,6 +86,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); @@ -136,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/api/middleware/SubscriptionMiddleware.ts b/packages/server/src/api/middleware/SubscriptionMiddleware.ts new file mode 100644 index 000000000..6d840c666 --- /dev/null +++ b/packages/server/src/api/middleware/SubscriptionMiddleware.ts @@ -0,0 +1,29 @@ +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 { subscriptionRepository } = Container.get('repositories'); + + if (!tenant) { + throw new Error('Should load `TenancyMiddlware` before this middleware.'); + } + const subscription = await subscriptionRepository.getBySlugInTenant( + subscriptionSlug, + tenantId + ); + // 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/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/index.ts b/packages/server/src/exceptions/index.ts index 53bb38897..f04873ba5 100644 --- a/packages/server/src/exceptions/index.ts +++ b/packages/server/src/exceptions/index.ts @@ -1,3 +1,4 @@ +import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan'; import ServiceError from './ServiceError'; import ServiceErrors from './ServiceErrors'; import TenantAlreadyInitialized from './TenantAlreadyInitialized'; @@ -6,6 +7,7 @@ import TenantDBAlreadyExists from './TenantDBAlreadyExists'; import TenantDatabaseNotBuilt from './TenantDatabaseNotBuilt'; export { + NotAllowedChangeSubscriptionPlan, ServiceError, ServiceErrors, TenantAlreadyInitialized, 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/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/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/LemonSqueezyWebhooks.ts b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts new file mode 100644 index 000000000..ff21c1871 --- /dev/null +++ b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts @@ -0,0 +1,114 @@ +import { getPrice } from '@lemonsqueezy/lemonsqueezy.js'; +import config from '@/config'; +import { Inject, Service } from 'typedi'; +import { + compareSignatures, + configureLemonSqueezy, + createHmacSignature, + webhookHasData, + webhookHasMeta, +} from './utils'; +import { Plan } from '@/system/models'; +import { Subscription } from './Subscription'; + +@Service() +export class LemonSqueezyWebhooks { + @Inject() + private subscriptionService: Subscription; + + /** + * handle the LemonSqueezy webhooks. + * @param {string} rawBody + * @param {string} signature + * @returns {Promise} + */ + public async handlePostWebhook( + rawData: any, + data: Record, + signature: string + ): Promise { + configureLemonSqueezy(); + + if (!config.lemonSqueezy.webhookSecret) { + throw new Error('Lemon Squeezy Webhook Secret not set in .env'); + } + const secret = config.lemonSqueezy.webhookSecret; + const hmacSignature = createHmacSignature(secret, rawData); + + if (!compareSignatures(hmacSignature, signature)) { + 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); + } else { + throw new Error('Data invalid'); + } + } + + /** + * This action will process a webhook event in the database. + * @param {unknown} eventBody - + * @returns {Promise} + */ + 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)) { + 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 + // 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) { + throw new Error(`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) { + throw new Error( + `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; + + // Create a new subscription of the tenant. + if (webhookEvent === 'subscription_created') { + await this.subscriptionService.newSubscribtion( + tenantId, + 'pro-yearly', + 'year', + 1 + ); + } + } + } 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/Subscription.ts b/packages/server/src/services/Subscription/Subscription.ts new file mode 100644 index 000000000..6832f7127 --- /dev/null +++ b/packages/server/src/services/Subscription/Subscription.ts @@ -0,0 +1,48 @@ +import { Service } from 'typedi'; +import { NotAllowedChangeSubscriptionPlan } from '@/exceptions'; +import { Plan, Tenant } from '@/system/models'; + +@Service() +export class Subscription { + /** + * Give the tenant a new subscription. + * @param {number} tenantId - Tenant id. + * @param {string} planSlug - Plan slug. + * @param {string} invoiceInterval + * @param {number} invoicePeriod + * @param {string} subscriptionSlug + */ + 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) + .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 + ); + } + } +} diff --git a/packages/server/src/services/Subscription/SubscriptionPeriod.ts b/packages/server/src/services/Subscription/SubscriptionPeriod.ts new file mode 100644 index 000000000..2deb6b073 --- /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; + } +} diff --git a/packages/server/src/services/Subscription/SubscriptionService.ts b/packages/server/src/services/Subscription/SubscriptionService.ts new file mode 100644 index 000000000..8e70c55d8 --- /dev/null +++ b/packages/server/src/services/Subscription/SubscriptionService.ts @@ -0,0 +1,17 @@ +import { Service } from 'typedi'; +import { PlanSubscription } from '@/system/models'; + +@Service() +export default class SubscriptionService { + /** + * 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/utils.ts b/packages/server/src/services/Subscription/utils.ts new file mode 100644 index 000000000..56dc7e4b4 --- /dev/null +++ b/packages/server/src/services/Subscription/utils.ts @@ -0,0 +1,100 @@ +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) => { + // 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/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/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/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/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..b715adc8c 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, @@ -163,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 b6e3c1f45..e96fdd8ff 100644 --- a/packages/server/src/system/models/index.ts +++ b/packages/server/src/system/models/index.ts @@ -1,3 +1,5 @@ +import Plan from './Subscriptions/Plan'; +import PlanSubscription from './Subscriptions/PlanSubscription'; import Tenant from './Tenant'; import TenantMetadata from './TenantMetadata'; import SystemUser from './SystemUser'; @@ -7,6 +9,8 @@ import SystemPlaidItem from './SystemPlaidItem'; import { Import } from './Import'; export { + Plan, + PlanSubscription, 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, + }, + ]); + }); +}; 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/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/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/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..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 { omit } from 'lodash'; 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 } from '../state'; +import { useSetOrganizations, useSetSubscriptions } from '../state'; /** * 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/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, + }, + ); +}; 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' },