Compare commits

...

40 Commits

Author SHA1 Message Date
allcontributors[bot]
6012c035ec docs: update .all-contributorsrc [skip ci] 2024-04-22 08:20:43 +00:00
allcontributors[bot]
7587c6510c docs: update README.md [skip ci] 2024-04-22 08:20:42 +00:00
Ahmed Bouhuolia
2f06070ecb Merge pull request #414 from benpsnyder/feat/upgrade-to-latest-lerna
feat(repo): upgrade to latest lerna v8 and pnpm v9
2024-04-22 10:16:50 +02:00
Ahmed Bouhuolia
deefdb9bfd fix: update pnpm-lock.yaml file 2024-04-22 10:04:18 +02:00
Ben Snyder
3cc62d80de fix(repo): replace usage of yarn with pnpm/pnpx 2024-04-21 20:34:35 -04:00
Ben Snyder
4962c5d4d3 feat(repo): upgrade to latest lerna v8 and pnpm v9 2024-04-21 20:29:38 -04:00
Ahmed Bouhuolia
571a332658 Merge pull request #410 from bigcapitalhq/seed-free-subscription-to-tenants
feat: seed free subscription to tenants that have no subscription.
2024-04-19 09:36:35 +02:00
Ahmed Bouhuolia
b44c318a5d feat: seed free subscription to tenants that have no subscription. 2024-04-19 09:34:47 +02:00
Ahmed Bouhuolia
bd9717f4dc chore: Add Bigcapital Cloud link on README.md file 2024-04-17 19:51:05 +02:00
Ahmed Bouhuolia
f48aea8e5a feat: add the new env variables to docker compose 2024-04-17 19:21:35 +02:00
Ahmed Bouhuolia
0ac3a5dea9 fix: add /imports directory to storage dir 2024-04-17 18:25:17 +02:00
Ahmed Bouhuolia
56b40ad4cb fix: TS and linit errors 2024-04-17 17:51:21 +02:00
Ahmed Bouhuolia
9b6f934990 fix: add @lemonsqueezy/lemonsqueezy package dependencies. 2024-04-17 17:44:35 +02:00
Ahmed Bouhuolia
80e3522f8a Merge pull request #408 from bigcapitalhq/fix-import-store-absolute-path
fix: absolute storage imports path.
2024-04-17 17:37:39 +02:00
Ahmed Bouhuolia
7975643765 fix: absolute storage imports path. 2024-04-17 17:36:35 +02:00
Ahmed Bouhuolia
2ac7f86bdb Merge pull request #407 from bigcapitalhq/auto-subscribe-free
chore: add default value to env variable
2024-04-16 21:24:49 +02:00
Ahmed Bouhuolia
956b9b6812 chore: add default value to env variable 2024-04-16 21:22:40 +02:00
Ahmed Bouhuolia
60248ec3f6 Merge pull request #406 from bigcapitalhq/auto-subscribe-free
feat: auto subscribe to free plan once signup on community version.
2024-04-16 20:58:29 +02:00
Ahmed Bouhuolia
9d3f1541eb feat: auto subscribe to free plan once signup on community version. 2024-04-16 20:57:05 +02:00
Ahmed Bouhuolia
9b5f1a36ab Merge branch 'main' into develop 2024-04-16 12:58:22 +02:00
Ahmed Bouhuolia
8ee691e1ed Merge pull request #405 from bigcapitalhq/subscription-page-content
feat: subscription page content
2024-04-16 12:55:57 +02:00
Ahmed Bouhuolia
f9cb14da9e feat: subscription page content 2024-04-16 12:54:36 +02:00
Ahmed Bouhuolia
5e87581f4e hotfix: creating a vendor 2024-04-15 22:48:54 +02:00
Ahmed Bouhuolia
8ca9cf39da Merge pull request #404 from bigcapitalhq/optimize-ui-onboarding
feat: optimize the onboarding subscription experience.
2024-04-15 14:53:52 +02:00
Ahmed Bouhuolia
9001fea524 Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2024-04-15 14:51:19 +02:00
Ahmed Bouhuolia
dea0d71732 Merge pull request #402 from bigcapitalhq/lemon-squeezy-payment
feat: Integrate Lemon Squeezy payment
2024-04-15 14:49:39 +02:00
Ahmed Bouhuolia
c191c4bd26 feat: remove other payment methods 2024-04-15 14:49:27 +02:00
Ahmed Bouhuolia
47d82ce591 feat: optimize the onboarding subscription experience. 2024-04-15 12:48:16 +02:00
Ahmed Bouhuolia
9321db2a3a feat: sweep up lemon squeezy webhooks. 2024-04-14 12:58:53 +02:00
Ahmed Bouhuolia
e486333c96 feat: sweep up the Lemon Squeezy integration 2024-04-14 12:44:02 +02:00
Ahmed Bouhuolia
a9748b23c0 feat: listen LemonSqueezy webhooks 2024-04-14 11:55:36 +02:00
Ahmed Bouhuolia
693ae61141 feat: integrate LemonSqueezy to subscription payment 2024-04-14 10:33:29 +02:00
Ahmed Bouhuolia
9807ac04b0 Revert "feat(webapp): deprecate the subscription step in onboarding process"
This reverts commit 0c1bf302e5.
2024-04-13 15:18:59 +02:00
Ahmed Bouhuolia
bddfde4138 Revert "feat(webapp): deprecate the subscription step in onboarding process"
This reverts commit 0c1bf302e5.
2024-04-13 14:07:32 +02:00
Ahmed Bouhuolia
a39dcd00d5 Revert "feat(server): deprecated the subscription module."
This reverts commit 3b79ac66ae.
2024-04-13 11:05:53 +02:00
Ahmed Bouhuolia
4d616e9287 Revert "feat(server): deprecated the subscription module."
This reverts commit 44fc26b156.
2024-04-13 10:17:48 +02:00
Ahmed Bouhuolia
dc52fb1de5 fix: lint error 2024-04-09 22:24:03 +02:00
Ahmed Bouhuolia
21a1777424 Merge branch 'release/v0.15.2' into develop 2024-04-09 22:11:10 +02:00
Ahmed Bouhuolia
16b721db91 Merge pull request #401 from bigcapitalhq/import-fields-hints
feat: add hints to import fields
2024-04-09 22:01:16 +02:00
Ahmed Bouhuolia
2baa667c5d fix(webapp): hotfix pdf request hook 2024-03-19 05:22:15 +02:00
104 changed files with 2975 additions and 42316 deletions

View File

