Merge pull request #402 from bigcapitalhq/lemon-squeezy-payment

feat: Integrate Lemon Squeezy payment
This commit is contained in:
Ahmed Bouhuolia
2024-04-15 14:49:39 +02:00
committed by GitHub
40 changed files with 1118 additions and 28 deletions

View File

@@ -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=

View File

@@ -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,

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1 @@
export * from './SubscriptionController';

View File

@@ -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

View File

@@ -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());

View File

@@ -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();
};

View File

@@ -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,
},
};

View File

@@ -0,0 +1,8 @@
export default class NotAllowedChangeSubscriptionPlan {
constructor() {
this.name = "NotAllowedChangeSubscriptionPlan";
}
}

View File

@@ -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,

View File

@@ -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());

View File

@@ -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),
};
}

View File

@@ -144,6 +144,7 @@ export default class OrganizationService {
public async currentOrganization(tenantId: number): Promise<ITenant> {
const tenant = await Tenant.query()
.findById(tenantId)
.withGraphFetched('subscriptions')
.withGraphFetched('metadata');
this.throwIfTenantNotExists(tenant);

View File

@@ -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!',
},
});
}
}

View File

@@ -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<void>}
*/
public async handlePostWebhook(
rawData: any,
data: Record<string, any>,
signature: string
): Promise<void> {
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<void>}
*/
private async processWebhookEvent(eventBody): Promise<void> {
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 */
}
}
}
}

View File

@@ -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
);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<string, unknown> {
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<string, unknown> & {
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);
}

View File

@@ -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')
};

View File

@@ -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')
};

View File

@@ -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');
};

View File

@@ -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;
}
}

View File

@@ -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 });
}
}

View File

@@ -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(),
});
}
}

View File

@@ -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,

View File

@@ -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);
});
}
}

View File

@@ -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,
};

View File

@@ -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,
},
]);
});
};

View File

@@ -51,5 +51,6 @@
href="https://cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css"
type="text/css"
/>
<script src="https://app.lemonsqueezy.com/js/lemon.js"></script>
</body>
</html>

View File

@@ -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'),
},

View File

@@ -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 (
<section className={'setup-page__right-section'}>
@@ -53,6 +57,12 @@ export default compose(
isOrganizationBuildRunning,
}),
),
withSubscriptions(
({ isSubscriptionActive }) => ({
isSubscriptionActive,
}),
'main',
),
withSetupWizard(({ setupStepId, setupStepIndex }) => ({
setupStepId,
setupStepIndex,

View File

@@ -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(

View File

@@ -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 (
<div class="billing-plans">
<SubscriptionPlansSection />
<SubscriptionPeriodsSection />
<SubscriptionPaymentMethodsSection />
<div>
<Button type="submit" intent={Intent.PRIMARY} large={true}>
<T id={'submit_voucher'} />
</Button>
</div>
);
}
export default function SetupSubscriptionForm() {
return (
<Form>
<div class="billing-plans">
<SubscriptionPlansSection />
<SubscriptionPeriodsSection />
<StepSubscriptionActions />
</div>
</Form>
);
}

View File

@@ -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 }) {
<div class="setup-page-form">
<SetupSteps step={{ id: setupStepId }}>
<SetupSubscription id="subscription" />
<SetupOrganizationPage id="organization" />
<SetupInitializingForm id={'initializing'} />
<SetupCongratsPage id="congrats" />

View File

@@ -25,7 +25,6 @@ export default function WizardSetupSteps({ currentStep = 1 }) {
<WizardSetupStep
label={step.label}
isActive={index + 1 === currentStep}
key={index}
/>
))}
</ul>

View File

@@ -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]);
});

View File

@@ -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])
};
}, [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,
},
);
};

View File

@@ -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' },