├── .env.example ├── .github └── workflows │ ├── lint-pr.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── docker-compose.yml ├── docs └── screenshot.png ├── package.json ├── packages ├── app │ ├── .gitignore │ ├── .npmrc │ ├── app.config.ts │ ├── components │ │ ├── DatePicker.vue │ │ ├── Menu.vue │ │ ├── MenuItem.vue │ │ └── status │ │ │ ├── Invoice.vue │ │ │ ├── Payment.vue │ │ │ └── Subscription.vue │ ├── composables │ │ ├── auth.ts │ │ ├── client.ts │ │ └── format.ts │ ├── layouts │ │ ├── ParticleAnimation.ts │ │ ├── auth.vue │ │ └── default.vue │ ├── middleware │ │ └── auth.global.ts │ ├── nuxt.config.ts │ ├── package.json │ ├── pages │ │ ├── auth │ │ │ └── login.vue │ │ ├── customers │ │ │ ├── [customerId] │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ ├── index.vue │ │ ├── invoices │ │ │ ├── [invoiceId] │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ ├── payments │ │ │ ├── [paymentId] │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ ├── project │ │ │ └── settings.vue │ │ └── subscriptions │ │ │ ├── [subscriptionId] │ │ │ └── index.vue │ │ │ └── index.vue │ ├── public │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── logo_dark.svg │ │ └── logo_light.svg │ ├── server │ │ ├── api │ │ │ ├── auth │ │ │ │ ├── login.post.ts │ │ │ │ └── logout.get.ts │ │ │ └── user.get.ts │ │ ├── tsconfig.json │ │ └── utils │ │ │ └── auth.ts │ └── tsconfig.json ├── client │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.eslint.json │ └── tsconfig.json └── server │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── cli │ ├── build.ts │ └── generate-swagger-spec.ts │ ├── package.json │ ├── public │ ├── images │ │ └── invoice-logo.svg │ └── styles │ │ └── invoice.css │ ├── src │ ├── api │ │ ├── endpoints │ │ │ ├── __snapshots__ │ │ │ │ └── invoice.test.ts.snap │ │ │ ├── customer.test.ts │ │ │ ├── customer.ts │ │ │ ├── index.ts │ │ │ ├── invoice.test.ts │ │ │ ├── invoice.ts │ │ │ ├── mocked_checkout.ts │ │ │ ├── payment.test.ts │ │ │ ├── payment.ts │ │ │ ├── payment_method.test.ts │ │ │ ├── payment_method.ts │ │ │ ├── project.ts │ │ │ ├── subscription.test.ts │ │ │ └── subscription.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── schema.ts │ │ └── types.ts │ ├── config.ts │ ├── database.ts │ ├── entities │ │ ├── __snapshots__ │ │ │ └── subscription.test.ts.snap │ │ ├── customer.ts │ │ ├── index.ts │ │ ├── invoice.ts │ │ ├── invoice_item.ts │ │ ├── payment.ts │ │ ├── payment_method.ts │ │ ├── project.ts │ │ ├── project_invoice_data.ts │ │ ├── subscription.ts │ │ ├── subscription_change.ts │ │ ├── subscription_period.test.ts │ │ └── subscription_period.ts │ ├── index.ts │ ├── lib │ │ ├── dayjs.ts │ │ ├── development_proxy.ts │ │ └── exit_hooks.ts │ ├── log.ts │ ├── loop.test.ts │ ├── loop.ts │ ├── mail │ │ ├── index.ts │ │ └── templates.ts │ ├── migrations │ │ ├── 000_alter_column_logo_to_text.ts │ │ ├── 001_replace_start_and_end_with_date_invoice.ts │ │ ├── 002_set_next_payment_for_subscriptions.ts │ │ ├── 003_update_payment_status.ts │ │ ├── 004_update_invoice_add_customer_make_subscription_optional.ts │ │ ├── 005_update_status_optional_invoice_subscription.ts │ │ └── 006_set_payment_project.ts │ ├── payment_providers │ │ ├── index.ts │ │ ├── mocked.ts │ │ ├── mollie.ts │ │ └── types.ts │ ├── utils.test.ts │ ├── utils.ts │ └── webhook.ts │ ├── templates │ ├── invoice.hbs │ └── mocked-checkout.hbs │ ├── test │ └── fixtures.ts │ ├── tsconfig.eslint.json │ ├── tsconfig.json │ └── vitest.config.ts ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_URL=leave-empty-if-using-ngrok 2 | NGROK_ENABLE=true 3 | NGROK_AUTH_TOKEN=normally-not-needed 4 | POSTGRES_URL=postgresql://postgres:pA_sw0rd@localhost:5432/postgres 5 | ADMIN_TOKEN=supersecret 6 | DATA_PATH= 7 | GOTENBERG_URL= 8 | JWT_SECRET= 9 | CREATE_PROJECT_DATA='{ "name": "TestProject", "mollieApiKey": "123", "paymentProvider": "mollie", "webhookUrl": "http://localhost:4000/payments/webhook", "invoiceData": { "email": "test@example.com", "name": "Company ABC", "addressLine1": "Diagon Alley 1337", "addressLine2": "string", "zipCode": "12345", "city": "London", "country": "Germany", "logo": "https://geprog.com/logo.svg" } }' 10 | PORT= -------------------------------------------------------------------------------- /.github/workflows/lint-pr.yml: -------------------------------------------------------------------------------- 1 | name: 'Lint PR' 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # https://github.com/amannn/action-semantic-pull-request/releases 15 | - uses: amannn/action-semantic-pull-request@v3.4.0 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - '*' 12 | 13 | env: 14 | DOCKERHUB_ORG: geprog 15 | IMAGE_NAME: gringotts 16 | 17 | jobs: 18 | server: 19 | name: Release server 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v2 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: '22' 31 | 32 | - name: Cache pnpm modules 33 | uses: actions/cache@v2 34 | with: 35 | path: ~/.pnpm-store 36 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 37 | restore-keys: | 38 | ${{ runner.os }}- 39 | 40 | - uses: pnpm/action-setup@v2 41 | with: 42 | version: 9 43 | run_install: true 44 | 45 | - name: Build client 46 | working-directory: packages/client/ 47 | run: pnpm build 48 | 49 | - name: Build server 50 | working-directory: packages/server/ 51 | run: pnpm build 52 | 53 | # install app again to respect shamefully hoisted dependencies 54 | - name: Build app 55 | working-directory: packages/app/ 56 | run: | 57 | pnpm install 58 | pnpm build 59 | 60 | - name: Build the Docker image 61 | run: docker build --tag $IMAGE_NAME:latest --cache-from ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:latest . 62 | 63 | - name: Log in to Github registry 64 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin 65 | if: github.event_name != 'pull_request' 66 | 67 | - name: Push image to Github registry (latest & tag) 68 | run: | 69 | docker tag $IMAGE_NAME:latest ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:latest 70 | docker push ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:latest 71 | docker tag $IMAGE_NAME:latest ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:${GITHUB_REF#refs/*/} 72 | docker push ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:${GITHUB_REF#refs/*/} 73 | if: "github.ref_type == 'tag'" 74 | 75 | - name: Push image to Github registry (next) 76 | run: | 77 | docker tag $IMAGE_NAME:latest ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:next 78 | docker push ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:next 79 | if: github.event_name != 'pull_request' && github.ref_type != 'tag' 80 | 81 | client: 82 | name: Release client 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: Checkout 86 | uses: actions/checkout@v2 87 | with: 88 | fetch-depth: 0 89 | 90 | - name: Setup git user 91 | run: | 92 | git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)" 93 | git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)" 94 | 95 | - name: Setup Node.js 96 | uses: actions/setup-node@v3 97 | with: 98 | node-version: '22' 99 | registry-url: 'https://registry.npmjs.org' 100 | 101 | - name: Cache pnpm modules 102 | uses: actions/cache@v2 103 | with: 104 | path: ~/.pnpm-store 105 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 106 | restore-keys: | 107 | ${{ runner.os }}- 108 | 109 | - uses: pnpm/action-setup@v2 110 | with: 111 | version: 9 112 | run_install: true 113 | 114 | - name: Build 115 | working-directory: packages/client/ 116 | run: pnpm build 117 | 118 | - name: Generate api docs 119 | working-directory: packages/client/ 120 | run: pnpm run generate:docs 121 | 122 | - name: Set version 123 | working-directory: packages/client/ 124 | run: pnpm version ${GITHUB_REF#refs/*/} --no-commit-hooks --no-git-tag-version 125 | if: "github.ref_type == 'tag'" 126 | 127 | - name: Release 128 | working-directory: packages/client/ 129 | run: pnpm publish --no-git-check --access public 130 | env: 131 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 132 | if: "github.ref_type == 'tag'" 133 | 134 | - name: Deploy 🚀 135 | uses: JamesIves/github-pages-deploy-action@v4.2.3 136 | with: 137 | branch: gh-pages 138 | folder: packages/client/docs/ 139 | if: "github.ref_type == 'tag'" 140 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | unit-tests: 12 | name: Unit tests 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: '22' 24 | 25 | - name: Cache pnpm modules 26 | uses: actions/cache@v2 27 | with: 28 | path: ~/.pnpm-store 29 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 30 | restore-keys: | 31 | ${{ runner.os }}- 32 | 33 | - uses: pnpm/action-setup@v2 34 | with: 35 | version: 9 36 | run_install: true 37 | 38 | - name: Unit test 39 | run: pnpm run -r test 40 | 41 | typecheck: 42 | name: Typecheck 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v2 47 | with: 48 | fetch-depth: 0 49 | 50 | - name: Setup Node.js 51 | uses: actions/setup-node@v2 52 | with: 53 | node-version: '22' 54 | 55 | - name: Cache pnpm modules 56 | uses: actions/cache@v2 57 | with: 58 | path: ~/.pnpm-store 59 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 60 | restore-keys: | 61 | ${{ runner.os }}- 62 | 63 | - uses: pnpm/action-setup@v2 64 | with: 65 | version: 9 66 | run_install: true 67 | 68 | - name: Build client 69 | working-directory: packages/client/ 70 | run: pnpm build 71 | 72 | # install app again to respect shamefully hoisted dependencies 73 | - name: Install app 74 | working-directory: packages/app/ 75 | run: pnpm install 76 | 77 | - name: Typecheck 78 | run: pnpm run -r typecheck 79 | 80 | lint: 81 | name: Lint 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: Checkout 85 | uses: actions/checkout@v2 86 | with: 87 | fetch-depth: 0 88 | 89 | - name: Setup Node.js 90 | uses: actions/setup-node@v2 91 | with: 92 | node-version: '22' 93 | 94 | - name: Cache pnpm modules 95 | uses: actions/cache@v2 96 | with: 97 | path: ~/.pnpm-store 98 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 99 | restore-keys: | 100 | ${{ runner.os }}- 101 | 102 | - uses: pnpm/action-setup@v2 103 | with: 104 | version: 9 105 | run_install: true 106 | 107 | # needed to prepare the auto-generated files for the client package 108 | - name: Build client 109 | working-directory: packages/client/ 110 | run: pnpm build 111 | 112 | - name: Lint 113 | run: pnpm run -r lint 114 | 115 | check-format: 116 | name: Check format 117 | runs-on: ubuntu-latest 118 | steps: 119 | - name: Checkout 120 | uses: actions/checkout@v2 121 | with: 122 | fetch-depth: 0 123 | 124 | - name: Setup Node.js 125 | uses: actions/setup-node@v2 126 | with: 127 | node-version: '22' 128 | 129 | - name: Cache pnpm modules 130 | uses: actions/cache@v2 131 | with: 132 | path: ~/.pnpm-store 133 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 134 | restore-keys: | 135 | ${{ runner.os }}- 136 | 137 | - uses: pnpm/action-setup@v2 138 | with: 139 | version: 9 140 | run_install: true 141 | 142 | - name: Check format 143 | run: pnpm run format:check 144 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ 3 | dist/ 4 | coverage/ 5 | junit.xml 6 | *.tgz 7 | report.json 8 | 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | report.json 4 | pnpm-lock.yaml 5 | packages/client/src/api.ts 6 | packages/client/docs/ 7 | packages/server/swagger.json 8 | packages/server/templates/invoice.hbs 9 | .nuxt/ 10 | .output/ 11 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | endOfLine: 'lf', 8 | }; 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine as overmind 2 | WORKDIR /app 3 | RUN apk add --update curl gzip 4 | RUN curl https://github.com/DarthSim/overmind/releases/download/v2.4.0/overmind-v2.4.0-linux-amd64.gz -L -o overmind.gz 5 | RUN gunzip overmind.gz 6 | RUN chmod +x overmind 7 | 8 | FROM node:18-alpine 9 | ENV NODE_ENV=production 10 | ENV DATA_PATH=/app/data 11 | RUN apk --no-cache add ca-certificates tmux 12 | EXPOSE 7171 13 | WORKDIR /app 14 | CMD ["overmind", "start"] 15 | COPY Procfile . 16 | COPY --from=overmind /app/overmind /bin/overmind 17 | 18 | # server 19 | COPY ./packages/server/dist/ . 20 | COPY ./packages/server/public/ ./public 21 | COPY ./packages/server/templates/ ./templates 22 | 23 | # TODO: used to suppress warning remove after fixed 24 | RUN mkdir -p /static 25 | 26 | # app 27 | ENV NUXT_PUBLIC_API_CLIENT_BASE_URL=/api 28 | COPY ./packages/app/.output .output 29 | 30 | RUN chown -R node:node /app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 GEPROG GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | nuxt: PORT=3000 node .output/server/index.mjs 2 | api: PORT=7171 node --enable-source-maps index.js 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gringotts (:warning: BETA STATE :warning:) 2 | 3 | [![npm version](https://img.shields.io/npm/v/@geprog/gringotts-client)](https://www.npmjs.com/package/@geprog/gringotts-client) 4 | [![docker image Version (latest by date)](https://img.shields.io/docker/v/geprog/gringotts?label=docker)](https://github.com/geprog/gringotts-payments/pkgs/container/gringotts) 5 | 6 | Gringotts is a service which can be used as gateway between your payment provider and for example your SAAS application. It allows you to easily handle customers, subscriptions and invoice generation for your users. 7 | 8 | ![Gringotts](./docs/screenshot.png) 9 | 10 | ## Some (current) opinions 11 | 12 | - Subscriptions are always monthly 13 | - Subscription-periods start at the first day the customer started the subscription (exp. 2021-01-15 to 2021-02-14, 2021-02-15 to 2021-03-14, ...) 14 | - Invoices are generated at the end of the subscription-period (exp. 2021-01-15 to 2021-02-14 => invoice is generated on 2021-02-14) 15 | 16 | ## Usage 17 | 18 | ### Container image 19 | 20 | The container image can be found at `ghcr.io/geprog/gringotts`. 21 | 22 | ### Environment Variables 23 | 24 | | Name | Description | Default | 25 | | ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------- | 26 | | PORT | Port on which the server should listen | 7171 | 27 | | PUBLIC_URL | Url on which the server is reachable | http://localhost:7171 | 28 | | POSTGRES_URL | Url to the postgres database | postgres://postgres:postgres@localhost:5432/gringotts | 29 | | ADMIN_TOKEN | Token which is used to authenticate admin endpoints like project | | 30 | | CREATE_PROJECT_DATA | Json string which is used to create the first project | | 31 | | DATA_PATH | Path to the data directory | Local: ./data Container: /app/data | 32 | | GOTENBERG_URL | Url to the gotenberg server | http://localhost:3000 | 33 | | JWT_SECRET | Secret used to sign jwt tokens | supersecret | 34 | | MAIL_FROM | From address for emails `My Project | | 35 | | MAIL_HOST | Hostname of the smtp server | | 36 | | MAIL_PORT | Port of the smtp server | 25 | 37 | | MAIL_USERNAME | Username for the smtp server | | 38 | | MAIL_PASSWORD | Password for the smtp server | | 39 | | MAIL_SECURE | Use secure connection for the smtp server | false | 40 | | MAIL_REQUIRE_TLS | Require tls for the smtp server | false | 41 | 42 | To create a project on start you can set the `CREATE_PROJECT_DATA` environment variable to a json string like this: 43 | 44 | `CREATE_PROJECT_DATA='{ "name": "TestProject", "mollieApiKey": "123", "paymentProvider": "mollie", "webhookUrl": "http://localhost:4000/payments/webhook", "currency": "EUR", "vatRate": 19.0, "invoiceData": { "email": "test@example.com", "name": "Company ABC", "addressLine1": "Diagon Alley 1337", "addressLine2": "string", "zipCode": "12345", "city": "London", "country": "Germany", "logo": "data:image/svg+xml;base64,...." } }'` 45 | 46 | > Keep in mind that this wont be updated later if you change the environment variables after the first start. 47 | 48 | ### OpenApi Documention 49 | 50 | The OpenApi documentation can be found at `https:///docs` like 51 | 52 | ## Development 53 | 54 | ### Setup 55 | 56 | ```bash 57 | docker-compose up -d 58 | 59 | # install dependencies 60 | pnpm i 61 | 62 | # copy .env.example to .env and adjust the values 63 | cp .env.example .env 64 | 65 | # start the server 66 | pnpm start 67 | ``` 68 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | # app: 5 | # build: . 6 | # image: ghcr.io/geprog/gringotts:latest 7 | # ports: 8 | # - 7171:7171 9 | # env_file: .env 10 | # environment: 11 | # POSTGRES_URL: postgresql://postgres:pA_sw0rd@postgres:5432/postgres 12 | 13 | postgres: 14 | image: postgres:14.2 15 | ports: 16 | - 5432:5432 17 | volumes: 18 | - postgres-data:/var/lib/postgresql/data 19 | environment: 20 | POSTGRES_USER: postgres 21 | POSTGRES_PASSWORD: 'pA_sw0rd' 22 | 23 | gotenberg: 24 | image: gotenberg/gotenberg:7 25 | ports: 26 | - 3030:3000 27 | 28 | mail: 29 | image: mailhog/mailhog 30 | ports: 31 | - '127.0.0.1:1025:1025' 32 | - '127.0.0.1:8025:8025' 33 | - '[::1]:1025:1025' 34 | - '[::1]:8025:8025' 35 | 36 | volumes: 37 | postgres-data: 38 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geprog/gringotts/a52270b02053c6f516028a47461291b110b24622/docs/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@geprog/gringotts", 3 | "version": "0.0.0-ci-release", 4 | "description": "A payment gateway", 5 | "homepage": "https://geprog.com", 6 | "author": "GEPROG GmbH", 7 | "license": "MIT", 8 | "repository": "github:geprog/gringotts", 9 | "scripts": { 10 | "format:check": "prettier --check .", 11 | "format:fix": "prettier --write .", 12 | "start": "pnpm --parallel start" 13 | }, 14 | "devDependencies": { 15 | "prettier": "^2.5.1" 16 | }, 17 | "engines": { 18 | "node": ">=16", 19 | "pnpm": "9" 20 | }, 21 | "pnpm": { 22 | "overrides": { 23 | "ufo": "^1.3.1", 24 | "nuxt": "3.7.4" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .nuxt 4 | .nitro 5 | .cache 6 | dist 7 | 8 | # Node dependencies 9 | node_modules 10 | 11 | # Logs 12 | logs 13 | *.log 14 | 15 | # Misc 16 | .DS_Store 17 | .fleet 18 | .idea 19 | 20 | # Local env files 21 | .env 22 | .env.* 23 | !.env.example 24 | -------------------------------------------------------------------------------- /packages/app/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /packages/app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: 'zinc', 4 | input: { 5 | default: { 6 | size: 'lg', 7 | }, 8 | }, 9 | select: { 10 | default: { 11 | size: 'lg', 12 | }, 13 | }, 14 | selectMenu: { 15 | default: { 16 | size: 'lg', 17 | }, 18 | }, 19 | button: { 20 | default: { 21 | size: 'lg', 22 | }, 23 | }, 24 | card: { 25 | background: 'bg-white dark:bg-zinc-900', 26 | ring: 'ring-1 ring-zinc-200 dark:ring-zinc-800', 27 | divide: 'divide-y divide-zinc-200 dark:divide-zinc-800', 28 | }, 29 | popover: { 30 | background: 'bg-white dark:bg-zinc-900', 31 | ring: 'ring-1 ring-zinc-200 dark:ring-zinc-800', 32 | }, 33 | table: { 34 | divide: 'divide-y divide-zinc-300 dark:divide-zinc-700', 35 | tbody: 'divide-y divide-zinc-200 dark:divide-zinc-800', 36 | tr: { 37 | selected: 'bg-zinc-50 dark:bg-zinc-800/50', 38 | active: 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50 cursor-pointer', 39 | }, 40 | th: { 41 | color: 'text-zinc-900 dark:text-white', 42 | }, 43 | td: { 44 | color: 'text-zinc-500 dark:text-zinc-400', 45 | }, 46 | loadingState: { 47 | label: 'text-sm text-center text-zinc-900 dark:text-white', 48 | icon: 'w-6 h-6 mx-auto text-zinc-400 dark:text-zinc-500 mb-4 animate-spin', 49 | }, 50 | emptyState: { 51 | label: 'text-sm text-center text-zinc-900 dark:text-white', 52 | icon: 'w-6 h-6 mx-auto text-zinc-400 dark:text-zinc-500 mb-4', 53 | }, 54 | }, 55 | dropdown: { 56 | background: 'bg-white dark:bg-zinc-800', 57 | ring: 'ring-1 ring-zinc-200 dark:ring-zinc-700', 58 | divide: 'divide-y divide-zinc-200 dark:divide-zinc-700', 59 | item: { 60 | active: 'bg-zinc-100 dark:bg-zinc-900 text-zinc-900 dark:text-white', 61 | inactive: 'text-zinc-700 dark:text-zinc-200', 62 | icon: { 63 | active: 'text-zinc-500 dark:text-zinc-400', 64 | inactive: 'text-zinc-400 dark:text-zinc-500', 65 | }, 66 | }, 67 | }, 68 | notification: { 69 | background: 'bg-white dark:bg-zinc-900', 70 | ring: 'ring-1 ring-zinc-200 dark:ring-zinc-800', 71 | }, 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /packages/app/components/DatePicker.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 84 | -------------------------------------------------------------------------------- /packages/app/components/Menu.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 108 | -------------------------------------------------------------------------------- /packages/app/components/MenuItem.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /packages/app/components/status/Invoice.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /packages/app/components/status/Payment.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /packages/app/components/status/Subscription.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /packages/app/composables/auth.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | 3 | export const useAuth = defineStore('auth', () => { 4 | const { data: user, refresh: updateSession } = useFetch('/api/user'); 5 | 6 | const isAuthenticated = computed(() => !!user.value?.token); 7 | 8 | function login() { 9 | window.location.href = `/api/auth/login`; 10 | } 11 | 12 | function logout() { 13 | window.location.href = '/api/auth/logout'; 14 | } 15 | 16 | const loaded = ref(false); 17 | async function load() { 18 | if (loaded.value) { 19 | return; 20 | } 21 | 22 | await updateSession(); 23 | 24 | loaded.value = true; 25 | } 26 | 27 | return { 28 | load, 29 | isAuthenticated, 30 | user, 31 | login, 32 | logout, 33 | updateSession, 34 | }; 35 | }); 36 | -------------------------------------------------------------------------------- /packages/app/composables/client.ts: -------------------------------------------------------------------------------- 1 | import { gringottsClient } from '@geprog/gringotts-client'; 2 | 3 | export async function useGringottsClient() { 4 | const auth = useAuth(); 5 | await auth.load(); 6 | const user = auth.user; 7 | 8 | if (!user) { 9 | throw new Error('user is required'); 10 | } 11 | 12 | const config = useRuntimeConfig(); 13 | const url = process.client ? config.public.api.clientBaseUrl : config.public.api.baseUrl; 14 | return gringottsClient(url, { 15 | token: user.token, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/composables/format.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export function formatTime(date: Date) { 4 | return dayjs(date).format('HH:mm'); 5 | } 6 | 7 | export function formatDate(date: Date) { 8 | return dayjs(date).format('DD.MM.YYYY'); 9 | } 10 | 11 | export function formatDateTime(date: Date) { 12 | return dayjs(date).format('DD.MM.YYYY HH:mm'); 13 | } 14 | 15 | export function formatCurrency(amount: number, currency: string) { 16 | switch (currency) { 17 | case 'EUR': 18 | return `${amount.toFixed(2)} €`; 19 | case 'USD': 20 | return `$${amount.toFixed(2)}`; 21 | default: 22 | return `${amount.toFixed(2)} ${currency}`; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/app/layouts/ParticleAnimation.ts: -------------------------------------------------------------------------------- 1 | type Circle = { 2 | x: number; 3 | y: number; 4 | translateX: number; 5 | translateY: number; 6 | size: number; 7 | alpha: number; 8 | targetAlpha: number; 9 | dx: number; 10 | dy: number; 11 | magnetism: number; 12 | }; 13 | 14 | // Particle animation from https://cruip.com/how-to-create-a-beautiful-particle-animation-with-html-canvas/ 15 | export class ParticleAnimation { 16 | canvas: HTMLCanvasElement; 17 | mouse: { x: number; y: number }; 18 | canvasContainer: HTMLElement; 19 | context: CanvasRenderingContext2D; 20 | dpr: number; 21 | settings: { quantity: number; staticity: number; ease: number }; 22 | circles: Circle[]; 23 | canvasSize: { w: number; h: number }; 24 | 25 | constructor(el: HTMLCanvasElement, { quantity = 30, staticity = 50, ease = 50 } = {}) { 26 | this.canvas = el; 27 | const context = this.canvas.getContext('2d'); 28 | if (!this.canvas || !this.canvas.parentElement || !context) { 29 | throw new Error('Canvas not found'); 30 | } 31 | this.canvasContainer = this.canvas.parentElement; 32 | this.context = context; 33 | this.dpr = window.devicePixelRatio || 1; 34 | this.settings = { 35 | quantity, 36 | staticity, 37 | ease, 38 | }; 39 | this.circles = []; 40 | this.mouse = { 41 | x: 0, 42 | y: 0, 43 | }; 44 | this.canvasSize = { 45 | w: 0, 46 | h: 0, 47 | }; 48 | this.onMouseMove = this.onMouseMove.bind(this); 49 | this.initCanvas = this.initCanvas.bind(this); 50 | this.resizeCanvas = this.resizeCanvas.bind(this); 51 | this.drawCircle = this.drawCircle.bind(this); 52 | this.drawParticles = this.drawParticles.bind(this); 53 | this.remapValue = this.remapValue.bind(this); 54 | this.animate = this.animate.bind(this); 55 | this.init(); 56 | } 57 | 58 | init() { 59 | this.initCanvas(); 60 | this.animate(); 61 | window.addEventListener('resize', this.initCanvas); 62 | window.addEventListener('mousemove', this.onMouseMove); 63 | } 64 | 65 | initCanvas() { 66 | this.resizeCanvas(); 67 | this.drawParticles(); 68 | } 69 | 70 | onMouseMove(event: MouseEvent) { 71 | const { clientX, clientY } = event; 72 | const rect = this.canvas.getBoundingClientRect(); 73 | const { w, h } = this.canvasSize; 74 | const x = clientX - rect.left - w / 2; 75 | const y = clientY - rect.top - h / 2; 76 | const inside = x < w / 2 && x > -(w / 2) && y < h / 2 && y > -(h / 2); 77 | if (inside) { 78 | this.mouse.x = x; 79 | this.mouse.y = y; 80 | } 81 | } 82 | 83 | resizeCanvas() { 84 | this.circles.length = 0; 85 | this.canvasSize.w = this.canvasContainer.offsetWidth; 86 | this.canvasSize.h = this.canvasContainer.offsetHeight; 87 | this.canvas.width = this.canvasSize.w * this.dpr; 88 | this.canvas.height = this.canvasSize.h * this.dpr; 89 | this.canvas.style.width = this.canvasSize.w + 'px'; 90 | this.canvas.style.height = this.canvasSize.h + 'px'; 91 | this.context.scale(this.dpr, this.dpr); 92 | } 93 | 94 | circleParams() { 95 | const x = Math.floor(Math.random() * this.canvasSize.w); 96 | const y = Math.floor(Math.random() * this.canvasSize.h); 97 | const translateX = 0; 98 | const translateY = 0; 99 | const size = Math.floor(Math.random() * 2) + 1; 100 | const alpha = 0; 101 | const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)); 102 | const dx = (Math.random() - 0.5) * 0.2; 103 | const dy = (Math.random() - 0.5) * 0.2; 104 | const magnetism = 0.1 + Math.random() * 4; 105 | return { x, y, translateX, translateY, size, alpha, targetAlpha, dx, dy, magnetism }; 106 | } 107 | 108 | drawCircle(circle: Circle, update = false) { 109 | const { x, y, translateX, translateY, size, alpha } = circle; 110 | this.context.translate(translateX, translateY); 111 | this.context.beginPath(); 112 | this.context.arc(x, y, size, 0, 2 * Math.PI); 113 | this.context.fillStyle = `rgba(255, 255, 255, ${alpha})`; 114 | this.context.fill(); 115 | this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); 116 | if (!update) { 117 | this.circles.push(circle); 118 | } 119 | } 120 | 121 | clearContext() { 122 | this.context.clearRect(0, 0, this.canvasSize.w, this.canvasSize.h); 123 | } 124 | 125 | drawParticles() { 126 | this.clearContext(); 127 | const particleCount = this.settings.quantity; 128 | for (let i = 0; i < particleCount; i++) { 129 | const circle = this.circleParams(); 130 | this.drawCircle(circle); 131 | } 132 | } 133 | 134 | // This function remaps a value from one range to another range 135 | remapValue(value: number, start1: number, end1: number, start2: number, end2: number) { 136 | const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2; 137 | return remapped > 0 ? remapped : 0; 138 | } 139 | 140 | animate() { 141 | this.clearContext(); 142 | this.circles.forEach((circle, i) => { 143 | // Handle the alpha value 144 | const edge = [ 145 | circle.x + circle.translateX - circle.size, // distance from left edge 146 | this.canvasSize.w - circle.x - circle.translateX - circle.size, // distance from right edge 147 | circle.y + circle.translateY - circle.size, // distance from top edge 148 | this.canvasSize.h - circle.y - circle.translateY - circle.size, // distance from bottom edge 149 | ]; 150 | const closestEdge = edge.reduce((a, b) => Math.min(a, b)); 151 | const remapClosestEdge = parseInt(this.remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), 10); 152 | if (remapClosestEdge > 1) { 153 | circle.alpha += 0.02; 154 | if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha; 155 | } else { 156 | circle.alpha = circle.targetAlpha * remapClosestEdge; 157 | } 158 | circle.x += circle.dx; 159 | circle.y += circle.dy; 160 | circle.translateX += 161 | (this.mouse.x / (this.settings.staticity / circle.magnetism) - circle.translateX) / this.settings.ease; 162 | circle.translateY += 163 | (this.mouse.y / (this.settings.staticity / circle.magnetism) - circle.translateY) / this.settings.ease; 164 | // circle gets out of the canvas 165 | if ( 166 | circle.x < -circle.size || 167 | circle.x > this.canvasSize.w + circle.size || 168 | circle.y < -circle.size || 169 | circle.y > this.canvasSize.h + circle.size 170 | ) { 171 | // remove the circle from the array 172 | this.circles.splice(i, 1); 173 | // create a new circle 174 | const circle = this.circleParams(); 175 | this.drawCircle(circle); 176 | // update the circle position 177 | } else { 178 | this.drawCircle( 179 | { 180 | ...circle, 181 | x: circle.x, 182 | y: circle.y, 183 | translateX: circle.translateX, 184 | translateY: circle.translateY, 185 | alpha: circle.alpha, 186 | }, 187 | true, 188 | ); 189 | } 190 | }); 191 | window.requestAnimationFrame(this.animate); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /packages/app/layouts/auth.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 50 | -------------------------------------------------------------------------------- /packages/app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 33 | 34 | 42 | -------------------------------------------------------------------------------- /packages/app/middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to, from) => { 2 | const auth = useAuth(); 3 | await auth.load(); 4 | const { user } = auth; 5 | 6 | if (!user && to.path !== '/auth/login') { 7 | return navigateTo('/auth/login'); 8 | } 9 | 10 | if (user && to.path === '/auth/login') { 11 | return navigateTo('/'); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /packages/app/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | modules: ['nuxt-icon', '@nuxt/ui', '@pinia/nuxt'], 4 | runtimeConfig: { 5 | auth: { 6 | name: 'nuxt-session', 7 | password: 'my-super-secret-password-is-minimum-32-characters-long', 8 | }, 9 | public: { 10 | api: { 11 | clientBaseUrl: 'http://localhost:7171/api', 12 | baseUrl: 'http://localhost:7171/api', 13 | }, 14 | }, 15 | }, 16 | ui: { 17 | icons: ['mdi', 'simple-icons', 'heroicons', 'ion'], 18 | }, 19 | app: { 20 | head: { 21 | title: 'Gringotts', 22 | link: [ 23 | { rel: 'alternate icon', type: 'image/png', href: '/logo.png' }, 24 | { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }, 25 | ], 26 | }, 27 | }, 28 | // nitro: { 29 | // preset: 'node', 30 | // }, 31 | }); 32 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@geprog/gringotts-app", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "start": "nuxt dev", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare", 10 | "typecheck": "vue-tsc --noEmit" 11 | }, 12 | "devDependencies": { 13 | "@iconify/json": "^2.2.130", 14 | "@nuxt/devtools": "latest", 15 | "@nuxt/ui": "^2.9.0", 16 | "@nuxtjs/tailwindcss": "^6.8.0", 17 | "@types/node": "^18.16.19", 18 | "nuxt": "^3.7.4", 19 | "nuxt-icon": "^0.5.0", 20 | "typescript": "^5.1.6", 21 | "vue-tsc": "^1.8.21" 22 | }, 23 | "dependencies": { 24 | "@geprog/gringotts-client": "workspace:*", 25 | "@pinia/nuxt": "^0.5.1", 26 | "@popperjs/core": "^2.11.8", 27 | "cross-fetch": "^4.0.0", 28 | "dayjs": "^1.11.10", 29 | "pinia": "^2.1.7", 30 | "v-calendar": "^3.1.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/app/pages/auth/login.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 45 | -------------------------------------------------------------------------------- /packages/app/pages/customers/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 50 | -------------------------------------------------------------------------------- /packages/app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/app/pages/invoices/[invoiceId]/index.vue: -------------------------------------------------------------------------------- 1 | 117 | 118 | 161 | -------------------------------------------------------------------------------- /packages/app/pages/invoices/index.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 74 | -------------------------------------------------------------------------------- /packages/app/pages/payments/[paymentId]/index.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 64 | -------------------------------------------------------------------------------- /packages/app/pages/payments/index.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 58 | -------------------------------------------------------------------------------- /packages/app/pages/project/settings.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 95 | -------------------------------------------------------------------------------- /packages/app/pages/subscriptions/index.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 58 | -------------------------------------------------------------------------------- /packages/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geprog/gringotts/a52270b02053c6f516028a47461291b110b24622/packages/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/app/public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/public/logo_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /packages/app/public/logo_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /packages/app/server/api/auth/login.post.ts: -------------------------------------------------------------------------------- 1 | import { gringottsClient } from '@geprog/gringotts-client'; 2 | import fetch from 'cross-fetch'; 3 | 4 | export default defineEventHandler(async (event) => { 5 | const body = await readBody(event); 6 | const { token } = body; 7 | 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 400, 11 | message: 'token is required', 12 | }); 13 | } 14 | 15 | const config = useRuntimeConfig(); 16 | const client = gringottsClient(config.public.api.baseUrl, { 17 | customFetch: fetch, 18 | token, 19 | }); 20 | 21 | try { 22 | await client.project.getProject('token-project'); 23 | } catch (error) { 24 | console.error(error); 25 | throw createError({ 26 | statusCode: 401, 27 | message: 'project-token is invalid', 28 | }); 29 | } 30 | 31 | const session = await useAuthSession(event); 32 | await session.update({ 33 | token, 34 | }); 35 | 36 | return { 37 | ok: true, 38 | }; 39 | }); 40 | -------------------------------------------------------------------------------- /packages/app/server/api/auth/logout.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const session = await useAuthSession(event); 3 | await session.clear(); 4 | return sendRedirect(event, '/'); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/app/server/api/user.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | return await getUser(event); 3 | }); 4 | -------------------------------------------------------------------------------- /packages/app/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/server/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event, SessionConfig } from 'h3'; 2 | 3 | type User = { 4 | token: string; 5 | name: string; 6 | avatarUrl?: string; 7 | }; 8 | 9 | const sessionConfig: SessionConfig = useRuntimeConfig().auth || {}; 10 | 11 | export type AuthSession = { 12 | token: string; 13 | }; 14 | 15 | export async function useAuthSession(event: H3Event) { 16 | const session = await useSession(event, sessionConfig); 17 | return session; 18 | } 19 | 20 | export async function getUser(event: H3Event): Promise { 21 | const session = await useAuthSession(event); 22 | if (!session.data?.token) { 23 | return undefined; 24 | } 25 | 26 | return { 27 | name: 'Gringotts', 28 | avatarUrl: undefined, 29 | token: session.data.token, 30 | }; 31 | } 32 | 33 | export async function requireUser(event: H3Event): Promise { 34 | const user = await getUser(event); 35 | if (!user) { 36 | throw createError({ 37 | statusCode: 401, 38 | statusMessage: 'Unauthorized', 39 | }); 40 | } 41 | 42 | return user; 43 | } 44 | 45 | export async function getSessionHeader(event: H3Event) { 46 | const config = useRuntimeConfig(); 47 | 48 | const sessionName = config.auth.name || 'h3'; 49 | 50 | let sealedSession: string | undefined; 51 | 52 | // Try header first 53 | if (config.sessionHeader !== false) { 54 | const headerName = 55 | typeof config.sessionHeader === 'string' 56 | ? config.sessionHeader.toLowerCase() 57 | : `x-${sessionName.toLowerCase()}-session`; 58 | const headerValue = event.node.req.headers[headerName]; 59 | if (typeof headerValue === 'string') { 60 | sealedSession = headerValue; 61 | } 62 | } 63 | 64 | // Fallback to cookies 65 | if (!sealedSession) { 66 | sealedSession = getCookie(event, sessionName); 67 | } 68 | 69 | if (!sealedSession) { 70 | throw createError({ 71 | statusCode: 401, 72 | statusMessage: 'Unauthorized', 73 | }); 74 | } 75 | 76 | return { [`x-${sessionName.toLowerCase()}-session`]: sealedSession }; 77 | } 78 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /packages/client/.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | docs/ 4 | -------------------------------------------------------------------------------- /packages/client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | require('@geprog/eslint-config/patch/modern-module-resolution'); 4 | 5 | module.exports = { 6 | extends: ['@geprog', '@geprog/eslint-config/jest'], 7 | 8 | env: { 9 | 'shared-node-browser': true, 10 | }, 11 | 12 | parserOptions: { 13 | project: ['./tsconfig.eslint.json'], 14 | tsconfigRootDir: __dirname, 15 | extraFileExtensions: ['.cjs'], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/client/.gitignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | coverage/ 3 | junit.xml 4 | *.tgz 5 | report.json 6 | src/api.ts 7 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@geprog/gringotts-client", 3 | "version": "0.0.0-ci-release", 4 | "description": "API client for the Gringotts server", 5 | "exports": { 6 | ".": { 7 | "import": "./dist/index.mjs", 8 | "require": "./dist/index.js" 9 | } 10 | }, 11 | "main": "./dist/index.js", 12 | "module": "./dist/index.mjs", 13 | "types": "./dist/index.d.ts", 14 | "files": [ 15 | "/dist" 16 | ], 17 | "scripts": { 18 | "generate:api": "pnpm --filter ../server generate-swagger-spec && swagger-typescript-api -p ../server/swagger.json -o ./src -n api.ts", 19 | "generate:docs": "typedoc ./src/index.ts", 20 | "build": "pnpm generate:api && tsup src/index.ts --dts --format cjs,esm", 21 | "clean": "rm -rf dist/ node_modules/", 22 | "typecheck": "tsc --noEmit", 23 | "lint": "eslint --max-warnings 0 ." 24 | }, 25 | "devDependencies": { 26 | "@types/jsonwebtoken": "^8.5.8", 27 | "@types/node": "^18.6.0", 28 | "c8": "^7.12.0", 29 | "eslint": "^8.20.0", 30 | "swagger-typescript-api": "^9.3.1", 31 | "tsup": "^6.1.3", 32 | "typedoc": "^0.23.8", 33 | "typescript": "^4.7.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/client/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Api, ApiConfig } from './api'; 2 | 3 | export function gringottsClient( 4 | baseUrl: string, 5 | options?: ApiConfig & { token?: string }, 6 | ): Api { 7 | return new Api({ 8 | ...options, 9 | baseUrl, 10 | baseApiParams: { 11 | format: 'json', 12 | }, 13 | securityWorker: () => { 14 | if (!options?.token) { 15 | return; 16 | } 17 | 18 | return { 19 | secure: true, 20 | headers: { 21 | Authorization: `Bearer ${options.token}`, 22 | }, 23 | }; 24 | }, 25 | }); 26 | } 27 | 28 | export * from './api'; 29 | -------------------------------------------------------------------------------- /packages/client/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [".eslintrc.cjs", "vitest.config.ts", "src", "test"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "lib": ["ESNext", "DOM"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "strictFunctionTypes": true, 11 | "resolveJsonModule": true, 12 | "rootDir": ".", 13 | "baseUrl": "src", 14 | "skipLibCheck": true, 15 | "noUnusedLocals": true, 16 | "paths": { 17 | "~/*": ["./*"], 18 | "$/*": ["../test/*"] 19 | } 20 | }, 21 | "include": ["src", "test"], 22 | "exclude": ["node_modules", "**/dist", "docs"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /packages/server/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | require('@geprog/eslint-config/patch/modern-module-resolution'); 4 | 5 | module.exports = { 6 | extends: ['@geprog', '@geprog/eslint-config/jest'], 7 | 8 | env: { 9 | 'shared-node-browser': true, 10 | }, 11 | 12 | parserOptions: { 13 | project: ['./tsconfig.eslint.json'], 14 | tsconfigRootDir: __dirname, 15 | extraFileExtensions: ['.cjs'], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | swagger.json 2 | /data/ -------------------------------------------------------------------------------- /packages/server/cli/build.ts: -------------------------------------------------------------------------------- 1 | /** eslint-env node */ 2 | import esbuild from 'esbuild'; 3 | import path from 'path'; 4 | 5 | esbuild 6 | .build({ 7 | entryPoints: [path.join(__dirname, '..', 'src', 'index.ts')], 8 | outfile: path.join(__dirname, '..', 'dist', 'index.js'), 9 | platform: 'node', 10 | bundle: true, 11 | minify: process.env.node_env === 'production', 12 | external: [ 13 | '@mikro-orm/seeder', 14 | '@mikro-orm/mongodb', 15 | '@mikro-orm/mysql', 16 | '@mikro-orm/mariadb', 17 | '@mikro-orm/better-sqlite', 18 | '@mikro-orm/sqlite', 19 | '@mikro-orm/entity-generator', 20 | // '@mikro-orm/migrations', 21 | 'sqlite3', 22 | 'mysql', 23 | 'mysql2', 24 | 'better-sqlite3', 25 | 'pg-native', 26 | 'pg-query-stream', 27 | 'tedious', 28 | 'oracledb', 29 | ], 30 | sourcemap: true, 31 | tsconfig: path.join(__dirname, '..', 'tsconfig.json'), 32 | }) 33 | .catch(() => { 34 | process.exit(1); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/server/cli/generate-swagger-spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { init } from '~/api'; 5 | import { config } from '~/config'; 6 | 7 | async function generateSwaggerSpec() { 8 | config.adminToken = '123'; // TODO: find proper solution 9 | 10 | const server = await init(); 11 | await server.ready(); 12 | const spec = server.swagger(); 13 | const jsonSpec = JSON.stringify(spec, null, 2); 14 | const filePath = path.join(__dirname, '..', '/swagger.json'); 15 | fs.writeFileSync(filePath, jsonSpec); 16 | } 17 | 18 | void generateSwaggerSpec(); 19 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@geprog/gringotts-server", 3 | "version": "0.0.0-ci-release", 4 | "description": "Server of the Gringotts system", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "start": "tsx watch src/index.ts", 8 | "build": "tsx cli/build.ts", 9 | "clean": "rm -rf dist/ node_modules/", 10 | "lint": "eslint --max-warnings 0 .", 11 | "test": "TZ=UTC vitest run --coverage", 12 | "test:watch": "TZ=UTC vitest --ui --open false", 13 | "typecheck": "tsc --noEmit", 14 | "generate-swagger-spec": "tsx cli/generate-swagger-spec.ts" 15 | }, 16 | "dependencies": { 17 | "@fastify/cors": "^8.3.0", 18 | "@fastify/formbody": "^7.0.1", 19 | "@fastify/reply-from": "^9.4.0", 20 | "@fastify/static": "^6.11.2", 21 | "@fastify/swagger": "^7.4.1", 22 | "@fastify/view": "^7.1.0", 23 | "@mikro-orm/core": "^5.6.11", 24 | "@mikro-orm/migrations": "^5.6.11", 25 | "@mikro-orm/postgresql": "^5.6.11", 26 | "@mollie/api-client": "^3.6.0", 27 | "@vitest/ui": "^0.18.1", 28 | "cross-fetch": "^3.1.5", 29 | "dayjs": "^1.11.4", 30 | "dotenv": "^16.0.1", 31 | "fastify": "^4.3.0", 32 | "form-data": "^4.0.0", 33 | "got": "^12.2.0", 34 | "handlebars": "^4.7.7", 35 | "httpxy": "^0.1.5", 36 | "jsonwebtoken": "^9.0.0", 37 | "ngrok": "^4.3.1", 38 | "nodemailer": "^6.9.7", 39 | "pg": "^8.7.3", 40 | "pg-hstore": "^2.3.4", 41 | "pino": "^8.11.0", 42 | "prettier": "^2.5.1", 43 | "uuid": "^8.3.2" 44 | }, 45 | "devDependencies": { 46 | "@geprog/eslint-config": "^1.1.0", 47 | "@types/jsonwebtoken": "^8.5.8", 48 | "@types/node": "^18.6.0", 49 | "@types/nodemailer": "^6.4.14", 50 | "@types/uuid": "^8.3.4", 51 | "@vitest/coverage-c8": "^0.29.7", 52 | "c8": "^7.13.0", 53 | "esbuild": "^0.14.49", 54 | "eslint": "^8.20.0", 55 | "pino-pretty": "^9.0.0", 56 | "tsx": "^4.7.0", 57 | "typescript": "^4.7.4", 58 | "vite": "^4.2.0", 59 | "vitest": "^0.29.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/server/public/images/invoice-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/server/public/styles/invoice.css: -------------------------------------------------------------------------------- 1 | .invoice-box { 2 | max-width: 800px; 3 | margin: auto; 4 | padding: 30px; 5 | font-size: 16px; 6 | line-height: 24px; 7 | font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; 8 | color: #555; 9 | } 10 | 11 | .invoice-box table { 12 | width: 100%; 13 | line-height: inherit; 14 | text-align: left; 15 | } 16 | 17 | .invoice-box table td { 18 | padding: 5px; 19 | vertical-align: top; 20 | } 21 | 22 | .invoice-box table tr td:nth-child(2), 23 | .invoice-box table tr td:nth-child(3) { 24 | text-align: right; 25 | } 26 | 27 | .invoice-box table tr.top table td { 28 | padding-bottom: 20px; 29 | } 30 | 31 | .invoice-box table tr.top table table td { 32 | padding-bottom: 0; 33 | } 34 | 35 | .invoice-box table tr.top table td.title { 36 | font-size: 45px; 37 | line-height: 45px; 38 | color: #333; 39 | } 40 | 41 | .invoice-box table tr.information table td { 42 | padding-bottom: 40px; 43 | } 44 | 45 | .invoice-box table tr.heading td { 46 | background: #eee; 47 | border-bottom: 1px solid #ddd; 48 | font-weight: bold; 49 | } 50 | 51 | .invoice-box table tr.details td { 52 | padding-bottom: 20px; 53 | } 54 | 55 | .invoice-box table tr.item td { 56 | border-bottom: 1px solid #eee; 57 | } 58 | 59 | .invoice-box table tr.item.last td { 60 | border-bottom: none; 61 | } 62 | 63 | .invoice-box table tr.total > td:nth-child(2), 64 | .invoice-box table tr.total > td:nth-child(3) { 65 | border-top: 2px solid #eee; 66 | font-weight: bold; 67 | } 68 | 69 | @media only screen and (max-width: 600px) { 70 | .invoice-box table tr.top table td { 71 | width: 100%; 72 | display: block; 73 | text-align: center; 74 | } 75 | 76 | .invoice-box table tr.information table td { 77 | width: 100%; 78 | display: block; 79 | text-align: center; 80 | } 81 | } 82 | 83 | /** RTL **/ 84 | .invoice-box.rtl { 85 | direction: rtl; 86 | font-family: Tahoma, 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; 87 | } 88 | 89 | .invoice-box.rtl table { 90 | text-align: right; 91 | } 92 | 93 | .invoice-box.rtl table tr td:nth-child(2), 94 | .invoice-box.rtl table tr td:nth-child(3) { 95 | text-align: left; 96 | } 97 | 98 | .invoice-box .sum > td { 99 | padding: 0; 100 | } 101 | 102 | .invoice-box .sum table { 103 | border-collapse: collapse; 104 | width: min-content; 105 | margin-left: auto; 106 | } 107 | 108 | .invoice-box .sum table td { 109 | white-space: nowrap; 110 | } 111 | -------------------------------------------------------------------------------- /packages/server/src/api/endpoints/__snapshots__/invoice.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Invoice endpoints > should get an html invoice 1`] = ` 4 | " 5 | 6 | 7 | 8 | 9 | 10 | Invoice 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 43 | 44 | 45 | 46 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 108 | 109 |
20 | 21 | 22 | 25 | 26 | 40 | 41 |
23 | 24 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
Number:INV-F1B-0B6H-002
Date:31.01.2020
38 |
39 |
42 |
47 | 48 | 49 | 56 | 57 | 67 | 68 |
50 | John Doe
51 | BigBen Street 954
52 | 123
53 | ENG-1234 London
54 | GB 55 |
58 |
59 | Test company
60 | My Street 123
61 | Postbox 321
62 | GB-12345 London
63 | GB
64 | test@example.tld 65 |
66 |
69 |
ItemUnitsPrice
Test item1312.34 €
Second test item154.32 €
93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
Amount214.74 €
Vat (19%)40.80 €
Total255.54 €
107 |
110 | 111 |