@@ -105,6 +105,15 @@
"contributions": [
"bug"
]
},
{
"login": "benpsnyder",
"name": "Ben Snyder",
"avatar_url": "https://avatars.githubusercontent.com/u/707567?v=4",
"profile": "https://snyder.tech",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@@ -95,3 +95,8 @@ 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

@@ -8,14 +8,14 @@ on:
- '**.ts'
- '**.tsx'
- '**/tsconfig.json'
- 'yarn.lock'
- 'pnpm-lock.yaml'
- '.github/workflows/e2e.yml'
pull_request:
paths:
- '**.ts'
- '**.tsx'
- '**/tsconfig.json'
- 'yarn.lock'
- 'pnpm-lock.yaml'
- '.github/workflows/e2e.yml'
defaults:

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn commitlint --edit
pnpx commitlint --edit

View File

@@ -25,6 +25,10 @@
<img src="https://img.shields.io/twitter/follow/bigcapitalhq?style=social" alt="twitter" />
</a>
</p>
<p align="center">
<a href="https://app.bigcapital.ly">Bigcapital Cloud</a>
</p>
</p>
# What's Bigcapital?
@@ -118,6 +122,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ANasouf"><img src="https://avatars.githubusercontent.com/u/19536487?v=4?s=100" width="100px;" alt="ANasouf"/><br /><sub><b>ANasouf</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=ANasouf" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://ragnarlaud.dev"><img src="https://avatars.githubusercontent.com/u/3042904?v=4?s=100" width="100px;" alt="Ragnar Laud"/><br /><sub><b>Ragnar Laud</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Axprnio" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/asenawritescode"><img src="https://avatars.githubusercontent.com/u/67445192?v=4?s=100" width="100px;" alt="Asena"/><br /><sub><b>Asena</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Aasenawritescode" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://snyder.tech"><img src="https://avatars.githubusercontent.com/u/707567?v=4?s=100" width="100px;" alt="Ben Snyder"/><br /><sub><b>Ben Snyder</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=benpsnyder" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -21,16 +21,12 @@ services:
depends_on:
- server
- webapp
deploy:
restart_policy:
condition: unless-stopped
restart: on-failure
webapp:
container_name: bigcapital-webapp
image: ghcr.io/bigcapitalhq/webapp:latest
deploy:
restart_policy:
condition: unless-stopped
restart: on-failure
server:
container_name: bigcapital-server
@@ -45,9 +41,7 @@ services:
- mysql
- mongo
- redis
deploy:
restart_policy:
condition: unless-stopped
restart: on-failure
environment:
# Mail
- MAIL_HOST=${MAIL_HOST}
@@ -92,6 +86,12 @@ services:
- GOTENBERG_URL=${GOTENBERG_URL}
- GOTENBERG_DOCS_URL=${GOTENBERG_DOCS_URL}
# Lemon Squeez
- LEMONSQUEEZY_API_KEY=${LEMONSQUEEZY_API_KEY}
- LEMONSQUEEZY_STORE_ID=${LEMONSQUEEZY_STORE_ID}
- LEMONSQUEEZY_WEBHOOK_SECRET=${LEMONSQUEEZY_WEBHOOK_SECRET}
- HOSTED_ON_BIGCAPITAL_CLOUD=${HOSTED_ON_BIGCAPITAL_CLOUD}
database_migration:
container_name: bigcapital-database-migration
build:
@@ -111,9 +111,7 @@ services:
mysql:
container_name: bigcapital-mysql
deploy:
restart_policy:
condition: unless-stopped
restart: on-failure
build:
context: ./docker/mariadb
environment:
@@ -128,9 +126,7 @@ services:
mongo:
container_name: bigcapital-mongo
deploy:
restart_policy:
condition: unless-stopped
restart: on-failure
build: ./docker/mongo
expose:
- '27017'
@@ -139,9 +135,7 @@ services:
redis:
container_name: bigcapital-redis
deploy:
restart_policy:
condition: unless-stopped
restart: on-failure
build:
context: ./docker/redis
expose:

View File

@@ -2,6 +2,7 @@
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "independent",
"npmClient": "pnpm",
"useWorkspaces": true,
"packages": ["packages/*"]
}
"packages": [
"packages/*"
]
}

View File

