Compare commits
48 Commits
clean-up-t
...
backup-scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
341bcbea7d | ||
|
|
b7214044bb | ||
|
|
93cb3615c3 | ||
|
|
7abfa6a162 | ||
|
|
1372a1f0a8 | ||
|
|
484024ec28 | ||
|
|
46639c7b86 | ||
|
|
d10d1654c1 | ||
|
|
2f06070ecb | ||
|
|
deefdb9bfd | ||
|
|
3cc62d80de | ||
|
|
4962c5d4d3 | ||
|
|
571a332658 | ||
|
|
b44c318a5d | ||
|
|
bd9717f4dc | ||
|
|
f48aea8e5a | ||
|
|
0ac3a5dea9 | ||
|
|
56b40ad4cb | ||
|
|
9b6f934990 | ||
|
|
80e3522f8a | ||
|
|
7975643765 | ||
|
|
2ac7f86bdb | ||
|
|
956b9b6812 | ||
|
|
60248ec3f6 | ||
|
|
9d3f1541eb | ||
|
|
9b5f1a36ab | ||
|
|
8ee691e1ed | ||
|
|
f9cb14da9e | ||
|
|
5e87581f4e | ||
|
|
8ca9cf39da | ||
|
|
9001fea524 | ||
|
|
dea0d71732 | ||
|
|
c191c4bd26 | ||
|
|
47d82ce591 | ||
|
|
9321db2a3a | ||
|
|
e486333c96 | ||
|
|
a9748b23c0 | ||
|
|
693ae61141 | ||
|
|
9807ac04b0 | ||
|
|
bddfde4138 | ||
|
|
a39dcd00d5 | ||
|
|
4d616e9287 | ||
|
|
dc52fb1de5 | ||
|
|
21a1777424 | ||
|
|
16b721db91 | ||
|
|
079491823d | ||
|
|
f7a87a6e9c | ||
|
|
2baa667c5d |
@@ -105,6 +105,24 @@
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "benpsnyder",
|
||||
"name": "Ben Snyder",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/707567?v=4",
|
||||
"profile": "https://snyder.tech",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "cloudsbird",
|
||||
"name": "Vederis Leunardus",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/13505006?v=4",
|
||||
"profile": "http://vederis.id",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -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=
|
||||
|
||||
89
.github/workflows/build-deploy-container.yml
vendored
89
.github/workflows/build-deploy-container.yml
vendored
@@ -12,20 +12,37 @@ env:
|
||||
|
||||
jobs:
|
||||
build-publish-webapp:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
name: Build and deploy webapp container
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login to Container registry.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GH_TOKEN }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
@@ -35,14 +52,29 @@ jobs:
|
||||
|
||||
# Builds and push the Docker image.
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: ./packages/webapp/Dockerfile
|
||||
push: true
|
||||
tags: ghcr.io/bigcapitalhq/webapp:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
context: .
|
||||
file: ./packages/webapp/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ghcr.io/bigcapitalhq/webapp:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-main-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
# Send notification to Slack channel.
|
||||
- name: Slack Notification built and published webapp container successfully.
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
@@ -53,12 +85,23 @@ jobs:
|
||||
name: Build and deploy server container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login to Container registry.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -66,14 +109,30 @@ jobs:
|
||||
|
||||
# Builds and push the Docker image.
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: ./
|
||||
file: ./packages/server/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ghcr.io/bigcapitalhq/server:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-main-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Send notification to Slack channel.
|
||||
- name: Slack Notification built and published server container successfully.
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
|
||||
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn commitlint --edit
|
||||
pnpx commitlint --edit
|
||||
|
||||
@@ -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,8 @@ 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>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://vederis.id"><img src="https://avatars.githubusercontent.com/u/13505006?v=4?s=100" width="100px;" alt="Vederis Leunardus"/><br /><sub><b>Vederis Leunardus</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=cloudsbird" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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,22 @@ services:
|
||||
- GOTENBERG_URL=${GOTENBERG_URL}
|
||||
- GOTENBERG_DOCS_URL=${GOTENBERG_DOCS_URL}
|
||||
|
||||
# Bank Sync
|
||||
- BANKING_CONNECT=${BANKING_CONNECT}
|
||||
|
||||
# Plaid
|
||||
- PLAID_ENV=${PLAID_ENV}
|
||||
- PLAID_CLIENT_ID=${PLAID_CLIENT_ID}
|
||||
- PLAID_SECRET_DEVELOPMENT=${PLAID_SECRET_DEVELOPMENT}
|
||||
- PLAID_SECRET_SANDBOX=${b8cf42b441e110451e2f69ad7e1e9f}
|
||||
- PLAID_LINK_WEBHOOK=${PLAID_LINK_WEBHOOK}
|
||||
|
||||
# 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 +121,7 @@ services:
|
||||
|
||||
mysql:
|
||||
container_name: bigcapital-mysql
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: unless-stopped
|
||||
restart: on-failure
|
||||
build:
|
||||
context: ./docker/mariadb
|
||||
environment:
|
||||
@@ -128,9 +136,7 @@ services:
|
||||
|
||||
mongo:
|
||||
container_name: bigcapital-mongo
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: unless-stopped
|
||||
restart: on-failure
|
||||
build: ./docker/mongo
|
||||
expose:
|
||||
- '27017'
|
||||
@@ -139,9 +145,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: bigcapital-redis
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: unless-stopped
|
||||
restart: on-failure
|
||||
build:
|
||||
context: ./docker/redis
|
||||
expose:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "independent",
|
||||
"npmClient": "pnpm",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"]
|
||||
}
|
||||
"packages": [
|
||||
"packages/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
4
packages/server/.gitignore
vendored
4
packages/server/.gitignore
vendored
@@ -3,4 +3,6 @@
|
||||
stdout.log
|
||||
/dist
|
||||
/build
|
||||
/public/imports
|
||||
/public/imports
|
||||
|
||||
dist
|
||||
17747
packages/server/package-lock.json
generated
17747
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -8,10 +8,10 @@ export default class DashboardMetaController {
|
||||
dashboardService: DashboardService;
|
||||
|
||||
/**
|
||||
*
|
||||
* Constructor router.
|
||||
* @returns
|
||||
*/
|
||||
router() {
|
||||
public router() {
|
||||
const router = Router();
|
||||
|
||||
router.get('/boot', this.getDashboardBoot);
|
||||
@@ -25,7 +25,7 @@ export default class DashboardMetaController {
|
||||
* @param {Response} res -
|
||||
* @param {NextFunction} next -
|
||||
*/
|
||||
getDashboardBoot = async (
|
||||
private getDashboardBoot = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SubscriptionController';
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
29
packages/server/src/api/middleware/SubscriptionMiddleware.ts
Normal file
29
packages/server/src/api/middleware/SubscriptionMiddleware.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
@@ -180,6 +180,14 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Bank Synchronization.
|
||||
*/
|
||||
bankSync: {
|
||||
enabled: parseBoolean(defaultTo(process.env.BANKING_CONNECT, false), false),
|
||||
provider: 'plaid',
|
||||
},
|
||||
|
||||
/**
|
||||
* Plaid.
|
||||
*/
|
||||
@@ -190,6 +198,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
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
export default class NotAllowedChangeSubscriptionPlan {
|
||||
|
||||
constructor() {
|
||||
this.name = "NotAllowedChangeSubscriptionPlan";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum Features {
|
||||
WAREHOUSES = 'warehouses',
|
||||
BRANCHES = 'branches',
|
||||
BankSyncing = 'BankSyncing'
|
||||
}
|
||||
|
||||
export interface IFeatureAllItem {
|
||||
|
||||
@@ -62,13 +62,13 @@ export default class MetableStore implements IMetableStore {
|
||||
* @param {String} key -
|
||||
* @param {Mixied} defaultValue -
|
||||
*/
|
||||
get(query: string | IMetaQuery, defaultValue: any): any | false {
|
||||
get(query: string | IMetaQuery, defaultValue: any): any | null {
|
||||
const metadata = this.find(query);
|
||||
return metadata
|
||||
? metadata.value
|
||||
: typeof defaultValue !== 'undefined'
|
||||
? defaultValue
|
||||
: false;
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export default {
|
||||
relationModel: 'Item',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the item name or code."
|
||||
},
|
||||
rate: {
|
||||
name: 'Rate',
|
||||
|
||||
@@ -84,6 +84,7 @@ export default {
|
||||
name: 'bill_payment.field.payment_number',
|
||||
fieldType: 'text',
|
||||
unique: true,
|
||||
importHint: "The payment number should be unique."
|
||||
},
|
||||
paymentAccountId: {
|
||||
name: 'bill_payment.field.payment_account',
|
||||
@@ -91,6 +92,7 @@ export default {
|
||||
relationModel: 'Account',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the account name or code."
|
||||
},
|
||||
exchangeRate: {
|
||||
name: 'bill_payment.field.exchange_rate',
|
||||
@@ -118,6 +120,7 @@ export default {
|
||||
relationModel: 'Bill',
|
||||
relationImportMatch: 'billNumber',
|
||||
required: true,
|
||||
importHint: "Matches the bill number."
|
||||
},
|
||||
paymentAmount: {
|
||||
name: 'bill_payment.field.entries.payment_amount',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ export default {
|
||||
relationModel: 'Item',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: 'Matches the item name or code.',
|
||||
},
|
||||
rate: {
|
||||
name: 'Rate',
|
||||
|
||||
@@ -68,6 +68,7 @@ export default {
|
||||
relationModel: 'Account',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the account name or code."
|
||||
},
|
||||
referenceNo: {
|
||||
name: 'expense.field.reference_no',
|
||||
@@ -101,6 +102,7 @@ export default {
|
||||
relationModel: 'Account',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the account name or code."
|
||||
},
|
||||
amount: {
|
||||
name: 'expense.field.amount',
|
||||
|
||||
@@ -124,117 +124,82 @@ export default {
|
||||
fields2: {
|
||||
type: {
|
||||
name: 'item.field.type',
|
||||
column: 'type',
|
||||
fieldType: 'enumeration',
|
||||
options: [
|
||||
{ key: 'inventory', label: 'item.field.type.inventory' },
|
||||
{ key: 'service', label: 'item.field.type.service' },
|
||||
{ key: 'non-inventory', label: 'item.field.type.non-inventory' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
name: 'item.field.name',
|
||||
column: 'name',
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
},
|
||||
code: {
|
||||
name: 'item.field.code',
|
||||
column: 'code',
|
||||
fieldType: 'text',
|
||||
},
|
||||
sellable: {
|
||||
name: 'item.field.sellable',
|
||||
column: 'sellable',
|
||||
fieldType: 'boolean',
|
||||
},
|
||||
purchasable: {
|
||||
name: 'item.field.purchasable',
|
||||
column: 'purchasable',
|
||||
fieldType: 'boolean',
|
||||
},
|
||||
sell_price: {
|
||||
name: 'item.field.cost_price',
|
||||
column: 'sell_price',
|
||||
sellPrice: {
|
||||
name: 'item.field.sell_price',
|
||||
fieldType: 'number',
|
||||
},
|
||||
cost_price: {
|
||||
name: 'item.field.cost_price',
|
||||
fieldType: 'number',
|
||||
},
|
||||
costAccount: {
|
||||
name: 'item.field.cost_account',
|
||||
column: 'cost_price',
|
||||
fieldType: 'number',
|
||||
fieldType: 'relation',
|
||||
relationModel: 'Account',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
importHint: 'Matches the account name or code.',
|
||||
},
|
||||
cost_account: {
|
||||
sellAccount: {
|
||||
name: 'item.field.sell_account',
|
||||
column: 'cost_account_id',
|
||||
fieldType: 'relation',
|
||||
|
||||
relationType: 'enumeration',
|
||||
relationKey: 'costAccount',
|
||||
|
||||
relationEntityLabel: 'name',
|
||||
relationEntityKey: 'slug',
|
||||
relationModel: 'Account',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
importHint: 'Matches the account name or code.',
|
||||
},
|
||||
sell_account: {
|
||||
name: 'item.field.sell_description',
|
||||
column: 'sell_account_id',
|
||||
fieldType: 'relation',
|
||||
|
||||
relationType: 'enumeration',
|
||||
relationKey: 'sellAccount',
|
||||
|
||||
relationEntityLabel: 'name',
|
||||
relationEntityKey: 'slug',
|
||||
},
|
||||
inventory_account: {
|
||||
inventoryAccount: {
|
||||
name: 'item.field.inventory_account',
|
||||
column: 'inventory_account_id',
|
||||
|
||||
relationType: 'enumeration',
|
||||
relationKey: 'inventoryAccount',
|
||||
|
||||
relationEntityLabel: 'name',
|
||||
relationEntityKey: 'slug',
|
||||
fieldType: 'relation',
|
||||
relationModel: 'Account',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
importHint: 'Matches the account name or code.',
|
||||
},
|
||||
sell_description: {
|
||||
name: 'Sell description',
|
||||
column: 'sell_description',
|
||||
sellDescription: {
|
||||
name: 'Sell Description',
|
||||
fieldType: 'text',
|
||||
},
|
||||
purchase_description: {
|
||||
name: 'Purchase description',
|
||||
column: 'purchase_description',
|
||||
purchaseDescription: {
|
||||
name: 'Purchase Description',
|
||||
fieldType: 'text',
|
||||
},
|
||||
quantity_on_hand: {
|
||||
name: 'item.field.quantity_on_hand',
|
||||
column: 'quantity_on_hand',
|
||||
fieldType: 'number',
|
||||
},
|
||||
note: {
|
||||
name: 'item.field.note',
|
||||
column: 'note',
|
||||
fieldType: 'text',
|
||||
},
|
||||
category: {
|
||||
name: 'item.field.category',
|
||||
column: 'category_id',
|
||||
|
||||
relationType: 'enumeration',
|
||||
relationKey: 'category',
|
||||
|
||||
relationEntityLabel: 'name',
|
||||
relationEntityKey: 'id',
|
||||
fieldType: 'relation',
|
||||
relationModel: 'ItemCategory',
|
||||
relationImportMatch: ['name'],
|
||||
importHint: "Matches the category name."
|
||||
},
|
||||
active: {
|
||||
name: 'item.field.active',
|
||||
column: 'active',
|
||||
fieldType: 'boolean',
|
||||
filterable: false,
|
||||
},
|
||||
created_at: {
|
||||
name: 'item.field.created_at',
|
||||
column: 'created_at',
|
||||
columnType: 'date',
|
||||
fieldType: 'date',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -84,10 +84,12 @@ export default {
|
||||
relationModel: 'Account',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the account name or code."
|
||||
},
|
||||
paymentReceiveNo: {
|
||||
name: 'payment_receive.field.payment_receive_no',
|
||||
fieldType: 'text',
|
||||
importHint: "The payment number should be unique."
|
||||
},
|
||||
statement: {
|
||||
name: 'payment_receive.field.statement',
|
||||
@@ -106,6 +108,7 @@ export default {
|
||||
relationModel: 'SaleInvoice',
|
||||
relationImportMatch: 'invoiceNo',
|
||||
required: true,
|
||||
importHint: "Matches the invoice number."
|
||||
},
|
||||
paymentAmount: {
|
||||
name: 'payment_receive.field.entries.payment_amount',
|
||||
|
||||
@@ -132,6 +132,7 @@ export default {
|
||||
relationModel: 'Item',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the item name or code."
|
||||
},
|
||||
rate: {
|
||||
name: 'invoice.field.rate',
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
relationModel: 'Item',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the item name or code."
|
||||
},
|
||||
rate: {
|
||||
name: 'invoice.field.rate',
|
||||
|
||||
@@ -126,6 +126,7 @@ export default {
|
||||
relationModel: 'Item',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the item name or code."
|
||||
},
|
||||
rate: {
|
||||
name: 'invoice.field.rate',
|
||||
|
||||
@@ -53,23 +53,19 @@ export default {
|
||||
},
|
||||
payee: {
|
||||
name: 'Payee',
|
||||
column: 'payee',
|
||||
fieldType: 'text',
|
||||
},
|
||||
description: {
|
||||
name: 'Description',
|
||||
column: 'description',
|
||||
fieldType: 'text',
|
||||
},
|
||||
referenceNo: {
|
||||
name: 'Reference No.',
|
||||
column: 'reference_no',
|
||||
fieldType: 'text',
|
||||
},
|
||||
amount: {
|
||||
name: 'Amount',
|
||||
column: 'Amount',
|
||||
fieldType: 'numeric',
|
||||
fieldType: 'number',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -122,6 +122,7 @@ export default {
|
||||
relationModel: 'Item',
|
||||
relationImportMatch: ['name', 'code'],
|
||||
required: true,
|
||||
importHint: "Matches the item name or code."
|
||||
},
|
||||
rate: {
|
||||
name: 'Rate',
|
||||
|
||||
@@ -79,27 +79,25 @@ export interface ICashflowTransactionTypeMeta {
|
||||
}
|
||||
|
||||
export const BankTransactionsSampleData = [
|
||||
[
|
||||
{
|
||||
Amount: '6,410.19',
|
||||
Date: '2024-03-26',
|
||||
Payee: 'MacGyver and Sons',
|
||||
'Reference No.': 'REF-1',
|
||||
Description: 'Commodi quo labore.',
|
||||
},
|
||||
{
|
||||
Amount: '8,914.17',
|
||||
Date: '2024-01-05',
|
||||
Payee: 'Eichmann - Bergnaum',
|
||||
'Reference No.': 'REF-1',
|
||||
Description: 'Quia enim et.',
|
||||
},
|
||||
{
|
||||
Amount: '6,200.88',
|
||||
Date: '2024-02-17',
|
||||
Payee: 'Luettgen, Mraz and Legros',
|
||||
'Reference No.': 'REF-1',
|
||||
Description: 'Occaecati consequuntur cum impedit illo.',
|
||||
},
|
||||
],
|
||||
{
|
||||
Amount: '6,410.19',
|
||||
Date: '2024-03-26',
|
||||
Payee: 'MacGyver and Sons',
|
||||
'Reference No.': 'REF-1',
|
||||
Description: 'Commodi quo labore.',
|
||||
},
|
||||
{
|
||||
Amount: '8,914.17',
|
||||
Date: '2024-01-05',
|
||||
Payee: 'Eichmann - Bergnaum',
|
||||
'Reference No.': 'REF-1',
|
||||
Description: 'Quia enim et.',
|
||||
},
|
||||
{
|
||||
Amount: '6,200.88',
|
||||
Date: '2024-02-17',
|
||||
Payee: 'Luettgen, Mraz and Legros',
|
||||
'Reference No.': 'REF-1',
|
||||
Description: 'Occaecati consequuntur cum impedit illo.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ICreditNoteCreatedPayload,
|
||||
ICreditNoteCreatingPayload,
|
||||
ICreditNoteNewDTO,
|
||||
ISystemUser,
|
||||
} from '@/interfaces';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import ItemsEntriesService from '@/services/Items/ItemsEntriesService';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { defaultTo } from 'lodash';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { omit } from 'lodash';
|
||||
import { FeaturesSettingsDriver } from './FeaturesSettingsDriver';
|
||||
import { FeaturesConfigureManager } from './FeaturesConfigureManager';
|
||||
import { IFeatureAllItem } from '@/interfaces';
|
||||
|
||||
@Service()
|
||||
@@ -10,9 +7,6 @@ export class FeaturesManager {
|
||||
@Inject()
|
||||
private drive: FeaturesSettingsDriver;
|
||||
|
||||
@Inject()
|
||||
private configure: FeaturesConfigureManager;
|
||||
|
||||
/**
|
||||
* Turns-on the given feature name.
|
||||
* @param {number} tenantId
|
||||
@@ -40,35 +34,15 @@ export class FeaturesManager {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async accessible(tenantId: number, feature: string) {
|
||||
// Retrieves the feature default accessible value.
|
||||
const defaultValue = this.configure.getFeatureConfigure(
|
||||
feature,
|
||||
'defaultValue'
|
||||
);
|
||||
const isAccessible = await this.drive.accessible(tenantId, feature);
|
||||
|
||||
return defaultTo(isAccessible, defaultValue);
|
||||
return this.drive.accessible(tenantId, feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the all features and their accessible value and default value.
|
||||
* @param {number} tenantId
|
||||
* @returns
|
||||
* @returns {Promise<IFeatureAllItem[]>}
|
||||
*/
|
||||
public async all(tenantId: number): Promise<IFeatureAllItem[]> {
|
||||
const all = await this.drive.all(tenantId);
|
||||
|
||||
return all.map((feature: IFeatureAllItem) => {
|
||||
const defaultAccessible = this.configure.getFeatureConfigure(
|
||||
feature.name,
|
||||
'defaultValue'
|
||||
);
|
||||
const isAccessible = feature.isAccessible;
|
||||
|
||||
return {
|
||||
...feature,
|
||||
isAccessible: defaultTo(isAccessible, defaultAccessible),
|
||||
};
|
||||
});
|
||||
return this.drive.all(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ import { Service, Inject } from 'typedi';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { FeaturesConfigure } from './constants';
|
||||
import { IFeatureAllItem } from '@/interfaces';
|
||||
import { FeaturesConfigureManager } from './FeaturesConfigureManager';
|
||||
|
||||
@Service()
|
||||
export class FeaturesSettingsDriver {
|
||||
@Inject()
|
||||
tenancy: HasTenancyService;
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private configure: FeaturesConfigureManager;
|
||||
|
||||
/**
|
||||
* Turns-on the given feature name.
|
||||
@@ -41,7 +45,15 @@ export class FeaturesSettingsDriver {
|
||||
async accessible(tenantId: number, feature: string) {
|
||||
const settings = this.tenancy.settings(tenantId);
|
||||
|
||||
return !!settings.get({ group: 'features', key: feature });
|
||||
const defaultValue = this.configure.getFeatureConfigure(
|
||||
feature,
|
||||
'defaultValue'
|
||||
);
|
||||
const settingValue = settings.get(
|
||||
{ group: 'features', key: feature },
|
||||
defaultValue
|
||||
);
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Features, IFeatureConfiugration } from '@/interfaces';
|
||||
import config from '@/config';
|
||||
import { defaultTo } from 'lodash';
|
||||
|
||||
export const FeaturesConfigure: IFeatureConfiugration[] = [
|
||||
{
|
||||
@@ -9,4 +11,8 @@ export const FeaturesConfigure: IFeatureConfiugration[] = [
|
||||
name: Features.WAREHOUSES,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: Features.BankSyncing,
|
||||
defaultValue: defaultTo(config.bankSync.enabled, false),
|
||||
}
|
||||
];
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Inject, Service } from 'typedi';
|
||||
import { chain } from 'lodash';
|
||||
import { Knex } from 'knex';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { ERRORS, getSheetColumns, getUnmappedSheetColumns, readImportFile } from './_utils';
|
||||
import {
|
||||
ERRORS,
|
||||
getSheetColumns,
|
||||
getUnmappedSheetColumns,
|
||||
readImportFile,
|
||||
} from './_utils';
|
||||
import { ImportFileCommon } from './ImportFileCommon';
|
||||
import { ImportFileDataTransformer } from './ImportFileDataTransformer';
|
||||
import ResourceService from '../Resource/ResourceService';
|
||||
@@ -49,10 +54,9 @@ export class ImportFileProcess {
|
||||
const sheetData = this.importCommon.parseXlsxSheet(buffer);
|
||||
const header = getSheetColumns(sheetData);
|
||||
|
||||
const resourceFields = this.resource.getResourceFields2(
|
||||
tenantId,
|
||||
importFile.resource
|
||||
);
|
||||
const resource = importFile.resource;
|
||||
const resourceFields = this.resource.getResourceFields2(tenantId, resource);
|
||||
|
||||
// Runs the importing operation with ability to return errors that will happen.
|
||||
const [successedImport, failedImport, allData] =
|
||||
await this.uow.withTransaction(
|
||||
@@ -91,6 +95,7 @@ export class ImportFileProcess {
|
||||
const skippedCount = errorsCount;
|
||||
|
||||
return {
|
||||
resource,
|
||||
createdCount,
|
||||
skippedCount,
|
||||
totalCount,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ImportFileMapPOJO {
|
||||
}
|
||||
|
||||
export interface ImportFilePreviewPOJO {
|
||||
resource: string;
|
||||
createdCount: number;
|
||||
skippedCount: number;
|
||||
totalCount: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Knex } from 'knex';
|
||||
import { Importable } from '@/services/Import/Importable';
|
||||
import { IItemCreateDTO } from '@/interfaces';
|
||||
import { CreateItem } from './CreateItem';
|
||||
import { ItemsSampleData } from './constants';
|
||||
|
||||
@Service()
|
||||
export class ItemsImportable extends Importable {
|
||||
@@ -28,6 +29,6 @@ export class ItemsImportable extends Importable {
|
||||
* Retrieves the sample data of customers used to download sample sheet.
|
||||
*/
|
||||
public sampleData(): any[] {
|
||||
return [];
|
||||
return ItemsSampleData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
export const ERRORS = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND',
|
||||
@@ -19,7 +18,8 @@ export const ERRORS = {
|
||||
ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT:
|
||||
'ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT',
|
||||
ITEM_CANNOT_CHANGE_INVENTORY_TYPE: 'ITEM_CANNOT_CHANGE_INVENTORY_TYPE',
|
||||
TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS: 'TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS',
|
||||
TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS:
|
||||
'TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS',
|
||||
INVENTORY_ACCOUNT_CANNOT_MODIFIED: 'INVENTORY_ACCOUNT_CANNOT_MODIFIED',
|
||||
|
||||
ITEM_HAS_ASSOCIATED_TRANSACTIONS: 'ITEM_HAS_ASSOCIATED_TRANSACTIONS',
|
||||
@@ -53,8 +53,84 @@ export const DEFAULT_VIEWS = [
|
||||
slug: 'non-inventory',
|
||||
rolesLogicExpression: '1',
|
||||
roles: [
|
||||
{ index: 1, fieldKey: 'type', comparator: 'equals', value: 'non-inventory' },
|
||||
{
|
||||
index: 1,
|
||||
fieldKey: 'type',
|
||||
comparator: 'equals',
|
||||
value: 'non-inventory',
|
||||
},
|
||||
],
|
||||
columns: DEFAULT_VIEW_COLUMNS,
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export const ItemsSampleData = [
|
||||
{
|
||||
'Item Type': 'Inventory',
|
||||
'Item Name': 'Hettinger, Schumm and Bartoletti',
|
||||
'Item Code': '1000',
|
||||
Sellable: 'T',
|
||||
Purchasable: 'T',
|
||||
'Cost Price': '10000',
|
||||
'Sell Price': '1000',
|
||||
'Cost Account': 'Cost of Goods Sold',
|
||||
'Sell Account': 'Other Income',
|
||||
'Inventory Account': 'Inventory Asset',
|
||||
'Sell Description': 'Description ....',
|
||||
'Purchase Description': 'Description ....',
|
||||
Category: 'sdafasdfsadf',
|
||||
Note: 'At dolor est non tempore et quisquam.',
|
||||
Active: 'TRUE',
|
||||
},
|
||||
{
|
||||
'Item Type': 'Inventory',
|
||||
'Item Name': 'Schmitt Group',
|
||||
'Item Code': '1001',
|
||||
Sellable: 'T',
|
||||
Purchasable: 'T',
|
||||
'Cost Price': '10000',
|
||||
'Sell Price': '1000',
|
||||
'Cost Account': 'Cost of Goods Sold',
|
||||
'Sell Account': 'Other Income',
|
||||
'Inventory Account': 'Inventory Asset',
|
||||
'Sell Description': 'Description ....',
|
||||
'Purchase Description': 'Description ....',
|
||||
Category: 'sdafasdfsadf',
|
||||
Note: 'Id perspiciatis at adipisci minus accusamus dolor iure dolore.',
|
||||
Active: 'TRUE',
|
||||
},
|
||||
{
|
||||
'Item Type': 'Inventory',
|
||||
'Item Name': 'Marks - Carroll',
|
||||
'Item Code': '1002',
|
||||
Sellable: 'T',
|
||||
Purchasable: 'T',
|
||||
'Cost Price': '10000',
|
||||
'Sell Price': '1000',
|
||||
'Cost Account': 'Cost of Goods Sold',
|
||||
'Sell Account': 'Other Income',
|
||||
'Inventory Account': 'Inventory Asset',
|
||||
'Sell Description': 'Description ....',
|
||||
'Purchase Description': 'Description ....',
|
||||
Category: 'sdafasdfsadf',
|
||||
Note: 'Odio odio minus similique.',
|
||||
Active: 'TRUE',
|
||||
},
|
||||
{
|
||||
'Item Type': 'Inventory',
|
||||
'Item Name': 'VonRueden, Ruecker and Hettinger',
|
||||
'Item Code': '1003',
|
||||
Sellable: 'T',
|
||||
Purchasable: 'T',
|
||||
'Cost Price': '10000',
|
||||
'Sell Price': '1000',
|
||||
'Cost Account': 'Cost of Goods Sold',
|
||||
'Sell Account': 'Other Income',
|
||||
'Inventory Account': 'Inventory Asset',
|
||||
'Sell Description': 'Description ....',
|
||||
'Purchase Description': 'Description ....',
|
||||
Category: 'sdafasdfsadf',
|
||||
Note: 'Quibusdam dolores illo.',
|
||||
Active: 'TRUE',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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', 'early-adaptor');
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/server/src/services/Subscription/Subscription.ts
Normal file
52
packages/server/src/services/Subscription/Subscription.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
100
packages/server/src/services/Subscription/utils.ts
Normal file
100
packages/server/src/services/Subscription/utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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')
|
||||
};
|
||||
@@ -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')
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.seed.run({
|
||||
specific: 'seed_tenants_free_subscription.js',
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {};
|
||||
82
packages/server/src/system/models/Subscriptions/Plan.ts
Normal file
82
packages/server/src/system/models/Subscriptions/Plan.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
26
packages/server/src/system/seeds/seed_subscriptions_plans.js
Normal file
26
packages/server/src/system/seeds/seed_subscriptions_plans.js
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
1
packages/server/storage/.gitignore
vendored
1
packages/server/storage/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
*
|
||||
!pdf/
|
||||
!imports/
|
||||
!.gitignore
|
||||
2
packages/server/storage/imports/.gitignore
vendored
Normal file
2
packages/server/storage/imports/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
3
packages/webapp/.gitignore
vendored
3
packages/webapp/.gitignore
vendored
@@ -20,4 +20,5 @@
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
dist
|
||||
@@ -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>
|
||||
|
||||
@@ -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']};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
125
packages/webapp/src/components/PricingPlan/PricingPlan.tsx
Normal file
125
packages/webapp/src/components/PricingPlan/PricingPlan.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -5,5 +5,6 @@ export const Features = {
|
||||
Warehouses: 'warehouses',
|
||||
Branches: 'branches',
|
||||
ManualJournal: 'manualJournal',
|
||||
Projects:'Projects'
|
||||
Projects:'Projects',
|
||||
BankSyncing: 'BankSyncing',
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Can,
|
||||
Icon,
|
||||
FormattedMessage as T,
|
||||
FeatureCan,
|
||||
} from '@/components';
|
||||
import { useRefreshCashflowAccounts } from '@/hooks/query';
|
||||
import { CashflowAction, AbilitySubject } from '@/constants/abilityOption';
|
||||
@@ -21,7 +22,7 @@ import withCashflowAccountsTableActions from '../AccountTransactions/withCashflo
|
||||
|
||||
import { AccountDialogAction } from '@/containers/Dialogs/AccountDialog/utils';
|
||||
|
||||
import { ACCOUNT_TYPE } from '@/constants';
|
||||
import { ACCOUNT_TYPE, Features } from '@/constants';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
|
||||
import { compose } from '@/utils';
|
||||
@@ -110,12 +111,14 @@ function CashFlowAccountsActionsBar({
|
||||
</NavbarGroup>
|
||||
|
||||
<NavbarGroup align={Alignment.RIGHT}>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
text={'Connect to Bank / Credit Card'}
|
||||
onClick={handleConnectToBank}
|
||||
/>
|
||||
<FeatureCan feature={Features.BankSyncing}>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
text={'Connect to Bank / Credit Card'}
|
||||
onClick={handleConnectToBank}
|
||||
/>
|
||||
<NavbarDivider />
|
||||
</FeatureCan>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon="refresh-16" iconSize={14} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
.root{
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user