The total amount will be debited from your bank account soon.

112 |
113 | 114 | 115 | " 116 | `; 117 | -------------------------------------------------------------------------------- /packages/server/src/api/endpoints/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | 3 | import { config } from '~/config'; 4 | import { database } from '~/database'; 5 | 6 | import { customerEndpoints } from './customer'; 7 | import { invoiceEndpoints } from './invoice'; 8 | import { mockedCheckoutEndpoints } from './mocked_checkout'; 9 | import { paymentEndpoints } from './payment'; 10 | import { paymentMethodEndpoints } from './payment_method'; 11 | import { projectEndpoints } from './project'; 12 | import { subscriptionEndpoints } from './subscription'; 13 | 14 | export async function apiEndpoints(server: FastifyInstance): Promise { 15 | server.addHook('onRequest', async (request, reply) => { 16 | // skip nuxt api routes 17 | if (request.url === '/api/auth/login' || request.url === '/api/auth/logout' || request.url === '/api/user') { 18 | return; 19 | } 20 | 21 | if (request.routerPath === '/api/invoice/download') { 22 | return; 23 | } 24 | 25 | if (request.routerPath === '/api/payment/webhook/:projectId') { 26 | return; 27 | } 28 | 29 | if (request.routerPath === '/api/mocked/checkout/:paymentId') { 30 | return; 31 | } 32 | 33 | const apiToken = 34 | (request.headers?.authorization || '').replace('Bearer ', '') || (request.query as { token: string }).token; 35 | if (!apiToken) { 36 | await reply.code(401).send({ error: 'Missing api token' }); 37 | return reply; 38 | } 39 | 40 | if (request.routerPath?.startsWith('/api/project') && request.url !== '/api/project/token-project') { 41 | if (apiToken === config.adminToken) { 42 | request.admin = true; 43 | return; 44 | } 45 | 46 | await reply.code(401).send({ error: 'You need to have admin access' }); 47 | return reply; 48 | } 49 | 50 | const project = await database.projects.findOne({ apiToken }, { populate: ['invoiceData'] }); 51 | if (!project) { 52 | await reply.code(401).send({ error: 'Invalid api token' }); 53 | return reply; 54 | } 55 | 56 | request.project = project; 57 | }); 58 | 59 | await server.register(subscriptionEndpoints); 60 | await server.register(customerEndpoints); 61 | await server.register(invoiceEndpoints); 62 | await server.register(paymentEndpoints); 63 | await server.register(projectEndpoints); 64 | await server.register(paymentMethodEndpoints); 65 | await server.register(mockedCheckoutEndpoints); 66 | } 67 | -------------------------------------------------------------------------------- /packages/server/src/api/endpoints/invoice.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, it, vi } from 'vitest'; 2 | 3 | import { init as apiInit } from '~/api'; 4 | import * as config from '~/config'; 5 | import * as database from '~/database'; 6 | import { Invoice } from '~/entities'; 7 | import { getFixtures, mockConfig } from '$/fixtures'; 8 | 9 | describe('Invoice endpoints', () => { 10 | beforeAll(async () => { 11 | vi.spyOn(config, 'config', 'get').mockReturnValue(mockConfig); 12 | 13 | await database.database.init(); 14 | }); 15 | 16 | it('should get an invoice', async () => { 17 | // given 18 | const testData = getFixtures(); 19 | 20 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 21 | invoices: { 22 | findOne() { 23 | return Promise.resolve(testData.invoice); 24 | }, 25 | }, 26 | customers: { 27 | findOne() { 28 | return Promise.resolve(testData.customer); 29 | }, 30 | }, 31 | subscriptions: { 32 | findOne() { 33 | return Promise.resolve(testData.subscription); 34 | }, 35 | }, 36 | projects: { 37 | findOne() { 38 | return Promise.resolve(testData.project); 39 | }, 40 | }, 41 | } as unknown as database.Database); 42 | 43 | const server = await apiInit(); 44 | 45 | // when 46 | const response = await server.inject({ 47 | method: 'GET', 48 | headers: { 49 | authorization: `Bearer ${testData.project.apiToken}`, 50 | }, 51 | url: `/api/invoice/${testData.invoice._id}`, 52 | }); 53 | 54 | // then 55 | expect(response.statusCode).toBe(200); 56 | 57 | const responseData: Invoice = response.json(); 58 | expect(responseData._id).toBe(testData.invoice._id); 59 | expect(responseData.amount).toBe(214.74); 60 | expect(responseData.totalAmount).toBe(Invoice.roundPrice(214.74 * 1.19)); 61 | }); 62 | 63 | it('should get an html invoice', async () => { 64 | // given 65 | const testData = getFixtures(); 66 | 67 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 68 | invoices: { 69 | findOne() { 70 | return Promise.resolve(testData.invoice); 71 | }, 72 | }, 73 | customers: { 74 | findOne() { 75 | return Promise.resolve(testData.customer); 76 | }, 77 | }, 78 | subscriptions: { 79 | findOne() { 80 | return Promise.resolve(testData.subscription); 81 | }, 82 | }, 83 | projects: { 84 | findOne() { 85 | return Promise.resolve(testData.project); 86 | }, 87 | }, 88 | } as unknown as database.Database); 89 | 90 | const server = await apiInit(); 91 | 92 | // when 93 | const response = await server.inject({ 94 | method: 'GET', 95 | headers: { 96 | authorization: `Bearer ${testData.project.apiToken}`, 97 | }, 98 | url: `/api/invoice/${testData.invoice._id}/html`, 99 | }); 100 | 101 | // then 102 | expect(response.statusCode).toBe(200); 103 | 104 | const responseData = response.body; 105 | expect(responseData).toMatchSnapshot(); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/server/src/api/endpoints/mocked_checkout.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import path from 'path'; 3 | 4 | import { database } from '~/database'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/require-await 7 | export async function mockedCheckoutEndpoints(server: FastifyInstance): Promise { 8 | server.get( 9 | '/mocked/checkout/:paymentId', 10 | { 11 | schema: { hide: true }, 12 | }, 13 | async (request, reply) => { 14 | const params = request.params as { paymentId?: string }; 15 | if (!params.paymentId) { 16 | return reply.code(400).send({ 17 | error: 'Missing paymentId', 18 | }); 19 | } 20 | 21 | const query = request.query as { redirect_url?: string }; 22 | if (!query.redirect_url) { 23 | return reply.code(400).send({ 24 | error: 'Missing redirect_url', 25 | }); 26 | } 27 | 28 | const payment = await database.payments.findOne( 29 | { _id: params.paymentId }, 30 | { populate: ['customer', 'customer.project'] }, 31 | ); 32 | if (!payment) { 33 | return reply.code(404).send({ 34 | error: 'Payment not found', 35 | }); 36 | } 37 | 38 | if (payment.customer.project.paymentProvider !== 'mocked') { 39 | return reply.code(404).send({ 40 | error: 'Payment not found', // don't leak that we are in dev mode 41 | }); 42 | } 43 | 44 | await reply.view(path.join('templates', 'mocked-checkout.hbs'), { payment, redirect_url: query.redirect_url }); 45 | }, 46 | ); 47 | 48 | server.post('/mocked/checkout/:paymentId', { 49 | schema: { 50 | hide: true, 51 | summary: 'Do a checkout for a payment in development mode', 52 | tags: ['dev'], 53 | params: { 54 | type: 'object', 55 | required: ['paymentId'], 56 | additionalProperties: false, 57 | properties: { 58 | paymentId: { type: 'string' }, 59 | }, 60 | }, 61 | body: { 62 | type: 'object', 63 | required: ['status', 'redirect_url'], 64 | additionalProperties: false, 65 | properties: { 66 | status: { type: 'string' }, 67 | redirect_url: { type: 'string' }, 68 | }, 69 | }, 70 | 71 | response: { 72 | 200: { 73 | $ref: 'Customer', 74 | }, 75 | 400: { 76 | $ref: 'ErrorResponse', 77 | }, 78 | 500: { 79 | $ref: 'ErrorResponse', 80 | }, 81 | }, 82 | }, 83 | handler: async (request, reply) => { 84 | const params = request.params as { paymentId?: string }; 85 | if (!params.paymentId) { 86 | return reply.code(400).send({ 87 | error: 'Missing paymentId', 88 | }); 89 | } 90 | 91 | const body = request.body as { redirect_url?: string; status?: 'paid' | 'failed' }; 92 | if (!body.redirect_url) { 93 | return reply.code(400).send({ 94 | error: 'Missing redirect_url', 95 | }); 96 | } 97 | if (!body.status) { 98 | return reply.code(400).send({ 99 | error: 'Missing status', 100 | }); 101 | } 102 | 103 | const payment = await database.payments.findOne( 104 | { _id: params.paymentId }, 105 | { populate: ['customer', 'customer.project'] }, 106 | ); 107 | if (!payment) { 108 | return reply.code(404).send({ 109 | error: 'Payment not found', 110 | }); 111 | } 112 | 113 | if (payment.customer.project.paymentProvider !== 'mocked') { 114 | return reply.code(404).send({ 115 | error: 'Payment not found', // don't leak that we are in dev mode 116 | }); 117 | } 118 | 119 | const { project } = payment.customer; 120 | 121 | const response = await server.inject({ 122 | method: 'POST', 123 | headers: { 124 | authorization: `Bearer ${project.apiToken}`, 125 | }, 126 | url: `/api/payment/webhook/${project._id}`, 127 | payload: { 128 | paymentId: payment._id, 129 | paymentStatus: body.status, 130 | paidAt: new Date().toISOString(), 131 | }, 132 | }); 133 | 134 | if (response.statusCode !== 200) { 135 | return reply.view(path.join('templates', 'mocked-checkout.hbs'), { 136 | payment, 137 | redirect_url: body.redirect_url, 138 | error: 'Payment webhook failed', 139 | }); 140 | } 141 | 142 | await reply.redirect(body.redirect_url); 143 | }, 144 | }); 145 | } 146 | -------------------------------------------------------------------------------- /packages/server/src/api/endpoints/payment.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, it, vi } from 'vitest'; 2 | 3 | import { init as apiInit } from '~/api'; 4 | import * as config from '~/config'; 5 | import * as database from '~/database'; 6 | import { Customer, Payment, PaymentMethod } from '~/entities'; 7 | import { getFixtures, mockConfig } from '$/fixtures'; 8 | 9 | describe('Payment webhook endpoints', () => { 10 | beforeAll(async () => { 11 | vi.spyOn(config, 'config', 'get').mockReturnValue(mockConfig); 12 | 13 | await database.database.init(); 14 | }); 15 | 16 | describe('verification payment', () => { 17 | it('should verify a payment-method', async () => { 18 | // given 19 | const testData = getFixtures(); 20 | 21 | const payment = new Payment({ 22 | amount: 1, 23 | currency: 'EUR', 24 | customer: testData.customer, 25 | status: 'processing', 26 | type: 'verification', 27 | description: 'Verification payment', 28 | project: testData.project, 29 | }); 30 | 31 | const persistAndFlush = vi.fn(); 32 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 33 | customers: { 34 | findOne() { 35 | return Promise.resolve(null); 36 | }, 37 | }, 38 | payments: { 39 | findOne() { 40 | return Promise.resolve(payment); 41 | }, 42 | }, 43 | projects: { 44 | findOne() { 45 | return Promise.resolve(testData.project); 46 | }, 47 | }, 48 | em: { 49 | persistAndFlush, 50 | }, 51 | } as unknown as database.Database); 52 | 53 | const payload = { 54 | paymentId: payment._id, 55 | paymentStatus: 'paid', 56 | paidAt: new Date().toISOString(), 57 | }; 58 | 59 | const server = await apiInit(); 60 | 61 | // before check 62 | expect(testData.customer.balance).toStrictEqual(0); 63 | 64 | // when 65 | const response = await server.inject({ 66 | method: 'POST', 67 | headers: { 68 | authorization: `Bearer ${testData.project.apiToken}`, 69 | }, 70 | url: `/api/payment/webhook/${testData.project._id}`, 71 | payload, 72 | }); 73 | 74 | // then 75 | expect(response.statusCode).toBe(200); 76 | expect(response.json()).toStrictEqual({ ok: true }); 77 | 78 | expect(persistAndFlush).toBeCalledTimes(2); 79 | 80 | const [[updatedPayment]] = persistAndFlush.mock.calls[0] as [[Payment]]; 81 | expect(updatedPayment).toBeDefined(); 82 | expect(updatedPayment.status).toStrictEqual('paid'); 83 | 84 | const [[customer, paymentMethod]] = persistAndFlush.mock.calls[1] as [[Customer, PaymentMethod]]; 85 | expect(customer).toBeDefined(); 86 | expect(customer.activePaymentMethod?._id).toStrictEqual(paymentMethod._id); 87 | expect(customer.balance).toStrictEqual(1); 88 | 89 | expect(paymentMethod).toBeDefined(); 90 | expect(paymentMethod.type).toStrictEqual(paymentMethod.type); 91 | }); 92 | 93 | it.todo('should not verify a payment-method if the payment failed'); 94 | }); 95 | 96 | describe('linked to subscription', () => { 97 | it.todo('should update a subscription'); 98 | }); 99 | 100 | describe('linked to invoice', () => { 101 | it.todo('should update an invoice'); 102 | 103 | it.todo('should update an invoice if the payment failed'); 104 | }); 105 | 106 | describe.todo('one-off payment'); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/server/src/api/endpoints/payment_method.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, it, vi } from 'vitest'; 2 | 3 | import { init as apiInit } from '~/api'; 4 | import * as config from '~/config'; 5 | import * as database from '~/database'; 6 | import { Customer, PaymentMethod, Project } from '~/entities'; 7 | import { getPaymentProvider } from '~/payment_providers'; 8 | import { getFixtures, mockConfig } from '$/fixtures'; 9 | 10 | describe('Payment-method endpoints', () => { 11 | beforeAll(async () => { 12 | vi.spyOn(config, 'config', 'get').mockReturnValue(mockConfig); 13 | 14 | await database.database.init(); 15 | }); 16 | 17 | it('should create a payment-method', async () => { 18 | // given 19 | const testData = getFixtures(); 20 | 21 | const customer = { 22 | _id: '123', 23 | name: 'John Doe', 24 | email: 'john@doe.com', 25 | addressLine1: 'BigBen Street 954', 26 | addressLine2: '123', 27 | city: 'London', 28 | country: 'GB', 29 | zipCode: 'ENG-1234', 30 | }; 31 | 32 | const persistAndFlush = vi.fn(); 33 | 34 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 35 | customers: { 36 | findOne() { 37 | return Promise.resolve(customer); 38 | }, 39 | }, 40 | projects: { 41 | findOne() { 42 | return Promise.resolve(testData.project); 43 | }, 44 | }, 45 | em: { 46 | persistAndFlush, 47 | }, 48 | } as unknown as database.Database); 49 | 50 | const paymentProvider = getPaymentProvider({ paymentProvider: 'mocked' } as Project); 51 | await paymentProvider?.createCustomer(customer); 52 | 53 | const server = await apiInit(); 54 | 55 | const paymentMethodPayload = { 56 | redirectUrl: 'https://example.com', 57 | customerId: customer._id, 58 | }; 59 | 60 | // when 61 | const response = await server.inject({ 62 | method: 'POST', 63 | headers: { 64 | authorization: `Bearer ${testData.project.apiToken}`, 65 | }, 66 | url: `/api/customer/${customer._id}/payment-method`, 67 | payload: paymentMethodPayload, 68 | }); 69 | 70 | // then 71 | expect(response.statusCode).toBe(200); 72 | 73 | const { checkoutUrl }: { checkoutUrl?: string } = response.json(); 74 | expect(checkoutUrl).toBeDefined(); 75 | 76 | expect(persistAndFlush).toHaveBeenCalled(); 77 | const [[paymentMethod]] = persistAndFlush.mock.lastCall as [[PaymentMethod]]; 78 | expect(paymentMethod).toContain({ 79 | description: 'Payment method verification', 80 | type: 'verification', 81 | }); 82 | }); 83 | 84 | it('should get a payment-method of a customer by its id', async () => { 85 | // given 86 | const testData = getFixtures(); 87 | 88 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 89 | paymentMethods: { 90 | findOne() { 91 | return Promise.resolve(testData.paymentMethod); 92 | }, 93 | }, 94 | projects: { 95 | findOne() { 96 | return Promise.resolve(testData.project); 97 | }, 98 | }, 99 | } as unknown as database.Database); 100 | 101 | const server = await apiInit(); 102 | 103 | // when 104 | const response = await server.inject({ 105 | method: 'GET', 106 | url: `/api/customer/${testData.customer._id}/payment-method/${testData.paymentMethod._id}}`, 107 | headers: { 108 | authorization: `Bearer ${testData.project.apiToken}`, 109 | }, 110 | }); 111 | 112 | // then 113 | expect(response.statusCode).toBe(200); 114 | 115 | const paymentMethod: PaymentMethod = response.json(); 116 | expect(paymentMethod).toBeDefined(); 117 | expect(paymentMethod._id).toStrictEqual(testData.paymentMethod._id); 118 | expect(paymentMethod.name).toStrictEqual(testData.paymentMethod.name); 119 | }); 120 | 121 | it('should get all payment-methods of a customer', async () => { 122 | // given 123 | const testData = getFixtures(); 124 | 125 | const paymentMethod2 = { 126 | _id: '123', 127 | customer: testData.customer, 128 | paymentProviderId: '123', 129 | type: 'credit-card', 130 | name: 'John Doe', 131 | }; 132 | 133 | testData.customer.paymentMethods.add([new PaymentMethod(paymentMethod2)]); 134 | 135 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 136 | customers: { 137 | findOne() { 138 | return Promise.resolve(testData.customer); 139 | }, 140 | }, 141 | projects: { 142 | findOne() { 143 | return Promise.resolve(testData.project); 144 | }, 145 | }, 146 | } as unknown as database.Database); 147 | 148 | const server = await apiInit(); 149 | 150 | // when 151 | const response = await server.inject({ 152 | method: 'GET', 153 | url: `/api/customer/${testData.customer._id}/payment-method`, 154 | headers: { 155 | authorization: `Bearer ${testData.project.apiToken}`, 156 | }, 157 | }); 158 | 159 | // then 160 | expect(response.statusCode).toBe(200); 161 | 162 | const paymentMethods: PaymentMethod[] = response.json(); 163 | expect(paymentMethods).toBeDefined(); 164 | expect(paymentMethods).toHaveLength(2); 165 | expect(paymentMethods[0]._id).toEqual(testData.paymentMethod._id); 166 | expect(paymentMethods[1]._id).toEqual(paymentMethod2._id); 167 | }); 168 | 169 | it('should delete a payment-method', async () => { 170 | // given 171 | const testData = getFixtures(); 172 | 173 | const paymentMethodData = { 174 | _id: '1234', 175 | type: 'credit-card', 176 | name: 'VISA', 177 | customer: testData.customer, 178 | paymentProviderId: '123', 179 | }; 180 | 181 | const removeAndFlush = vi.fn(); 182 | 183 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 184 | paymentMethods: { 185 | findOne() { 186 | return Promise.resolve(new PaymentMethod({ ...paymentMethodData, customer: testData.customer })); 187 | }, 188 | }, 189 | projects: { 190 | findOne() { 191 | return Promise.resolve(testData.project); 192 | }, 193 | }, 194 | em: { 195 | removeAndFlush, 196 | }, 197 | } as unknown as database.Database); 198 | 199 | const server = await apiInit(); 200 | 201 | // when 202 | const response = await server.inject({ 203 | method: 'DELETE', 204 | headers: { 205 | authorization: `Bearer ${testData.project.apiToken}`, 206 | }, 207 | url: `/api/customer/${testData.customer._id}/payment-method/${paymentMethodData._id}`, 208 | }); 209 | 210 | // then 211 | expect(response.statusCode).toBe(200); 212 | 213 | const paymentMethodResponse: { ok: boolean } = response.json(); 214 | expect(paymentMethodResponse).toBeDefined(); 215 | expect(paymentMethodResponse).toStrictEqual({ ok: true }); 216 | expect(removeAndFlush).toBeCalledTimes(1); 217 | expect(removeAndFlush).toHaveBeenCalledWith(paymentMethodData); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /packages/server/src/api/endpoints/subscription.test.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { beforeAll, describe, expect, it, MockContext, vi } from 'vitest'; 3 | 4 | import { init as apiInit } from '~/api'; 5 | import * as config from '~/config'; 6 | import * as database from '~/database'; 7 | import { Customer, Invoice, Project, Subscription } from '~/entities'; 8 | import { getPaymentProvider } from '~/payment_providers'; 9 | import { getFixtures, mockConfig } from '$/fixtures'; 10 | 11 | describe('Subscription endpoints', () => { 12 | beforeAll(async () => { 13 | vi.spyOn(config, 'config', 'get').mockReturnValue(mockConfig); 14 | 15 | await database.database.init(); 16 | }); 17 | 18 | it('should create a subscription', async () => { 19 | // given 20 | const testData = getFixtures(); 21 | 22 | const persistAndFlush = vi.fn(); 23 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 24 | customers: { 25 | findOne() { 26 | return Promise.resolve(testData.customer); 27 | }, 28 | }, 29 | projects: { 30 | findOne() { 31 | return Promise.resolve(testData.project); 32 | }, 33 | }, 34 | em: { 35 | persistAndFlush, 36 | }, 37 | } as unknown as database.Database); 38 | 39 | const paymentProvider = getPaymentProvider({ paymentProvider: 'mocked' } as Project); 40 | await paymentProvider?.createCustomer(testData.customer); 41 | 42 | const server = await apiInit(); 43 | 44 | const subscriptionPayload = { 45 | pricePerUnit: 15.69, 46 | units: 123, 47 | redirectUrl: 'https://example.com', 48 | customerId: testData.customer._id, 49 | }; 50 | 51 | const date = new Date('2021-01-01'); 52 | vi.setSystemTime(date); 53 | 54 | // when 55 | const response = await server.inject({ 56 | method: 'POST', 57 | headers: { 58 | authorization: `Bearer ${testData.project.apiToken}`, 59 | }, 60 | url: '/api/subscription', 61 | payload: subscriptionPayload, 62 | }); 63 | 64 | // then 65 | expect(response.statusCode).toBe(200); 66 | 67 | const responseData: Subscription = response.json(); 68 | expect(responseData).toBeDefined(); 69 | 70 | expect(persistAndFlush).toHaveBeenCalledTimes(1); 71 | const [[, subscription]] = persistAndFlush.mock.lastCall as [[Customer, Subscription]]; 72 | expect(responseData._id).toStrictEqual(subscription._id); 73 | expect(dayjs(responseData.currentPeriodStart).toDate()).toStrictEqual(date); 74 | expect(dayjs(responseData.currentPeriodEnd).toDate()).toStrictEqual( 75 | // set ms to 0 because db does not store ms 76 | dayjs(date).add(1, 'month').subtract(1, 'day').endOf('day').set('millisecond', 0).toDate(), 77 | ); 78 | }); 79 | 80 | it('should update a subscription', async () => { 81 | // given 82 | const testData = getFixtures(); 83 | 84 | const customer = { 85 | _id: '123', 86 | name: 'John Doe', 87 | email: 'john@doe.com', 88 | addressLine1: 'BigBen Street 954', 89 | addressLine2: '123', 90 | city: 'London', 91 | country: 'GB', 92 | zipCode: 'ENG-1234', 93 | }; 94 | 95 | const subscription = new Subscription({ 96 | _id: 'sub-123', 97 | customer, 98 | anchorDate: new Date(), 99 | createdAt: new Date(), 100 | updatedAt: new Date(), 101 | }); 102 | subscription.changePlan({ units: 123, pricePerUnit: 15.69 }); 103 | 104 | const dbPersistAndFlush = vi.fn(); 105 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 106 | subscriptions: { 107 | findOne() { 108 | return Promise.resolve(subscription); 109 | }, 110 | }, 111 | projects: { 112 | findOne() { 113 | return Promise.resolve(testData.project); 114 | }, 115 | }, 116 | em: { 117 | persistAndFlush: dbPersistAndFlush, 118 | }, 119 | } as unknown as database.Database); 120 | 121 | const paymentProvider = getPaymentProvider({ paymentProvider: 'mocked' } as Project); 122 | await paymentProvider?.createCustomer(customer); 123 | 124 | const subscriptionPayload = { 125 | pricePerUnit: 15.69, 126 | units: 33, 127 | }; 128 | 129 | const server = await apiInit(); 130 | 131 | // when 132 | const response = await server.inject({ 133 | method: 'PATCH', 134 | headers: { 135 | authorization: `Bearer ${testData.project.apiToken}`, 136 | }, 137 | url: `/api/subscription/${subscription._id}`, 138 | payload: subscriptionPayload, 139 | }); 140 | 141 | // then 142 | expect(response.statusCode).toBe(200); 143 | 144 | const responseData: { ok: boolean } = response.json(); 145 | expect(responseData).toBeDefined(); 146 | expect(responseData).toEqual({ ok: true }); 147 | 148 | expect(dbPersistAndFlush).toHaveBeenCalledTimes(1); 149 | const [_subscription] = (dbPersistAndFlush.mock as MockContext<[Subscription], unknown>).calls[0]; 150 | expect(_subscription.changes).toHaveLength(2); 151 | expect(_subscription.changes[1]).toContain(subscriptionPayload); 152 | }); 153 | 154 | it('should get all invoices of a subscription', async () => { 155 | // given 156 | const testData = getFixtures(); 157 | 158 | const dbPersistAndFlush = vi.fn(); 159 | vi.spyOn(database, 'database', 'get').mockReturnValue({ 160 | subscriptions: { 161 | findOne() { 162 | return Promise.resolve(testData.subscription); 163 | }, 164 | }, 165 | invoices: { 166 | find() { 167 | return Promise.resolve([testData.invoice]); 168 | }, 169 | }, 170 | projects: { 171 | findOne() { 172 | return Promise.resolve(testData.project); 173 | }, 174 | }, 175 | em: { 176 | persistAndFlush: dbPersistAndFlush, 177 | }, 178 | } as unknown as database.Database); 179 | 180 | const server = await apiInit(); 181 | 182 | // when 183 | const response = await server.inject({ 184 | method: 'GET', 185 | headers: { 186 | authorization: `Bearer ${testData.project.apiToken}`, 187 | }, 188 | url: `/api/subscription/${testData.subscription._id}/invoice`, 189 | }); 190 | 191 | // then 192 | expect(response.statusCode).toBe(200); 193 | 194 | const invoices: Invoice[] = response.json(); 195 | expect(invoices).toHaveLength(1); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /packages/server/src/api/helpers.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify'; 2 | 3 | import { Project } from '~/entities'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/require-await 6 | export async function getProjectFromRequest(request: FastifyRequest): Promise { 7 | if (!request.project) { 8 | throw new Error('request.project should be defined'); 9 | } 10 | 11 | return request.project; 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import cors from '@fastify/cors'; 2 | import fastifyFormBody from '@fastify/formbody'; 3 | import fastifyReplyFrom from '@fastify/reply-from'; 4 | import fastifyStatic from '@fastify/static'; 5 | import fastifySwagger from '@fastify/swagger'; 6 | import fastifyView from '@fastify/view'; 7 | import fastify, { FastifyInstance } from 'fastify'; 8 | import Handlebars from 'handlebars'; 9 | import path from 'path'; 10 | import pino from 'pino'; 11 | 12 | import { config } from '~/config'; 13 | import { Invoice } from '~/entities'; 14 | import { Currency } from '~/entities/payment'; 15 | import { formatDate } from '~/lib/dayjs'; 16 | import { log } from '~/log'; 17 | 18 | import { apiEndpoints } from './endpoints'; 19 | import { addSchemas } from './schema'; 20 | 21 | // routing priority: 22 | // api routes -> static files -> nuxt -> 404 23 | 24 | export async function init(): Promise { 25 | const logger = 26 | process.env.NODE_ENV === 'test' 27 | ? pino( 28 | {}, 29 | { 30 | // eslint-disable-next-line no-console 31 | write: (data: string) => console.log(data), 32 | }, 33 | ) 34 | : log; 35 | 36 | const server = fastify({ 37 | logger, 38 | // disableRequestLogging: process.env.NODE_ENV === 'production', 39 | disableRequestLogging: true, 40 | }); 41 | 42 | await server.register(cors, { 43 | origin: '*', 44 | }); 45 | 46 | await server.register(fastifyFormBody); 47 | 48 | await server.register(fastifyStatic, { 49 | root: 50 | process.env.NODE_ENV === 'production' 51 | ? path.join(__dirname, 'public') 52 | : path.join(__dirname, '..', '..', 'public'), 53 | prefix: '/static/', 54 | }); 55 | 56 | await server.register(fastifyReplyFrom, { 57 | base: 'http://localhost:3000/', // TODO: allow to configure 58 | disableRequestLogging: true, 59 | }); 60 | 61 | server.setNotFoundHandler(async (request, reply) => { 62 | if ( 63 | request.url?.startsWith('/api') && 64 | request.url !== '/api/auth/login' && 65 | request.url !== '/api/auth/logout' && 66 | request.url !== '/api/user' 67 | ) { 68 | await reply.code(404).send({ 69 | error: 'Not found', 70 | }); 71 | return; 72 | } 73 | 74 | // forward to nuxt 75 | try { 76 | await reply.from(request.url); 77 | } catch (error) { 78 | await reply.code(500).send({ 79 | error: 'Proxy error' + (error as Error).toString(), 80 | }); 81 | } 82 | }); 83 | 84 | await server.register(fastifyView, { 85 | engine: { 86 | handlebars: Handlebars, 87 | }, 88 | }); 89 | 90 | Handlebars.registerHelper('formatDate', (date: Date, format: string) => formatDate(date, format)); 91 | Handlebars.registerHelper('amountToPrice', (amount: number, currency: Currency) => 92 | Invoice.amountToPrice(amount, currency), 93 | ); 94 | 95 | await server.register(fastifySwagger, { 96 | routePrefix: '/docs', 97 | swagger: { 98 | info: { 99 | title: 'Gringotts api', 100 | description: 'Documentation for the Gringotts api', 101 | version: '0.1.0', 102 | }, 103 | host: `localhost:${config.port}`, 104 | basePath: '/api', 105 | schemes: ['http', 'https'], 106 | consumes: ['application/json'], 107 | produces: ['application/json'], 108 | securityDefinitions: { 109 | authorization: { 110 | type: 'apiKey', 111 | name: 'Authorization', 112 | in: 'header', 113 | }, 114 | }, 115 | security: [{ authorization: [] }], 116 | }, 117 | refResolver: { 118 | buildLocalReference(json) { 119 | if (!json.title && json.$id) { 120 | json.title = json.$id; 121 | } 122 | return json.$id as string; 123 | }, 124 | }, 125 | exposeRoute: true, 126 | }); 127 | 128 | addSchemas(server); 129 | 130 | await server.register(apiEndpoints, { prefix: '/api' }); 131 | 132 | return server; 133 | } 134 | -------------------------------------------------------------------------------- /packages/server/src/api/schema.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | 3 | export function addSchemas(server: FastifyInstance): void { 4 | server.addSchema({ 5 | $id: 'SuccessResponse', 6 | type: 'object', 7 | description: 'Success response', 8 | properties: { 9 | ok: { type: 'boolean' }, 10 | }, 11 | }); 12 | 13 | server.addSchema({ 14 | $id: 'ErrorResponse', 15 | type: 'object', 16 | description: 'Error response', 17 | properties: { 18 | error: { type: 'string' }, 19 | }, 20 | }); 21 | 22 | server.addSchema({ 23 | $id: 'ProjectInvoiceData', 24 | type: 'object', 25 | properties: { 26 | _id: { type: 'string' }, 27 | email: { type: 'string' }, 28 | name: { type: 'string' }, 29 | addressLine1: { type: 'string' }, 30 | addressLine2: { type: 'string' }, 31 | zipCode: { type: 'string' }, 32 | city: { type: 'string' }, 33 | country: { type: 'string' }, 34 | }, 35 | }); 36 | 37 | server.addSchema({ 38 | $id: 'Project', 39 | type: 'object', 40 | properties: { 41 | _id: { type: 'string' }, 42 | name: { type: 'string' }, 43 | apiToken: { type: 'string' }, 44 | mollieApiKey: { type: 'string' }, 45 | paymentProvider: { type: 'string' }, 46 | webhookUrl: { type: 'string' }, 47 | invoiceData: { $ref: 'ProjectInvoiceData' }, 48 | currency: { type: 'string' }, 49 | vatRate: { type: 'number' }, 50 | }, 51 | }); 52 | 53 | server.addSchema({ 54 | $id: 'Payment', 55 | type: 'object', 56 | properties: { 57 | _id: { type: 'string' }, 58 | type: { type: 'string' }, 59 | status: { type: 'string' }, 60 | currency: { type: 'string' }, 61 | amount: { type: 'number' }, 62 | description: { type: 'string' }, 63 | invoice: { type: 'object', properties: { _id: { type: 'string' } }, additionalProperties: false }, 64 | customer: { $ref: 'Customer' }, 65 | subscription: { $ref: 'Subscription' }, 66 | }, 67 | }); 68 | 69 | server.addSchema({ 70 | $id: 'InvoiceItem', 71 | type: 'object', 72 | properties: { 73 | _id: { type: 'string' }, 74 | description: { type: 'string' }, 75 | units: { type: 'number' }, 76 | pricePerUnit: { type: 'number' }, 77 | }, 78 | }); 79 | 80 | server.addSchema({ 81 | $id: 'Invoice', 82 | type: 'object', 83 | properties: { 84 | _id: { type: 'string' }, 85 | date: { type: 'string' }, 86 | sequentialId: { type: 'number' }, 87 | items: { 88 | type: 'array', 89 | items: { 90 | $ref: 'InvoiceItem', 91 | }, 92 | }, 93 | subscription: { type: 'object', properties: { _id: { type: 'string' } }, additionalProperties: false }, 94 | // subscription: { $ref: 'Subscription' }, 95 | customer: { $ref: 'Customer' }, 96 | status: { type: 'string' }, 97 | currency: { type: 'string' }, 98 | vatRate: { type: 'number' }, 99 | amount: { type: 'number' }, 100 | vatAmount: { type: 'number' }, 101 | totalAmount: { type: 'number' }, 102 | number: { type: 'string' }, 103 | }, 104 | }); 105 | 106 | server.addSchema({ 107 | $id: 'Customer', 108 | type: 'object', 109 | properties: { 110 | _id: { type: 'string' }, 111 | email: { type: 'string' }, 112 | name: { type: 'string' }, 113 | addressLine1: { type: 'string' }, 114 | addressLine2: { type: 'string' }, 115 | zipCode: { type: 'string' }, 116 | city: { type: 'string' }, 117 | country: { type: 'string' }, 118 | invoicePrefix: { type: 'string' }, 119 | invoiceCounter: { type: 'string' }, 120 | balance: { type: 'number' }, 121 | activePaymentMethod: { $ref: 'PaymentMethod' }, 122 | language: { type: 'string' }, 123 | }, 124 | }); 125 | 126 | server.addSchema({ 127 | $id: 'SubscriptionChange', 128 | type: 'object', 129 | properties: { 130 | _id: { type: 'string' }, 131 | start: { type: 'string' }, 132 | end: { type: 'string' }, 133 | pricePerUnit: { type: 'number' }, 134 | units: { type: 'number' }, 135 | }, 136 | }); 137 | 138 | server.addSchema({ 139 | $id: 'Subscription', 140 | type: 'object', 141 | properties: { 142 | _id: { type: 'string' }, 143 | metadata: { type: ['object', 'null'], additionalProperties: true }, 144 | anchorDate: { type: 'string' }, 145 | status: { type: 'string' }, 146 | error: { type: 'string' }, 147 | lastPayment: { type: 'string' }, 148 | currentPeriodStart: { type: 'string' }, 149 | currentPeriodEnd: { type: 'string' }, 150 | customer: { $ref: 'Customer' }, 151 | changes: { 152 | type: 'array', 153 | items: { $ref: 'SubscriptionChange' }, 154 | }, 155 | // invoices: { 156 | // type: 'array', 157 | // items: { $ref: 'Invoice' }, 158 | // }, 159 | }, 160 | }); 161 | 162 | server.addSchema({ 163 | $id: 'PaymentMethod', 164 | type: 'object', 165 | properties: { 166 | _id: { type: 'string' }, 167 | customer: { $ref: 'Customer' }, 168 | type: { type: 'string' }, 169 | name: { type: 'string' }, 170 | }, 171 | }); 172 | } 173 | -------------------------------------------------------------------------------- /packages/server/src/api/types.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '~/entities'; 2 | 3 | declare module 'fastify' { 4 | export interface FastifyRequest { 5 | project?: Project; 6 | admin?: boolean; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import path from 'path'; 3 | 4 | dotenv.config({ 5 | path: path.join(__dirname, '..', '..', '..', '.env'), 6 | }); 7 | 8 | const defaultPort = 7171; 9 | 10 | const port = process.env.PORT ? parseInt(process.env.PORT) : defaultPort; 11 | 12 | export const config = { 13 | port, 14 | publicUrl: process.env.PUBLIC_URL || `http://localhost:${port}`, 15 | postgresUrl: process.env.POSTGRES_URL as string, 16 | adminToken: process.env.ADMIN_TOKEN as string, 17 | jwtSecret: process.env.JWT_SECRET as string, 18 | gotenbergUrl: process.env.GOTENBERG_URL || 'http://localhost:3030', 19 | dataPath: process.env.DATA_PATH || path.join(__dirname, '..', 'data'), 20 | mail: { 21 | host: process.env.MAIL_HOST, 22 | port: parseInt(process.env.MAIL_PORT || '25'), 23 | from: process.env.MAIL_FROM, 24 | secure: process.env.MAIL_SECURE === 'true', 25 | requireTLS: process.env.MAIL_REQUIRE_TLS === 'true', 26 | username: process.env.MAIL_USERNAME, 27 | password: process.env.MAIL_PASSWORD, 28 | }, 29 | }; 30 | 31 | export type Config = typeof config; 32 | 33 | export function checkConfig(): void { 34 | const _config = config as Partial; 35 | 36 | if (!_config.publicUrl) { 37 | throw new Error('Please configure PUBLIC_URL'); 38 | } 39 | 40 | if (!_config.postgresUrl) { 41 | throw new Error('Please configure POSTGRES_URL'); 42 | } 43 | 44 | if (!_config.adminToken) { 45 | throw new Error('Please configure ADMIN_TOKEN'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/server/src/database.ts: -------------------------------------------------------------------------------- 1 | import { MikroORM } from '@mikro-orm/core'; 2 | import { EntityManager, EntityRepository, PostgreSqlDriver } from '@mikro-orm/postgresql'; 3 | 4 | import { config } from '~/config'; 5 | import { 6 | Customer, 7 | customerSchema, 8 | Invoice, 9 | invoiceItemSchema, 10 | invoiceSchema, 11 | Payment, 12 | PaymentMethod, 13 | paymentMethodSchema, 14 | paymentSchema, 15 | Project, 16 | projectInvoiceDataSchema, 17 | projectSchema, 18 | Subscription, 19 | subscriptionChangeSchema, 20 | subscriptionSchema, 21 | } from '~/entities'; 22 | import { addExitHook } from '~/lib/exit_hooks'; 23 | import { MigrationAlterColumnLogo } from '~/migrations/000_alter_column_logo_to_text'; 24 | import { MigrationReplaceStartAndEndWithDate } from '~/migrations/001_replace_start_and_end_with_date_invoice'; 25 | import { MigrationSetNextPaymentForSubscriptions } from '~/migrations/002_set_next_payment_for_subscriptions'; 26 | import { MigrationPaymentStatusFromPendingToProcessing } from '~/migrations/003_update_payment_status'; 27 | import { MigrationUpdateInvoiceAddCustomerAndAllowOptionalSubscription } from '~/migrations/004_update_invoice_add_customer_make_subscription_optional'; 28 | import { MigrationReplaceNextPaymentWithCurrentPeriod } from '~/migrations/005_update_status_optional_invoice_subscription'; 29 | import { MigrationSetPaymentProject } from '~/migrations/006_set_payment_project'; 30 | 31 | export class Database { 32 | orm!: MikroORM; 33 | 34 | async init(): Promise { 35 | if (!config.postgresUrl) { 36 | throw new Error('POSTGRES_URL is not set'); 37 | } 38 | 39 | this.orm = await MikroORM.init( 40 | { 41 | type: 'postgresql', 42 | clientUrl: config.postgresUrl, 43 | entities: [ 44 | customerSchema, 45 | subscriptionSchema, 46 | subscriptionChangeSchema, 47 | paymentSchema, 48 | invoiceSchema, 49 | invoiceItemSchema, 50 | projectSchema, 51 | projectInvoiceDataSchema, 52 | paymentMethodSchema, 53 | ], 54 | discovery: { disableDynamicFileAccess: true }, 55 | migrations: { 56 | migrationsList: [ 57 | { 58 | name: 'MigrationReplaceStartAndEndWithDate', 59 | class: MigrationReplaceStartAndEndWithDate, 60 | }, 61 | { 62 | name: 'MigrationAlterColumnLogo', 63 | class: MigrationAlterColumnLogo, 64 | }, 65 | { 66 | name: 'MigrationSetNextPaymentForSubscriptions', 67 | class: MigrationSetNextPaymentForSubscriptions, 68 | }, 69 | { 70 | name: 'MigrationPaymentStatusFromPendingToProcessing', 71 | class: MigrationPaymentStatusFromPendingToProcessing, 72 | }, 73 | { 74 | name: 'MigrationUpdateInvoiceAddCustomerAndAllowOptionalSubscription', 75 | class: MigrationUpdateInvoiceAddCustomerAndAllowOptionalSubscription, 76 | }, 77 | { 78 | name: 'MigrationReplaceNextPaymentWithCurrentPeriod', 79 | class: MigrationReplaceNextPaymentWithCurrentPeriod, 80 | }, 81 | { 82 | name: 'MigrationSetPaymentProject', 83 | class: MigrationSetPaymentProject, 84 | }, 85 | ], 86 | disableForeignKeys: false, 87 | }, 88 | schemaGenerator: { 89 | disableForeignKeys: false, 90 | }, 91 | }, 92 | false, 93 | ); 94 | } 95 | 96 | async connect(): Promise { 97 | await this.orm.connect(); 98 | 99 | await this.orm.getMigrator().up(); 100 | 101 | const generator = this.orm.getSchemaGenerator(); 102 | await generator.updateSchema(); 103 | 104 | addExitHook(() => this.orm.close()); 105 | } 106 | 107 | get em(): EntityManager { 108 | return this.orm.em.fork() as EntityManager; 109 | } 110 | 111 | get projects(): EntityRepository { 112 | return this.em.getRepository(Project); 113 | } 114 | 115 | get subscriptions(): EntityRepository { 116 | return this.em.getRepository(Subscription); 117 | } 118 | 119 | get customers(): EntityRepository { 120 | return this.em.getRepository(Customer); 121 | } 122 | 123 | get payments(): EntityRepository { 124 | return this.em.getRepository(Payment); 125 | } 126 | 127 | get invoices(): EntityRepository { 128 | return this.em.getRepository(Invoice); 129 | } 130 | 131 | get paymentMethods(): EntityRepository { 132 | return this.em.getRepository(PaymentMethod); 133 | } 134 | } 135 | 136 | export const database = new Database(); 137 | -------------------------------------------------------------------------------- /packages/server/src/entities/__snapshots__/subscription.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`Subscription > should generate nice string invoices 1`] = ` 4 | "Invoice from 01.01.2020 00:00 to 01.02.2020 00:00 5 | 1: 01.01.2020 00:00 - 16.01.2020 00:00: 6 | 15 days of 31 = 0.48% 7 | 1$ * 50units = 50$ 8 | 0.48% * 50$ = 24.19$ 9 | 2: 16.01.2020 00:00 - 19.01.2020 00:00: 10 | 3 days of 31 = 0.1% 11 | 1.5$ * 50units = 75$ 12 | 0.1% * 75$ = 7.26$ 13 | 3: 19.01.2020 00:00 - 01.02.2020 00:00: 14 | 13 days of 31 = 0.42% 15 | 2$ * 50units = 100$ 16 | 0.42% * 100$ = 41.94$ 17 | Total: 73.39$" 18 | `; 19 | -------------------------------------------------------------------------------- /packages/server/src/entities/customer.ts: -------------------------------------------------------------------------------- 1 | import { Collection, EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { Invoice } from '~/entities/invoice'; 5 | import { PaymentMethod } from '~/entities/payment_method'; 6 | import { Project } from '~/entities/project'; 7 | import { Subscription } from '~/entities/subscription'; 8 | 9 | export class Customer { 10 | _id: string = v4(); 11 | paymentProviderId!: string; 12 | balance = 0; 13 | name!: string; 14 | email!: string; 15 | addressLine1!: string; 16 | addressLine2?: string; 17 | city!: string; 18 | zipCode!: string; 19 | country!: string; 20 | subscriptions = new Collection(this); 21 | invoicePrefix!: string; 22 | invoiceCounter = 0; 23 | project!: Project; 24 | activePaymentMethod?: PaymentMethod; 25 | paymentMethods = new Collection(this); 26 | language = 'en'; 27 | 28 | constructor(data?: Partial) { 29 | Object.assign(this, data); 30 | if (!this.invoicePrefix) { 31 | this.createInvoicePrefix(); 32 | } 33 | } 34 | 35 | createInvoicePrefix(): void { 36 | const appId = 'INV'; 37 | const randomId = (Math.random() + 1).toString(36).substring(2, 6); // 4 letter 38 | const customerId = this._id.substring(this._id.length - 3); 39 | this.invoicePrefix = [appId, customerId, randomId].map((s) => s.toUpperCase()).join('-'); 40 | } 41 | 42 | toJSON(): Customer { 43 | return { 44 | ...this, 45 | activePaymentMethod: this.activePaymentMethod?.toJSON(), 46 | }; 47 | } 48 | } 49 | 50 | export const customerSchema = new EntitySchema({ 51 | class: Customer, 52 | properties: { 53 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 54 | paymentProviderId: { type: 'string' }, 55 | balance: { type: 'float', default: 0 }, 56 | name: { type: 'string' }, 57 | email: { type: 'string' }, 58 | addressLine1: { type: 'string' }, 59 | addressLine2: { type: 'string', nullable: true }, 60 | city: { type: 'string' }, 61 | zipCode: { type: 'string' }, 62 | country: { type: 'string' }, 63 | invoicePrefix: { type: 'string' }, 64 | invoiceCounter: { type: 'number' }, 65 | subscriptions: { 66 | reference: ReferenceType.ONE_TO_MANY, 67 | entity: () => Subscription, 68 | mappedBy: (subscription: Subscription) => subscription.customer, 69 | }, 70 | invoices: { 71 | reference: ReferenceType.ONE_TO_MANY, 72 | entity: () => Invoice, 73 | mappedBy: (invoice: Invoice) => invoice.customer, 74 | }, 75 | project: { 76 | reference: ReferenceType.MANY_TO_ONE, 77 | entity: () => Project, 78 | }, 79 | paymentMethods: { 80 | reference: ReferenceType.ONE_TO_MANY, 81 | entity: () => PaymentMethod, 82 | mappedBy: (paymentMethod: PaymentMethod) => paymentMethod.customer, 83 | }, 84 | activePaymentMethod: { reference: ReferenceType.MANY_TO_ONE, entity: () => PaymentMethod, nullable: true }, 85 | language: { type: 'string', default: 'en' }, 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /packages/server/src/entities/index.ts: -------------------------------------------------------------------------------- 1 | import { Customer, customerSchema } from './customer'; 2 | import { Invoice, invoiceSchema } from './invoice'; 3 | import { InvoiceItem, invoiceItemSchema } from './invoice_item'; 4 | import { Currency, Payment, paymentSchema } from './payment'; 5 | import { PaymentMethod, paymentMethodSchema } from './payment_method'; 6 | import { Project, projectSchema } from './project'; 7 | import { ProjectInvoiceData, projectInvoiceDataSchema } from './project_invoice_data'; 8 | import { Subscription, subscriptionSchema } from './subscription'; 9 | import { SubscriptionChange, subscriptionChangeSchema } from './subscription_change'; 10 | import { SubscriptionPeriod } from './subscription_period'; 11 | 12 | export { 13 | Currency, 14 | Customer, 15 | customerSchema, 16 | Invoice, 17 | InvoiceItem, 18 | invoiceItemSchema, 19 | invoiceSchema, 20 | Payment, 21 | PaymentMethod, 22 | paymentMethodSchema, 23 | paymentSchema, 24 | Project, 25 | ProjectInvoiceData, 26 | projectInvoiceDataSchema, 27 | projectSchema, 28 | Subscription, 29 | SubscriptionChange, 30 | subscriptionChangeSchema, 31 | SubscriptionPeriod, 32 | subscriptionSchema, 33 | }; 34 | -------------------------------------------------------------------------------- /packages/server/src/entities/invoice.ts: -------------------------------------------------------------------------------- 1 | import { Collection, EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import fetch from 'cross-fetch'; 3 | import NodeFormData from 'form-data'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import stream from 'stream/promises'; 7 | import { v4 } from 'uuid'; 8 | 9 | import { config } from '~/config'; 10 | import { Customer } from '~/entities/customer'; 11 | import { InvoiceItem } from '~/entities/invoice_item'; 12 | import { Currency, Payment } from '~/entities/payment'; 13 | import { Project } from '~/entities/project'; 14 | import { Subscription } from '~/entities/subscription'; 15 | import dayjs from '~/lib/dayjs'; 16 | 17 | export type InvoiceStatus = 18 | | 'draft' // invoice is not yet ready to be charged 19 | | 'pending' // invoice is waiting to be charged / picked up by loop 20 | | 'processing' // invoice is waiting for the payment to be processed 21 | | 'paid' // invoice is paid 22 | | 'failed'; // invoice failed to be paid 23 | 24 | export class Invoice { 25 | _id: string = v4(); 26 | date!: Date; 27 | sequentialId!: number; 28 | items = new Collection(this); 29 | status: InvoiceStatus = 'draft'; 30 | subscription?: Subscription; 31 | customer!: Customer; 32 | currency!: Currency; 33 | vatRate!: number; 34 | payment?: Payment; 35 | project!: Project; 36 | 37 | constructor(data?: Partial) { 38 | Object.assign(this, data); 39 | } 40 | 41 | get amount(): number { 42 | return Invoice.roundPrice(this.items.getItems().reduce((acc, item) => acc + item.pricePerUnit * item.units, 0)); 43 | } 44 | 45 | get vatAmount(): number { 46 | return Invoice.roundPrice(this.amount * (this.vatRate / 100)); 47 | } 48 | 49 | get totalAmount(): number { 50 | return Invoice.roundPrice(this.amount + this.vatAmount); 51 | } 52 | 53 | get number(): string { 54 | const invoicePrefix = this.subscription?.customer?.invoicePrefix || 'INV-___-____'; 55 | const sequentialId = String(this.sequentialId || 0).padStart(3, '0'); 56 | return [invoicePrefix, sequentialId].join('-'); 57 | } 58 | 59 | static roundPrice(_price: number): number { 60 | const price = Math.round((_price + Number.EPSILON) * 100) / 100; 61 | return price === 0 ? 0 : price; // convert -0 to 0 62 | } 63 | 64 | static amountToPrice(amount: number, currency: Currency): string { 65 | switch (currency) { 66 | case 'EUR': 67 | return `${Invoice.roundPrice(amount).toFixed(2)} €`; 68 | default: 69 | throw new Error('Currency not supported'); 70 | } 71 | } 72 | 73 | async generateInvoicePdf(): Promise { 74 | const filePath = path.join(config.dataPath, 'invoices', this.getInvoicePath()); 75 | if (fs.existsSync(filePath)) { 76 | return; 77 | } 78 | 79 | const formData = new NodeFormData() as unknown as FormData; 80 | formData.append('url', `${config.publicUrl}/api/invoice/${this._id}/html?token=${this.project.apiToken}`); 81 | 82 | const response = await fetch(`${config.gotenbergUrl}/forms/chromium/convert/url`, { 83 | method: 'POST', 84 | body: formData, 85 | }); 86 | 87 | if (!response.ok || !response.body) { 88 | throw new Error(`unexpected response ${response.statusText}`); 89 | } 90 | 91 | if (!fs.existsSync(path.dirname(filePath))) { 92 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); 93 | } 94 | 95 | // cast as response.body is a ReadableStream from DOM and not NodeJS.ReadableStream 96 | const httpStream = response.body as unknown as NodeJS.ReadableStream; 97 | 98 | await stream.pipeline(httpStream, fs.createWriteStream(filePath)); 99 | } 100 | 101 | getInvoicePath(): string { 102 | return path.join(this.project._id, `invoice-${this._id}.pdf`); 103 | } 104 | 105 | toString(): string { 106 | const formatDate = (date: Date) => dayjs(date).format('DD.MM.YYYY HH:mm'); 107 | return `Invoice from ${formatDate(this.date)}\n${this.items 108 | .getItems() 109 | .map((item) => { 110 | const basePrice = Invoice.roundPrice(item.pricePerUnit * item.units); 111 | return `\n\t\t${item.pricePerUnit}$ * ${item.units}units = ${basePrice}$`; 112 | }) 113 | .join('\n')}\nVat (${this.vatRate}%): ${Invoice.amountToPrice( 114 | this.vatAmount, 115 | this.currency, 116 | )} \nTotal: ${Invoice.amountToPrice(this.totalAmount, this.currency)}$`; 117 | } 118 | 119 | toJSON(): Invoice { 120 | return { 121 | ...this, 122 | subscription: this.subscription?.toJSON(), 123 | customer: this.customer.toJSON(), 124 | vatAmount: this.vatAmount, 125 | amount: this.amount, 126 | totalAmount: this.totalAmount, 127 | number: this.number, 128 | items: this.items.getItems(), 129 | }; 130 | } 131 | } 132 | 133 | export const invoiceSchema = new EntitySchema({ 134 | class: Invoice, 135 | properties: { 136 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 137 | number: { type: 'string' }, 138 | date: { type: 'date' }, 139 | status: { type: String }, 140 | items: { 141 | reference: ReferenceType.ONE_TO_MANY, 142 | entity: () => InvoiceItem, 143 | mappedBy: (item: InvoiceItem) => item.invoice, 144 | }, 145 | subscription: { 146 | reference: ReferenceType.MANY_TO_ONE, 147 | entity: () => Subscription, 148 | nullable: true, 149 | }, 150 | customer: { 151 | reference: ReferenceType.MANY_TO_ONE, 152 | entity: () => Customer, 153 | }, 154 | payment: { 155 | reference: ReferenceType.ONE_TO_ONE, 156 | entity: () => Payment, 157 | nullable: true, 158 | }, 159 | vatRate: { type: 'float' }, 160 | currency: { type: String }, 161 | sequentialId: { type: Number }, 162 | project: { 163 | reference: ReferenceType.MANY_TO_ONE, 164 | entity: () => Project, 165 | }, 166 | file: { type: 'string', nullable: true }, 167 | }, 168 | }); 169 | -------------------------------------------------------------------------------- /packages/server/src/entities/invoice_item.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { Invoice } from './invoice'; 5 | 6 | export class InvoiceItem { 7 | _id: string = v4(); 8 | pricePerUnit!: number; 9 | units!: number; 10 | description?: string; 11 | invoice!: Invoice; 12 | 13 | constructor(data: Partial) { 14 | Object.assign(this, data); 15 | } 16 | } 17 | 18 | export const invoiceItemSchema = new EntitySchema({ 19 | class: InvoiceItem, 20 | properties: { 21 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 22 | pricePerUnit: { type: 'float' }, 23 | units: { type: Number }, 24 | description: { type: String }, 25 | invoice: { 26 | reference: ReferenceType.MANY_TO_ONE, 27 | entity: () => Invoice, 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /packages/server/src/entities/payment.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { Customer } from '~/entities/customer'; 5 | import { Invoice } from '~/entities/invoice'; 6 | import { Project } from '~/entities/project'; 7 | import { Subscription } from '~/entities/subscription'; 8 | 9 | export type PaymentStatus = 'processing' | 'paid' | 'failed'; 10 | 11 | export type Currency = 'EUR'; 12 | 13 | export class Payment { 14 | _id: string = v4(); 15 | project!: Project; 16 | status: PaymentStatus = 'processing'; 17 | type!: 'recurring' | 'one-off' | 'verification'; 18 | currency!: Currency; 19 | customer!: Customer; 20 | amount!: number; 21 | description!: string; 22 | subscription?: Subscription; 23 | invoice?: Invoice; 24 | 25 | constructor(data?: Partial) { 26 | Object.assign(this, data); 27 | } 28 | 29 | toJSON(): Payment { 30 | return { 31 | ...this, 32 | subscription: this.subscription?.toJSON(), 33 | customer: this.customer.toJSON(), 34 | invoice: this.invoice 35 | ? { 36 | _id: this.invoice?._id, 37 | } 38 | : undefined, 39 | }; 40 | } 41 | } 42 | 43 | export const paymentSchema = new EntitySchema({ 44 | class: Payment, 45 | properties: { 46 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 47 | status: { type: 'string' }, 48 | type: { type: 'string', default: 'recurring' }, 49 | currency: { type: 'string' }, 50 | amount: { type: 'float' }, 51 | description: { type: 'string' }, 52 | customer: { 53 | reference: ReferenceType.MANY_TO_ONE, 54 | entity: () => Customer, 55 | }, 56 | subscription: { 57 | reference: ReferenceType.MANY_TO_ONE, 58 | entity: () => Subscription, 59 | nullable: true, 60 | }, 61 | project: { 62 | reference: ReferenceType.MANY_TO_ONE, 63 | entity: () => Project, 64 | }, 65 | invoice: { 66 | reference: ReferenceType.ONE_TO_ONE, 67 | entity: () => Invoice, 68 | mappedBy: (invoice: Invoice) => invoice.payment, 69 | nullable: true, 70 | }, 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /packages/server/src/entities/payment_method.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { Customer } from '~/entities/customer'; 5 | import { Project } from '~/entities/project'; 6 | 7 | export type PaymentMethodStatus = 'pending' | 'verified' | 'rejected'; 8 | 9 | // The PaymentMethod entity is used to store payment methods for a customer. 10 | export class PaymentMethod { 11 | _id: string = v4(); 12 | paymentProviderId!: string; // the id of the payment method in the payment provider 13 | type!: string; 14 | name!: string; 15 | customer!: Customer; 16 | project!: Project; 17 | 18 | constructor(data?: Partial) { 19 | Object.assign(this, data); 20 | } 21 | 22 | toJSON(): PaymentMethod { 23 | return { 24 | ...this, 25 | project: undefined, 26 | customer: undefined, 27 | }; 28 | } 29 | } 30 | 31 | export const paymentMethodSchema = new EntitySchema({ 32 | class: PaymentMethod, 33 | properties: { 34 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 35 | paymentProviderId: { type: 'string' }, 36 | type: { type: 'string' }, 37 | name: { type: 'string' }, 38 | customer: { 39 | reference: ReferenceType.MANY_TO_ONE, 40 | entity: () => Customer, 41 | }, 42 | project: { 43 | reference: ReferenceType.MANY_TO_ONE, 44 | entity: () => Project, 45 | }, 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /packages/server/src/entities/project.ts: -------------------------------------------------------------------------------- 1 | import { Collection, EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { Customer } from '~/entities/customer'; 5 | import { Invoice } from '~/entities/invoice'; 6 | import { Currency } from '~/entities/payment'; 7 | import { ProjectInvoiceData } from '~/entities/project_invoice_data'; 8 | import { Subscription } from '~/entities/subscription'; 9 | 10 | export class Project { 11 | _id: string = v4(); 12 | name!: string; 13 | customers = new Collection(this); 14 | subscriptions = new Collection(this); 15 | invoices = new Collection(this); 16 | invoiceData?: ProjectInvoiceData; 17 | apiToken!: string; 18 | webhookUrl!: string; 19 | paymentProvider!: 'mocked' | 'mollie'; 20 | mollieApiKey?: string; 21 | currency!: Currency; 22 | vatRate!: number; 23 | 24 | constructor(data?: Partial) { 25 | Object.assign(this, data); 26 | } 27 | } 28 | 29 | export const projectSchema = new EntitySchema({ 30 | class: Project, 31 | properties: { 32 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 33 | name: { type: String }, 34 | customers: { 35 | reference: ReferenceType.ONE_TO_MANY, 36 | entity: () => Customer, 37 | }, 38 | subscriptions: { 39 | reference: ReferenceType.ONE_TO_MANY, 40 | entity: () => Subscription, 41 | }, 42 | invoices: { 43 | reference: ReferenceType.ONE_TO_MANY, 44 | entity: () => Invoice, 45 | }, 46 | invoiceData: { 47 | reference: ReferenceType.ONE_TO_ONE, 48 | entity: () => ProjectInvoiceData, 49 | }, 50 | apiToken: { type: 'string' }, 51 | webhookUrl: { type: 'string' }, 52 | paymentProvider: { type: 'string' }, 53 | mollieApiKey: { type: 'string' }, 54 | currency: { type: 'string', default: 'EUR' }, 55 | vatRate: { type: 'number', default: 19.0 }, 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /packages/server/src/entities/project_invoice_data.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | 4 | export class ProjectInvoiceData { 5 | _id: string = v4(); 6 | name!: string; 7 | email!: string; 8 | addressLine1!: string; 9 | addressLine2!: string; 10 | city!: string; 11 | zipCode!: string; 12 | country!: string; 13 | logo!: string; 14 | 15 | constructor(data?: Partial) { 16 | Object.assign(this, data); 17 | } 18 | } 19 | 20 | export const projectInvoiceDataSchema = new EntitySchema({ 21 | class: ProjectInvoiceData, 22 | properties: { 23 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 24 | name: { type: 'string' }, 25 | email: { type: 'string' }, 26 | addressLine1: { type: 'string' }, 27 | addressLine2: { type: 'string', nullable: true }, 28 | city: { type: 'string' }, 29 | zipCode: { type: 'string' }, 30 | country: { type: 'string' }, 31 | logo: { type: 'text', nullable: true }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/server/src/entities/subscription.ts: -------------------------------------------------------------------------------- 1 | import { Collection, EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import dayjs from 'dayjs'; 3 | import { v4 } from 'uuid'; 4 | 5 | import { Customer } from '~/entities/customer'; 6 | import { Invoice } from '~/entities/invoice'; 7 | import { Project } from '~/entities/project'; 8 | import { SubscriptionChange } from '~/entities/subscription_change'; 9 | import { SubscriptionPeriod } from '~/entities/subscription_period'; 10 | 11 | export class Subscription { 12 | _id: string = v4(); 13 | metadata?: Record; 14 | anchorDate!: Date; // first date a user ever started a subscription for the object 15 | status: 'processing' | 'active' | 'error' | 'paused' | 'canceled' = 'active'; 16 | error?: string; 17 | lastPayment?: Date; 18 | currentPeriodStart!: Date; 19 | currentPeriodEnd!: Date; 20 | customer!: Customer; 21 | changes = new Collection(this); 22 | createdAt: Date = new Date(); 23 | updatedAt: Date = new Date(); 24 | canceledAt?: Date; 25 | invoices = new Collection(this); 26 | project!: Project; 27 | 28 | constructor(data?: Partial) { 29 | Object.assign(this, data); 30 | } 31 | 32 | cleanupChanges(): void { 33 | const changes = this.changes.getItems(); 34 | 35 | // set end date of existing changes 36 | if (changes.length > 0) { 37 | // sort changes in reverse order by start date 38 | changes.sort((a, b) => b.start.getTime() - a.start.getTime()); 39 | 40 | let lastEnd: Date | undefined = undefined; 41 | for (const change of changes) { 42 | if (!change.end) { 43 | change.end = lastEnd; 44 | } 45 | lastEnd = dayjs(change.start).subtract(1, 'second').toDate(); 46 | } 47 | 48 | this.changes.set(changes); 49 | } 50 | } 51 | 52 | /** 53 | * End the current plan and start with a new one 54 | * @param data.pricePerUnit Price per unit for the new plan 55 | * @param data.units Units for the new plan 56 | * @param data.changeDate Date when to end the current plan and start with a new one 57 | */ 58 | changePlan(data: { pricePerUnit: number; units: number; changeDate?: Date }): void { 59 | this.changes.add( 60 | new SubscriptionChange({ 61 | start: this.changes.count() === 0 ? this.anchorDate : (data.changeDate as Date), 62 | pricePerUnit: data.pricePerUnit, 63 | units: data.units, 64 | subscription: this, 65 | }), 66 | ); 67 | 68 | this.cleanupChanges(); 69 | } 70 | 71 | getPeriod(start: Date, end: Date): SubscriptionPeriod { 72 | return new SubscriptionPeriod(this, start, end); 73 | } 74 | 75 | toJSON(): Subscription { 76 | return { 77 | ...this, 78 | changes: this.changes?.isInitialized() 79 | ? this.changes.getItems().map((change) => ({ ...change, subscription: undefined })) 80 | : [], 81 | }; 82 | } 83 | } 84 | 85 | export const subscriptionSchema = new EntitySchema({ 86 | class: Subscription, 87 | properties: { 88 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 89 | metadata: { type: 'json', nullable: true }, 90 | anchorDate: { type: Date }, 91 | status: { type: 'string', default: 'active' }, 92 | error: { type: 'string', nullable: true }, 93 | lastPayment: { type: Date, nullable: true }, 94 | currentPeriodStart: { type: Date }, 95 | currentPeriodEnd: { type: Date }, 96 | customer: { 97 | reference: ReferenceType.MANY_TO_ONE, 98 | entity: () => Customer, 99 | }, 100 | changes: { 101 | reference: ReferenceType.ONE_TO_MANY, 102 | entity: () => SubscriptionChange, 103 | mappedBy: (change: SubscriptionChange) => change.subscription, 104 | }, 105 | createdAt: { type: Date, onCreate: () => new Date() }, 106 | updatedAt: { type: Date, onUpdate: () => new Date() }, 107 | canceledAt: { type: Date, nullable: true }, 108 | invoices: { 109 | reference: ReferenceType.ONE_TO_MANY, 110 | entity: () => Invoice, 111 | mappedBy: (invoice: Invoice) => invoice.subscription, 112 | }, 113 | project: { 114 | reference: ReferenceType.MANY_TO_ONE, 115 | entity: () => Project, 116 | }, 117 | }, 118 | }); 119 | -------------------------------------------------------------------------------- /packages/server/src/entities/subscription_change.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema, ReferenceType } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { Subscription } from '~/entities/subscription'; 5 | 6 | export class SubscriptionChange { 7 | _id: string = v4(); 8 | start!: Date; 9 | end?: Date; 10 | pricePerUnit!: number; 11 | units!: number; 12 | subscription!: Subscription; 13 | 14 | constructor(data?: Partial) { 15 | Object.assign(this, data); 16 | } 17 | } 18 | 19 | export const subscriptionChangeSchema = new EntitySchema({ 20 | class: SubscriptionChange, 21 | properties: { 22 | _id: { type: 'uuid', onCreate: () => v4(), primary: true }, 23 | start: { type: Date }, 24 | end: { type: Date, nullable: true }, 25 | pricePerUnit: { type: 'float' }, 26 | units: { type: Number }, 27 | subscription: { 28 | reference: ReferenceType.MANY_TO_ONE, 29 | entity: () => Subscription, 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /packages/server/src/entities/subscription_period.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, it } from 'vitest'; 2 | 3 | import { config } from '~/config'; 4 | import { database } from '~/database'; 5 | import dayjs from '~/lib/dayjs'; 6 | import { getPeriodFromAnchorDate } from '~/utils'; 7 | 8 | import { Subscription } from './subscription'; 9 | import { SubscriptionPeriod } from './subscription_period'; 10 | 11 | describe('Subscription period', () => { 12 | beforeAll(async () => { 13 | config.postgresUrl = 'postgres://postgres:postgres@localhost:5432/postgres'; // set to dummy value so we can init database 14 | await database.init(); 15 | }); 16 | 17 | it('should handle simple month to month periods', () => { 18 | // given 19 | const subscription = new Subscription({ 20 | anchorDate: dayjs('2020-01-01').toDate(), 21 | }); 22 | 23 | // when 24 | subscription.changePlan({ pricePerUnit: 1, units: 50 }); 25 | const { start, end } = getPeriodFromAnchorDate(dayjs('2020-01-31').toDate(), subscription.anchorDate); 26 | const period = new SubscriptionPeriod(subscription, start, end); 27 | const invoiceItems = period.getInvoiceItems(); 28 | 29 | // then 30 | expect(invoiceItems).toHaveLength(1); 31 | expect(invoiceItems[0].pricePerUnit).toBe(50); 32 | expect(invoiceItems[0].units).toBe(1); 33 | }); 34 | 35 | it('should handle middle of month to middle of next month periods', () => { 36 | // given 37 | const subscription = new Subscription({ 38 | anchorDate: dayjs('2020-01-15').toDate(), 39 | }); 40 | subscription.changePlan({ pricePerUnit: 1, units: 50 }); 41 | 42 | // when 43 | const { start, end } = getPeriodFromAnchorDate(dayjs('2020-02-01').toDate(), subscription.anchorDate); 44 | const period = new SubscriptionPeriod(subscription, start, end); 45 | const invoiceItems = period.getInvoiceItems(); 46 | 47 | // then 48 | expect(invoiceItems).toHaveLength(1); 49 | expect(invoiceItems[0].pricePerUnit).toBe(50); 50 | expect(invoiceItems[0].units).toBe(1); 51 | }); 52 | 53 | it('should handle multiple changes of units and prices', () => { 54 | // given 55 | const subscription = new Subscription({ 56 | anchorDate: dayjs('2020-01-01').toDate(), 57 | }); 58 | subscription.changePlan({ pricePerUnit: 1, units: 50 }); 59 | subscription.changePlan({ pricePerUnit: 1.5, units: 50, changeDate: dayjs('2020-01-16').toDate() }); 60 | subscription.changePlan({ pricePerUnit: 2, units: 50, changeDate: dayjs('2020-01-19').toDate() }); 61 | 62 | // when 63 | const { start, end } = getPeriodFromAnchorDate(dayjs('2020-01-01').toDate(), subscription.anchorDate); 64 | const period = new SubscriptionPeriod(subscription, start, end); 65 | const invoiceItems = period.getInvoiceItems(); 66 | 67 | // then 68 | expect(invoiceItems).toHaveLength(3); 69 | }); 70 | 71 | it('should handle same day changes', () => { 72 | // given 73 | const anchorDate = dayjs('2020-01-01').toDate(); 74 | const subscription = new Subscription({ 75 | anchorDate, 76 | }); 77 | subscription.changePlan({ pricePerUnit: 1, units: 50 }); 78 | const { start, end } = getPeriodFromAnchorDate(dayjs('2020-01-31').toDate(), subscription.anchorDate); 79 | subscription.changePlan({ pricePerUnit: 5, units: 50, changeDate: dayjs(end).subtract(5, 'hours').toDate() }); 80 | 81 | // when 82 | const subscriptionPeriod = subscription.getPeriod(start, end); 83 | const invoiceItems = subscriptionPeriod.getInvoiceItems(); 84 | 85 | // then 86 | expect(invoiceItems).toHaveLength(2); 87 | }); 88 | 89 | it('should generate nice string invoices', () => { 90 | // given 91 | const anchorDate = dayjs('2020-01-01').toDate(); 92 | const subscription = new Subscription({ 93 | anchorDate, 94 | }); 95 | subscription.changePlan({ pricePerUnit: 1, units: 50 }); 96 | subscription.changePlan({ pricePerUnit: 1.5, units: 50, changeDate: dayjs('2020-01-16').toDate() }); 97 | subscription.changePlan({ pricePerUnit: 2, units: 50, changeDate: dayjs('2020-01-19').toDate() }); 98 | const { start, end } = getPeriodFromAnchorDate(dayjs('2020-01-31').toDate(), subscription.anchorDate); 99 | 100 | // when 101 | const subscriptionPeriod = subscription.getPeriod(start, end); 102 | const invoiceItems = subscriptionPeriod.getInvoiceItems(); 103 | 104 | // then 105 | expect(invoiceItems).toHaveLength(3); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/server/src/entities/subscription_period.ts: -------------------------------------------------------------------------------- 1 | import { Invoice } from '~/entities/invoice'; 2 | import { InvoiceItem } from '~/entities/invoice_item'; 3 | import { Subscription } from '~/entities/subscription'; 4 | import { SubscriptionChange } from '~/entities/subscription_change'; 5 | import dayjs from '~/lib/dayjs'; 6 | 7 | type InvoiceSubscriptionItem = { 8 | start: Date; 9 | end: Date; 10 | units: number; 11 | pricePerUnit: number; 12 | }; 13 | 14 | export class SubscriptionPeriod { 15 | readonly start: Date; 16 | readonly end: Date; 17 | readonly changes: SubscriptionChange[]; 18 | 19 | constructor(subscription: Subscription, start: Date, end: Date) { 20 | this.start = start; 21 | this.end = end; 22 | 23 | this.changes = subscription.changes 24 | .getItems() 25 | .filter((change) => { 26 | const changeEnd = change.end || end; 27 | return dayjs(changeEnd).isBetween(start, end, 'day', '[]'); 28 | }) 29 | .sort((a, b) => a.start.getTime() - b.start.getTime()); 30 | } 31 | 32 | private getPriceForInvoiceItem(item: InvoiceSubscriptionItem): number { 33 | const basePrice = item.pricePerUnit * item.units; 34 | const start = dayjs(item.start); 35 | const end = dayjs(item.end); 36 | const itemDiff = dayjs(start).diff(end); 37 | const invoiceDiff = dayjs(this.start).diff(this.end); 38 | return (basePrice / invoiceDiff) * itemDiff; 39 | } 40 | 41 | getInvoiceItems(): InvoiceItem[] { 42 | const items: InvoiceItem[] = []; 43 | 44 | const periodDays = dayjs(this.end).diff(this.start); 45 | const diffMsToDates = (diffMs: number) => Math.round(diffMs / (1000 * 60 * 60 * 24)); 46 | const formatDate = (date: Date) => dayjs(date).format('DD.MM.YYYY'); 47 | 48 | let start = this.start; 49 | for (const [i, change] of this.changes.entries()) { 50 | if (!change.end && i !== this.changes.length - 1 && this.changes.length > 1) { 51 | throw new Error('Only the last item is allowed to have no end date'); 52 | } 53 | 54 | const item: InvoiceSubscriptionItem = { 55 | start, 56 | end: change.end || this.end, // use this.end for the last entry 57 | units: change.units, 58 | pricePerUnit: change.pricePerUnit, 59 | }; 60 | 61 | const priceForPeriod = this.getPriceForInvoiceItem(item); 62 | const basePrice = item.pricePerUnit * item.units; 63 | const period = dayjs(item.end).diff(item.start); 64 | const percentDays = Math.floor(Invoice.roundPrice(period / periodDays) * 100); 65 | const currency = 'EUR'; // TODO: use appropriate currency 66 | let description = `${formatDate(item.start)} - ${formatDate(item.end)}:`; 67 | description += `\n\t${diffMsToDates(period)} days of ${diffMsToDates(periodDays)} = ${percentDays}%`; 68 | description += `\n\t${Invoice.amountToPrice(item.pricePerUnit, currency)} * ${ 69 | item.units 70 | } units = ${Invoice.amountToPrice(basePrice, currency)}`; 71 | description += `\n\t${percentDays}% * ${Invoice.amountToPrice(basePrice, currency)} = ${Invoice.amountToPrice( 72 | this.getPriceForInvoiceItem(item), 73 | currency, 74 | )}`; 75 | 76 | items.push( 77 | new InvoiceItem({ 78 | units: 1, 79 | pricePerUnit: Invoice.roundPrice(priceForPeriod), 80 | description, 81 | }), 82 | ); 83 | 84 | start = change.end as Date; 85 | } 86 | 87 | return items; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { init as serverInit } from '~/api'; 2 | import { checkConfig, config } from '~/config'; 3 | import { database } from '~/database'; 4 | import { loadNgrok } from '~/lib/development_proxy'; 5 | import { log } from '~/log'; 6 | import { startLoops } from '~/loop'; 7 | import { init as mailInit } from '~/mail'; 8 | 9 | async function start() { 10 | checkConfig(); 11 | 12 | await loadNgrok(); 13 | 14 | await database.init(); 15 | await database.connect(); 16 | 17 | mailInit(); 18 | 19 | startLoops(); 20 | 21 | const server = await serverInit(); 22 | 23 | if (process.env.CREATE_PROJECT_DATA) { 24 | // check if there is already a project 25 | const projectsAmount = await database.projects.count({}); 26 | if (projectsAmount === 0) { 27 | log.info('Creating project ...', JSON.parse(process.env.CREATE_PROJECT_DATA)); 28 | const response = await server.inject({ 29 | method: 'POST', 30 | headers: { 31 | authorization: `Bearer ${config.adminToken}`, 32 | }, 33 | url: '/api/project', 34 | payload: JSON.parse(process.env.CREATE_PROJECT_DATA) as Record, 35 | }); 36 | 37 | if (response.statusCode !== 200) { 38 | // eslint-disable-next-line no-console 39 | console.error(response.body); 40 | process.exit(1); 41 | } 42 | } 43 | } 44 | 45 | try { 46 | log.info(`Starting server ${config.publicUrl} ...`); 47 | await server.listen({ port: config.port, host: '::' }); 48 | } catch (err) { 49 | // eslint-disable-next-line no-console 50 | console.error(err); 51 | process.exit(1); 52 | } 53 | } 54 | 55 | void start(); 56 | -------------------------------------------------------------------------------- /packages/server/src/lib/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import dayjsCustomParseFormat from 'dayjs/plugin/customParseFormat'; 3 | import dayjsIsBetween from 'dayjs/plugin/isBetween'; 4 | import dayjsMinMax from 'dayjs/plugin/minMax'; 5 | 6 | dayjs.extend(dayjsCustomParseFormat); 7 | dayjs.extend(dayjsMinMax); 8 | dayjs.extend(dayjsIsBetween); 9 | 10 | export default dayjs; 11 | 12 | export function formatDate(date: Date, format = 'dd.mm.YYYY'): string { 13 | return dayjs(date).format(format); 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/lib/development_proxy.ts: -------------------------------------------------------------------------------- 1 | // import exitHook from 'exit-hook'; 2 | import { connect, disconnect } from 'ngrok'; 3 | 4 | import { config } from '~/config'; 5 | import { addExitHook } from '~/lib/exit_hooks'; 6 | 7 | export async function loadNgrok(): Promise { 8 | if (process.env.NGROK_ENABLE !== 'true') { 9 | return; 10 | } 11 | 12 | const ngrokUrl = await connect({ 13 | authtoken: process.env.NGROK_AUTH_TOKEN, 14 | addr: config.port, 15 | }); 16 | 17 | addExitHook(() => disconnect()); 18 | 19 | config.publicUrl = ngrokUrl; 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/lib/exit_hooks.ts: -------------------------------------------------------------------------------- 1 | type CallbackFnc = () => void | Promise; 2 | 3 | const callbacks: CallbackFnc[] = []; 4 | let isCalled = false; 5 | 6 | function registerExitHook(): void { 7 | function exit(shouldManuallyExit: boolean, signal: number) { 8 | void (async () => { 9 | if (isCalled) { 10 | return; 11 | } 12 | 13 | isCalled = true; 14 | 15 | for await (const callback of callbacks) { 16 | // eslint-disable-next-line promise/prefer-await-to-callbacks 17 | await callback(); 18 | } 19 | 20 | if (shouldManuallyExit === true) { 21 | process.exit(128 + signal); 22 | } 23 | })(); 24 | } 25 | 26 | process.once('exit', exit.bind(undefined, false, 0)); 27 | process.once('SIGINT', exit.bind(undefined, true, 2)); 28 | process.once('SIGTERM', exit.bind(undefined, true, 15)); 29 | } 30 | 31 | registerExitHook(); 32 | 33 | export function addExitHook(exitHook: CallbackFnc): void { 34 | callbacks.push(exitHook); 35 | } 36 | -------------------------------------------------------------------------------- /packages/server/src/log.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino'; 2 | 3 | export const log = pino({ 4 | level: process.env.LOG_LEVEL || 'info', 5 | transport: 6 | process.env.NODE_ENV === 'production' 7 | ? undefined 8 | : { 9 | target: 'pino-pretty', 10 | options: { 11 | translateTime: 'HH:MM:ss Z', 12 | ignore: 'pid,hostname', 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /packages/server/src/mail/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import nodemailer from 'nodemailer'; 3 | import path from 'path'; 4 | 5 | import { config } from '~/config'; 6 | import { Customer, Invoice } from '~/entities'; 7 | import { log } from '~/log'; 8 | 9 | import { getTemplate } from './templates'; 10 | 11 | let transporter: nodemailer.Transporter; 12 | 13 | export function init(): void { 14 | const mailConfig = config.mail; 15 | 16 | if (!mailConfig.host || !mailConfig.port || !mailConfig.username || !mailConfig.password) { 17 | log.info('No mail config found, skipping mail initialization'); 18 | return; 19 | } 20 | 21 | transporter = nodemailer.createTransport({ 22 | host: mailConfig.host, 23 | port: mailConfig.port, 24 | secure: mailConfig.secure, 25 | requireTLS: mailConfig.requireTLS, 26 | auth: { 27 | user: mailConfig.username, 28 | pass: mailConfig.password, 29 | }, 30 | }); 31 | } 32 | 33 | export async function sendInvoiceMail(invoice: Invoice, customer: Customer): Promise { 34 | if (!transporter) { 35 | return; 36 | } 37 | 38 | if (invoice.totalAmount === 0) { 39 | log.debug('Skipping sending mail for 0 amount invoice', { invoiceId: invoice._id }); 40 | return; 41 | } 42 | 43 | const template = getTemplate(customer, 'newInvoice'); 44 | 45 | const subject = template.subject({ 46 | invoice: invoice.toJSON(), 47 | project: invoice.project, 48 | }); 49 | 50 | const text = template.text({ 51 | invoice: invoice.toJSON(), 52 | customer: invoice.customer, 53 | }); 54 | 55 | try { 56 | const invoicePath = path.join(config.dataPath, 'invoices', invoice.getInvoicePath()); 57 | if (!fs.existsSync(invoicePath)) { 58 | await invoice.generateInvoicePdf(); 59 | } 60 | 61 | await transporter.sendMail({ 62 | from: config.mail.from, 63 | to: customer.email, 64 | subject, 65 | text, 66 | attachments: [ 67 | { 68 | filename: 'invoice.pdf', 69 | contentType: 'application/pdf', 70 | path: invoicePath, 71 | }, 72 | ], 73 | }); 74 | } catch (error) { 75 | // eslint-disable-next-line no-console 76 | console.error('Problem sending invoice mail', error); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/server/src/mail/templates.ts: -------------------------------------------------------------------------------- 1 | import handlebars, { TemplateDelegate } from 'handlebars'; 2 | 3 | import { Currency, Customer, Invoice, Project } from '~/entities'; 4 | import { formatDate } from '~/lib/dayjs'; 5 | 6 | type TemplateContext = Record; 7 | 8 | type Template = { 9 | subject: TemplateDelegate; 10 | text: TemplateDelegate; 11 | }; 12 | 13 | type Languages = 'en' | 'de'; 14 | 15 | type MultiLanguageTemplate = { 16 | subject: Record['subject']>; 17 | text: Record['text']>; 18 | }; 19 | 20 | /** 21 | * The template contexts must match the following type Record. 22 | * If this does not match the type Templates below will throw a typescript error. 23 | */ 24 | type TemplateContexts = { 25 | newInvoice: { 26 | subject: { project: Project; invoice: Invoice }; 27 | text: { customer: Customer; invoice: Invoice }; 28 | }; 29 | }; 30 | 31 | type Templates = { 32 | [K in keyof TemplateContexts]: MultiLanguageTemplate; 33 | }; 34 | 35 | handlebars.registerHelper('formatDate', (date: Date, format: string) => formatDate(date, format)); 36 | handlebars.registerHelper('amountToPrice', (amount: number, currency: Currency) => 37 | Invoice.amountToPrice(amount, currency), 38 | ); 39 | 40 | // When adding a new template, you must specify the context first in the type "TemplateContexts" above. 41 | // Only the variables specified in the contexts should be used in the actual mail template, because everything else would not be typechecked. 42 | const templates: Templates = { 43 | newInvoice: { 44 | subject: { 45 | en: handlebars.compile(`{{ project.name }} - New invoice {{ invoice.number }}`), 46 | de: handlebars.compile(`{{ project.name }} - Neue Rechnung {{ invoice.number }}`), 47 | }, 48 | text: { 49 | en: handlebars.compile(` 50 | Hello {{ customer.name }}, 51 | 52 | we have created a new invoice for you. 53 | 54 | Please find the invoice {{ invoice.number }} attached as PDF. 55 | 56 | The invoice amount of {{{amountToPrice invoice.totalAmount invoice.currency}}} will be charged to your account soon. 57 | 58 | To view and print the invoice you need a PDF reader. 59 | 60 | Please do not reply to this email. 61 | 62 | Best regards 63 | `), 64 | de: handlebars.compile(` 65 | Hallo {{ customer.name }}, 66 | 67 | anbei erhalten Sie Ihre aktuelle Rechnung {{ invoice.number }} als PDF-Dokument. 68 | 69 | Der Rechnungsbetrag von {{{amountToPrice invoice.totalAmount invoice.currency}}} wird demnächst von Ihrem Konto abgebucht. 70 | 71 | Zum Ansehen und Ausdrucken der Rechnung wird ein PDF-Reader benötigt. 72 | 73 | Antworten Sie bitte nicht auf diese E-Mail. 74 | 75 | Viele Grüße 76 | `), 77 | }, 78 | }, 79 | }; 80 | 81 | export function getTemplate( 82 | customer: Customer, 83 | template: K, 84 | ): Template { 85 | const language = (customer.language || 'en') as Languages; 86 | return { 87 | subject: templates[template].subject[language], 88 | text: templates[template].text[language], 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /packages/server/src/migrations/000_alter_column_logo_to_text.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class MigrationAlterColumnLogo extends Migration { 4 | async up(): Promise { 5 | if (!(await this.ctx?.schema.hasColumn('project_invoice_data', 'logo'))) { 6 | return; 7 | } 8 | 9 | await this.ctx?.schema.alterTable('project_invoice_data', (table) => { 10 | table.text('logo').nullable().alter(); 11 | }); 12 | } 13 | 14 | async down(): Promise { 15 | if (!(await this.ctx?.schema.hasColumn('project_invoice_data', 'logo'))) { 16 | return; 17 | } 18 | 19 | await this.ctx?.schema.alterTable('project_invoice_data', (table) => { 20 | table.string('logo', 255).alter(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/migrations/001_replace_start_and_end_with_date_invoice.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class MigrationReplaceStartAndEndWithDate extends Migration { 4 | async up(): Promise { 5 | if ( 6 | !(await this.ctx?.schema.hasColumn('invoice', 'start')) || 7 | !(await this.ctx?.schema.hasColumn('invoice', 'end')) 8 | ) { 9 | return; 10 | } 11 | 12 | await this.ctx?.schema.alterTable('invoice', (table) => { 13 | table.dropColumn('start'); 14 | table.renameColumn('end', 'date'); 15 | }); 16 | } 17 | 18 | async down(): Promise { 19 | if (!(await this.ctx?.schema.hasColumn('invoice', 'date'))) { 20 | return; 21 | } 22 | 23 | await this.ctx?.schema.alterTable('invoice', (table) => { 24 | table.date('start'); 25 | table.renameColumn('date', 'end'); 26 | }); 27 | 28 | // TODO: set value for start 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/server/src/migrations/002_set_next_payment_for_subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | type Invoice = { 4 | _id: string; 5 | subscription__id: string; 6 | date: Date; 7 | status: string; 8 | }; 9 | 10 | type Subscription = { 11 | _id: string; 12 | anchor_date: Date; 13 | next_payment: Date; 14 | status: 'active' | 'error'; 15 | error?: string; 16 | }; 17 | 18 | export class MigrationSetNextPaymentForSubscriptions extends Migration { 19 | async up(): Promise { 20 | if ( 21 | !(await this.ctx?.schema.hasTable('subscription')) || 22 | (await this.ctx?.schema.hasColumn('subscription', 'next_payment')) 23 | ) { 24 | return; 25 | } 26 | 27 | await this.ctx?.schema.alterTable('subscription', (table) => { 28 | table.date('next_payment').nullable(); 29 | table.string('status').notNullable().defaultTo('active'); 30 | table.string('error').nullable(); 31 | }); 32 | 33 | const subscriptions = await this.ctx?.table('subscription').select(); 34 | for await (const subscription of subscriptions || []) { 35 | const latestDraftInvoice = await this.ctx 36 | ?.table('invoice') 37 | .where({ subscription__id: subscription._id, status: 'draft' }) 38 | .orderBy('date', 'desc') 39 | .first(); 40 | 41 | const nextPayment = latestDraftInvoice?.date; 42 | if (nextPayment) { 43 | await this.ctx 44 | ?.table('subscription') 45 | .where({ _id: subscription._id }) 46 | .update({ next_payment: nextPayment }); 47 | } else { 48 | await this.ctx 49 | ?.table('subscription') 50 | .where({ _id: subscription._id }) 51 | .update({ status: 'error', error: 'No draft subscription found', next_payment: subscription.anchor_date }); 52 | } 53 | } 54 | 55 | await this.ctx?.schema.alterTable('subscription', (table) => { 56 | table.dropNullable('next_payment'); 57 | }); 58 | 59 | // draft invoices where used to loop due subscriptions 60 | await this.ctx?.table('invoice').where({ status: 'draft' }).delete(); 61 | } 62 | 63 | async down(): Promise { 64 | if (!(await this.ctx?.schema.hasColumn('subscription', 'next_payment'))) { 65 | return; 66 | } 67 | 68 | await this.ctx?.schema.alterTable('subscription', (table) => { 69 | table.dropColumn('next_payment'); 70 | table.dropColumn('status'); 71 | table.dropColumn('error'); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/server/src/migrations/003_update_payment_status.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | type Payment = { 4 | _id: string; 5 | status: 'pending' | 'processing'; 6 | }; 7 | 8 | export class MigrationPaymentStatusFromPendingToProcessing extends Migration { 9 | async up(): Promise { 10 | if (!(await this.ctx?.schema.hasTable('payment'))) { 11 | return; 12 | } 13 | 14 | await this.ctx?.table('payment').where({ status: 'pending' }).update({ 15 | status: 'processing', 16 | }); 17 | } 18 | 19 | async down(): Promise { 20 | await this.ctx?.table('pending').where({ status: 'processing' }).update({ 21 | status: 'pending', 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/src/migrations/004_update_invoice_add_customer_make_subscription_optional.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | type Invoice = { 4 | _id: string; 5 | subscription__id: string; 6 | customer__id: string; 7 | }; 8 | 9 | type Subscription = { 10 | _id: string; 11 | customer__id: string; 12 | anchor_date: Date; 13 | next_payment: Date; 14 | current_period_start: Date; 15 | current_period_end: Date; 16 | }; 17 | 18 | export class MigrationUpdateInvoiceAddCustomerAndAllowOptionalSubscription extends Migration { 19 | async up(): Promise { 20 | if ( 21 | !(await this.ctx?.schema.hasTable('invoice')) || 22 | (await this.ctx?.schema.hasColumn('invoice', 'customer__id')) 23 | ) { 24 | return; 25 | } 26 | 27 | // add customer to invoices directly (previously it was only set on a linked subscription) 28 | // and make subscription optional 29 | await this.ctx?.schema.alterTable('invoice', (table) => { 30 | table.uuid('customer__id').nullable(); 31 | table.setNullable('subscription__id'); 32 | }); 33 | 34 | const invoices = await this.ctx?.table('invoice').select(); 35 | for await (const invoice of invoices || []) { 36 | const subscription = await this.ctx 37 | ?.table('subscription') 38 | .where({ _id: invoice.subscription__id }) 39 | .first(); 40 | 41 | if (!subscription) { 42 | throw new Error(`Subscription ${invoice.subscription__id} not found although it is required`); 43 | } 44 | 45 | await this.ctx?.table('invoice').where({ _id: invoice._id }).update({ 46 | customer__id: subscription.customer__id, 47 | }); 48 | } 49 | 50 | await this.ctx?.schema.alterTable('invoice', (table) => { 51 | table.dropNullable('customer__id'); 52 | }); 53 | } 54 | 55 | async down(): Promise { 56 | if (!(await this.ctx?.schema.hasColumn('invoice', 'customer__id'))) { 57 | return; 58 | } 59 | 60 | await this.ctx 61 | ?.table('invoice') 62 | .where({ 63 | subscription__id: undefined, 64 | }) 65 | .delete(); 66 | 67 | await this.ctx?.schema.alterTable('invoice', (table) => { 68 | table.dropNullable('subscription__id'); 69 | table.dropColumn('customer__id'); 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/server/src/migrations/005_update_status_optional_invoice_subscription.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | import dayjs from 'dayjs'; 3 | 4 | import { getPreviousPeriod } from '~/utils'; 5 | 6 | type Subscription = { 7 | _id: string; 8 | customer__id: string; 9 | anchor_date: Date; 10 | next_payment: Date; 11 | current_period_start: Date; 12 | current_period_end: Date; 13 | }; 14 | 15 | export class MigrationReplaceNextPaymentWithCurrentPeriod extends Migration { 16 | async up(): Promise { 17 | if ( 18 | !(await this.ctx?.schema.hasTable('subscription')) || 19 | (await this.ctx?.schema.hasColumn('subscription', 'current_period_start')) 20 | ) { 21 | return; 22 | } 23 | 24 | await this.ctx?.schema.alterTable('subscription', (table) => { 25 | table.date('current_period_start').nullable(); 26 | table.date('current_period_end').nullable(); 27 | }); 28 | 29 | const subscriptions = await this.ctx?.table('subscription').select(); 30 | for await (const subscription of subscriptions || []) { 31 | const currentPeriod = getPreviousPeriod(subscription.next_payment, subscription.anchor_date); 32 | 33 | await this.ctx?.table('subscription').where({ _id: subscription._id }).update({ 34 | current_period_start: currentPeriod.start, 35 | current_period_end: currentPeriod.end, 36 | }); 37 | } 38 | 39 | await this.ctx?.schema.alterTable('subscription', (table) => { 40 | table.dropColumn('next_payment'); 41 | table.dropNullable('current_period_start'); 42 | table.dropNullable('current_period_end'); 43 | }); 44 | } 45 | 46 | async down(): Promise { 47 | if (!(await this.ctx?.schema.hasColumn('subscription', 'current_period_start'))) { 48 | return; 49 | } 50 | 51 | await this.ctx?.schema.alterTable('subscription', (table) => { 52 | table.date('next_payment').nullable(); 53 | }); 54 | 55 | const subscriptions = await this.ctx?.table('subscription').select(); 56 | for await (const subscription of subscriptions || []) { 57 | const next_payment = dayjs(subscription.current_period_end).add(1, 'day').startOf('day').add(1, 'hour').toDate(); 58 | 59 | await this.ctx?.table('subscription').where({ _id: subscription._id }).update({ 60 | next_payment, 61 | }); 62 | } 63 | 64 | await this.ctx?.schema.alterTable('subscription', (table) => { 65 | table.dropNullable('next_payment'); 66 | table.dropColumn('status'); 67 | table.dropColumn('error'); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/server/src/migrations/006_set_payment_project.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | type Payment = { 4 | _id: string; 5 | customer__id: string; 6 | project__id: string; 7 | }; 8 | 9 | type Customer = { 10 | _id: string; 11 | project__id: string; 12 | }; 13 | 14 | export class MigrationSetPaymentProject extends Migration { 15 | async up(): Promise { 16 | if (!(await this.ctx?.schema.hasTable('payment')) || (await this.ctx?.schema.hasColumn('payment', 'project__id'))) { 17 | return; 18 | } 19 | 20 | await this.ctx?.schema.alterTable('payment', (table) => { 21 | table.uuid('project__id').nullable(); 22 | }); 23 | 24 | const payments = await this.ctx?.table('payment').select(); 25 | for await (const payment of payments || []) { 26 | const customer = await this.ctx 27 | ?.table('customer') 28 | .where({ _id: payment.customer__id }) 29 | .first(); 30 | 31 | await this.ctx?.table('payment').where({ _id: payment._id }).update({ 32 | project__id: customer?.project__id, 33 | }); 34 | } 35 | 36 | await this.ctx?.schema.alterTable('payment', (table) => { 37 | table.dropNullable('project__id'); 38 | }); 39 | } 40 | 41 | async down(): Promise { 42 | if (!(await this.ctx?.schema.hasColumn('payment', 'project__id'))) { 43 | return; 44 | } 45 | 46 | await this.ctx?.schema.alterTable('payment', (table) => { 47 | table.dropColumn('project__id'); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/server/src/payment_providers/index.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '~/entities'; 2 | import { Mocked } from '~/payment_providers/mocked'; 3 | import { Mollie } from '~/payment_providers/mollie'; 4 | import { PaymentProvider } from '~/payment_providers/types'; 5 | 6 | export function getPaymentProvider(project: Project): PaymentProvider | undefined { 7 | if (project.paymentProvider === 'mocked') { 8 | return new Mocked(); 9 | } 10 | 11 | if (project.paymentProvider === 'mollie' && project.mollieApiKey) { 12 | return new Mollie({ apiKey: project.mollieApiKey }); 13 | } 14 | 15 | return undefined; 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/src/payment_providers/mocked.ts: -------------------------------------------------------------------------------- 1 | import { config } from '~/config'; 2 | import { Customer, Payment, PaymentMethod, Project } from '~/entities'; 3 | import { PaymentProvider } from '~/payment_providers/types'; 4 | 5 | export class Mocked implements PaymentProvider { 6 | // eslint-disable-next-line @typescript-eslint/require-await 7 | async chargeForegroundPayment({ 8 | payment, 9 | redirectUrl, 10 | }: { 11 | project: Project; 12 | payment: Payment; 13 | redirectUrl: string; 14 | }): Promise<{ checkoutUrl: string }> { 15 | const checkoutUrl = `${config.publicUrl}/api/mocked/checkout/${payment._id}?redirect_url=${redirectUrl}`; 16 | 17 | return { checkoutUrl }; 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | async chargeBackgroundPayment({ payment }: { project: Project; payment: Payment }): Promise { 22 | // 23 | } 24 | 25 | // eslint-disable-next-line @typescript-eslint/require-await 26 | async parsePaymentWebhook( 27 | payload: unknown, 28 | ): Promise<{ paymentId: string; paidAt: Date | undefined; paymentStatus: 'pending' | 'paid' | 'failed' }> { 29 | const { paymentId, paymentStatus, paidAt } = payload as { 30 | paymentStatus: 'pending' | 'paid' | 'failed'; 31 | paidAt: string; 32 | paymentId: string; 33 | }; 34 | 35 | return { 36 | paymentStatus, 37 | paidAt: new Date(paidAt), 38 | paymentId, 39 | }; 40 | } 41 | 42 | // eslint-disable-next-line @typescript-eslint/require-await 43 | async createCustomer(customer: Customer): Promise { 44 | customer.paymentProviderId = 'mocked-123'; 45 | return customer; 46 | } 47 | 48 | // eslint-disable-next-line @typescript-eslint/require-await 49 | async updateCustomer(customer: Customer): Promise { 50 | return customer; 51 | } 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 54 | async deleteCustomer(customer: Customer): Promise { 55 | // 56 | } 57 | 58 | // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars 59 | async getPaymentMethod(paymentId: string): Promise { 60 | return new PaymentMethod({ 61 | name: `test **${Date.now().toString().slice(-2)}`, 62 | paymentProviderId: '123', 63 | type: 'credit_card', 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/server/src/payment_providers/mollie.ts: -------------------------------------------------------------------------------- 1 | import { createMollieClient, Mandate, MollieClient, PaymentStatus, SequenceType } from '@mollie/api-client'; 2 | 3 | import { config } from '~/config'; 4 | import { Customer, Payment, PaymentMethod, Project } from '~/entities'; 5 | import { PaymentProvider } from '~/payment_providers/types'; 6 | 7 | type Metadata = { 8 | paymentId: string; 9 | }; 10 | 11 | export class Mollie implements PaymentProvider { 12 | api: MollieClient; 13 | 14 | constructor({ apiKey }: { apiKey: string }) { 15 | if (!apiKey) { 16 | throw new Error('No api key'); 17 | } 18 | 19 | this.api = createMollieClient({ apiKey }); 20 | } 21 | 22 | async chargeForegroundPayment({ 23 | project, 24 | payment, 25 | redirectUrl, 26 | }: { 27 | project: Project; 28 | payment: Payment; 29 | redirectUrl: string; 30 | }): Promise<{ checkoutUrl: string }> { 31 | const customer = await this.api.customers.get(payment.customer.paymentProviderId); 32 | 33 | const _payment = await this.api.payments.create({ 34 | amount: { 35 | value: this.priceToMolliePrice(payment.amount), 36 | currency: payment.currency, 37 | }, 38 | customerId: customer.id, 39 | description: payment.description, 40 | sequenceType: SequenceType.first, 41 | redirectUrl, 42 | webhookUrl: `${config.publicUrl}/api/payment/webhook/${project._id}`, 43 | metadata: { 44 | paymentId: payment._id, 45 | }, 46 | }); 47 | 48 | const checkoutUrl = _payment._links.checkout?.href; 49 | if (!checkoutUrl) { 50 | throw new Error('No checkout url received'); 51 | } 52 | 53 | return { checkoutUrl }; 54 | } 55 | 56 | async chargeBackgroundPayment({ payment, project }: { payment: Payment; project: Project }): Promise { 57 | if (!payment.customer) { 58 | throw new Error('No customer configured for this payment'); 59 | } 60 | 61 | const paymentMethod = payment.customer.activePaymentMethod; 62 | if (!paymentMethod) { 63 | throw new Error('No payment method configured for this customer'); 64 | } 65 | 66 | await this.api.payments.create({ 67 | amount: { 68 | value: this.priceToMolliePrice(payment.amount), 69 | currency: payment.currency, 70 | }, 71 | description: payment.description, 72 | sequenceType: SequenceType.recurring, 73 | customerId: payment.customer.paymentProviderId, 74 | mandateId: paymentMethod.paymentProviderId, 75 | webhookUrl: `${config.publicUrl}/api/payment/webhook/${project._id}`, 76 | metadata: { 77 | paymentId: payment._id, 78 | }, 79 | }); 80 | } 81 | 82 | private convertPaymentStatus(paymentStatus: PaymentStatus) { 83 | // TODO: check meaning of authorized 84 | if (paymentStatus === PaymentStatus.paid || paymentStatus === PaymentStatus.authorized) { 85 | return 'paid'; 86 | } 87 | 88 | if ( 89 | paymentStatus === PaymentStatus.failed || 90 | paymentStatus === PaymentStatus.expired || 91 | paymentStatus === PaymentStatus.canceled 92 | ) { 93 | return 'failed'; 94 | } 95 | 96 | return 'pending'; 97 | } 98 | 99 | async parsePaymentWebhook( 100 | payload: unknown, 101 | ): Promise<{ paymentId: string; paidAt: Date | undefined; paymentStatus: 'pending' | 'paid' | 'failed' }> { 102 | const _payload = payload as { id?: string }; 103 | if (!_payload.id) { 104 | throw new Error('No id defined in payload'); 105 | } 106 | 107 | const payment = await this.api.payments.get(_payload.id); 108 | 109 | const metadata = payment.metadata as Metadata; 110 | 111 | return { 112 | paymentStatus: this.convertPaymentStatus(payment.status), 113 | paidAt: payment.paidAt ? new Date(payment.paidAt) : undefined, 114 | paymentId: metadata.paymentId, 115 | }; 116 | } 117 | 118 | async createCustomer(customer: Customer): Promise { 119 | const mollieCustomer = await this.api.customers.create({ 120 | name: customer.name, 121 | email: customer.email, 122 | }); 123 | 124 | customer.paymentProviderId = mollieCustomer.id; 125 | 126 | return customer; 127 | } 128 | 129 | async updateCustomer(customer: Customer): Promise { 130 | const mollieCustomer = await this.api.customers.update(customer.paymentProviderId, { 131 | name: customer.name, 132 | email: customer.email, 133 | }); 134 | 135 | customer.paymentProviderId = mollieCustomer.id; 136 | 137 | return customer; 138 | } 139 | 140 | async deleteCustomer(customer: Customer): Promise { 141 | await this.api.customers.delete(customer.paymentProviderId); 142 | } 143 | 144 | async getPaymentMethod(payload: unknown): Promise { 145 | const _payload = payload as { id?: string }; 146 | if (!_payload.id) { 147 | throw new Error('No id defined in payload'); 148 | } 149 | 150 | const payment = await this.api.payments.get(_payload.id); 151 | 152 | if (!payment.mandateId) { 153 | throw new Error('No mandate id set'); 154 | } 155 | 156 | if (!payment.customerId) { 157 | throw new Error('No customer id set'); 158 | } 159 | 160 | const mandate = await this.api.customerMandates.get(payment.mandateId, { customerId: payment.customerId }); 161 | 162 | const details = this.getPaymentMethodDetails(mandate); 163 | 164 | return new PaymentMethod({ 165 | paymentProviderId: mandate.id, 166 | name: details.name, 167 | type: details.type, 168 | }); 169 | } 170 | 171 | private getPaymentMethodDetails(mandate: Mandate): { type: string; name: string } { 172 | if ((mandate.details as MandateDetailsCreditCard).cardNumber) { 173 | const details = mandate.details as MandateDetailsCreditCard; 174 | return { 175 | type: 'credit_card', 176 | name: `**** ${details.cardNumber.substring(details.cardNumber.length - 4)}`, 177 | }; 178 | } 179 | 180 | if ((mandate.details as MandateDetailsDirectDebit).consumerName) { 181 | const details = mandate.details as MandateDetailsDirectDebit; 182 | return { 183 | type: 'direct_debit', 184 | name: `**** ${details.consumerAccount.substring(details.consumerAccount.length - 4)}`, 185 | }; 186 | } 187 | 188 | return { 189 | type: 'unknown', 190 | name: '', 191 | }; 192 | } 193 | 194 | private priceToMolliePrice(price: number): string { 195 | return `${(Math.round(price * 100) / 100).toFixed(2)}`; 196 | } 197 | } 198 | 199 | type MandateDetailsDirectDebit = { 200 | consumerName: string; 201 | consumerAccount: string; 202 | consumerBic: string; 203 | }; 204 | 205 | type MandateDetailsCreditCard = { 206 | cardHolder: string; 207 | cardNumber: string; 208 | }; 209 | -------------------------------------------------------------------------------- /packages/server/src/payment_providers/types.ts: -------------------------------------------------------------------------------- 1 | import { Customer, Payment, PaymentMethod, Project } from '~/entities'; 2 | 3 | export interface PaymentProvider { 4 | createCustomer(customer: Customer): Promise; 5 | deleteCustomer(customer: Customer): Promise; 6 | updateCustomer(customer: Customer): Promise; 7 | chargeForegroundPayment(d: { 8 | project: Project; 9 | payment: Payment; 10 | redirectUrl: string; 11 | }): Promise<{ checkoutUrl: string }>; 12 | chargeBackgroundPayment(d: { project: Project; payment: Payment }): Promise; 13 | parsePaymentWebhook( 14 | payload: unknown, 15 | ): Promise<{ paymentId: string; paidAt: Date | undefined; paymentStatus: 'pending' | 'paid' | 'failed' }>; 16 | getPaymentMethod(payload: unknown): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /packages/server/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import dayjs from '~/lib/dayjs'; 4 | 5 | import { getNextPeriod, getPeriodFromAnchorDate, getPreviousPeriod } from './utils'; 6 | 7 | describe('utils', () => { 8 | // For example, a customer with a monthly subscription set to cycle on the 2nd of the month will 9 | // always be billed on the 2nd. 10 | 11 | // If a month doesn’t have the anchor day, the subscription will be billed on the last day of the month. 12 | // For example, a subscription starting on January 31 bills on February 28 (or February 29 in a leap year), 13 | // then March 31, April 30, and so on. 14 | 15 | it.each([ 16 | // randomDate, anchorDate, start, end 17 | 18 | // 01.01, 01.02, 01.03, 01.04 19 | ['2022-01-15', '2022-01-01', '2022-01-01', '2022-01-31'], 20 | ['2022-02-15', '2022-01-01', '2022-02-01', '2022-02-28'], 21 | 22 | // 15.01, 15.02, 15.03, 15.04 23 | ['2022-01-28', '2022-01-15', '2022-01-15', '2022-02-14'], 24 | ['2022-02-28', '2022-01-15', '2022-02-15', '2022-03-14'], 25 | ['2022-03-31', '2022-01-15', '2022-03-15', '2022-04-14'], 26 | ['2022-02-05', '2022-01-15', '2022-01-15', '2022-02-14'], // randomDate 5th before anchorDate 15th 27 | 28 | // 30.01, 28.02, 30.03, 30.04 29 | ['2022-02-15', '2022-01-30', '2022-01-30', '2022-02-27'], 30 | ['2022-03-15', '2022-01-30', '2022-02-28', '2022-03-29'], // randomDate before anchorDate 31 | 32 | // 31.01, 28.02, 31.03, 30.04 33 | ['2022-02-15', '2022-01-31', '2022-01-31', '2022-02-27'], // anchorDate is 31st 34 | ['2022-03-15', '2022-01-31', '2022-02-28', '2022-03-30'], // randomDate before anchorDate 35 | 36 | // 31.01, 29.02, 31.03, 30.04 (leap year) 37 | ['2020-02-15', '2020-01-31', '2020-01-31', '2020-02-28'], // anchorDate is 31st 38 | ['2021-02-15', '2020-02-29', '2021-01-29', '2021-02-27'], // anchorDate is 31st 39 | ])( 40 | 'should return period boundaries of "%s" with anchor: "%s" => "%s - %s"', 41 | (randomDate, anchorDate, _start, _end) => { 42 | const start = dayjs(_start).startOf('day'); 43 | const end = dayjs(_end).endOf('day'); 44 | const d = getPeriodFromAnchorDate(new Date(randomDate), new Date(anchorDate)); 45 | 46 | expect(dayjs(d.start).format('DD.MM.YYYY'), 'start date').toStrictEqual(start.format('DD.MM.YYYY')); 47 | expect(dayjs(d.end).format('DD.MM.YYYY'), 'end date').toStrictEqual(end.format('DD.MM.YYYY')); 48 | expect(dayjs(randomDate).isBetween(start, end)).toBe(true); 49 | }, 50 | ); 51 | 52 | it('should get the previous period from a date', () => { 53 | const { start, end } = getPreviousPeriod(new Date('2022-02-16'), new Date('2022-01-15')); 54 | expect(dayjs(start).format('DD.MM.YYYY')).toStrictEqual('15.01.2022'); 55 | expect(dayjs(end).format('DD.MM.YYYY')).toStrictEqual('14.02.2022'); 56 | }); 57 | 58 | it('should get the next period from a date', () => { 59 | const { start, end } = getNextPeriod(new Date('2022-01-16'), new Date('2022-01-15')); 60 | expect(dayjs(start).format('DD.MM.YYYY')).toStrictEqual('15.02.2022'); 61 | expect(dayjs(end).format('DD.MM.YYYY')).toStrictEqual('14.03.2022'); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/server/src/utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs from '~/lib/dayjs'; 2 | 3 | export function getPeriodFromAnchorDate(someDateInPeriod: Date, anchorDate: Date): { start: Date; end: Date } { 4 | const startDate = Math.min(dayjs(someDateInPeriod).endOf('month').date(), dayjs(anchorDate).date()); 5 | let start = dayjs(someDateInPeriod).set('date', startDate).startOf('day'); 6 | 7 | if (start.isAfter(someDateInPeriod)) { 8 | // select previous period 9 | const newStart = start.subtract(1, 'month'); 10 | let date = Math.max(newStart.date(), dayjs(anchorDate).date()); 11 | date = Math.min(date, newStart.endOf('month').date()); 12 | start = newStart.set('date', date); 13 | } 14 | 15 | const endDate = Math.min(start.add(1, 'month').endOf('month').date(), dayjs(anchorDate).date()); 16 | const end = start.add(1, 'month').endOf('month').set('date', endDate).subtract(1, 'day').endOf('day'); 17 | 18 | return { start: start.toDate(), end: end.toDate() }; 19 | } 20 | 21 | export function getNextPeriod(someDateInPeriod: Date, anchorDate: Date): { start: Date; end: Date } { 22 | const { end } = getPeriodFromAnchorDate(someDateInPeriod, anchorDate); 23 | return getPeriodFromAnchorDate(dayjs(end).add(1, 'day').toDate(), anchorDate); 24 | } 25 | 26 | export function getPreviousPeriod(nextPayment: Date, anchorDate: Date): { start: Date; end: Date } { 27 | const { start } = getPeriodFromAnchorDate(nextPayment, anchorDate); 28 | return getPeriodFromAnchorDate(dayjs(start).subtract(1, 'day').toDate(), anchorDate); 29 | } 30 | -------------------------------------------------------------------------------- /packages/server/src/webhook.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | 3 | export async function triggerWebhook({ url, body }: { url: string; body: unknown }): Promise { 4 | try { 5 | await got.post(url, { 6 | json: body as string, 7 | retry: { limit: 10, methods: ['POST'] }, 8 | }); 9 | return true; 10 | } catch (e) { 11 | // eslint-disable-next-line no-console 12 | console.error(e); 13 | } 14 | 15 | return false; 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/templates/invoice.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Invoice 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 44 | 45 | 46 | 47 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | {{#each invoice.items}} 84 | 85 | 86 | 87 | 88 | 89 | {{/each}} 90 | 91 | 92 | 93 | 94 | 110 | 111 |
17 | 18 | 19 | 26 | 27 | 41 | 42 |
20 | {{#if project.invoiceData.logo}} 21 | 22 | {{else}} 23 | 24 | {{/if}} 25 | 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
Number:{{invoice.number}}
Date:{{{formatDate invoice.date "DD.MM.YYYY"}}}
39 |
40 |
43 |
48 | 49 | 50 | 59 | 60 | 72 | 73 |
51 | {{customer.name}}
52 | {{customer.addressLine1}}
53 | {{#if customer.addressLine2}} 54 | {{customer.addressLine2}}
55 | {{/if}} 56 | {{customer.zipCode}} {{customer.city}}
57 | {{customer.country}} 58 |
61 |
62 | {{project.invoiceData.name}}
63 | {{project.invoiceData.addressLine1}}
64 | {{#if project.invoiceData.addressLine2}} 65 | {{project.invoiceData.addressLine2}}
66 | {{/if}} 67 | {{project.invoiceData.zipCode}} {{project.invoiceData.city}}
68 | {{project.invoiceData.country}}
69 | {{project.invoiceData.email}} 70 |
71 |
74 |
ItemUnitsPrice
{{this.description}}{{this.units}}{{{amountToPrice this.pricePerUnit invoice.currency}}}
95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
Amount{{{amountToPrice invoice.amount invoice.currency}}}
Vat ({{invoice.vatRate}}%){{{amountToPrice invoice.vatAmount invoice.currency}}}
Total{{{amountToPrice invoice.totalAmount invoice.currency}}}
109 |
112 | 113 |

The total amount will be debited from your bank account soon.

114 |
115 | 116 | 117 | -------------------------------------------------------------------------------- /packages/server/templates/mocked-checkout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test payment checkout 6 | 7 | 8 | 9 | 10 | 19 | 20 | 21 | 22 |
23 |

Test payment checkout

24 |

You can now select the final state of your test payment. The payment status will be sent to your website.

25 |
26 | 27 | 28 |

Payment status:

29 | 30 |
31 | 32 | 33 |
34 |
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /packages/server/test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '~/config'; 2 | import { Customer, Invoice, InvoiceItem, PaymentMethod, Project, ProjectInvoiceData, Subscription } from '~/entities'; 3 | import dayjs from '~/lib/dayjs'; 4 | import { getPeriodFromAnchorDate } from '~/utils'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 7 | export function getFixtures() { 8 | const projectInvoiceData = new ProjectInvoiceData({ 9 | name: 'Test company', 10 | addressLine1: 'My Street 123', 11 | addressLine2: 'Postbox 321', 12 | email: 'test@example.tld', 13 | logo: 'http://localhost/my-logo.png', 14 | zipCode: 'GB-12345', 15 | city: 'London', 16 | country: 'GB', 17 | }); 18 | 19 | const project = new Project({ 20 | _id: '123', 21 | apiToken: 'abc-123', 22 | invoiceData: projectInvoiceData, 23 | paymentProvider: 'mocked', 24 | webhookUrl: 'http://localhost', 25 | name: 'Test Project', 26 | mollieApiKey: 'not-used', 27 | currency: 'EUR', 28 | vatRate: 19.0, 29 | }); 30 | 31 | const customer = new Customer({ 32 | addressLine1: 'BigBen Street 954', 33 | addressLine2: '123', 34 | city: 'London', 35 | country: 'GB', 36 | email: 'john@doe.co.uk', 37 | name: 'John Doe', 38 | zipCode: 'ENG-1234', 39 | paymentProviderId: '123', 40 | invoicePrefix: 'INV-F1B-0B6H', 41 | project, 42 | }); 43 | 44 | const anchorDate = dayjs('2020-01-01 07:23').toDate(); 45 | 46 | const currentPeriod = getPeriodFromAnchorDate(anchorDate, anchorDate); 47 | const subscription = new Subscription({ 48 | anchorDate, 49 | customer, 50 | project, 51 | currentPeriodStart: currentPeriod.start, 52 | currentPeriodEnd: currentPeriod.end, 53 | }); 54 | subscription.changePlan({ pricePerUnit: 12.34, units: 12 }); 55 | subscription.changePlan({ pricePerUnit: 12.34, units: 15, changeDate: dayjs('2020-01-15').toDate() }); 56 | subscription.changePlan({ pricePerUnit: 5.43, units: 15, changeDate: dayjs('2020-01-20').toDate() }); 57 | 58 | const { end } = getPeriodFromAnchorDate(dayjs('2020-01-15').toDate(), subscription.anchorDate); 59 | 60 | const invoice = new Invoice({ 61 | _id: '123', 62 | vatRate: 19.0, 63 | currency: 'EUR', 64 | date: end, 65 | sequentialId: 2, 66 | status: 'draft', 67 | customer, 68 | subscription, 69 | project, 70 | }); 71 | 72 | invoice.items.add( 73 | new InvoiceItem({ 74 | description: 'Test item', 75 | pricePerUnit: 12.34, 76 | units: 13, 77 | }), 78 | ); 79 | 80 | invoice.items.add( 81 | new InvoiceItem({ 82 | description: 'Second test item', 83 | pricePerUnit: 54.32, 84 | units: 1, 85 | }), 86 | ); 87 | 88 | const paymentMethod = new PaymentMethod({ 89 | _id: '123', 90 | customer, 91 | paymentProviderId: 'mock-123', 92 | name: 'Visa', 93 | type: 'credit-card', 94 | project, 95 | }); 96 | customer.paymentMethods.add(paymentMethod); 97 | customer.activePaymentMethod = paymentMethod; 98 | 99 | return { customer, subscription, invoice, project, paymentMethod }; 100 | } 101 | 102 | export const mockConfig: Config = { 103 | port: 1234, 104 | adminToken: '', 105 | postgresUrl: 'postgres://postgres:postgres@localhost:5432/postgres', 106 | publicUrl: '', 107 | dataPath: '', 108 | gotenbergUrl: '', 109 | jwtSecret: '', 110 | mail: { 111 | from: '', 112 | host: '', 113 | port: 0, 114 | secure: false, 115 | password: '', 116 | username: '', 117 | requireTLS: false, 118 | }, 119 | }; 120 | -------------------------------------------------------------------------------- /packages/server/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [".eslintrc.cjs", "vitest.config.ts", "src", "test", "cli"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "lib": ["ESNext"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "strictFunctionTypes": true, 11 | "resolveJsonModule": true, 12 | "rootDir": ".", 13 | "baseUrl": "src", 14 | "skipLibCheck": true, 15 | "noUnusedLocals": true, 16 | "paths": { 17 | "~/*": ["./*"], 18 | "$/*": ["../test/*"] 19 | } 20 | }, 21 | "include": ["src", "test", "cli"], 22 | "exclude": ["node_modules", "**/dist"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | '~/': `${path.resolve(__dirname, 'src')}/`, 8 | '$/': `${path.resolve(__dirname, 'test')}/`, 9 | }, 10 | }, 11 | // test: { 12 | // coverage: { 13 | // '100': true, 14 | // }, 15 | // }, 16 | }); 17 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**' 3 | --------------------------------------------------------------------------------