@@ -19,7 +19,8 @@
"@faker-js/faker": "^8.0.2",
"@playwright/test": "^1.32.3",
"husky": "^8.0.3",
"lerna": "^6.4.1"
"lerna": "^8.1.2",
"pnpm": "^9.0.5"
},
"engines": {
"node": "16.x || 17.x || 18.x"

View File

@@ -3,4 +3,6 @@
stdout.log
/dist
/build
/public/imports
/public/imports
dist

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
"dependencies": {
"@casl/ability": "^5.4.3",
"@hapi/boom": "^7.4.3",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@types/i18n": "^0.8.7",
"@types/knex": "^0.16.1",
"@types/mathjs": "^6.0.12",
@@ -89,17 +90,17 @@
"objection-filter": "^4.0.1",
"objection-soft-delete": "^1.0.7",
"objection-unique": "^1.2.2",
"plaid": "^10.3.0",
"pluralize": "^8.0.0",
"pug": "^3.0.2",
"puppeteer": "^10.2.0",
"plaid": "^10.3.0",
"qim": "0.0.52",
"ramda": "^0.27.1",
"rate-limiter-flexible": "^2.1.14",
"reflect-metadata": "^0.1.13",
"rtl-detect": "^1.0.4",
"source-map-loader": "^4.0.1",
"socket.io": "^4.7.4",
"source-map-loader": "^4.0.1",
"tmp-promise": "^3.0.3",
"ts-transformer-keys": "^0.4.2",
"tsyringe": "^4.3.0",

View File

@@ -144,10 +144,8 @@ export default class VendorsController extends ContactsController {
try {
const vendor = await this.vendorsApplication.createVendor(
tenantId,
contactDTO,
user
contactDTO
);
return res.status(200).send({
id: vendor.id,
message: 'The vendor has been created successfully.',

View File

@@ -1,5 +1,6 @@
import Multer from 'multer';
import { ServiceError } from '@/exceptions';
import { getImportsStoragePath } from '@/services/Import/_utils';
export function allowSheetExtensions(req, file, cb) {
if (
@@ -16,7 +17,8 @@ export function allowSheetExtensions(req, file, cb) {
const storage = Multer.diskStorage({
destination: function (req, file, cb) {
cb(null, './public/imports');
const path = getImportsStoragePath();
cb(null, path);
},
filename: function (req, file, cb) {
// Add the creation timestamp to clean up temp files later.

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

@@ -297,8 +297,7 @@ export default class VendorCreditController extends BaseController {
try {
const vendorCredit = await this.createVendorCreditService.newVendorCredit(
tenantId,
vendorCreditCreateDTO,
user
vendorCreditCreateDTO
);
return res.status(200).send({

View File

@@ -338,8 +338,7 @@ export default class PaymentReceivesController extends BaseController {
try {
const creditNote = await this.createCreditNoteService.newCreditNote(
tenantId,
creditNoteDTO,
user
creditNoteDTO
);
return res.status(200).send({
id: creditNote.id,

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

@@ -1,6 +1,6 @@
import dotenv from 'dotenv';
import path from 'path';
import { toInteger } from 'lodash';
import { defaultTo, toInteger } from 'lodash';
import { castCommaListEnvVarToArray, parseBoolean } from '@/utils';
dotenv.config();
@@ -190,6 +190,24 @@ 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,
},
/**
* Bigcapital (Cloud).
* NOTE: DO NOT CHANGE THIS OPTION OR ADD THIS ENV VAR.
*/
hostedOnBigcapitalCloud: parseBoolean(
defaultTo(process.env.HOSTED_ON_BIGCAPITAL_CLOUD, false),
false
),
};

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

@@ -89,7 +89,9 @@ import { InvoiceChangeStatusOnMailSentSubscriber } from '@/services/Sales/Invoic
import { SaleReceiptMarkClosedOnMailSentSubcriber } from '@/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber';
import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent';
import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize';
import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete'; }
import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete';
import { SubscribeFreeOnSignupCommunity } from '@/services/Subscription/events/SubscribeFreeOnSignupCommunity';
export default () => {
return new EventPublisher();
@@ -218,6 +220,8 @@ export const susbcribers = () => {
// Cashflow
DeleteCashflowTransactionOnUncategorize,
PreventDeleteTransactionOnDelete
PreventDeleteTransactionOnDelete,
SubscribeFreeOnSignupCommunity
];
};

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

@@ -187,18 +187,4 @@ export default class Contact extends TenantModel {
},
};
}
static get fields() {
return {
contact_service: {
column: 'contact_service',
},
display_name: {
column: 'display_name',
},
created_at: {
column: 'created_at',
},
};
}
}

View File

@@ -50,10 +50,7 @@ export class CustomersApplication {
* @param {ISystemUser} authorizedUser
* @returns {Promise<ICustomer>}
*/
public createCustomer = (
tenantId: number,
customerDTO: ICustomerNewDTO,
) => {
public createCustomer = (tenantId: number, customerDTO: ICustomerNewDTO) => {
return this.createCustomerService.createCustomer(tenantId, customerDTO);
};

View File

@@ -1,4 +1,5 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import {
ISystemUser,
IVendorEditDTO,
@@ -42,13 +43,9 @@ export class VendorsApplication {
public createVendor = (
tenantId: number,
vendorDTO: IVendorNewDTO,
authorizedUser: ISystemUser
trx?: Knex.Transaction
) => {
return this.createVendorService.createVendor(
tenantId,
vendorDTO,
authorizedUser
);
return this.createVendorService.createVendor(tenantId, vendorDTO, trx);
};
/**

View File

@@ -4,7 +4,6 @@ import {
ICreditNoteCreatedPayload,
ICreditNoteCreatingPayload,
ICreditNoteNewDTO,
ISystemUser,
} from '@/interfaces';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import ItemsEntriesService from '@/services/Items/ItemsEntriesService';

View File

@@ -38,8 +38,6 @@ export class ImportFileUploadService {
filename: string,
params: Record<string, number | string>
): Promise<ImportFileUploadPOJO> {
console.log(filename, 'filename');
try {
return await this.importUnhandled(
tenantId,

View File

@@ -3,6 +3,7 @@ import moment from 'moment';
import * as R from 'ramda';
import { Knex } from 'knex';
import fs from 'fs/promises';
import path from 'path';
import {
defaultTo,
upperFirst,
@@ -353,7 +354,6 @@ export const parseKey = R.curry(
_key = `${fieldKey}`;
}
}
console.log(_key);
return _key;
}
);
@@ -432,13 +432,19 @@ export const sanitizeSheetData = (json) => {
export const getMapToPath = (to: string, group = '') =>
group ? `${group}.${to}` : to;
export const getImportsStoragePath = () => {
return path.join(global.__storage_dir, `/imports`);
}
/**
* Deletes the imported file from the storage and database.
* @param {string} filename
*/
export const deleteImportFile = async (filename: string) => {
const filePath = getImportsStoragePath();
// Deletes the imported file.
await fs.unlink(`public/imports/${filename}`);
await fs.unlink(`${filePath}/${filename}`);
};
/**
@@ -447,5 +453,7 @@ export const deleteImportFile = async (filename: string) => {
* @returns {Promise<Buffer>}
*/
export const readImportFile = (filename: string) => {
return fs.readFile(`public/imports/${filename}`);
const filePath = getImportsStoragePath();
return fs.readFile(`${filePath}/${filename}`);
};

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

@@ -30,6 +30,7 @@ export default class CreateVendorCredit extends BaseVendorCredit {
* Creates a new vendor credit.
* @param {number} tenantId -
* @param {IVendorCreditCreateDTO} vendorCreditCreateDTO -
* @param {Knex.Transaction} trx -
*/
public newVendorCredit = async (
tenantId: number,

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,112 @@
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,
'early-adaptor',
);
}
}
} 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,52 @@
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,
subscriptionSlug: string = 'main'
) {
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
const isFree = plan.price === 0;
// Take the invoice interval and period from the given plan.
const invoiceInterval = plan.invoiceInternal;
const invoicePeriod = isFree ? Infinity : plan.invoicePeriod;
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,49 @@
import moment, { unitOfTime } from 'moment';
export default class SubscriptionPeriod {
private start: Date;
private end: Date;
private interval: string;
private count: number;
/**
* Constructor method.
* @param {string} interval -
* @param {number} count -
* @param {Date} start -
*/
constructor(
interval: unitOfTime.DurationConstructor = 'month',
count: number,
start?: Date
) {
this.interval = interval;
this.count = count;
this.start = start;
if (!start) {
this.start = moment().toDate();
}
if (count === Infinity) {
this.end = null;
} else {
this.end = moment(start).add(count, interval).toDate();
}
}
getStartDate() {
return this.start;
}
getEndDate() {
return this.end;
}
getInterval() {
return this.interval;
}
getIntervalCount() {
return this.count;
}
}

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,36 @@
import { IAuthSignedUpEventPayload } from '@/interfaces';
import events from '@/subscribers/events';
import config from '@/config';
import { Subscription } from '../Subscription';
import { Inject, Service } from 'typedi';
@Service()
export class SubscribeFreeOnSignupCommunity {
@Inject()
private subscriptionService: Subscription;
/**
* Attaches events with handlers.
*/
public attach = (bus) => {
bus.subscribe(
events.auth.signUp,
this.subscribeFreeOnSigupCommunity.bind(this)
);
};
/**
* Creates a new free subscription once the user signup if the app is self-hosted.
* @param {IAuthSignedUpEventPayload}
* @returns {Promise<void>}
*/
private async subscribeFreeOnSigupCommunity({
signupDTO,
tenant,
user,
}: IAuthSignedUpEventPayload) {
if (config.hostedOnBigcapitalCloud) return null;
await this.subscriptionService.newSubscribtion(tenant.id, 'free');
}
}

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,7 @@
exports.up = function (knex) {
return knex.seed.run({
specific: 'seed_tenants_free_subscription.js',
});
};
exports.down = function (knex) {};

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,48 @@ 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: 'month' | 'year',
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,26 @@
exports.seed = (knex) => {
// Deletes ALL existing entries
return knex('subscription_plans')
.del()
.then(() => {
// Inserts seed entries
return knex('subscription_plans').insert([
{
name: 'Free',
slug: 'free',
price: 0,
active: true,
currency: 'USD',
},
{
name: 'Early Adaptor',
slug: 'early-adaptor',
price: 29,
active: true,
currency: 'USD',
invoice_period: 12,
invoice_interval: 'month',
},
]);
});
};

View File

@@ -0,0 +1,26 @@
exports.seed = (knex) => {
// Deletes ALL existing entries
return knex('subscription_plan_subscriptions')
.then(async () => {
const tenants = await knex('tenants');
for (const tenant of tenants) {
const existingSubscription = await knex('subscription_plan_subscriptions')
.where('tenantId', tenant.id)
.first();
if (!existingSubscription) {
const freePlan = await knex('subscription_plans').where('slug', 'free').first();
await knex('subscription_plan_subscriptions').insert({
tenantId: tenant.id,
planId: freePlan.id,
slug: 'main',
startsAt: knex.fn.now(),
endsAt: null,
createdAt: knex.fn.now(),
});
}
}
});
};

View File

@@ -1,3 +1,4 @@
*
!pdf/
!imports/
!.gitignore

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -20,4 +20,5 @@
.env.production.local
npm-debug.log*
yarn-debug.log*
dist

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

@@ -48,7 +48,7 @@ const GroupStyled = styled(Box)`
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
align-items: ${(props: GroupProps) => (props.align || 'center')};
flex-wrap: ${(props: GroupProps) => (props.noWrap ? 'nowrap' : 'wrap')};
justify-content: ${(props: GroupProps) =>
GROUP_POSITIONS[props.position || 'left']};

View File

@@ -0,0 +1,59 @@
.root{
border-radius: 5px;
padding: 40px 15px;
position: relative;
border: 1px solid #D8DEE4;
padding-top: 45px;
flex: 1;
&.isFeatured {
background-color: #F5F6F8;
border: 0;
}
}
.featuredBox {
background-color: #A3ACBA;
height: 30px;
line-height: 30px;
position: absolute;
top: 0;
left: 0;
right: 0;
border-radius: 5px 5px 0 0;
color: #fff;
text-align: center;
font-size: 12px;
}
.label {
font-size: 14px;
font-weight: 600;
color: #2F343C;
}
.description{
font-size: 14px;
color: #687385;
line-height: 1.5;
}
.buttonCTA {
min-height: 34px;
border-radius: 5px;
}
.features {
margin-top: 1rem;
}
.priceRoot{
padding-bottom: 10px;
}
.price {
font-size: 18px;
line-height: 1;
font-weight: 500;
color: #404854;
}
.pricePer{
color: #738091;
font-size: 12px;
line-height: 1;
}

View File

@@ -0,0 +1,125 @@
import { Button, ButtonProps, Intent } from '@blueprintjs/core';
import clsx from 'classnames';
import { Box, Group, Stack } from '../Layout';
import styles from './PricingPlan.module.scss';
import { CheckCircled } from '@/icons/CheckCircled';
export interface PricingPlanProps {
featured?: boolean;
children: React.ReactNode;
}
/**
* Displays a pricing plan.
* @param featured - Whether the plan is featured.
* @param children - The content of the plan.
*/
export const PricingPlan = ({ featured, children }: PricingPlanProps) => {
return (
<Stack
spacing={8}
className={clsx(styles.root, { [styles.isFeatured]: featured })}
>
<>{children}</>
</Stack>
);
};
/**
* Displays a featured section within a pricing plan.
* @param children - The content of the featured section.
*/
PricingPlan.Featured = ({ children }: { children: React.ReactNode }) => {
return <Box className={styles.featuredBox}>{children}</Box>;
};
export interface PricingHeaderProps {
label: string;
description: string;
}
/**
* Displays the header of a pricing plan.
* @param label - The label of the plan.
* @param description - The description of the plan.
*/
PricingPlan.Header = ({ label, description }: PricingHeaderProps) => {
return (
<Stack spacing={4}>
<h4 className={styles.label}>{label}</h4>
{description && <p className={styles.description}>{description}</p>}
</Stack>
);
};
export interface PricingPriceProps {
price: string;
subPrice: string;
}
/**
* Displays the price of a pricing plan.
* @param price - The main price of the plan.
* @param subPrice - The sub-price of the plan.
*/
PricingPlan.Price = ({ price, subPrice }: PricingPriceProps) => {
return (
<Stack spacing={6} className={styles.priceRoot}>
<h4 className={styles.price}>{price}</h4>
<span className={styles.pricePer}>{subPrice}</span>
</Stack>
);
};
export interface PricingBuyButtonProps extends ButtonProps {}
/**
* Displays a buy button within a pricing plan.
* @param children - The content of the button.
* @param props - Additional button props.
*/
PricingPlan.BuyButton = ({ children, ...props }: PricingBuyButtonProps) => {
return (
<Button
intent={Intent.PRIMARY}
{...props}
fill={true}
className={styles.buttonCTA}
>
{children}
</Button>
);
};
export interface PricingFeaturesProps {
children: React.ReactNode;
}
/**
* Displays a list of features within a pricing plan.
* @param children - The list of features.
*/
PricingPlan.Features = ({ children }: PricingFeaturesProps) => {
return (
<Stack spacing={10} className={styles.features}>
{children}
</Stack>
);
};
export interface PricingFeatureLineProps {
children: React.ReactNode;
}
/**
* Displays a single feature line within a list of features.
* @param children - The content of the feature line.
*/
PricingPlan.FeatureLine = ({ children }: PricingFeatureLineProps) => {
return (
<Group noWrap spacing={12}>
<CheckCircled height={12} width={12} />
<Box className={styles.featureItem}>{children}</Box>
</Group>
);
};

View File

@@ -1,136 +0,0 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import { T } from '@/components';
import { saveInvoke } from '@/utils';
import '@/style/pages/Subscription/PlanRadio.scss';
import '@/style/pages/Subscription/PlanPeriodRadio.scss';
export function SubscriptionPlans({ value, plans, onSelect }) {
const handleSelect = (value) => {
onSelect && onSelect(value);
};
return (
<div className={'plan-radios'}>
{plans.map((plan) => (
<SubscriptionPlan
name={plan.name}
description={plan.description}
slug={plan.slug}
price={plan.price}
currencyCode={plan.currencyCode}
value={plan.slug}
onSelected={handleSelect}
selectedOption={value}
/>
))}
</div>
);
}
export function SubscriptionPlan({
name,
description,
price,
currencyCode,
value,
selectedOption,
onSelected,
}) {
const handlePlanClick = () => {
saveInvoke(onSelected, value);
};
return (
<div
id={'basic-plan'}
className={classNames('plan-radio', {
'is-selected': selectedOption === value,
})}
onClick={handlePlanClick}
>
<div className={'plan-radio__header'}>
<div className={'plan-radio__name'}>{name}</div>
</div>
<div className={'plan-radio__description'}>
<ul>
{description.map((line) => (
<li>{line}</li>
))}
</ul>
</div>
<div className={'plan-radio__price'}>
<span className={'plan-radio__amount'}>
{price} {currencyCode}
</span>
<span className={'plan-radio__period'}>
<T id={'monthly'} />
</span>
</div>
</div>
);
}
/**
* Subscription periods.
*/
export function SubscriptionPeriods({ periods, selectedPeriod, onPeriodSelect }) {
const handleSelected = (value) => {
saveInvoke(onPeriodSelect, value);
};
return (
<div className={'plan-periods'}>
{periods.map((period) => (
<SubscriptionPeriod
period={period.slug}
label={period.label}
onSelected={handleSelected}
price={period.price}
selectedPeriod={selectedPeriod}
/>
))}
</div>
);
}
/**
* Billing period.
*/
export function SubscriptionPeriod({
// #ownProps
label,
selectedPeriod,
onSelected,
period,
price,
currencyCode,
}) {
const handlePeriodClick = () => {
saveInvoke(onSelected, period);
};
return (
<div
id={`plan-period-${period}`}
className={classNames(
{ 'is-selected': period === selectedPeriod },
'period-radio',
)}
onClick={handlePeriodClick}
>
<span className={'period-radio__label'}>{label}</span>
<div className={'period-radio__price'}>
<span className={'period-radio__amount'}>
{price} {currencyCode}
</span>
<span className={'period-radio__period'}>{label}</span>
</div>
</div>
);
}

View File

@@ -13,7 +13,6 @@ export * from './PdfPreview';
export * from './Details';
export * from './TotalLines/index';
export * from './Alert';
export * from './Subscriptions';
export * from './Dashboard';
export * from './Drawer';
export * from './Forms';

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

@@ -1,14 +0,0 @@
// @ts-nocheck
import React from 'react';
import PaymentViaVoucherDialog from '@/containers/Dialogs/PaymentViaVoucherDialog';
/**
* Setup dialogs.
*/
export default function SetupDialogs() {
return (
<div class="setup-dialogs">
<PaymentViaVoucherDialog dialogName={'payment-via-voucher'} />
</div>
)
}

View File

@@ -54,7 +54,6 @@ function SetupLeftSectionHeader() {
<p className={'content__text'}>
<T id={'setup.left_side.description'} />
</p>
<div class="content__divider"></div>
<div className={'content__organization'}>
<span class="signout">

View File

@@ -1,12 +1,12 @@
// @ts-nocheck
import React from 'react';
import SetupDialogs from './SetupDialogs';
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,14 +22,13 @@ function SetupRightSection({
// #withSetupWizard
setupStepId,
setupStepIndex,
// #withSubscriptions
isSubscriptionActive,
}) {
return (
<section className={'setup-page__right-section'}>
<SetupWizardContent
setupStepId={setupStepId}
setupStepIndex={setupStepIndex}
/>
<SetupDialogs />
<SetupWizardContent stepId={setupStepId} stepIndex={setupStepIndex} />
</section>
);
}
@@ -53,6 +52,12 @@ export default compose(
isOrganizationBuildRunning,
}),
),
withSubscriptions(
({ isSubscriptionActive }) => ({
isSubscriptionActive,
}),
'main',
),
withSetupWizard(({ setupStepId, setupStepIndex }) => ({
setupStepId,
setupStepIndex,

View File

@@ -1,9 +0,0 @@
// @ts-nocheck
import React from 'react';
export default function SetupSteps({ step, children }) {
const activeStep = React.Children.toArray(children).filter(
(child) => child.props.id === step.id,
);
return activeStep;
}

View File

@@ -1,50 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Formik } from 'formik';
import * as R from 'ramda';
import '@/style/pages/Setup/Subscription.scss';
import SetupSubscriptionForm from './SetupSubscription/SetupSubscriptionForm';
import { getSubscriptionFormSchema } from './SubscriptionForm.schema';
import withSubscriptionPlansActions from '../Subscriptions/withSubscriptionPlansActions';
/**
* Subscription step of wizard setup.
*/
function SetupSubscription({
// #withSubscriptionPlansActions
initSubscriptionPlans,
}) {
React.useEffect(() => {
initSubscriptionPlans();
}, [initSubscriptionPlans]);
// Initial values.
const initialValues = {
plan_slug: 'essentials',
period: 'month',
license_code: '',
};
// Handle form submit.
const handleSubmit = (values) => {};
// Retrieve momerized subscription form schema.
const SubscriptionFormSchema = React.useMemo(
() => getSubscriptionFormSchema(),
[],
);
return (
<div className={'setup-subscription-form'}>
<Formik
validationSchema={SubscriptionFormSchema}
initialValues={initialValues}
component={SetupSubscriptionForm}
onSubmit={handleSubmit}
/>
</div>
);
}
export default R.compose(withSubscriptionPlansActions)(SetupSubscription);

View File

@@ -0,0 +1,5 @@
.root{
margin: 0 auto;
padding: 0 40px;
}

View File

@@ -0,0 +1,38 @@
// @ts-nocheck
import { useEffect } from 'react';
import * as R from 'ramda';
import { Box } from '@/components';
import { SubscriptionPlansSection } from './SubscriptionPlansSection';
import withSubscriptionPlansActions from '../../Subscriptions/withSubscriptionPlansActions';
import styles from './SetupSubscription.module.scss';
/**
* Subscription step of wizard setup.
*/
function SetupSubscription({
// #withSubscriptionPlansActions
initSubscriptionPlans,
}) {
useEffect(() => {
initSubscriptionPlans();
}, [initSubscriptionPlans]);
useEffect(() => {
window.LemonSqueezy.Setup({
eventHandler: (event) => {
// Do whatever you want with this event data
if (event.event === 'Checkout.Success') {
}
},
});
}, []);
return (
<Box className={styles.root}>
<SubscriptionPlansSection />
</Box>
);
}
export default R.compose(withSubscriptionPlansActions)(SetupSubscription);

View File

@@ -1,17 +0,0 @@
// @ts-nocheck
import React from 'react';
import SubscriptionPlansSection from './SubscriptionPlansSection';
import SubscriptionPeriodsSection from './SubscriptionPeriodsSection';
import SubscriptionPaymentMethodsSection from './SubscriptionPaymentsMethodsSection';
export default function SetupSubscriptionForm() {
return (
<div class="billing-plans">
<SubscriptionPlansSection />
<SubscriptionPeriodsSection />
<SubscriptionPaymentMethodsSection />
</div>
);
}

View File

@@ -1,20 +0,0 @@
// @ts-nocheck
import React from 'react';
import { T } from '@/components';
import { PaymentMethodTabs } from '../../Subscriptions/SubscriptionTabs';
export default ({ formik, title, description }) => {
return (
<section class="billing-plans__section">
<h1 className="title">
<T id={'setup.plans.payment_methods.title'} />
</h1>
<p className="paragraph">
<T id={'setup.plans.payment_methods.description'} />
</p>
<PaymentMethodTabs formik={formik} />
</section>
);
};

View File

@@ -1,48 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Field } from 'formik';
import * as R from 'ramda';
import { T, SubscriptionPeriods } from '@/components';
import withPlan from '../../Subscriptions/withPlan';
const SubscriptionPeriodsEnhanced = R.compose(
withPlan(({ plan }) => ({ plan })),
)(({ plan, ...restProps }) => {
// Can't continue if the current plan of the form not selected.
if (!plan) {
return null;
}
return <SubscriptionPeriods periods={plan.periods} {...restProps} />;
});
/**
* Billing periods.
*/
export default function SubscriptionPeriodsSection() {
return (
<section class="billing-plans__section">
<h1 class="title">
<T id={'setup.plans.select_period.title'} />
</h1>
<div class="description">
<p className="paragraph">
<T id={'setup.plans.select_period.description'} />
</p>
</div>
<Field name={'period'}>
{({ form: { setFieldValue, values }, field: { value } }) => (
<SubscriptionPeriodsEnhanced
planSlug={values.plan_slug}
selectedPeriod={value}
onPeriodSelect={(period) => {
setFieldValue('period', period);
}}
/>
)}
</Field>
</section>
);
}

View File

@@ -0,0 +1,78 @@
// @ts-nocheck
import { AppToaster, Group, T } from '@/components';
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
import { Intent } from '@blueprintjs/core';
import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
interface SubscriptionPricingProps {
slug: string;
label: string;
description: string;
features?: Array<String>;
featured?: boolean;
price: string;
pricePeriod: string;
}
function SubscriptionPricing({
featured,
label,
description,
features,
price,
pricePeriod,
}: SubscriptionPricingProps) {
const { mutateAsync: getLemonCheckout, isLoading } =
useGetLemonSqueezyCheckout();
const handleClick = () => {
getLemonCheckout({ variantId: '338516' })
.then((res) => {
const checkoutUrl = res.data.data.attributes.url;
window.LemonSqueezy.Url.Open(checkoutUrl);
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong!',
intent: Intent.DANGER,
});
});
};
return (
<PricingPlan featured={featured}>
{featured && <PricingPlan.Featured>Most Popular</PricingPlan.Featured>}
<PricingPlan.Header label={label} description={description} />
<PricingPlan.Price price={price} subPrice={pricePeriod} />
<PricingPlan.BuyButton loading={isLoading} onClick={handleClick}>
Subscribe
</PricingPlan.BuyButton>
<PricingPlan.Features>
{features?.map((feature) => (
<PricingPlan.FeatureLine>{feature}</PricingPlan.FeatureLine>
))}
</PricingPlan.Features>
</PricingPlan>
);
}
export function SubscriptionPlans({ plans }) {
return (
<Group spacing={18} noWrap align='stretch'>
{plans.map((plan, index) => (
<SubscriptionPricing
key={index}
slug={plan.slug}
label={plan.name}
description={plan.description}
features={plan.features}
featured={plan.featured}
price={plan.price}
pricePeriod={plan.pricePeriod}
/>
))}
</Group>
);
}

View File

@@ -1,42 +1,30 @@
// @ts-nocheck
import React from 'react';
import { Field } from 'formik';
import { T } from '@/components';
import { T, SubscriptionPlans } from '@/components';
import { compose } from '@/utils';
import { SubscriptionPlans } from './SubscriptionPlan';
import withPlans from '../../Subscriptions/withPlans';
import { compose } from '@/utils';
import { Callout, Intent } from '@blueprintjs/core';
/**
* Billing plans.
*/
function SubscriptionPlansSection({ plans }) {
function SubscriptionPlansSectionRoot({ plans }) {
return (
<section class="billing-plans__section">
<h1 class="title">
<T id={'setup.plans.select_plan.title'} />
</h1>
<div class="description">
<p className="paragraph">
<T id={'setup.plans.select_plan.description'} />
</p>
</div>
<Field name={'plan_slug'}>
{({ form: { setFieldValue }, field: { value } }) => (
<SubscriptionPlans
value={value}
plans={plans}
onSelect={(value) => {
setFieldValue('plan_slug', value);
}}
/>
)}
</Field>
<section>
<Callout
style={{ marginBottom: '1.5rem' }}
icon={null}
title={'Early Adaptors Plan'}
>
We're looking for 200 early adaptors, when you subscribe you'll get
the full features and unlimited users for a year regardless of the subscribed plan.
</Callout>
<SubscriptionPlans plans={plans} />
</section>
);
}
export default compose(withPlans(({ plans }) => ({ plans })))(
SubscriptionPlansSection,
);
export const SubscriptionPlansSection = compose(
withPlans(({ plans }) => ({ plans })),
)(SubscriptionPlansSectionRoot);

View File

@@ -0,0 +1,3 @@
.items {
padding: 40px 40px 20px;
}

View File

@@ -1,28 +1,50 @@
// @ts-nocheck
import React from 'react';
import SetupSteps from './SetupSteps';
import WizardSetupSteps from './WizardSetupSteps';
import SetupSubscription from './SetupSubscription/SetupSubscription';
import SetupOrganizationPage from './SetupOrganizationPage';
import SetupInitializingForm from './SetupInitializingForm';
import SetupCongratsPage from './SetupCongratsPage';
import { Stepper } from '@/components/Stepper';
import styles from './SetupWizardContent.module.scss';
interface SetupWizardContentProps {
stepIndex: number;
stepId: string;
}
/**
* Setup wizard content.
*/
export default function SetupWizardContent({ setupStepIndex, setupStepId }) {
export default function SetupWizardContent({
stepIndex,
stepId,
}: SetupWizardContentProps) {
return (
<div class="setup-page__content">
<WizardSetupSteps currentStep={setupStepIndex} />
<Stepper
active={stepIndex}
classNames={{
content: styles.content,
items: styles.items,
}}
>
<Stepper.Step label={'Subscription'}>
<SetupSubscription />
</Stepper.Step>
<div class="setup-page-form">
<SetupSteps step={{ id: setupStepId }}>
<Stepper.Step label={'Organization'}>
<SetupOrganizationPage id="organization" />
</Stepper.Step>
<Stepper.Step label={'Initiializing'}>
<SetupInitializingForm id={'initializing'} />
</Stepper.Step>
<Stepper.Step label={'Congrats'}>
<SetupCongratsPage id="congrats" />
</SetupSteps>
</div>
</Stepper.Step>
</Stepper>
</div>
);
}

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

@@ -3,7 +3,7 @@ import { useQueryClient, useMutation } from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import { transformPagination } from '@/utils';
import useApiRequest from '../useRequest';
import { useRequestPdf } from '../utils';
import { useRequestPdf } from '../useRequestPdf';
import t from './types';
const commonInvalidateQueries = (queryClient) => {
@@ -354,7 +354,5 @@ export function useRefundCreditTransaction(id, props, requestProps) {
* Retrieve the credit note pdf document data,
*/
export function usePdfCreditNote(creditNoteId) {
return useRequestPdf({
url: `sales/credit_notes/${creditNoteId}`,
});
return useRequestPdf({ url: `sales/credit_notes/${creditNoteId}` });
}

View File

@@ -2,10 +2,9 @@
import { useQueryClient, useMutation } from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import useApiRequest from '../useRequest';
import { useRequestPdf } from '../utils';
import { transformPagination } from '@/utils';
import t from './types';
import { useRequestPdf } from '../useRequestPdf';
const commonInvalidateQueries = (queryClient) => {
// Invalidate estimates.

View File

@@ -3,7 +3,7 @@ import { useQueryClient, useMutation } from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import { transformPagination } from '@/utils';
import useApiRequest from '../useRequest';
import { useRequestPdf } from '../utils';
import { useRequestPdf } from '../useRequestPdf';
import t from './types';
// Common invalidate queries.

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

@@ -3,9 +3,9 @@ import { useMutation, useQueryClient } from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import useApiRequest from '../useRequest';
import { transformPagination, saveInvoke } from '@/utils';
import { useRequestPdf } from '../utils';
import t from './types';
import { useRequestPdf } from '../useRequestPdf';
// Common invalidate queries.
const commonInvalidateQueries = (client) => {

View File

@@ -1,9 +1,9 @@
// @ts-nocheck
import { useQueryClient, useMutation } from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import { useRequestPdf } from '../utils';
import useApiRequest from '../useRequest';
import { transformPagination } from '@/utils';
import { useRequestPdf } from '../useRequestPdf';
import t from './types';
const commonInvalidateQueries = (queryClient) => {
@@ -165,9 +165,7 @@ export function useReceipt(id, props) {
* @param {number} receiptId -
*/
export function usePdfReceipt(receiptId: number) {
return useRequestPdf({
url: `sales/receipts/${receiptId}`,
});
return useRequestPdf({ url: `sales/receipts/${receiptId}` });
}
export function useRefreshReceipts() {

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

@@ -4,7 +4,6 @@ export * from './usePrevious';
export * from './useUpdateEffect';
export * from './useWatch';
export * from './useWhen';
export * from './useRequestPdf';
export * from './useIntersectionObserver';
export * from './useAbilityContext';
export * from './useCustomCompareEffect';

View File

@@ -1,39 +0,0 @@
// @ts-nocheck
import React from 'react';
import useApiRequest from '../useRequest';
export const useRequestPdf = (url) => {
const apiRequest = useApiRequest();
const [isLoading, setIsLoading] = React.useState(false);
const [isLoaded, setIsLoaded] = React.useState(false);
const [pdfUrl, setPdfUrl] = React.useState('');
const [response, setResponse] = React.useState(null);
React.useEffect(() => {
setIsLoading(true);
apiRequest
.get(url, {
headers: { accept: 'application/pdf' },
responseType: 'blob',
})
.then((response) => {
// Create a Blob from the PDF Stream.
const file = new Blob([response.data], { type: 'application/pdf' });
// Build a URL from the file
const fileURL = URL.createObjectURL(file);
setPdfUrl(fileURL);
setIsLoading(false);
setIsLoaded(true);
setResponse(response);
});
}, []);
return {
isLoading,
isLoaded,
pdfUrl,
response,
};
};

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
interface Props extends React.SVGProps<SVGSVGElement> {
height: number;
width: number;
}
export const CheckCircled = ({ width, height, ...props }: Props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill="none"
{...props}
>
<g fill="#3FA40D" fillRule="evenodd" clipPath="url(#a)" clipRule="evenodd">
<path d="M9.21 3.915a.562.562 0 0 1 0 .795L5.647 8.272a.563.563 0 0 1-.795 0L2.978 6.397a.562.562 0 0 1 .796-.795L5.25 7.08l3.165-3.165a.563.563 0 0 1 .795 0Z" />
<path d="M6 10.875A4.875 4.875 0 0 0 10.875 6 4.87 4.87 0 0 0 6 1.125a4.875 4.875 0 1 0 0 9.75ZM6 12a6 6 0 0 0 6-6c0-3.314-2.678-6-6-6a6 6 0 0 0 0 12Z" />
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h12v12H0z" />
</clipPath>
</defs>
</svg>
);

View File

@@ -1279,27 +1279,6 @@
"setup.initializing.please_refresh_the_page": "يرجى تحديث الصفحة",
"setup.organization.title": "دعنا نبدأ",
"setup.organization.description": "أخبر النظام قليلاً عن مؤسستك.",
"plan.essential.title": "الاساسية",
"plan.plus.title": "الاضافية",
"plan.professional.title": "الاحترافية",
"plan.feature.sale_purchase_invoice": "فواتير البيع والشراء.",
"plan.feature.receivable_payable_accounts": "حسابات العملاء والموردين.",
"plan.feature.expenses_tracking": "تتبع المصروفات",
"plan.feature.manual_journal": "القيود اليدوية",
"plan.feature.financial_reports": "القوائم المالية",
"plan.feature.one_user_with_accountant": "لمستخدم واحد والمحاسب",
"plan.feature.all_capital_essential": "جميع مميزات الباقة الاساسية",
"plan.feature.multi_currency": "تعدد العملات",
"plan.feature.purchase_sell_orders": "أوامر الشراء والبيع.",
"plan.feature.multi_inventory_managment": "تعدد المخازن.",
"plan.feature.three_users": "ثلاثة مستخدمين مع المحاسب",
"plan.feature.advanced_financial_reports": "تقارير المالية المتقدمة",
"plan.feature.tracking_multi_locations": "تتبع الفروع والمواقع",
"plan.feture.all_capital_professional_features": "جميع مميزات الباقة الاحترافية",
"plan.feature.projects_accounting": "محاسبة المشاريع والجداول الزمنية",
"plan.feature.accounting_dimensions": "محاسبة ثلاثية الابعاد",
"plan.monthly": "شهريا",
"plan.yearly": "سنوياً",
"payment_via_voucher.success_message": "تم الدفع وتجديد واشتراكك بنجاح.",
"payment_via_voucher.license_code_not_valid": "رقم الرخصة غير صالح ، يرجي المحاولة مرة أخرى",
"payment_via_voucher.dialog.description": "الرجاء إدخال رقم الرخصة التي استلمتها عند تجديد او طلب الاشتراك .",

View File

@@ -1265,54 +1265,6 @@
"setup.initializing.please_refresh_the_page": "Please refresh the page",
"setup.organization.title": "Lets Get Started",
"setup.organization.description": "Tell the system a little bit about your organization.",
"plan.capital_basic.title": "Capital Basic",
"plan.feature.sales_invoices": "Sales Invoices.",
"plan.feature.sales_estimates": "Sales Estimates.",
"plan.feature.customers": "Customers.",
"plan.feature.credit_notes": "Credit notes.",
"plan.feature.manual_journals": "Manual Journals.",
"plan.feature.expenses_tracking": "Expenses Tracking",
"plan.feature.basic_financial_reports": "Basic Financial Reports.",
"plan.capital_plus.title": "Capital Plus",
"plan.feature.all_capital_basic": "All Capital Basic features.",
"plan.feature.predefined_user_roles": "Predefined User Roles.",
"plan.feature.custom_tables_views": "Custom Tables Views.",
"plan.feature.transactions_locking": "Transactions Locking.",
"plan.feature.plus_financial_reports": "Plus Financial Reports.",
"plan.feature.custom_fields_resources": "Custom Fields & Resources.",
"plan.essential.title": "Capital Essential",
"plan.feature.all_capital_plus": "All Capital Basic features.",
"plan.feature.sales_purchases_order": "Sales & Purchases Order.",
"plan.feature.purchase_invoices": "Purchase Invoices.",
"plan.feature.inventory_tracking": "Inventory Tracking.",
"plan.feature.custom_roles": "Custom Roles.",
"plan.feature.multiply_currency_transcations": "Multiply Currency Transcations.",
"plan.feature.inventory_reports": "Inventory Reports.",
"plan.feature.landed_cost": "Landed cost.",
"plan.capital_enterprise.title": "Capital Enterprise",
"plan.feature.all_capital_essential": "All Capital Essential features.",
"plan.feature.multiply_branches": "Multiply Branches.",
"plan.feature.multiply_warehouses": "Multiply Warehouses.",
"plan.feature.accounting_dimensions": "Accounting Dimensions.",
"plan.feature.warehouses_reports": "Warehouses Reports.",
"plan.feature.branches_reports": "Branches Reports.",
"plan.professional.title": "Pro",
"plan.plus.title": "Plus+",
"plan.feature.sale_purchase_invoice": "Sale and purchase invoices.",
"plan.feature.receivable_payable_accounts": "Customers/vendors accounts.",
"plan.feature.manual_journal": "Manual journals.",
"plan.feature.financial_reports": "Financial reports.",
"plan.feature.one_user_with_accountant": "For one user and accountant",
"plan.feture.all_capital_professional_features": "All Capital Pro features.",
"plan.feature.multi_currency": "Multi-currency.",
"plan.feature.purchase_sell_orders": "Purchase and sell orders.",
"plan.feature.multi_inventory_managment": "Mutli-inventory managment.",
"plan.feature.three_users": "Three users with your accountant",
"plan.feature.advanced_financial_reports": "Advanced financial reports",
"plan.feature.tracking_multi_locations": "Track multi-branches and locations",
"plan.feature.projects_accounting": "Projects accounting and timesheets",
"plan.monthly": "Monthly",
"plan.yearly": "Yearly",
"payment_via_voucher.success_message": "Payment has been done successfully.",
"payment_via_voucher.license_code_not_valid": "The license code is not valid, please try agin.",
"payment_via_voucher.dialog.description": "Pleasse enter your voucher number that you received from reseller.",

View File

@@ -1093,13 +1093,6 @@ export const getDashboardRoutes = () => [
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
// Subscription billing.
{
path: `/billing`,
component: lazy(() => import('@/containers/Subscriptions/BillingForm')),
breadcrumb: intl.get('new_billing'),
subscriptionInactive: [SUBSCRIPTION_TYPE.MAIN],
},
// Payment modes.
{
path: `/payment-mades/import`,

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' },
@@ -23,7 +26,7 @@ export default (mapState) => {
const mapped = {
...condits,
setupStepId: setupStep?.step,
setupStepIndex: scenarios.indexOf(setupStep) + 1,
setupStepIndex: scenarios.indexOf(setupStep),
};
return mapState ? mapState(mapped, state, props) : mapped;
};

View File

@@ -1,126 +1,55 @@
// @ts-nocheck
import { createReducer } from '@reduxjs/toolkit';
import intl from 'react-intl-universal';
import t from '@/store/types';
const getSubscriptionPeriods = () => [
{
slug: 'month',
label: intl.get('plan.monthly'),
},
{
slug: 'year',
label: intl.get('plan.yearly'),
},
];
const getSubscriptionPlans = () => [
{
name: intl.get('plan.capital_basic.title'),
name: 'Capital Basic',
slug: 'capital_basic',
description: [
intl.get('plan.feature.sales_invoices'),
intl.get('plan.feature.sales_estimates'),
intl.get('plan.feature.customers'),
intl.get('plan.feature.credit_notes'),
intl.get('plan.feature.manual_journals'),
intl.get('plan.feature.expenses_tracking'),
intl.get('plan.feature.basic_financial_reports'),
description: 'Good for service businesses that just started.',
features: [
'Sale Invoices and Estimates',
'Tracking Expenses',
'Customize Invoice',
'Manual Journals',
'Bank Reconciliation',
'Chart of Accounts',
'Taxes',
'Basic Financial Reports & Insights',
],
price: '55',
periods: [
{
slug: 'month',
label: intl.get('plan.monthly'),
price: '55',
},
{
slug: 'year',
label: intl.get('plan.yearly'),
price: '595',
},
],
currencyCode: 'LYD',
price: '$29',
pricePeriod: 'Per Year',
},
{
name: intl.get('plan.capital_plus.title'),
name: 'Capital Plus',
slug: 'capital_plus',
description: [
intl.get('plan.feature.all_capital_basic'),
intl.get('plan.feature.predefined_user_roles'),
intl.get('plan.feature.custom_tables_views'),
intl.get('plan.feature.transactions_locking'),
intl.get('plan.feature.plus_financial_reports'),
intl.get('plan.feature.custom_fields_resources'),
description:
'Good for businesses have inventory and want more financial reports.',
features: [
'All Capital Basic features',
'Manage Bills',
'Inventory Tracking',
'Multi Currencies',
'Predefined user roles.',
'Transactions locking.',
'Smart Financial Reports.',
],
price: '75',
periods: [
{
slug: 'month',
label: intl.get('plan.monthly'),
price: '75',
},
{
slug: 'year',
label: intl.get('plan.yearly'),
price: '795',
},
],
currencyCode: 'LYD',
price: '$29',
pricePeriod: 'Per Year',
featured: true,
},
{
name: intl.get('plan.essential.title'),
name: 'Capital Big',
slug: 'essentials',
description: [
intl.get('plan.feature.all_capital_plus'),
intl.get('plan.feature.sales_purchases_order'),
intl.get('plan.feature.purchase_invoices'),
intl.get('plan.feature.inventory_tracking'),
intl.get('plan.feature.custom_roles'),
intl.get('plan.feature.multiply_currency_transcations'),
intl.get('plan.feature.inventory_reports'),
intl.get('plan.feature.landed_cost'),
],
price: '95',
periods: [
{
slug: 'month',
label: intl.get('plan.monthly'),
price: '95',
},
{
slug: 'year',
label: intl.get('plan.yearly'),
price: '995',
},
],
currencyCode: 'LYD',
},
{
name: intl.get('plan.capital_enterprise.title'),
slug: 'enterprise',
description: [
intl.get('plan.feature.all_capital_essential'),
intl.get('plan.feature.multiply_branches'),
intl.get('plan.feature.multiply_warehouses'),
intl.get('plan.feature.accounting_dimensions'),
intl.get('plan.feature.warehouses_reports'),
intl.get('plan.feature.branches_reports'),
],
price: '120',
currencyCode: 'LYD',
periods: [
{
slug: 'month',
label: intl.get('plan.monthly'),
price: '120',
},
{
slug: 'year',
label: intl.get('plan.yearly'),
price: '1,195',
},
description: 'Good for businesses have multiple inventory or branches.',
features: [
'All Capital Plus features',
'Multiple Warehouses',
'Multiple Branches',
'Invite >= 15 Users',
],
price: '$29',
pricePeriod: 'Per Year',
},
];
@@ -135,9 +64,7 @@ export default createReducer(initialState, {
*/
[t.INIT_SUBSCRIPTION_PLANS]: (state) => {
const plans = getSubscriptionPlans();
const periods = getSubscriptionPeriods();
state.plans = plans;
state.periods = periods;
},
});

View File

@@ -286,4 +286,10 @@ html[lang^='ar'] {
span.table-tooltip-overview-target {
display: inline;
}
.bp4-callout .bp4-heading:first-child {
font-size: 14px;
margin-bottom: 5px;
font-weight: 600;
}

View File

@@ -16,7 +16,7 @@
max-width: 600px;
min-width: 600px;
@media only screen and (max-width: 1200px) {
@media only screen and (max-width: 1500px) {
min-width: 500px;
max-width: 500px;
}
@@ -54,7 +54,7 @@
top: 0;
width: 600px;
@media only screen and (max-width: 1200px) {
@media only screen and (max-width: 1500px) {
width: 500px;
}
@media only screen and (max-width: 1024px) {
@@ -99,6 +99,7 @@
&__organization {
font-size: 16px;
opacity: 0.75;
margin-top: 2.4rem;
span>a {
text-decoration: underline;
@@ -108,17 +109,8 @@
}
}
&__divider {
height: 1px;
width: 60%;
background: rgba(255, 255, 255, 0.25);
margin: 18px 0;
}
&__footer {
margin-top: auto;
border-top: 1px solid rgba(255, 255, 255, 0.25);
padding-top: 20px;
}
&__links {

View File

@@ -1,6 +1,5 @@
.setup-subscription-form{
margin: 0 auto;
padding: 0 80px;
margin-top: 40px;
padding: 0 40px;
}

View File

@@ -1,73 +0,0 @@
.billing-plans{
max-width: 753px;
.paragraph{
font-size: 15px;
}
&__section{
margin-bottom: 40px;
.title{
font-size: 20px;
font-weight: 600;
color: #6b7382;
margin-top: 0;
margin-bottom: 12px;
}
.bp4-tab-list {
border-bottom: 2px solid #e6e6e6;
width: 95%;
.bp4-tab-indicator-wrapper .bp4-tab-indicator{
bottom: -2px;
}
}
.bp4-tab-panel{
margin-top: 26px;
}
.subscribe-button {
.bp4-button {
background-color: #0063ff;
min-height: 41px;
width: 240px;
}
}
.plan-radios,
.plan-periods{
margin-top: 20px;
}
}
.license-container {
.bp4-button{
margin-top: 14px;
padding: 0 30px;
}
.form-group-license_code{
margin-top: 20px;
}
.bp4-form-content {
.bp4-input-group {
display: block;
position: relative;
}
.bp4-input {
position: relative;
width: 59%;
height: 41px;
}
}
h4 {
font-size: 18px;
font-weight: 400;
color: #444444;
}
p {
margin-top: 15px;
font-size: 14px;
}
}
}

View File

@@ -1,43 +0,0 @@
// Plan period radio component.
// ---------------------
.period-radios{
display: flex;
}
.period-radio{
display: inline-flex;
background-color: #fcfdff;
justify-content: space-between;
align-items: center;
width: 240px;
height: 36px;
border-radius: 5px;
padding: 8px 10px;
color: #000;
border: 1px solid #dcdcdc;
cursor: pointer;
text-decoration: none;
&.is-selected {
border: 1px solid #0069ff;
background-color: #fcfdff;
}
&:not(:first-child) {
margin-left: 20px;
}
&__amount{
font-weight: 600;
}
&__period{
color: #2f3863;
font-size: 14px;
font-weight: 500;
&::before {
content: '/';
display: inline-block;
margin: 0 2px;
}
}
}

Some files were not shown because too many files have changed in this diff Show More