├── apps ├── frontend │ ├── models │ │ ├── datatable.ts │ │ ├── client.ts │ │ ├── document.ts │ │ ├── paginator.ts │ │ ├── dashboard.ts │ │ ├── organization.ts │ │ ├── user.ts │ │ └── template.ts │ ├── components │ │ ├── Profile │ │ │ ├── TokenForm.vue │ │ │ ├── Password.vue │ │ │ ├── Tokens.vue │ │ │ └── Information.vue │ │ ├── Settings │ │ │ ├── Clients.vue │ │ │ ├── Locale.vue │ │ │ ├── Format.vue │ │ │ ├── Units.vue │ │ │ └── ReminderFees.vue │ │ ├── Loading.vue │ │ ├── Logo.vue │ │ ├── ContextMenu.vue │ │ ├── Document │ │ │ ├── TaxOptions.vue │ │ │ ├── TemplateAutoComplete.vue │ │ │ ├── ClientAutoComplete.vue │ │ │ ├── Settings.vue │ │ │ ├── Totals.vue │ │ │ └── ItemDiscountCharge.vue │ │ ├── App │ │ │ └── Confirm.vue │ │ ├── Navigation │ │ │ ├── Secondary.vue │ │ │ ├── Main.vue │ │ │ └── Settings.vue │ │ ├── Form │ │ │ ├── Section.vue │ │ │ └── Header.vue │ │ ├── Template │ │ │ ├── InputColor.vue │ │ │ └── List.vue │ │ ├── KBCheatSheet.vue │ │ ├── Client │ │ │ ├── Form │ │ │ │ ├── Contact.vue │ │ │ │ ├── Basic.vue │ │ │ │ └── Address.vue │ │ │ └── Form.vue │ │ ├── Preview.vue │ │ ├── DatePicker.vue │ │ └── Editor.vue │ ├── .prettierrc │ ├── assets │ │ └── logo.png │ ├── public │ │ └── favicon.ico │ ├── pages │ │ ├── logout.vue │ │ ├── users │ │ │ ├── [id].vue │ │ │ └── index.vue │ │ ├── offers │ │ │ ├── [id].vue │ │ │ ├── index.vue │ │ │ └── client │ │ │ │ └── [id].vue │ │ ├── templates │ │ │ ├── [id].vue │ │ │ └── index.vue │ │ ├── clients │ │ │ ├── [id] │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ ├── invoices │ │ │ ├── [id].vue │ │ │ ├── index.vue │ │ │ ├── client │ │ │ │ └── [id].vue │ │ │ └── offer │ │ │ │ └── [id].vue │ │ ├── reminders │ │ │ ├── [id].vue │ │ │ ├── index.vue │ │ │ ├── client │ │ │ │ └── [id].vue │ │ │ └── invoice │ │ │ │ └── [id].vue │ │ ├── profile.vue │ │ └── settings │ │ │ └── [menu].vue │ ├── plugins │ │ ├── additions.ts │ │ ├── mitt.ts │ │ ├── popper.ts │ │ ├── draggable.ts │ │ ├── pinia.ts │ │ ├── fontawesome.ts │ │ └── notification.ts │ ├── Dockerfile │ ├── server │ │ └── api │ │ │ └── info.get.ts │ ├── tsconfig.json │ ├── .gitignore │ ├── composables │ │ ├── useToast.ts │ │ ├── useDashboard.ts │ │ ├── useDrawer.ts │ │ ├── useRender.ts │ │ ├── useFormat.ts │ │ ├── useInfo.ts │ │ ├── useExample.ts │ │ ├── useSettings.ts │ │ ├── useAuth.ts │ │ ├── useUser.ts │ │ ├── useClient.ts │ │ ├── useSignup.ts │ │ ├── useTemplate.ts │ │ ├── useProfile.ts │ │ ├── useApp.ts │ │ └── useHttp.ts │ ├── .dockerignore │ ├── app.vue │ ├── tailwind.config.js │ ├── nuxt.config.ts │ ├── middleware │ │ └── auth.global.ts │ ├── README.md │ ├── package.json │ └── layouts │ │ └── core.vue └── backend │ ├── .prettierignore │ ├── .eslintignore │ ├── database │ ├── factories │ │ └── index.ts │ └── migrations │ │ ├── 1759063693366_reminders.ts │ │ ├── 1669800117270_invoice_to_offers.ts │ │ ├── 1758566801707_templates.ts │ │ └── 1759157172522_recurrings.ts │ ├── .eslintrc.json │ ├── startup.sh │ ├── .gitignore │ ├── .dockerignore │ ├── tests │ ├── functional │ │ └── hello_world.spec.ts │ └── bootstrap.ts │ ├── start │ ├── events.ts │ ├── kernel.ts │ └── routes.ts │ ├── contracts │ ├── database.ts │ ├── tests.ts │ ├── hash.ts │ ├── drive.ts │ ├── env.ts │ ├── events.ts │ └── auth.ts │ ├── nodemon.json │ ├── .prettierrc │ ├── .env.example │ ├── .git-oneflowrc │ ├── .editorconfig │ ├── Dockerfile │ ├── app │ ├── Controllers │ │ └── Http │ │ │ ├── InfoController.ts │ │ │ ├── OrganizationsController.ts │ │ │ ├── SettingsController.ts │ │ │ ├── RootController.ts │ │ │ ├── OfferToInvoiceController.ts │ │ │ ├── DocumentsStatusController.ts │ │ │ ├── ProfileController.ts │ │ │ ├── NumbersController.ts │ │ │ ├── RegisterController.ts │ │ │ ├── RenderController.ts │ │ │ ├── AuthController.ts │ │ │ ├── TokensController.ts │ │ │ ├── RunRecurringInvoicesController.ts │ │ │ └── UsersController.ts │ ├── Validators │ │ ├── Token.ts │ │ ├── RecurringInvoice.ts │ │ ├── Render.ts │ │ ├── Organization.ts │ │ ├── Register.ts │ │ ├── Template.ts │ │ ├── Document.ts │ │ ├── Client.ts │ │ └── User.ts │ ├── Middleware │ │ ├── JsonError.ts │ │ ├── PaginationHeaders.ts │ │ ├── SilentAuth.ts │ │ ├── HashIdParser.ts │ │ └── Auth.ts │ ├── Exceptions │ │ └── Handler.ts │ ├── Helpers │ │ └── hashids.ts │ ├── Services │ │ ├── Document.ts │ │ └── Number.ts │ └── Models │ │ ├── Organization.ts │ │ ├── RecurringInvoice.ts │ │ ├── Template.ts │ │ ├── User.ts │ │ └── Client.ts │ ├── ace │ ├── tsconfig.json │ ├── server.ts │ ├── providers │ └── AppProvider.ts │ ├── commands │ └── index.ts │ ├── env.ts │ ├── .adonisrc.json │ ├── test.ts │ ├── config │ ├── database.ts │ └── hash.ts │ └── package.json ├── tsconfig.json ├── .github ├── screenshots │ ├── clients.png │ ├── dashboard.png │ ├── invoices.png │ ├── options.png │ ├── recurring.png │ ├── settings.png │ ├── settings2.png │ ├── template.png │ └── create-invoice.png ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.yml │ └── documentation.yml └── PULL_REQUEST_TEMPLATE.md ├── .vscode └── settings.json ├── packages ├── common │ ├── src │ │ ├── User.ts │ │ ├── index.ts │ │ ├── Base.ts │ │ ├── Format.ts │ │ ├── Helpers.ts │ │ ├── Locale.ts │ │ ├── Client.ts │ │ └── Example.ts │ ├── tsup.config.ts │ ├── tests │ │ └── Locale.test.ts │ ├── tsconfig.json │ └── package.json └── typescript-config │ ├── package.json │ └── base.json ├── .dockerignore ├── .npmrc ├── pnpm-workspace.yaml ├── docker └── init-db.sh ├── .eslintrc.js ├── entrypoint.sh ├── package.json ├── turbo.json ├── .gitignore ├── docker-compose.yaml ├── Dockerfile └── Caddyfile /apps/frontend/models/datatable.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/backend/.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /apps/frontend/components/Profile/TokenForm.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/frontend/models/client.ts: -------------------------------------------------------------------------------- 1 | export { Client } from "@repo/common"; 2 | -------------------------------------------------------------------------------- /apps/frontend/models/document.ts: -------------------------------------------------------------------------------- 1 | export * from "@repo/common/Document"; 2 | -------------------------------------------------------------------------------- /apps/backend/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | tmp 5 | *.js 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // "extends": "@repo/typescript-config/base.json" 3 | } 4 | -------------------------------------------------------------------------------- /apps/backend/database/factories/index.ts: -------------------------------------------------------------------------------- 1 | // import Factory from '@ioc:Adonis/Lucid/Factory' 2 | -------------------------------------------------------------------------------- /apps/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "htmlWhitespaceSensitivity": "ignore", 3 | "printWidth": 140 4 | } 5 | -------------------------------------------------------------------------------- /apps/frontend/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/apps/frontend/assets/logo.png -------------------------------------------------------------------------------- /.github/screenshots/clients.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/clients.png -------------------------------------------------------------------------------- /.github/screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/dashboard.png -------------------------------------------------------------------------------- /.github/screenshots/invoices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/invoices.png -------------------------------------------------------------------------------- /.github/screenshots/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/options.png -------------------------------------------------------------------------------- /.github/screenshots/recurring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/recurring.png -------------------------------------------------------------------------------- /.github/screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/settings.png -------------------------------------------------------------------------------- /.github/screenshots/settings2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/settings2.png -------------------------------------------------------------------------------- /.github/screenshots/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/template.png -------------------------------------------------------------------------------- /apps/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/apps/frontend/public/favicon.ico -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["plugin:adonis/typescriptApp", "prettier"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/screenshots/create-invoice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/rachoon/HEAD/.github/screenshots/create-invoice.png -------------------------------------------------------------------------------- /apps/frontend/pages/logout.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /apps/backend/startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | node ace -v 3 | node ace migration:run --force 4 | node ace db:seed 5 | node build/server.js 6 | -------------------------------------------------------------------------------- /apps/backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | .vscode 5 | .DS_STORE 6 | .env 7 | tmp 8 | build.sh 9 | captain-definition 10 | -------------------------------------------------------------------------------- /packages/common/src/User.ts: -------------------------------------------------------------------------------- 1 | enum UserRole { 2 | VIEWER = 0, 3 | EDITOR = 1, 4 | ADMIN = 2, 5 | SUPERADMIN = 3, 6 | } 7 | 8 | export { UserRole }; 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .nuxt 2 | .output 3 | build 4 | node_modules 5 | .DS_Store 6 | .env 7 | .env.*.local 8 | .vscode 9 | .idea 10 | *.log 11 | Dockerfile 12 | -------------------------------------------------------------------------------- /apps/frontend/plugins/additions.ts: -------------------------------------------------------------------------------- 1 | import Maska from "maska"; 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | nuxtApp.vueApp.use(Maska); 5 | }); 6 | -------------------------------------------------------------------------------- /apps/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | .vscode 5 | .DS_STORE 6 | .env 7 | tmp 8 | build.sh 9 | captain-definition 10 | Dockerfile -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | side-effects-cache=false 4 | auto-install-peers=true 5 | enable-pre-post-scripts=true 6 | unsafe-perm=true 7 | -------------------------------------------------------------------------------- /apps/backend/tests/functional/hello_world.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | 3 | test('display welcome page', async () => { 4 | console.log('Test') 5 | }) 6 | -------------------------------------------------------------------------------- /apps/backend/start/events.ts: -------------------------------------------------------------------------------- 1 | import Event from '@ioc:Adonis/Core/Event' 2 | import Database from '@ioc:Adonis/Lucid/Database' 3 | 4 | Event.on('db:query', Database.prettyPrint) 5 | -------------------------------------------------------------------------------- /apps/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 as builder 2 | WORKDIR /app 3 | COPY package.json . 4 | RUN npm install --force 5 | COPY . . 6 | RUN npm run build 7 | ENTRYPOINT ["node", "./.output/server/index.mjs"] -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | - "!**/build" 5 | - "!**/dist" 6 | - "!**/node_modules" 7 | neverBuiltDependencies: [] 8 | dangerouslyAllowAllBuilds: true 9 | -------------------------------------------------------------------------------- /apps/frontend/plugins/mitt.ts: -------------------------------------------------------------------------------- 1 | import mitt from 'mitt' 2 | 3 | export default defineNuxtPlugin(() => { 4 | const emitter = mitt() 5 | return { 6 | provide: { emit: emitter.emit, on: emitter.on }, 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /apps/frontend/pages/users/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/users/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/offers/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/offers/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/templates/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 11 | -------------------------------------------------------------------------------- /apps/frontend/plugins/popper.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from 'nuxt/app' 2 | 3 | import Popper from 'vue3-popper' 4 | export default defineNuxtPlugin((nuxtApp) => { 5 | nuxtApp.vueApp.component('Popper', Popper) 6 | }) 7 | -------------------------------------------------------------------------------- /apps/backend/contracts/database.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Adonis/Lucid/Orm' { 2 | interface ModelQueryBuilderContract> { 3 | getCount(): Promise 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/pages/clients/[id]/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/invoices/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/invoices/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/reminders/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/reminders/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/templates/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /packages/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/typescript-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/plugins/draggable.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "nuxt/app"; 2 | 3 | import draggable from "vuedraggable"; 4 | export default defineNuxtPlugin((nuxtApp) => { 5 | nuxtApp.vueApp.component("Draggable", draggable); 6 | }); 7 | -------------------------------------------------------------------------------- /apps/frontend/pages/clients/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 15 | -------------------------------------------------------------------------------- /apps/backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "app", 4 | "../../packages/common/dist" 5 | ], 6 | "ext": "ts,js,cjs", 7 | "exec": "node ace serve --watch", 8 | "ignore": [ 9 | "node_modules", 10 | "build" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/server/api/info.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (): Promise => { 2 | const baseUrl = process.env.NODE_ENV === "development" ? "http://localhost:3333" : ""; 3 | 4 | return { 5 | BASE_URL: baseUrl, 6 | }; 7 | }); 8 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Base"; 2 | export * from "./Client"; 3 | export * from "./Document"; 4 | export * from "./Example"; 5 | export * from "./Format"; 6 | export * from "./Helpers"; 7 | export * from "./Locale"; 8 | export * from "./User"; 9 | -------------------------------------------------------------------------------- /docker/init-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL 5 | SELECT 'CREATE DATABASE rachoon' 6 | WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'rachoon')\gexec 7 | EOSQL 8 | -------------------------------------------------------------------------------- /apps/backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "useTabs": false, 6 | "quoteProps": "consistent", 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "printWidth": 140, 10 | "tabWidth": 4 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT=3333 2 | HOST=0.0.0.0 3 | NODE_ENV=development 4 | APP_KEY=C4S2ooVEkrYYavNL2HszQww30RPasd0K 5 | DRIVE_DISK=local 6 | DB_CONNECTION=pg 7 | PG_HOST=localhost 8 | PG_PORT=5432 9 | PG_USER=lucid 10 | PG_PASSWORD= 11 | PG_DB_NAME=lucid 12 | CACHE_VIEWS=false 13 | -------------------------------------------------------------------------------- /apps/frontend/pages/invoices/client/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/invoices/offer/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/reminders/client/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /packages/common/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/**/*.ts"], 5 | format: ["cjs", "esm"], 6 | dts: true, 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | outDir: "dist", 11 | }); 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/reminders/invoice/[id].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/backend/.git-oneflowrc: -------------------------------------------------------------------------------- 1 | { 2 | "main": "* main", 3 | "features": "feature", 4 | "releases": "release", 5 | "hotfixes": "hotfix", 6 | "strategy": "rebase-no-ff", 7 | "interactive": true, 8 | "pushAfterMerge": false, 9 | "deleteAfterMerge": false, 10 | "tagCommit": true 11 | } -------------------------------------------------------------------------------- /packages/common/tests/Locale.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Locale } from "../src/Locale.ts"; 3 | 4 | test("Locale should work", () => { 5 | expect(Locale.t("en", "Invoices %d", 1)).toBe("Invoices 1"); 6 | expect(Locale.t("de-AT", "invoice")).toBe("Rechnung"); 7 | }); 8 | -------------------------------------------------------------------------------- /apps/backend/.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*] 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.json] 11 | insert_final_newline = ignore 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /apps/frontend/pages/offers/client/[id].vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json", 4 | "compilerOptions": { 5 | "sourceMap": true 6 | }, 7 | "experimentalDecorators": true, 8 | "emitDecoratorMetadata": true, 9 | "esModuleInterop": true 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/components/Settings/Clients.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /packages/common/src/Base.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | interface IBase { 3 | errors: () => string[]; 4 | } 5 | class Base { 6 | public constructor(json?: T) { 7 | if (json) { 8 | _.merge(this, json); 9 | } 10 | } 11 | public toJSON() { 12 | return { ...this }; 13 | } 14 | } 15 | 16 | export { Base, type IBase }; 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // This configuration only applies to the package manager root. 2 | /** @type {import("eslint").Linter.Config} */ 3 | module.exports = { 4 | ignorePatterns: ["apps/**", "packages/**"], 5 | // extends: ["@repo/eslint-config/library.js"], 6 | parser: "@typescript-eslint/parser", 7 | parserOptions: { 8 | project: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /apps/frontend/components/Loading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /apps/frontend/composables/useToast.ts: -------------------------------------------------------------------------------- 1 | export default function useToast(title: string, subtitle: string, type: "info" | "success" | "warning" | "error" = "info") { 2 | const { $toast } = useNuxtApp(); 3 | $toast(`
${title}
${subtitle}
`, { 4 | type: type, 5 | dangerouslyHTMLString: true, 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /apps/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | 3 | .output 4 | .data 5 | .nuxt 6 | .nitro 7 | .cache 8 | dist 9 | 10 | # Node dependencies 11 | 12 | node_modules 13 | 14 | # Logs 15 | 16 | logs 17 | \*.log 18 | 19 | # Misc 20 | 21 | .DS_Store 22 | .fleet 23 | .idea 24 | 25 | # Local env files 26 | 27 | .env 28 | .env.\* 29 | !.env.example 30 | 31 | Dockerfile 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: security vulnerability 4 | url: https://github.com/ad-on-is/rachoon/security/advisories/new 5 | about: report a security vulnerability privately 6 | - name: github discussions 7 | url: https://github.com/ad-on-is/rachoon/discussions 8 | about: ask questions and discuss with the community 9 | -------------------------------------------------------------------------------- /apps/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM zenika/alpine-chrome 2 | 3 | LABEL maintainer="Adis Durakovic " 4 | USER root 5 | 6 | RUN apk add --no-cache --update \ 7 | nodejs npm graphicsmagick ghostscript 8 | 9 | WORKDIR /app 10 | 11 | COPY package.json . 12 | RUN npm install 13 | COPY . . 14 | RUN npm run build 15 | COPY startup.sh /app 16 | ENTRYPOINT ["sh", "startup.sh"] 17 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/InfoController.ts: -------------------------------------------------------------------------------- 1 | export default class AuthController { 2 | public async index() { 3 | const envs = {} 4 | process.env['RACHOON_VERSION'] = process.env.APP_VERSION 5 | Object.keys(process.env).forEach((key) => { 6 | if (key.startsWith('RACHOON_')) { 7 | envs[key] = process.env[key] 8 | } 9 | }) 10 | return { ...envs } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/Token.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages } from '@ioc:Adonis/Core/Validator' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | 4 | export default class TokenValidator { 5 | constructor(protected ctx: HttpContextContract) {} 6 | 7 | public schema = schema.create({ 8 | name: schema.string(), 9 | }) 10 | 11 | public messages: CustomMessages = {} 12 | } 13 | -------------------------------------------------------------------------------- /apps/backend/contracts/tests.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contract source: https://bit.ly/3DP1ypf 3 | * 4 | * Feel free to let us know via PR, if you find something broken in this contract 5 | * file. 6 | */ 7 | 8 | import '@japa/runner' 9 | 10 | declare module '@japa/runner' { 11 | interface TestContext { 12 | // Extend context 13 | } 14 | 15 | interface Test { 16 | // Extend test 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/RecurringInvoice.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages } from '@ioc:Adonis/Core/Validator' 2 | 3 | class RecurringInvoiceValidator { 4 | public schema = schema.create({ 5 | actcive: schema.boolean(), 6 | invoiceId: schema.number(), 7 | cron: schema.string(), 8 | startDate: schema.date(), 9 | }) 10 | 11 | public messages: CustomMessages = {} 12 | } 13 | 14 | export { RecurringInvoiceValidator } 15 | -------------------------------------------------------------------------------- /apps/backend/contracts/hash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contract source: https://git.io/Jfefs 3 | * 4 | * Feel free to let us know via PR, if you find something broken in this contract 5 | * file. 6 | */ 7 | 8 | import { InferListFromConfig } from '@adonisjs/core/build/config' 9 | import hashConfig from '../config/hash' 10 | 11 | declare module '@ioc:Adonis/Core/Hash' { 12 | interface HashersList extends InferListFromConfig {} 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/composables/useDashboard.ts: -------------------------------------------------------------------------------- 1 | import { Dashboard } from "~~/models/dashboard"; 2 | 3 | class DashboardStore { 4 | dashboard = ref(new Dashboard()); 5 | loading = ref(true); 6 | 7 | get = async () => { 8 | this.loading.value = true; 9 | this.dashboard.value = await useApi().dashboard().get(); 10 | this.loading.value = false; 11 | }; 12 | } 13 | 14 | export default defineStore("dashboard", () => new DashboardStore()); 15 | -------------------------------------------------------------------------------- /apps/backend/contracts/drive.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contract source: https://git.io/JBt3I 3 | * 4 | * Feel free to let us know via PR, if you find something broken in this contract 5 | * file. 6 | */ 7 | 8 | import { InferDisksFromConfig } from '@adonisjs/core/build/config' 9 | import driveConfig from '../config/drive' 10 | 11 | declare module '@ioc:Adonis/Core/Drive' { 12 | interface DisksList extends InferDisksFromConfig {} 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/components/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/OrganizationsController.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import OrganizationValidator from 'App/Validators/Organization' 3 | 4 | export default class SettingsController { 5 | public async store(ctx: HttpContextContract) { 6 | const body = await ctx.request.validate(new OrganizationValidator(ctx)) 7 | 8 | return ctx.auth.user?.organization.merge(body).save() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/Render.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages } from '@ioc:Adonis/Core/Validator' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | 4 | export default class RenderValidator { 5 | constructor(protected ctx: HttpContextContract) {} 6 | 7 | public schema = schema.create({ 8 | templateId: schema.number(), 9 | data: schema.object().anyMembers(), 10 | }) 11 | 12 | public messages: CustomMessages = {} 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/SettingsController.ts: -------------------------------------------------------------------------------- 1 | import Organization from 'App/Models/Organization' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | 4 | export default class SettingsController { 5 | public async store(ctx: HttpContextContract) { 6 | return Organization.query() 7 | .where({ id: ctx.auth.user?.organization.id }) 8 | .update({ 9 | settings: ctx.request.body(), 10 | }) 11 | .first() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/models/paginator.ts: -------------------------------------------------------------------------------- 1 | export default class Paginator { 2 | public total: number; 3 | public perPage: number; 4 | public page: number; 5 | public pages: number; 6 | public rows: T[]; 7 | 8 | constructor(data: { total: number; perPage: number; page: number; pages: number; rows: T[] }) { 9 | this.total = data.total; 10 | this.perPage = data.perPage; 11 | this.page = data.page; 12 | this.pages = data.pages; 13 | this.rows = data.rows; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/plugins/pinia.ts: -------------------------------------------------------------------------------- 1 | import type { PiniaPluginContext } from "pinia"; 2 | import { cloneDeep } from "lodash"; 3 | 4 | function ResetStore({ store }: PiniaPluginContext) { 5 | const initialState = cloneDeep(store.$state); 6 | store.$reset = () => store.$patch(cloneDeep(initialState)); 7 | // Note this has to be typed if you are using TS 8 | return { creationTime: new Date() }; 9 | } 10 | 11 | export default defineNuxtPlugin(({ $pinia }) => { 12 | $pinia.use(ResetStore); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/base.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "outDir": "./dist", 13 | "rootDir": "./src" 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/RootController.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import OrganizationHelper from 'App/Helpers/organization' 3 | 4 | export default class AuthController { 5 | public async index(ctx: HttpContextContract) { 6 | const organization = await OrganizationHelper.getFromContext(ctx) 7 | if (!organization) { 8 | return ctx.response.notFound('No organization') 9 | } else { 10 | return organization 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/app/Middleware/JsonError.ts: -------------------------------------------------------------------------------- 1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | 3 | export default class JsonError { 4 | public async handle({ response }: HttpContextContract, next: () => Promise) { 5 | await next() 6 | const code = response.response.statusCode 7 | const body = response.getBody() 8 | if (code >= 400) { 9 | response.json({ 10 | errors: [{ message: body }], 11 | code: code, 12 | }) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/Organization.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages } from '@ioc:Adonis/Core/Validator' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | export default class OrganizationValidator { 4 | constructor(protected ctx: HttpContextContract) {} 5 | public schema = schema.create({ 6 | name: schema.string(), 7 | data: schema.object().anyMembers(), 8 | settings: schema.object().anyMembers(), 9 | }) 10 | 11 | public messages: CustomMessages = {} 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/composables/useDrawer.ts: -------------------------------------------------------------------------------- 1 | export const useDrawer = () => { 2 | const isOpen = useState("drawer-open", () => false); 3 | const router = useRouter(); 4 | 5 | // Close drawer on route change 6 | watch( 7 | () => router.currentRoute.value.path, 8 | () => { 9 | isOpen.value = false; 10 | }, 11 | ); 12 | 13 | return { 14 | isOpen, 15 | toggle: () => (isOpen.value = !isOpen.value), 16 | close: () => (isOpen.value = false), 17 | open: () => (isOpen.value = true), 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/OfferToInvoiceController.ts: -------------------------------------------------------------------------------- 1 | import Document from 'App/Models/Document' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | 4 | export default class OfferToInvoiceController { 5 | public async update(ctx: HttpContextContract) { 6 | return await Document.query() 7 | .where({ 8 | type: 'offer', 9 | id: ctx.request.param('id'), 10 | organizationId: ctx.auth.user?.organization.id, 11 | }) 12 | .update({ type: 'invoice' }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo " 4 | #!/bin/sh 5 | curl http://localhost:3333/api/run/recurring 6 | " >/etc/periodic/hourly/recurring-invoices 7 | 8 | chmod +x /etc/periodic/hourly/recurring-invoices 9 | 10 | cd /app/backend/apps/backend || exit 11 | 12 | export PORT=3333 13 | export NODE_ENV=production 14 | 15 | node ace migration:run --force 16 | node ace db:seed 17 | node server.js & 18 | 19 | cd /app/frontend || exit 20 | PORT=3000 node ./server/index.mjs & 21 | 22 | crond -f & 23 | 24 | caddy run --config /app/Caddyfile 25 | -------------------------------------------------------------------------------- /apps/backend/ace: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Ace Commands 4 | |-------------------------------------------------------------------------- 5 | | 6 | | This file is the entry point for running ace commands. 7 | | 8 | */ 9 | 10 | require('reflect-metadata') 11 | require('source-map-support').install({ handleUncaughtExceptions: false }) 12 | 13 | const { Ignitor } = require('@adonisjs/core/build/standalone') 14 | new Ignitor(__dirname) 15 | .ace() 16 | .handle(process.argv.slice(2)) 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rachoon-turbo", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo build", 6 | "dev": "turbo dev", 7 | "lint": "turbo lint", 8 | "format": "prettier --write \"**/*.{ts,tsx,md}\"" 9 | }, 10 | "devDependencies": { 11 | "minimatch": "^10.1.1", 12 | "pino-std-serializers": "^7.0.0", 13 | "prettier": "^3.6.2", 14 | "turbo": "latest" 15 | }, 16 | "packageManager": "pnpm@10.20.0", 17 | "engines": { 18 | "node": ">=18" 19 | }, 20 | "pnpm": { 21 | "injectWorkspacePackages": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": [ 4 | "**/.env.*local" 5 | ], 6 | "tasks": { 7 | "build": { 8 | "dependsOn": [ 9 | "^build" 10 | ], 11 | "outputs": [ 12 | ".output/**", 13 | "build/**", 14 | "dist/**" 15 | ] 16 | }, 17 | "lint": { 18 | "dependsOn": [ 19 | "^lint" 20 | ] 21 | }, 22 | "dev": { 23 | "cache": false, 24 | "persistent": true, 25 | "dependsOn": [ 26 | "^build" 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/frontend/composables/useRender.ts: -------------------------------------------------------------------------------- 1 | export default async function useRender(object: any, preview: boolean = false, tpl: string = ""): Promise { 2 | if (object.templateId && object.templateId !== "") { 3 | tpl = object.templateId; 4 | } 5 | let template = useTemplate().defaultTemplate; 6 | if (tpl !== "" && tpl !== "null") { 7 | template = await useApi().templates().get(tpl); 8 | } 9 | 10 | return ( 11 | await useHttp.post(`/api/render${preview ? "?preview=true" : ""}`, { 12 | templateId: template.id, 13 | data: object, 14 | }) 15 | ).body; 16 | } 17 | -------------------------------------------------------------------------------- /apps/frontend/plugins/fontawesome.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "nuxt/app"; 2 | 3 | import { library, config } from "@fortawesome/fontawesome-svg-core"; 4 | import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; 5 | import { fas } from "@fortawesome/free-solid-svg-icons"; 6 | import { far } from "@fortawesome/free-regular-svg-icons"; 7 | import { fab } from "@fortawesome/free-brands-svg-icons"; 8 | 9 | config.autoAddCss = false; 10 | 11 | library.add(fas, far, fab); 12 | 13 | export default defineNuxtPlugin((nuxtApp) => { 14 | nuxtApp.vueApp.component("FaIcon", FontAwesomeIcon); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/typescript-config/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "incremental": false, 8 | "isolatedModules": true, 9 | "lib": ["es2022", "DOM", "DOM.Iterable"], 10 | "module": "NodeNext", 11 | "moduleDetection": "force", 12 | "moduleResolution": "NodeNext", 13 | "noUncheckedIndexedAccess": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "target": "ES2022" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/app.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 27 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/DocumentsStatusController.ts: -------------------------------------------------------------------------------- 1 | import Document from 'App/Models/Document' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | import { StatusValidator } from 'App/Validators/Document' 4 | 5 | export default class InvoiceStatusController { 6 | public async update(ctx: HttpContextContract) { 7 | const body = await ctx.request.validate(StatusValidator) 8 | return await Document.query() 9 | .where({ 10 | id: ctx.request.param('id'), 11 | organizationId: ctx.auth.user?.organization.id, 12 | }) 13 | .update(body) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/database/migrations/1759063693366_reminders.ts: -------------------------------------------------------------------------------- 1 | import BaseSchema from '@ioc:Adonis/Lucid/Schema' 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = 'documents' 5 | 6 | public async up() { 7 | this.schema.alterTable(this.tableName, (table) => { 8 | table 9 | .integer('invoice_id') 10 | .unsigned() 11 | .nullable() 12 | .references('id') 13 | .inTable('documents') 14 | .onDelete('CASCADE') 15 | }) 16 | } 17 | 18 | public async down() { 19 | this.schema.alterTable(this.tableName, (table) => { 20 | table.dropColumn('invoice_id') 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/backend/app/Middleware/PaginationHeaders.ts: -------------------------------------------------------------------------------- 1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | 3 | export default class PaginationHeaders { 4 | public async handle({ response }: HttpContextContract, next: () => Promise) { 5 | await next() 6 | const body = response.lazyBody[0] 7 | 8 | if (body && body.constructor.name === 'ModelPaginator') { 9 | response.header('X-Total', body['total']) 10 | response.header('X-Pages', body['lastPage']) 11 | response.header('X-Page', body['currentPage']) 12 | response.header('X-Per-Page', body['perPage']) 13 | response.json(body['rows']) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/frontend/components/Document/TaxOptions.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 16 | -------------------------------------------------------------------------------- /apps/frontend/composables/useFormat.ts: -------------------------------------------------------------------------------- 1 | import { Format } from "@repo/common"; 2 | 3 | function toCurrency(value: any) { 4 | return Format.toCurrency(value, useSettings().settings.general.locale, useSettings().settings.general.currency); 5 | } 6 | 7 | function date(value: Date) { 8 | return Format.date(value, useSettings().settings.general.locale); 9 | } 10 | 11 | function longDate(value: Date) { 12 | return Format.longDate(value, useSettings().settings.general.locale); 13 | } 14 | 15 | function max100(val: string) { 16 | if (Number(val) > 100) val = "100"; 17 | return val; 18 | } 19 | 20 | export default { 21 | toCurrency, 22 | date, 23 | longDate, 24 | max100, 25 | }; 26 | -------------------------------------------------------------------------------- /apps/frontend/plugins/notification.ts: -------------------------------------------------------------------------------- 1 | // vue3-toastify.client.ts 2 | import Vue3Toastify, { toast, type ToastContainerOptions } from "vue3-toastify"; 3 | import { useRouter } from "#app"; 4 | 5 | export default defineNuxtPlugin((nuxtApp) => { 6 | const router = useRouter(); 7 | 8 | nuxtApp.vueApp.use(Vue3Toastify, { 9 | useHandler: (instance) => instance.use(router), 10 | position: "bottom-right", 11 | theme: "auto", 12 | autoClose: 3000, 13 | hideProgressBar: true, 14 | clearOnUrlChange: false, // This prevents clearing on navigation 15 | // other props... 16 | } as ToastContainerOptions); 17 | 18 | return { 19 | provide: { toast }, 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /apps/frontend/components/App/Confirm.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /apps/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | let colors = { 2 | primary: "#cba6f7", 3 | secondary: "#74c7ec", 4 | accent: "#94e2d5", 5 | neutral: "#313244", 6 | "base-100": "#09090b", 7 | "base-200": "#111014", 8 | "base-300": "#18191e", 9 | info: "#74c7ec", 10 | success: "#a6e3a1", 11 | warning: "#f9e2af", 12 | error: "#f38ba8", 13 | }; 14 | 15 | module.exports = { 16 | content: ["./**/*.{vue,css,html}"], 17 | 18 | plugins: [require("daisyui"), require("@tailwindcss/typography")], 19 | theme: { 20 | extend: { 21 | colors: colors, 22 | }, 23 | }, 24 | daisyui: { 25 | themes: [ 26 | "dark", 27 | { 28 | rachoon: colors, 29 | }, 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | deploy.sh 40 | 41 | .env.test 42 | 43 | 44 | #Ignore windsurf AI rules 45 | .windsurfrules 46 | /.windsurfrules 47 | /.claude 48 | .windsurf/ 49 | -------------------------------------------------------------------------------- /apps/frontend/components/Navigation/Secondary.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/ProfileController.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import { PasswordValidator, ProfileValidator } from 'App/Validators/User' 3 | 4 | export default class ProfileController { 5 | public async index(ctx: HttpContextContract) { 6 | return ctx.auth.user 7 | } 8 | 9 | public async store(ctx: HttpContextContract) { 10 | if (ctx.request.qs()['pwOnly'] === 'true') { 11 | const body = await ctx.request.validate(PasswordValidator) 12 | await ctx.auth.user?.merge(body).save() 13 | } else { 14 | const body = await ctx.request.validate(new ProfileValidator(ctx)) 15 | await ctx.auth.user?.merge(body).save() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/app/Middleware/SilentAuth.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | 3 | /** 4 | * Silent auth middleware can be used as a global middleware to silent check 5 | * if the user is logged-in or not. 6 | * 7 | * The request continues as usual, even when the user is not logged-in. 8 | */ 9 | export default class SilentAuthMiddleware { 10 | /** 11 | * Handle request 12 | */ 13 | public async handle({ auth }: HttpContextContract, next: () => Promise) { 14 | /** 15 | * Check if user is logged-in or not. If yes, then `ctx.auth.user` will be 16 | * set to the instance of the currently logged in user. 17 | */ 18 | await auth.check() 19 | await next() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/frontend/components/Form/Section.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 22 | -------------------------------------------------------------------------------- /apps/frontend/composables/useInfo.ts: -------------------------------------------------------------------------------- 1 | class InfoStore { 2 | info = >ref(null); 3 | 4 | loading = ref(false); 5 | init = async () => { 6 | if (this.info.value !== null) return; 7 | this.loading.value = true; 8 | this.info.value = ((await useFetch("/app/info")) as any).data.value; 9 | const url = useRequestURL(); 10 | if (this.info.value.BASE_URL === "") { 11 | this.info.value.BASE_URL = `${url.protocol}//${url.host}`; 12 | } 13 | const apiInfo = await useApi().info().get(); 14 | this.info.value = { ...this.info.value, ...apiInfo }; 15 | console.log(this.info.value); 16 | this.loading.value = false; 17 | }; 18 | } 19 | 20 | export default defineStore("info", () => new InfoStore()); 21 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/adonis-preset-ts/tsconfig.json", 3 | "include": ["**/*"], 4 | "exclude": ["node_modules", "build"], 5 | "compilerOptions": { 6 | "outDir": "build", 7 | "rootDir": "./", 8 | "sourceMap": true, 9 | "paths": { 10 | "App/*": ["./app/*"], 11 | "Config/*": ["./config/*"], 12 | "Contracts/*": ["./contracts/*"], 13 | "Database/*": ["./database/*"], 14 | "@repo/*": ["../../packages/*"] 15 | }, 16 | "types": [ 17 | "@adonisjs/core", 18 | "@adonisjs/repl", 19 | "@japa/preset-adonis/build/adonis-typings", 20 | "adonis-lucid-soft-deletes", 21 | "@adonisjs/lucid", 22 | "@adonisjs/auth", 23 | "@adonisjs/view" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/backend/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | AdonisJs Server 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The contents in this file is meant to bootstrap the AdonisJs application 7 | | and start the HTTP server to accept incoming connections. You must avoid 8 | | making this file dirty and instead make use of `lifecycle hooks` provided 9 | | by AdonisJs service providers for custom code. 10 | | 11 | */ 12 | 13 | import 'reflect-metadata' 14 | import sourceMapSupport from 'source-map-support' 15 | import { Ignitor } from '@adonisjs/core/build/standalone' 16 | 17 | sourceMapSupport.install({ handleUncaughtExceptions: false }) 18 | 19 | new Ignitor(__dirname).httpServer().start() 20 | -------------------------------------------------------------------------------- /apps/frontend/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | // 3 | export default defineNuxtConfig({ 4 | css: ["~/assets/style.scss", "@fortawesome/fontawesome-svg-core/styles.css"], 5 | app: { 6 | pageTransition: { name: "page", mode: "out-in" }, 7 | }, 8 | ssr: false, 9 | experimental: { 10 | payloadExtraction: false, 11 | }, 12 | nitro: { 13 | routeRules: { 14 | "/app/info": { proxy: "/api/info" }, 15 | }, 16 | }, 17 | modules: ["@pinia/nuxt", "@nuxtjs/tailwindcss"], 18 | build: { 19 | transpile: [ 20 | "h3", 21 | "@fortawesome/vue-fontawesome", 22 | "@fortawesome/fontawesome-svg-core", 23 | "@fortawesome/free-solid-svg-icons", 24 | "@fortawesome/free-regular-svg-icons", 25 | "@vuepic/vue-datepicker", 26 | ], 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /apps/backend/providers/AppProvider.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationContract } from '@ioc:Adonis/Core/Application' 2 | 3 | export default class AppProvider { 4 | constructor(protected app: ApplicationContract) {} 5 | 6 | public register() { 7 | // Register your own bindings 8 | } 9 | 10 | public async boot() { 11 | // IoC container is ready 12 | const { ModelQueryBuilder } = this.app.container.use('Adonis/Lucid/Database') 13 | 14 | ModelQueryBuilder.macro('getCount', async function () { 15 | const result = await this.count('* as total') 16 | return BigInt(result[0].$extras.total ? result[0].$extras.total : result[0].$original.total) 17 | }) 18 | } 19 | 20 | public async ready() { 21 | // App is ready 22 | } 23 | 24 | public async shutdown() { 25 | // Cleanup, since app is going down 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { listDirectoryFiles } from '@adonisjs/core/build/standalone' 2 | import Application from '@ioc:Adonis/Core/Application' 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Exporting an array of commands 7 | |-------------------------------------------------------------------------- 8 | | 9 | | Instead of manually exporting each file from this directory, we use the 10 | | helper `listDirectoryFiles` to recursively collect and export an array 11 | | of filenames. 12 | | 13 | | Couple of things to note: 14 | | 15 | | 1. The file path must be relative from the project root and not this directory. 16 | | 2. We must ignore this file to avoid getting into an infinite loop 17 | | 18 | */ 19 | export default listDirectoryFiles(__dirname, Application.appRoot, ['./commands/index']) 20 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/NumbersController.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import { DocumentType } from '@repo/common' 3 | import NumberService from 'App/Services/Number' 4 | 5 | export default class AuthController { 6 | public async index(ctx: HttpContextContract) { 7 | const type = ctx.request.param('type') 8 | if ( 9 | ![ 10 | `${DocumentType.Invoice}`, 11 | `${DocumentType.Offer}`, 12 | `${DocumentType.Reminder}`, 13 | 'client', 14 | ].includes(type) 15 | ) { 16 | return ctx.response.badRequest('Invalid type') 17 | } 18 | 19 | if (type === 'client') { 20 | return NumberService.client(ctx.auth.user!.organizationId) 21 | } 22 | 23 | return NumberService.document(ctx.auth.user!.organizationId, Number(type)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/backend/contracts/env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contract source: https://git.io/JTm6U 3 | * 4 | * Feel free to let us know via PR, if you find something broken in this contract 5 | * file. 6 | */ 7 | 8 | declare module '@ioc:Adonis/Core/Env' { 9 | /* 10 | |-------------------------------------------------------------------------- 11 | | Getting types for validated environment variables 12 | |-------------------------------------------------------------------------- 13 | | 14 | | The `default` export from the "../env.ts" file exports types for the 15 | | validated environment variables. Here we merge them with the `EnvTypes` 16 | | interface so that you can enjoy intellisense when using the "Env" 17 | | module. 18 | | 19 | */ 20 | 21 | type CustomTypes = typeof import('../env').default 22 | interface EnvTypes extends CustomTypes {} 23 | } 24 | -------------------------------------------------------------------------------- /apps/backend/env.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Validating Environment Variables 4 | |-------------------------------------------------------------------------- 5 | | 6 | | In this file we define the rules for validating environment variables. 7 | | By performing validation we ensure that your application is running in 8 | | a stable environment with correct configuration values. 9 | | 10 | | This file is read automatically by the framework during the boot lifecycle 11 | | and hence do not rename or move this file to a different location. 12 | | 13 | */ 14 | 15 | import Env from '@ioc:Adonis/Core/Env' 16 | 17 | export default Env.rules({ 18 | APP_KEY: Env.schema.string(), 19 | APP_NAME: Env.schema.string(), 20 | NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const), 21 | }) 22 | -------------------------------------------------------------------------------- /apps/backend/app/Exceptions/Handler.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Http Exception Handler 4 | |-------------------------------------------------------------------------- 5 | | 6 | | AdonisJs will forward all exceptions occurred during an HTTP request to 7 | | the following class. You can learn more about exception handling by 8 | | reading docs. 9 | | 10 | | The exception handler extends a base `HttpExceptionHandler` which is not 11 | | mandatory, however it can do lot of heavy lifting to handle the errors 12 | | properly. 13 | | 14 | */ 15 | 16 | import Logger from '@ioc:Adonis/Core/Logger' 17 | import HttpExceptionHandler from '@ioc:Adonis/Core/HttpExceptionHandler' 18 | 19 | export default class ExceptionHandler extends HttpExceptionHandler { 20 | constructor() { 21 | super(Logger) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/RegisterController.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import Organization from 'App/Models/Organization' 3 | import User from 'App/Models/User' 4 | import RegisterValidator from 'App/Validators/Register' 5 | import { UserRole } from '@repo/common' 6 | 7 | export default class RegisterController { 8 | public async store(ctx: HttpContextContract) { 9 | const body = await ctx.request.validate(RegisterValidator) 10 | const organization = await Organization.create({ 11 | name: body.organization.name, 12 | slug: body.organization.slug, 13 | }) 14 | return await User.create({ 15 | email: body.user.email, 16 | password: body.user.password, 17 | role: UserRole.ADMIN, 18 | organizationId: organization.id, 19 | data: body.user.data, 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/backend/app/Helpers/hashids.ts: -------------------------------------------------------------------------------- 1 | import Sqids from 'sqids' 2 | 3 | const alphababet = 4 | process.env.ALPHABET || 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 5 | export default class HashIDs { 6 | public static encode(val: number | null) { 7 | if (val === null) { 8 | return null 9 | } 10 | if (process.env.NODE_ENV === 'development') { 11 | return `${val}` 12 | } 13 | const hids = new Sqids({ minLength: 20, alphabet: alphababet }) 14 | return hids.encode([val]) 15 | } 16 | 17 | public static decode(val: string | null) { 18 | if (val === null || val === 'null') { 19 | return null 20 | } 21 | if (process.env.NODE_ENV === 'development') { 22 | return Number(val) 23 | } 24 | 25 | const hids = new Sqids({ minLength: 20, alphabet: alphababet }) 26 | return Number(hids.decode(val)[0]) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/frontend/models/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { Document } from "~~/models/document"; 2 | import _ from "lodash"; 3 | 4 | class Dashboard { 5 | invoices = { 6 | net: 0, 7 | total: 0, 8 | pending: [] as Document[], 9 | }; 10 | offers = { 11 | net: 0, 12 | total: 0, 13 | pending: [] as Document[], 14 | }; 15 | reminders = { 16 | net: 0, 17 | total: 0, 18 | pending: [] as Document[], 19 | }; 20 | 21 | constructor(json?: any) { 22 | if (json) { 23 | _.merge(this, json); 24 | this.invoices.pending = this.invoices.pending.map((i) => new Document(i)); 25 | this.offers.pending = this.offers.pending.map((i) => new Document(i)); 26 | this.reminders.pending = this.reminders.pending.map((i) => new Document(i)); 27 | } 28 | } 29 | 30 | public toJSON() { 31 | return { ...this }; 32 | } 33 | } 34 | 35 | export { Dashboard }; 36 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | rachoon: 3 | image: ghcr.io/ad-on-is/rachoon 4 | container_name: rachoon 5 | environment: 6 | - APP_KEY=e494d218125cd56396f2a12421d3a03a 7 | - DB_CONNECTION=pg 8 | - "GOTENBERG_URL=http://gotenberg:3000" 9 | - PG_HOST=postgres16 10 | - PG_PORT=5432 11 | - PG_USER=72323b 12 | - PG_PASSWORD=2b87b71a45db4bdd79a93382ad34e321 13 | - PG_DB_NAME=rachoon 14 | ports: 15 | - "8080:8080" 16 | gotenberg: 17 | image: "gotenberg/gotenberg:8" 18 | postgres16: 19 | container_name: postgres16 20 | image: "postgres:16" 21 | environment: 22 | - POSTGRES_USER=72323b 23 | - POSTGRES_PASSWORD=2b87b71a45db4bdd79a93382ad34e321 24 | - POSTGRES_DB=postgres 25 | volumes: 26 | - "./rachoon-data:/var/lib/postgresql/data" 27 | - "./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh" 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23-alpine AS builder 2 | 3 | WORKDIR /app 4 | COPY . . 5 | RUN npm install -g pnpm 6 | RUN pnpm install 7 | RUN pnpm build 8 | 9 | # adonisJS special handling 10 | RUN mkdir -p /tmp/apps 11 | RUN cp -r ./apps/backend/build /tmp/apps/backend/ 12 | RUN cp -r ./apps/backend/resources /tmp/apps/backend/ 13 | RUN cp -r ./packages /tmp/ 14 | RUN cp ./package.json /tmp/ 15 | RUN cp ./pnpm-workspace.yaml /tmp/ 16 | WORKDIR /tmp/ 17 | RUN pnpm install 18 | WORKDIR /tmp/apps/backend 19 | RUN pnpm install --prod --force 20 | 21 | 22 | 23 | FROM node:23-alpine 24 | 25 | USER root 26 | 27 | RUN apk add --no-cache --update graphicsmagick ghostscript caddy dcron 28 | 29 | WORKDIR /app 30 | COPY ./Caddyfile . 31 | COPY ./entrypoint.sh . 32 | COPY --from=builder /app/apps/frontend/.output ./frontend 33 | COPY --from=builder /tmp ./backend 34 | 35 | WORKDIR /app 36 | 37 | ENTRYPOINT ["./entrypoint.sh"] 38 | -------------------------------------------------------------------------------- /apps/frontend/components/Template/InputColor.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | -------------------------------------------------------------------------------- /apps/backend/contracts/events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contract source: https://git.io/JfefG 3 | * 4 | * Feel free to let us know via PR, if you find something broken in this contract 5 | * file. 6 | */ 7 | 8 | declare module '@ioc:Adonis/Core/Event' { 9 | /* 10 | |-------------------------------------------------------------------------- 11 | | Define typed events 12 | |-------------------------------------------------------------------------- 13 | | 14 | | You can define types for events inside the following interface and 15 | | AdonisJS will make sure that all listeners and emit calls adheres 16 | | to the defined types. 17 | | 18 | | For example: 19 | | 20 | | interface EventsList { 21 | | 'new:user': UserModel 22 | | } 23 | | 24 | | Now calling `Event.emit('new:user')` will statically ensure that passed value is 25 | | an instance of the the UserModel only. 26 | | 27 | */ 28 | interface EventsList {} 29 | } 30 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/Register.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages, rules } from '@ioc:Adonis/Core/Validator' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | 4 | export default class RegisterValidator { 5 | constructor(protected ctx: HttpContextContract) {} 6 | 7 | public schema = schema.create({ 8 | organization: schema.object().members({ 9 | name: schema.string(), 10 | slug: schema.string([ 11 | rules.unique({ table: 'organizations', column: 'slug' }), 12 | rules.notIn(['app', 'rachoon', 'api', 'www']), 13 | ]), 14 | }), 15 | user: schema.object().members({ 16 | email: schema.string([rules.email(), rules.unique({ table: 'users', column: 'email' })]), 17 | password: schema.string(), 18 | data: schema.object().members({ 19 | fullName: schema.string(), 20 | }), 21 | }), 22 | }) 23 | 24 | public messages: CustomMessages = {} 25 | } 26 | -------------------------------------------------------------------------------- /apps/frontend/components/KBCheatSheet.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 27 | -------------------------------------------------------------------------------- /apps/backend/database/migrations/1669800117270_invoice_to_offers.ts: -------------------------------------------------------------------------------- 1 | import BaseSchema from '@ioc:Adonis/Lucid/Schema' 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = 'invoice_to_offers' 5 | 6 | public async up() { 7 | this.schema.alterTable('documents', (table) => { 8 | table 9 | .integer('offer_id') 10 | .unsigned() 11 | .references('id') 12 | .inTable('documents') 13 | .onDelete('SET NULL') 14 | 15 | table.index('offer_id') 16 | table.dropForeign('client_id') 17 | table 18 | .integer('client_id') 19 | .alter() 20 | .unsigned() 21 | .references('id') 22 | .inTable('clients') 23 | .onDelete('SET NULL') 24 | .nullable() 25 | }) 26 | } 27 | 28 | public async down() { 29 | this.schema.alterTable('documents', (table) => { 30 | table.dropIndex('offer_id') 31 | table.dropColumn('offer_id') 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/backend/app/Services/Document.ts: -------------------------------------------------------------------------------- 1 | import Document from 'App/Models/Document' 2 | import NumberService from './Number' 3 | import { DateTime } from 'luxon' 4 | 5 | export default class DocumentService { 6 | public static async duplicate( 7 | id: number, 8 | organizationid: number, 9 | recurringId: number | null = null 10 | ) { 11 | const d = await Document.query().where({ id: id }).firstOrFail() 12 | const now = DateTime.now() 13 | 14 | const duplicate = new Document() 15 | duplicate.fill(d.$attributes) 16 | duplicate.number = await NumberService.document(organizationid, d.type) 17 | duplicate.data.date = now 18 | duplicate.data.dueDate = now.plus({ days: duplicate.data.dueDays }) 19 | 20 | delete duplicate.$attributes.id 21 | duplicate.$attributes.createdAt = now 22 | duplicate.$attributes.updatedAt = now 23 | 24 | if (recurringId !== null) { 25 | duplicate.recurringId = recurringId 26 | } 27 | 28 | return await duplicate.save() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/Template.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages } from '@ioc:Adonis/Core/Validator' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | 4 | export default class TemplateValidator { 5 | constructor(protected ctx: HttpContextContract) {} 6 | 7 | public schema = schema.create({ 8 | title: schema.string(), 9 | data: schema.object().members({ 10 | colors: schema.object().anyMembers(), 11 | texts: schema.object().members({ 12 | beforeTable: schema.string.optional({}), 13 | afterTable: schema.string.optional(), 14 | }), 15 | columns: schema.object().members({ 16 | first: schema.string.optional(), 17 | second: schema.string.optional(), 18 | third: schema.string.optional(), 19 | fourth: schema.string.optional(), 20 | }), 21 | }), 22 | html: schema.string.optional(), 23 | default: schema.boolean(), 24 | premium: schema.boolean(), 25 | }) 26 | 27 | public messages: CustomMessages = {} 28 | } 29 | -------------------------------------------------------------------------------- /apps/frontend/composables/useExample.ts: -------------------------------------------------------------------------------- 1 | import { Example, Format } from "@repo/common"; 2 | import { DocumentType } from "@repo/common"; 3 | 4 | export default defineStore("example", () => { 5 | async function preview(templateId: string = "") { 6 | const invoice = Example.get(DocumentType.Invoice); 7 | const offer = Example.get(DocumentType.Offer); 8 | const reminder = Example.get(DocumentType.Reminder); 9 | invoice.number = Format.number(useSettings().settings.invoices.number, 0); 10 | offer.number = Format.number(useSettings().settings.offers.number, 0); 11 | reminder.number = Format.number(useSettings().settings.reminders.number, 0); 12 | 13 | 14 | const [invoicePreview, offerPreview, reminderPreview] = await Promise.all([ 15 | useRender(invoice, true, templateId), 16 | useRender(offer, true, templateId), 17 | useRender(reminder, true, templateId), 18 | ]); 19 | 20 | return [...invoicePreview, ...offerPreview, ...reminderPreview]; 21 | } 22 | 23 | return { preview }; 24 | }); 25 | -------------------------------------------------------------------------------- /apps/frontend/components/Document/TemplateAutoComplete.vue: -------------------------------------------------------------------------------- 1 | 9 | 34 | -------------------------------------------------------------------------------- /apps/frontend/middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to, from) => { 2 | if (!useAuth().key() && !["/login", "/signup"].includes(to.path)) { 3 | useRouter().replace("/login"); 4 | return; 5 | } 6 | 7 | if (useAuth().key() && ["/login", "/signup"].includes(to.path)) { 8 | useRouter().replace("/"); 9 | return; 10 | } 11 | 12 | if ( 13 | useAuth().key() && 14 | useProfile().me.email !== "" && 15 | !to.path.includes("/settings") && 16 | !to.path.includes("/logout") && 17 | !to.path.includes("/profile") && 18 | useProfile().me.organization.data.address.street === "" && 19 | useProfile().me.organization.data.address.zip === "" && 20 | useProfile().me.organization.data.address.city === "" && 21 | useProfile().me.organization.data.address.country === "" 22 | ) { 23 | useToast(`Hey, ${useProfile().me.data.username}`, "Please setup your organiztion address first before proceeding.", "warning"); 24 | useRouter().replace("/settings/organization"); 25 | return; 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /apps/frontend/pages/profile.vue: -------------------------------------------------------------------------------- 1 | 6 | 33 | -------------------------------------------------------------------------------- /apps/frontend/pages/settings/[menu].vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 34 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/RenderController.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import Template from 'App/Models/Template' 3 | import Renderer from 'App/Services/Renderer' 4 | import RenderValidator from 'App/Validators/Render' 5 | 6 | export default class RenderController { 7 | public async store(ctx: HttpContextContract) { 8 | const body: any = await ctx.request.validate(RenderValidator) 9 | const org = ctx.auth.user!.organization 10 | const template = await Template.query() 11 | .if( 12 | body.templateId, 13 | (query) => { 14 | query 15 | .where({ id: body.templateId, organizationId: org.id }) 16 | .orWhere({ id: body.templateId, organizationId: null }) 17 | }, 18 | (query) => query.where({ organizationId: null }) 19 | ) 20 | .firstOrFail() 21 | 22 | const preview = ctx.request.qs()['preview'] || false 23 | 24 | const html = Renderer.prepareHtml(ctx.auth.user!, template, body.data) 25 | return await Renderer.generatePDFOrImage(html, preview, 1) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/app/Models/Organization.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import { column, hasMany, HasMany } from '@ioc:Adonis/Lucid/Orm' 3 | import Document from './Document' 4 | import Client from './Client' 5 | import User from './User' 6 | import HashIDs from 'App/Helpers/hashids' 7 | import BaseAppModel from './BaseAppModel' 8 | 9 | export default class Organization extends BaseAppModel { 10 | @column({ isPrimary: true, serialize: (val) => HashIDs.encode(val) }) 11 | public id: number 12 | 13 | @column() 14 | public name: string 15 | 16 | @column() 17 | public slug: string 18 | 19 | @column() 20 | public data: any 21 | 22 | @column() 23 | public settings: any 24 | 25 | @hasMany(() => Document) 26 | public documents: HasMany 27 | @hasMany(() => Client) 28 | public clients: HasMany 29 | 30 | @hasMany(() => User) 31 | public users: HasMany 32 | 33 | @column.dateTime({ autoCreate: true }) 34 | public createdAt: DateTime 35 | 36 | @column.dateTime({ autoCreate: true, autoUpdate: true }) 37 | public updatedAt: DateTime 38 | } 39 | -------------------------------------------------------------------------------- /apps/backend/app/Models/RecurringInvoice.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import { column, belongsTo, BelongsTo } from '@ioc:Adonis/Lucid/Orm' 3 | import Organization from './Organization' 4 | import HashIDs from '../Helpers/hashids' 5 | import BaseAppModel from './BaseAppModel' 6 | import Document from './Document' 7 | 8 | export default class RecurringInvoice extends BaseAppModel { 9 | @column({ isPrimary: true, serialize: (val) => HashIDs.encode(val) }) 10 | public id: number 11 | 12 | @column() 13 | public cron: string 14 | 15 | @column() 16 | public startDate: DateTime 17 | 18 | @column() 19 | public nextRun: DateTime 20 | 21 | @column() 22 | public active: boolean 23 | 24 | @column({ serialize: (val) => HashIDs.encode(val) }) 25 | public organizationId: number 26 | 27 | @belongsTo(() => Organization) 28 | public organization: BelongsTo 29 | 30 | @column({ serialize: (val) => HashIDs.encode(val) }) 31 | public invoiceId: number 32 | 33 | @belongsTo(() => Document, { foreignKey: 'invoiceId' }) 34 | public invoice: BelongsTo 35 | } 36 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/AuthController.ts: -------------------------------------------------------------------------------- 1 | import HashIDs from 'App/Helpers/hashids' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | import OrganizationHelper from 'App/Helpers/organization' 4 | import User from 'App/Models/User' 5 | 6 | export default class AuthController { 7 | public async store(ctx: HttpContextContract) { 8 | const email = ctx.request.input('email') 9 | const password = ctx.request.input('password') 10 | const organization = await OrganizationHelper.getFromContext(ctx) 11 | if (!organization) { 12 | return ctx.response.notFound('No such organization') 13 | } 14 | const user = await User.query() 15 | 16 | .where({ 17 | email: email, 18 | organizationId: HashIDs.decode(organization.id), 19 | }) 20 | .first() 21 | 22 | if (!user) { 23 | return ctx.response.notAcceptable('No user in that organization') 24 | } 25 | 26 | return await ctx.auth.use('api').attempt(email, password) 27 | } 28 | 29 | public async destroy(ctx: HttpContextContract) { 30 | return await ctx.auth.logout() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/frontend/composables/useSettings.ts: -------------------------------------------------------------------------------- 1 | import useApi from "./useApi"; 2 | 3 | class SettingsStore { 4 | settings = useProfile().me.organization.settings; 5 | organizationData = useProfile().me.organization.data; 6 | save = async () => { 7 | await useApi().organization().save(useProfile().me.organization); 8 | }; 9 | 10 | selectFile = async (e) => { 11 | const file = e.target.files[0]; 12 | 13 | /* Make sure file exists */ 14 | if (!file) return; 15 | const readData = (f): Promise => 16 | new Promise((resolve) => { 17 | const reader = new FileReader(); 18 | reader.onloadend = () => resolve(reader.result); 19 | reader.readAsDataURL(f); 20 | }); 21 | const data = await readData(file); 22 | const size = data.length / 1024; 23 | const { $toast } = useNuxtApp(); 24 | 25 | if (size > 16) { 26 | useToast("Invalid image", "The image is too large", "error"); 27 | 28 | return; 29 | } else { 30 | useProfile().me.organization.data.logo = data as string; 31 | } 32 | }; 33 | } 34 | 35 | export default defineStore("settings", () => new SettingsStore()); 36 | -------------------------------------------------------------------------------- /apps/frontend/composables/useAuth.ts: -------------------------------------------------------------------------------- 1 | class AuthStore { 2 | key = () => (process.server ? null : localStorage.getItem("auth-token")); 3 | loading = ref(false); 4 | loadingLogin = ref(false); 5 | org = ref(null); 6 | 7 | init = async () => { 8 | this.loading.value = true; 9 | this.org.value = await useApi().organization().getCurrent(); 10 | this.loading.value = false; 11 | }; 12 | 13 | loginEmailPassword = async (email: string, password: string, slug: string = "") => { 14 | this.loadingLogin.value = true; 15 | try { 16 | const res = await useApi().auth(slug).loginEmailPassword(email, password); 17 | if (res && res.token) { 18 | localStorage.setItem("auth-token", res.token); 19 | await useProfile().init(); 20 | useRouter().replace("/"); 21 | } 22 | } catch (e) { 23 | console.log(e); 24 | } 25 | this.loadingLogin.value = false; 26 | }; 27 | 28 | logout = async () => { 29 | await useApi().auth().logout(useProfile().me.id); 30 | localStorage.removeItem("auth-token"); 31 | navigateTo("login"); 32 | }; 33 | } 34 | 35 | export default defineStore("auth", () => new AuthStore()); 36 | -------------------------------------------------------------------------------- /apps/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # npm 11 | npm install 12 | 13 | # pnpm 14 | pnpm install 15 | 16 | # yarn 17 | yarn install 18 | 19 | # bun 20 | bun install 21 | ``` 22 | 23 | ## Development Server 24 | 25 | Start the development server on `http://localhost:3000`: 26 | 27 | ```bash 28 | # npm 29 | npm run dev 30 | 31 | # pnpm 32 | pnpm run dev 33 | 34 | # yarn 35 | yarn dev 36 | 37 | # bun 38 | bun run dev 39 | ``` 40 | 41 | ## Production 42 | 43 | Build the application for production: 44 | 45 | ```bash 46 | # npm 47 | npm run build 48 | 49 | # pnpm 50 | pnpm run build 51 | 52 | # yarn 53 | yarn build 54 | 55 | # bun 56 | bun run build 57 | ``` 58 | 59 | Locally preview production build: 60 | 61 | ```bash 62 | # npm 63 | npm run preview 64 | 65 | # pnpm 66 | pnpm run preview 67 | 68 | # yarn 69 | yarn preview 70 | 71 | # bun 72 | bun run preview 73 | ``` 74 | 75 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 76 | -------------------------------------------------------------------------------- /apps/frontend/components/Client/Form/Contact.vue: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /apps/frontend/components/Document/ClientAutoComplete.vue: -------------------------------------------------------------------------------- 1 | 6 | 40 | -------------------------------------------------------------------------------- /apps/frontend/models/organization.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "~~/models/settings"; 2 | import _ from "lodash"; 3 | 4 | interface OrganizationData { 5 | info: { 6 | vat: string; 7 | addition: string; 8 | }; 9 | address: { 10 | street: string; 11 | zip: string; 12 | city: string; 13 | country: string; 14 | }; 15 | logo: string; 16 | columns: { 17 | first: string; 18 | second: string; 19 | third: string; 20 | }; 21 | } 22 | 23 | class Organization { 24 | id: string = ""; 25 | createdAt: Date = new Date(); 26 | updatedAt: Date = new Date(); 27 | name: string = ""; 28 | slug: string = ""; 29 | data: OrganizationData = { 30 | address: { street: "", zip: "", city: "", country: "" }, 31 | info: { vat: "", addition: "" }, 32 | logo: "", 33 | columns: { 34 | first: "", 35 | second: "", 36 | third: "", 37 | }, 38 | }; 39 | settings: Settings = new Settings(); 40 | 41 | constructor(json?: any) { 42 | if (json) { 43 | _.merge(this, json); 44 | this.settings = new Settings(this.settings); 45 | } 46 | } 47 | public toJSON() { 48 | return { ...this }; 49 | } 50 | } 51 | 52 | export { Organization }; 53 | export type { OrganizationData }; 54 | -------------------------------------------------------------------------------- /apps/backend/.adonisrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript": true, 3 | "commands": [ 4 | "./commands", 5 | "@adonisjs/core/build/commands/index.js", 6 | "@adonisjs/repl/build/commands", 7 | "@adonisjs/lucid/build/commands" 8 | ], 9 | "exceptionHandlerNamespace": "App/Exceptions/Handler", 10 | "aliases": { 11 | "App": "app", 12 | "Config": "config", 13 | "Database": "database", 14 | "Contracts": "contracts" 15 | }, 16 | "preloads": [ 17 | "./start/routes", 18 | "./start/kernel", 19 | "./start/events" 20 | ], 21 | "providers": [ 22 | "./providers/AppProvider", 23 | "@adonisjs/core", 24 | "@adonisjs/lucid", 25 | "@adonisjs/auth", 26 | "@adonisjs/view", 27 | "adonis-lucid-soft-deletes" 28 | ], 29 | "aceProviders": [ 30 | "@adonisjs/repl" 31 | ], 32 | "tests": { 33 | "suites": [ 34 | { 35 | "name": "functional", 36 | "files": [ 37 | "tests/functional/**/*.spec(.ts|.js)" 38 | ], 39 | "timeout": 60000 40 | } 41 | ] 42 | }, 43 | "testProviders": [ 44 | "@japa/preset-adonis/TestsProvider" 45 | ], 46 | "metaFiles": [ 47 | { 48 | "pattern": "resources/views/**/*.edge", 49 | "reloadServer": false 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /apps/frontend/components/Document/Settings.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 34 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/Document.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages } from '@ioc:Adonis/Core/Validator' 2 | 3 | class DocumentValidator { 4 | public schema = schema.create({ 5 | clientId: schema.number(), 6 | number: schema.string(), 7 | status: schema.number(), 8 | offerId: schema.number.optional(), 9 | templateId: schema.number.optional(), 10 | invoiceId: schema.number.optional(), 11 | recurringInvoice: schema.object.optional().anyMembers(), 12 | data: schema.object().members({ 13 | positions: schema.array().anyMembers(), 14 | discountsCharges: schema.array.optional().anyMembers(), 15 | taxes: schema.object().anyMembers(), 16 | taxOption: schema.object().anyMembers(), 17 | date: schema.date(), 18 | dueDate: schema.date(), 19 | headingText: schema.string.optional(), 20 | footerText: schema.string.optional(), 21 | total: schema.number(), 22 | net: schema.number(), 23 | netNoDiscount: schema.number(), 24 | dueDays: schema.number(), 25 | }), 26 | }) 27 | 28 | public messages: CustomMessages = {} 29 | } 30 | 31 | class StatusValidator { 32 | public schema = schema.create({ 33 | status: schema.number(), 34 | }) 35 | public messages: CustomMessages = {} 36 | } 37 | 38 | export { DocumentValidator, StatusValidator } 39 | -------------------------------------------------------------------------------- /apps/frontend/components/Navigation/Main.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 42 | -------------------------------------------------------------------------------- /apps/frontend/composables/useUser.ts: -------------------------------------------------------------------------------- 1 | import { User } from "~~/models/user"; 2 | import _ from "lodash"; 3 | import Base from "./_base"; 4 | 5 | class UserStore extends Base { 6 | save = async (e: Event) => { 7 | super.save(e); 8 | const u = await useApi().users().saveOrUpdate(this.item.value!, !this.isNew()); 9 | if (this.isNew()) { 10 | useRouter().replace(`/users/${u.id}`); 11 | } 12 | }; 13 | 14 | form = async () => { 15 | const id = useRoute().params["id"] as string; 16 | 17 | this.loading.value = true; 18 | this.item.value = new User(); 19 | if (id !== "new") { 20 | this.item.value = _.mergeWith(this.item.value, await useApi().users().get(id)); 21 | } 22 | 23 | this.loading.value = false; 24 | }; 25 | 26 | delete = async (id?: string) => { 27 | useApp().confirm(async () => { 28 | await useApi() 29 | .users() 30 | .delete(id || this.item.value.id); 31 | if (id) { 32 | this.items.value = this.items.value.filter((i) => i.id !== (id || this.item.value.id)); 33 | } else { 34 | useRouter().replace(`/${this.type()}/`); 35 | } 36 | }, `Are you sure you want to delete the user ${this.item.value.data.username}?`); 37 | }; 38 | } 39 | 40 | export default defineStore("user", () => new UserStore(ref(new User()), useApi().users().getAll)); 41 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | (reverse-proxy-settings) { 2 | header_down X-Real-IP {remote} 3 | header_down -Access-Control-Allow-Origin "{http.request.header.origin}" 4 | header_down -Access-Control-Allow-Headers "{http.request.header.Access-Control-Request-Headers}" 5 | header_down -Access-Control-Expose-Headers "*" 6 | header_down -Access-Control-Allow-Credentials true 7 | header_down -Access-Control-Allow-Methods "OPTIONS,HEAD,GET,POST,PUT,PATCH,DELETE" 8 | header_down Host {host} 9 | header_down X-Forwarded-Proto {scheme} 10 | } 11 | 12 | (defaults) { 13 | encode gzip 14 | 15 | # cors 16 | header { 17 | Access-Control-Allow-Origin "{http.request.header.origin}" 18 | Access-Control-Allow-Headers "{http.request.header.Access-Control-Request-Headers}" 19 | Access-Control-Expose-Headers "*" 20 | Access-Control-Allow-Credentials true 21 | Access-Control-Allow-Methods "OPTIONS,HEAD,GET,POST,PUT,PATCH,DELETE" 22 | Vary Origin 23 | } 24 | } 25 | 26 | 27 | :8080 { 28 | import defaults 29 | 30 | route /api* { 31 | reverse_proxy localhost:3333 { 32 | import reverse-proxy-settings 33 | } 34 | } 35 | 36 | route /* { 37 | reverse_proxy localhost:3000 { 38 | import reverse-proxy-settings 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/frontend/components/Form/Header.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 36 | -------------------------------------------------------------------------------- /apps/backend/app/Models/Template.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import { column, belongsTo, BelongsTo } from '@ioc:Adonis/Lucid/Orm' 3 | import Organization from './Organization' 4 | import HashIDs from '../Helpers/hashids' 5 | import BaseAppModel from './BaseAppModel' 6 | 7 | export default class Template extends BaseAppModel { 8 | public serializeExtras() { 9 | return { 10 | isGlobal: this.organizationId === null, 11 | } 12 | } 13 | 14 | public static searchFields = ['title'] 15 | public static sortFields = ['title'] 16 | public isGlobal: boolean 17 | 18 | @column({ isPrimary: true, serialize: (val) => HashIDs.encode(val) }) 19 | public id: number 20 | 21 | @column() 22 | public title: string 23 | 24 | @column() 25 | public default: boolean 26 | 27 | @column() 28 | public premium: boolean 29 | 30 | @column() 31 | public data: any 32 | 33 | @column() 34 | public html: string 35 | 36 | @column() 37 | public thumbnail: string 38 | 39 | @column({ serialize: (val) => HashIDs.encode(val) }) 40 | public organizationId: number 41 | 42 | @belongsTo(() => Organization) 43 | public organization: BelongsTo 44 | 45 | @column.dateTime({ autoCreate: true }) 46 | public createdAt: DateTime 47 | 48 | @column.dateTime({ autoCreate: true, autoUpdate: true }) 49 | public updatedAt: DateTime 50 | } 51 | -------------------------------------------------------------------------------- /apps/frontend/components/Profile/Password.vue: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /packages/common/src/Format.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | class Format { 4 | static toCurrency(value: any, locale: string, currency: string) { 5 | const formatter = new Intl.NumberFormat(locale, { 6 | style: "currency", 7 | currency: currency, 8 | }); 9 | 10 | return formatter.format(Number(value)); 11 | } 12 | 13 | static date(value: Date, locale: string) { 14 | return DateTime.fromJSDate(value) 15 | .setLocale(locale) 16 | .toLocaleString(DateTime.DATE_SHORT); // const formatter = new Intl.DateTimeFormat(locale); 17 | } 18 | 19 | static longDate(value: Date, locale: string) { 20 | return DateTime.fromJSDate(value) 21 | .setLocale(locale) 22 | .toLocaleString(DateTime.DATE_FULL); 23 | } 24 | 25 | static max100(val: string) { 26 | if (Number(val) > 100) val = "100"; 27 | return val; 28 | } 29 | 30 | static number(entity: { format: string; padZeros: number }, add: number = 0) { 31 | let number = String(1 + add).padStart(entity.padZeros, "0"); 32 | 33 | number = entity.format.replace("{number}", number); 34 | const d = number.match(/\{date:[a-zA-Z_\-\.]+\}/); 35 | if (d) { 36 | const format = d[0].replace("{date:", "").replace("}", ""); 37 | const date = DateTime.now().toFormat(format); 38 | number = number.replace(d[0], date); 39 | } 40 | return number; 41 | } 42 | } 43 | 44 | export { Format }; 45 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/TokensController.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import Database from '@ioc:Adonis/Lucid/Database' 3 | import hashids from 'App/Helpers/hashids' 4 | import TokenValidator from 'App/Validators/Token' 5 | 6 | export default class TokensController { 7 | public async index(ctx: HttpContextContract) { 8 | const tokens = await Database.from('api_tokens') 9 | .where('user_id', ctx.auth.user!.id) 10 | .andWhereNot('name', 'Opaque Access Token') 11 | .orderBy('created_at', 'desc') 12 | .select('id', 'name', 'token', 'expires_at', 'created_at') 13 | 14 | return tokens.map((t) => { 15 | t.id = hashids.encode(t.id) 16 | return t 17 | }) 18 | } 19 | 20 | public async store(ctx: HttpContextContract) { 21 | const body = await ctx.request.validate(TokenValidator) 22 | return ctx.auth.use('api').generate(ctx.auth.user!, { name: body.name }) 23 | } 24 | 25 | public async destroy(ctx: HttpContextContract) { 26 | const token = await Database.from('api_tokens') 27 | .where('user_id', ctx.auth.user!.id) 28 | .andWhere('id', ctx.params.id) 29 | .firstOrFail() 30 | 31 | if (!token) { 32 | return ctx.response.status(404).json({ message: 'Token not found' }) 33 | } 34 | await Database.from('api_tokens').where('id', token.id).delete() 35 | return ctx.response.status(204) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/frontend/composables/useClient.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "~~/models/client"; 2 | import _ from "lodash"; 3 | import Base from "./_base"; 4 | 5 | class ClientStore extends Base { 6 | save = async (e: Event) => { 7 | super.save(e); 8 | const c = await useApi().clients().saveOrUpdate(this.item.value!, !this.isNew()); 9 | if (this.isNew()) { 10 | useRouter().replace(`/clients/${c.id}`); 11 | } 12 | }; 13 | 14 | delete = async (id?: string) => { 15 | useApp().confirm(async () => { 16 | await useApi() 17 | .clients() 18 | .delete(id || this.item.value.id); 19 | 20 | if (id) { 21 | this.items.value = this.items.value.filter((i) => i.id !== (id || this.item.value.id)); 22 | } else { 23 | useRouter().replace(`/${this.type()}/`); 24 | } 25 | }, `Are you sure you want to delete the client ${this.item.value.name}?`); 26 | }; 27 | 28 | form = async () => { 29 | const id = useRoute().params["id"] as string; 30 | 31 | this.loading.value = true; 32 | this.item.value = new Client(); 33 | if (id === "new") { 34 | this.item.value.number = await useApi().number("client").get(); 35 | } else { 36 | this.item.value = _.mergeWith(this.item.value, await useApi().clients().get(id)); 37 | } 38 | 39 | this.loading.value = false; 40 | }; 41 | } 42 | 43 | export default defineStore("client", () => new ClientStore(ref(new Client()), useApi().clients().getAll)); 44 | -------------------------------------------------------------------------------- /apps/frontend/components/Settings/Locale.vue: -------------------------------------------------------------------------------- 1 | 2 | 38 | -------------------------------------------------------------------------------- /apps/backend/test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Tests 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The contents in this file boots the AdonisJS application and configures 7 | | the Japa tests runner. 8 | | 9 | | For the most part you will never edit this file. The configuration 10 | | for the tests can be controlled via ".adonisrc.json" and 11 | | "tests/bootstrap.ts" files. 12 | | 13 | */ 14 | 15 | process.env.NODE_ENV = 'test' 16 | 17 | import 'reflect-metadata' 18 | import sourceMapSupport from 'source-map-support' 19 | import { Ignitor } from '@adonisjs/core/build/standalone' 20 | import { configure, processCliArgs, run, RunnerHooksHandler } from '@japa/runner' 21 | 22 | sourceMapSupport.install({ handleUncaughtExceptions: false }) 23 | 24 | const kernel = new Ignitor(__dirname).kernel('test') 25 | 26 | kernel 27 | .boot() 28 | .then(() => import('./tests/bootstrap')) 29 | .then(({ runnerHooks, ...config }) => { 30 | const app: RunnerHooksHandler[] = [() => kernel.start()] 31 | 32 | configure({ 33 | ...kernel.application.rcFile.tests, 34 | ...processCliArgs(process.argv.slice(2)), 35 | ...config, 36 | ...{ 37 | importer: (filePath) => import(filePath), 38 | setup: app.concat(runnerHooks.setup), 39 | teardown: runnerHooks.teardown, 40 | }, 41 | cwd: kernel.application.appRoot, 42 | }) 43 | 44 | run() 45 | }) 46 | -------------------------------------------------------------------------------- /apps/frontend/components/Client/Form/Basic.vue: -------------------------------------------------------------------------------- 1 | 4 | 47 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/RunRecurringInvoicesController.ts: -------------------------------------------------------------------------------- 1 | import Document from 'App/Models/Document' 2 | import RecurringInvoice from 'App/Models/RecurringInvoice' 3 | import DocumentService from 'App/Services/Document' 4 | import parser from 'cron-parser' 5 | import { DateTime } from 'luxon' 6 | 7 | export default class RunRecurringInvoicesController { 8 | public async index({ response }) { 9 | const today = new Date() 10 | today.setHours(0, 0, 0, 0) 11 | const recurrings = await RecurringInvoice.query() 12 | .where('startDate', '<=', today) 13 | .andWhere('nextRun', '<=', today) 14 | .andWhere('active', true) 15 | .preload('invoice') 16 | 17 | if (recurrings.length === 0) { 18 | response.json({ message: 'No recurring invoices to process' }) 19 | } 20 | 21 | for (const recurring of recurrings) { 22 | const cron = parser.parse(recurring.cron) 23 | const cronDate = cron.next().toDate() 24 | cronDate.setHours(0, 0, 0, 0) 25 | 26 | const existing = await Document.query() 27 | .where('createdAt', '>=', new Date(recurring.nextRun.toString())) 28 | .andWhere('id', recurring.invoiceId) 29 | .andWhereNotNull('recurring_id') 30 | .first() 31 | 32 | if (existing) continue 33 | await DocumentService.duplicate(recurring.invoiceId, recurring.organizationId, recurring.id) 34 | recurring.nextRun = DateTime.fromISO(cronDate.toISOString()) 35 | 36 | await recurring.save() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/Client.ts: -------------------------------------------------------------------------------- 1 | import { schema, CustomMessages, rules } from '@ioc:Adonis/Core/Validator' 2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 3 | 4 | export default class ClientValidator { 5 | constructor(protected ctx: HttpContextContract) {} 6 | 7 | public schema = schema.create({ 8 | name: schema.string(), 9 | number: schema.string(), 10 | data: schema.object().members({ 11 | address: schema.object().members({ 12 | street: schema.string(), 13 | zip: schema.string(), 14 | city: schema.string(), 15 | country: schema.string(), 16 | }), 17 | info: schema.object.optional().members({ 18 | vat: schema.string.optional(), 19 | addition: schema.string.optional(), 20 | }), 21 | contactPerson: schema.object().members({ 22 | fullName: schema.string.optional(), 23 | email: schema.string([rules.email()]), 24 | }), 25 | conditions: schema.object.optional().members({ 26 | earlyPayment: schema.object.optional().members({ 27 | days: schema.number.optional(), 28 | discount: schema.number.optional(), 29 | }), 30 | invoiceDueDays: schema.number.optional(), 31 | discount: schema.object.optional().members({ 32 | value: schema.number.optional(), 33 | valueType: schema.enum.optional(['percent', 'fixed']), 34 | }), 35 | rate: schema.number.optional(), 36 | }), 37 | }), 38 | }) 39 | 40 | public messages: CustomMessages = {} 41 | } 42 | -------------------------------------------------------------------------------- /apps/frontend/components/Settings/Format.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 55 | -------------------------------------------------------------------------------- /apps/backend/database/migrations/1758566801707_templates.ts: -------------------------------------------------------------------------------- 1 | import BaseSchema from '@ioc:Adonis/Lucid/Schema' 2 | import { DateTime } from 'luxon' 3 | 4 | export default class extends BaseSchema { 5 | protected tableName = 'templates' 6 | 7 | public async up() { 8 | this.schema.createTable(this.tableName, (table) => { 9 | table.increments('id').primary() 10 | 11 | table.string('title', 100) 12 | table.boolean('default').defaultTo(false) 13 | table.boolean('premium').defaultTo(false) 14 | table.jsonb('data').defaultTo('{}') 15 | table.text('html') 16 | table.text('thumbnail') 17 | table 18 | .integer('organization_id') 19 | .unsigned() 20 | .nullable() 21 | .references('id') 22 | .inTable('organizations') 23 | .onDelete('CASCADE') 24 | 25 | table.index('organization_id') 26 | table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(DateTime.now().toSQL()) 27 | table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(DateTime.now().toSQL()) 28 | table.timestamp('deleted_at', { useTz: true }).nullable() 29 | }) 30 | this.schema.alterTable('documents', (table) => { 31 | table 32 | .integer('template_id') 33 | .unsigned() 34 | .references('id') 35 | .inTable('templates') 36 | .onDelete('SET NULL') 37 | .nullable() 38 | }) 39 | } 40 | 41 | public async down() { 42 | this.schema.alterTable('documents', (table) => { 43 | table.dropColumn('template_id') 44 | }) 45 | this.schema.dropTable(this.tableName) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/backend/config/database.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Config source: https://git.io/JesV9 3 | * 4 | * Feel free to let us know via PR, if you find something broken in this config 5 | * file. 6 | */ 7 | 8 | import Env from '@ioc:Adonis/Core/Env' 9 | import { DatabaseConfig } from '@ioc:Adonis/Lucid/Database' 10 | 11 | const databaseConfig: DatabaseConfig = { 12 | /* 13 | |-------------------------------------------------------------------------- 14 | | Connection 15 | |-------------------------------------------------------------------------- 16 | | 17 | | The primary connection for making database queries across the application 18 | | You can use any key from the `connections` object defined in this same 19 | | file. 20 | | 21 | */ 22 | connection: Env.get('DB_CONNECTION'), 23 | 24 | connections: { 25 | /* 26 | |-------------------------------------------------------------------------- 27 | | PostgreSQL config 28 | |-------------------------------------------------------------------------- 29 | | 30 | | Configuration for PostgreSQL database. Make sure to install the driver 31 | | from npm when using this connection 32 | | 33 | | npm i pg 34 | | 35 | */ 36 | 37 | pg: { 38 | client: 'pg', 39 | connection: { 40 | host: Env.get('PG_HOST'), 41 | port: Env.get('PG_PORT'), 42 | user: Env.get('PG_USER'), 43 | password: Env.get('PG_PASSWORD', ''), 44 | database: Env.get('PG_DB_NAME'), 45 | }, 46 | migrations: { 47 | naturalSort: true, 48 | }, 49 | healthCheck: true, 50 | }, 51 | }, 52 | } 53 | 54 | export default databaseConfig 55 | -------------------------------------------------------------------------------- /apps/backend/app/Services/Number.ts: -------------------------------------------------------------------------------- 1 | import { DocumentType } from '@repo/common' 2 | import { Format } from '@repo/common' 3 | import Client from 'App/Models/Client' 4 | import Document from 'App/Models/Document' 5 | import Organization from 'App/Models/Organization' 6 | 7 | export default class Numberervice { 8 | public static async document(organizationId: number, type: DocumentType) { 9 | const count = await Document.query() 10 | .where({ 11 | organizationId: organizationId, 12 | type: type, 13 | }) 14 | .withTrashed() 15 | .getCount() 16 | 17 | const organization = await Organization.findOrFail(organizationId) 18 | 19 | let documentNumber: any 20 | switch (type) { 21 | case DocumentType.Invoice: 22 | documentNumber = organization.settings.invoices.number 23 | break 24 | case DocumentType.Offer: 25 | documentNumber = organization.settings.offers.number 26 | break 27 | case DocumentType.Reminder: 28 | documentNumber = organization.settings.reminders.number 29 | break 30 | default: 31 | throw new Error('Type must be invoice, offer or reminder') 32 | } 33 | 34 | return Format.number(documentNumber, Number(count)) 35 | } 36 | 37 | public static async client(organizationId: number) { 38 | const organization = await Organization.findOrFail(organizationId) 39 | 40 | const count = await Client.query() 41 | .where({ 42 | organizationId: organization.id, 43 | }) 44 | .withTrashed() 45 | .getCount() 46 | 47 | return Format.number(organization.settings.clients.number, Number(count)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /apps/frontend/components/Settings/Units.vue: -------------------------------------------------------------------------------- 1 | 5 | 47 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "prepare": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-svg-core": "^6.5.2", 14 | "@fortawesome/free-brands-svg-icons": "^6.5.2", 15 | "@fortawesome/free-regular-svg-icons": "^6.5.2", 16 | "@fortawesome/free-solid-svg-icons": "^6.5.2", 17 | "@fortawesome/vue-fontawesome": "^3.0.6", 18 | "@nuxtjs/tailwindcss": "^6.12.0", 19 | "@pinia/nuxt": "^0.5.1", 20 | "@repo/common": "workspace:*", 21 | "@tailwindcss/typography": "^0.5.13", 22 | "@tiptap/extension-placeholder": "^2.3.1", 23 | "@tiptap/starter-kit": "^2.3.1", 24 | "@tiptap/vue-3": "^2.3.1", 25 | "@types/lodash": "^4.17.1", 26 | "@vuepic/vue-datepicker": "^8.5.1", 27 | "@vueuse/core": "^13.9.0", 28 | "@vueuse/nuxt": "^13.9.0", 29 | "ace-builds": "^1.43.3", 30 | "camelcase-keys": "^9.1.3", 31 | "deepmerge": "^4.3.1", 32 | "lodash": "^4.17.21", 33 | "maska": "^1.5.1", 34 | "mitt": "^3.0.1", 35 | "nuxt": "^3.11.2", 36 | "pinia": "^2.1.7", 37 | "slugify": "^1.6.6", 38 | "sprintf-js": "^1.1.3", 39 | "vue": "^3.4.21", 40 | "vue-router": "^4.3.0", 41 | "vue3-ace-editor": "^2.2.4", 42 | "vue3-popper": "^1.5.0", 43 | "vue3-simple-typeahead": "^1.0.11", 44 | "vue3-toastify": "^0.2.8", 45 | "vuedraggable": "^4.1.0" 46 | }, 47 | "devDependencies": { 48 | "@types/node": "^20.12.10", 49 | "daisyui": "^3.9.4", 50 | "sass": "^1.77.0", 51 | "vue-good-table-next": "^0.2.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /apps/backend/database/migrations/1759157172522_recurrings.ts: -------------------------------------------------------------------------------- 1 | import BaseSchema from '@ioc:Adonis/Lucid/Schema' 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = 'recurring_invoices' 5 | public async up() { 6 | this.schema.createTable(this.tableName, (table) => { 7 | table.increments('id').primary() 8 | table 9 | .integer('invoice_id') 10 | .unsigned() 11 | .notNullable() 12 | .references('id') 13 | .inTable('documents') 14 | .onDelete('CASCADE') 15 | table.string('cron', 20).notNullable() 16 | table 17 | .integer('organization_id') 18 | .unsigned() 19 | .notNullable() 20 | .references('id') 21 | .inTable('organizations') 22 | .onDelete('CASCADE') 23 | 24 | table.date('deleted_at').nullable() 25 | table.date('start_date').notNullable() 26 | table.date('next_run').notNullable() 27 | table.boolean('active').notNullable().defaultTo(false) 28 | 29 | table.index('start_date') 30 | table.index('next_run') 31 | table.index('active') 32 | table.unique(['organization_id', 'invoice_id']) 33 | }) 34 | 35 | this.schema.alterTable('documents', (table) => { 36 | table 37 | .integer('recurring_id') 38 | .unsigned() 39 | .nullable() 40 | .references('id') 41 | .inTable(this.tableName) 42 | .onDelete('SET NULL') 43 | 44 | table.index('recurring_id') 45 | table.index('created_at') 46 | }) 47 | } 48 | 49 | public async down() { 50 | this.schema.alterTable('documents', (table) => { 51 | table.dropIndex('recurring_id') 52 | table.dropColumn('recurring_id') 53 | }) 54 | this.schema.dropTable(this.tableName) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/frontend/components/Template/List.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 45 | -------------------------------------------------------------------------------- /apps/frontend/components/Client/Form.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 46 | -------------------------------------------------------------------------------- /apps/frontend/components/Preview.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 64 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## description 2 | 3 | 4 | 5 | ## type of change 6 | 7 | 8 | 9 | - [ ] bug fix (non-breaking change which fixes an issue) 10 | - [ ] new feature (non-breaking change which adds functionality) 11 | - [ ] breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] documentation update 13 | - [ ] refactoring (no functional changes) 14 | - [ ] performance improvement 15 | - [ ] test coverage improvement 16 | 17 | ## related issues 18 | 19 | 20 | 21 | fixes # 22 | closes # 23 | related to # 24 | 25 | ## changes made 26 | 27 | 28 | 29 | - 30 | - 31 | - 32 | 33 | ## testing 34 | 35 | 36 | 37 | ### test configuration 38 | 39 | - node version: 40 | - pnpm version: 41 | - os: 42 | 43 | ### test steps 44 | 45 | 1. 46 | 2. 47 | 3. 48 | 49 | ## screenshots (if applicable) 50 | 51 | 52 | 53 | ## checklist 54 | 55 | 56 | 57 | - [ ] my code follows the project's style guidelines 58 | - [ ] i have performed a self-review of my code 59 | - [ ] i have commented my code, particularly in hard-to-understand areas 60 | - [ ] i have made corresponding changes to the documentation 61 | - [ ] my changes generate no new warnings 62 | - [ ] i have added tests that prove my fix is effective or that my feature works 63 | - [ ] new and existing unit tests pass locally with my changes 64 | - [ ] any dependent changes have been merged and published 65 | 66 | ## additional notes 67 | 68 | 69 | -------------------------------------------------------------------------------- /apps/backend/app/Controllers/Http/UsersController.ts: -------------------------------------------------------------------------------- 1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import User from 'App/Models/User' 3 | import { UserValidator } from 'App/Validators/User' 4 | 5 | export default class UsersController { 6 | public async index(ctx: HttpContextContract) { 7 | return User.query() 8 | .where({ organizationId: ctx.auth.user?.organization.id }) 9 | .withScopes((scopes) => scopes.sortBy(ctx, User)) 10 | .withScopes((scopes) => scopes.filterBy(ctx, User)) 11 | .withScopes((scopes) => scopes.searchBy(ctx, User)) 12 | 13 | .paginate(ctx.request.qs()['page'] || 1, ctx.request.qs()['perPage'] || 20) 14 | } 15 | 16 | public async show(ctx: HttpContextContract) { 17 | return await User.query() 18 | .where({ 19 | id: ctx.request.param('id'), 20 | organizationId: ctx.auth.user?.organization.id, 21 | }) 22 | .firstOrFail() 23 | } 24 | 25 | public async store(ctx: HttpContextContract) { 26 | const body = await ctx.request.validate(new UserValidator(ctx)) 27 | return await User.create({ ...body, organizationId: ctx.auth.user?.organization.id }) 28 | } 29 | 30 | public async update(ctx: HttpContextContract) { 31 | const body = await ctx.request.validate(new UserValidator(ctx)) 32 | await User.updateOrCreate( 33 | { 34 | organizationId: ctx.auth.user?.organization.id, 35 | id: ctx.request.param('id'), 36 | }, 37 | body 38 | ) 39 | } 40 | 41 | public async destroy(ctx: HttpContextContract) { 42 | return await ( 43 | await User.query() 44 | .where({ 45 | organizationId: ctx.auth.user?.organization.id, 46 | id: ctx.request.param('id'), 47 | }) 48 | .firstOrFail() 49 | ).delete() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/backend/start/kernel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Application middleware 4 | |-------------------------------------------------------------------------- 5 | | 6 | | This file is used to define middleware for HTTP requests. You can register 7 | | middleware as a `closure` or an IoC container binding. The bindings are 8 | | preferred, since they keep this file clean. 9 | | 10 | */ 11 | 12 | import Server from '@ioc:Adonis/Core/Server' 13 | 14 | /* 15 | |-------------------------------------------------------------------------- 16 | | Global middleware 17 | |-------------------------------------------------------------------------- 18 | | 19 | | An array of global middleware, that will be executed in the order they 20 | | are defined for every HTTP requests. 21 | | 22 | */ 23 | Server.middleware.register([ 24 | () => import('App/Middleware/JsonError'), 25 | () => import('App/Middleware/PaginationHeaders'), 26 | () => import('@ioc:Adonis/Core/BodyParser'), 27 | () => import('App/Middleware/HashIdParser'), 28 | ]) 29 | 30 | /* 31 | |-------------------------------------------------------------------------- 32 | | Named middleware 33 | |-------------------------------------------------------------------------- 34 | | 35 | | Named middleware are defined as key-value pair. The value is the namespace 36 | | or middleware function and key is the alias. Later you can use these 37 | | alias on individual routes. For example: 38 | | 39 | | { auth: () => import('App/Middleware/Auth') } 40 | | 41 | | and then use it as follows 42 | | 43 | | Route.get('dashboard', 'UserController.dashboard').middleware('auth') 44 | | 45 | */ 46 | Server.middleware.registerNamed({ 47 | auth: () => import('App/Middleware/Auth'), 48 | silentAuth: () => import('App/Middleware/SilentAuth'), 49 | }) 50 | -------------------------------------------------------------------------------- /apps/frontend/composables/useSignup.ts: -------------------------------------------------------------------------------- 1 | import { watchDebounced } from "@vueuse/core"; 2 | import slugify from "slugify"; 3 | 4 | export default defineStore("signup", () => { 5 | const user = ref({ 6 | email: null, 7 | password: null, 8 | passwordRepeat: null, 9 | data: { 10 | fullName: null, 11 | }, 12 | }); 13 | 14 | const organization = ref({ 15 | name: null, 16 | slug: null, 17 | }); 18 | 19 | const slug = ref("your-slug"); 20 | const slugInUse = >ref(null); 21 | 22 | watch(organization.value, () => { 23 | if (organization.value.slug) { 24 | slug.value = organization.value.slug; 25 | } else { 26 | slug.value = slugify(organization.value.name || "", { lower: true }); 27 | } 28 | }); 29 | 30 | watch(slug, () => { 31 | if (slug.value === "") { 32 | slugInUse.value = null; 33 | return; 34 | } 35 | }); 36 | 37 | watchDebounced( 38 | slug, 39 | async () => { 40 | if (slug.value === "") { 41 | slugInUse.value = null; 42 | return; 43 | } 44 | const org = await useApi().organization().getCurrent(slug.value); 45 | if (org?.slug) { 46 | slugInUse.value = true; 47 | } else { 48 | slugInUse.value = false; 49 | } 50 | }, 51 | { debounce: 300 }, 52 | ); 53 | 54 | const signUp = async (e: Event) => { 55 | e.preventDefault(); 56 | const res = await useHttp.post("/api/register", { 57 | user: user.value, 58 | organization: { ...organization.value, slug: slug.value }, 59 | }); 60 | 61 | if (res) { 62 | await useAuth().loginEmailPassword(user.value.email!, user.value.password!, slug.value); 63 | } 64 | }; 65 | 66 | return { 67 | user, 68 | organization, 69 | slug, 70 | slugInUse, 71 | signUp, 72 | }; 73 | }); 74 | -------------------------------------------------------------------------------- /apps/frontend/layouts/core.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 51 | -------------------------------------------------------------------------------- /packages/common/src/Helpers.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | class Helpers { 4 | static merge(defaultObj: T, remoteObj: T): T { 5 | const result = defaultObj as T; 6 | 7 | // Override with remote values that are not empty 8 | const allKeys = new Set([ 9 | ...Object.keys(defaultObj!), 10 | ...Object.keys(remoteObj!), 11 | ]) as Set; 12 | 13 | allKeys.forEach((key: keyof T) => { 14 | const val1 = defaultObj[key]; 15 | const val2 = remoteObj[key]; 16 | 17 | // Check if values are "empty" (null, undefined, empty string, empty array, empty object) 18 | const isEmpty1 = this.isEmptyValue(val1); 19 | const isEmpty2 = this.isEmptyValue(val2); 20 | 21 | // If both are empty, use undefined 22 | if (isEmpty1 && isEmpty2) { 23 | result[key] = val1; // or val2, doesn't matter 24 | } 25 | // If only val1 is empty, use val2 26 | else if (isEmpty1) { 27 | result[key] = val2; 28 | } 29 | // If only val2 is empty, use val1 30 | else if (isEmpty2) { 31 | result[key] = val1; 32 | } 33 | // If neither is empty, prefer val2 (second object takes precedence) 34 | else { 35 | result[key] = val2; 36 | } 37 | }); 38 | return result; 39 | } 40 | 41 | static isEmptyValue(value: any) { 42 | if (value === null || value === undefined) { 43 | return true; 44 | } 45 | 46 | if (value === "") { 47 | return true; 48 | } 49 | 50 | if (Array.isArray(value) && value.length === 0) { 51 | return true; 52 | } 53 | 54 | if ( 55 | typeof value === "object" && 56 | !Array.isArray(value) && 57 | Object.keys(value).length === 0 58 | ) { 59 | return true; 60 | } 61 | 62 | if (typeof value === "function") { 63 | return false; 64 | } 65 | 66 | return false; 67 | } 68 | } 69 | 70 | export { Helpers }; 71 | -------------------------------------------------------------------------------- /apps/backend/app/Models/User.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import Hash from '@ioc:Adonis/Core/Hash' 3 | import { column, beforeSave, belongsTo, BelongsTo, computed } from '@ioc:Adonis/Lucid/Orm' 4 | import Organization from './Organization' 5 | import HashIDs from 'App/Helpers/hashids' 6 | import BaseAppModel from './BaseAppModel' 7 | import { UserRole } from '@repo/common' 8 | 9 | export default class User extends BaseAppModel { 10 | public serializeExtras() { 11 | return { 12 | minutes: Number(this.$extras.minutes || 0), 13 | } 14 | } 15 | 16 | public static searchFields = ['email', 'data.fullName', 'data.username'] 17 | public static sortFields = ['email', 'role', 'data.fullName', 'data.username'] 18 | 19 | @column({ isPrimary: true, serialize: (val) => HashIDs.encode(val) }) 20 | public id: number 21 | 22 | @column() 23 | public email: string 24 | 25 | @column({ serializeAs: null }) 26 | public password: string 27 | 28 | @column() 29 | public role: UserRole 30 | 31 | @column() 32 | public rememberMeToken?: string 33 | 34 | @column() 35 | public data: any 36 | 37 | @column() 38 | public settings: any 39 | 40 | @column({ serialize: (val) => HashIDs.encode(val) }) 41 | public organizationId: number 42 | 43 | @belongsTo(() => Organization) 44 | public organization: BelongsTo 45 | 46 | @column.dateTime({ autoCreate: true }) 47 | public createdAt: DateTime 48 | 49 | @column.dateTime({ autoCreate: true, autoUpdate: true }) 50 | public updatedAt: DateTime 51 | 52 | @beforeSave() 53 | public static async hashPassword(User: User) { 54 | if (User.$dirty.password) { 55 | User.password = await Hash.make(User.password) 56 | } 57 | if (!User.data.username) { 58 | User.data.username = User.data.fullName.replace(' ', '').toLowerCase() 59 | } 60 | } 61 | 62 | @computed() 63 | public get isAdmin() { 64 | return this.role === UserRole.ADMIN 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /apps/backend/app/Validators/User.ts: -------------------------------------------------------------------------------- 1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 2 | import { schema, CustomMessages, rules } from '@ioc:Adonis/Core/Validator' 3 | import { UserRole } from '@repo/common' 4 | 5 | class ProfileValidator { 6 | constructor(protected ctx: HttpContextContract) {} 7 | public schema = schema.create({ 8 | email: schema.string([ 9 | rules.email(), 10 | rules.unique({ 11 | table: 'users', 12 | column: 'email', 13 | where: { organization_id: this.ctx.auth.user!.organizationId }, 14 | whereNot: { id: this.ctx.auth.user!.id }, 15 | }), 16 | ]), 17 | data: schema.object().members({ 18 | fullName: schema.string(), 19 | username: schema.string(), 20 | avatar: schema.string.optional(), 21 | }), 22 | }) 23 | 24 | public messages: CustomMessages = {} 25 | } 26 | 27 | class UserValidator { 28 | constructor(protected ctx: HttpContextContract) {} 29 | public schema = schema.create({ 30 | email: schema.string([ 31 | rules.email(), 32 | rules.unique({ 33 | table: 'users', 34 | column: 'email', 35 | where: { organization_id: this.ctx.auth.user!.organizationId }, 36 | whereNot: { id: this.ctx.request.param('id') ?? null }, 37 | }), 38 | ]), 39 | role: schema.number([rules.range(UserRole.EDITOR - 1, UserRole.ADMIN + 1)]), 40 | password: this.ctx.request.param('id') ? schema.string.optional() : schema.string(), 41 | data: schema.object().members({ 42 | fullName: schema.string(), 43 | username: schema.string(), 44 | avatar: schema.string.optional(), 45 | rate: schema.number.optional(), 46 | }), 47 | }) 48 | 49 | public messages: CustomMessages = {} 50 | } 51 | 52 | class PasswordValidator { 53 | public schema = schema.create({ 54 | password: schema.string(), 55 | }) 56 | 57 | public messages: CustomMessages = {} 58 | } 59 | 60 | export { ProfileValidator, PasswordValidator, UserValidator } 61 | -------------------------------------------------------------------------------- /packages/common/src/Locale.ts: -------------------------------------------------------------------------------- 1 | import { sprintf } from "sprintf-js"; 2 | class Locale { 3 | public static messages: { [l: string]: { [k: string]: string } } = { 4 | en: { 5 | invoice: "invoice", 6 | reminder: "reminder", 7 | offer: "offer", 8 | number: "number", 9 | no: "No.", 10 | date: "date", 11 | "due on": "due on", 12 | "your VAT": "your VAT", 13 | pos: "pos", 14 | duration: "duration", 15 | description: "description", 16 | quantity: "quantity", 17 | "unit price": "unit price", 18 | "total price": "total price", 19 | subtotal: "subtotal", 20 | net: "net", 21 | "incl. tax": "incl. tax", 22 | total: "total", 23 | user: "user", 24 | "sum total": "sum total", 25 | "duration total": "duration total", 26 | "payment conditions": "Payment conditions", 27 | "Payment within %d days": "Payment within %d days", 28 | }, 29 | "de-AT": { 30 | invoice: "Rechnung", 31 | reminder: "Mahnung", 32 | offer: "Angebot", 33 | number: "Nummer", 34 | no: "Nr.", 35 | date: "Datum", 36 | "due on": "fällig am", 37 | "your VAT": "Ihre USt-Id", 38 | pos: "Pos", 39 | duration: "Dauer", 40 | description: "Beschreibung", 41 | quantity: "Menge", 42 | "unit price": "Einzelpreis", 43 | "total price": "Gesamtpreis", 44 | subtotal: "Zwischensumme", 45 | net: "Netto", 46 | "incl. tax": "zzgl. USt", 47 | total: "Brutto", 48 | user: "User", 49 | "sum total": "Gesamt-Summe", 50 | "duration total": "Gesamt-Dauer", 51 | "Payment conditions": "Zahlungskonditionen", 52 | "Payment within %d days.": "Zahlung innerhalb von %d Tagen.", 53 | }, 54 | }; 55 | 56 | public static t(locale: string, key: string, ...values: any): string { 57 | const l = 58 | Locale.messages[locale] || 59 | (this.messages["en"] as { [key: string]: string }); 60 | const s = l[key] || key; 61 | return sprintf(s, ...values); 62 | } 63 | } 64 | 65 | export { Locale }; 66 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/common", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "tsup --watch", 7 | "build": "tsup", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "main": "./dist/index.cjs", 11 | "module": "./dist/index.js", 12 | "types": "./dist/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/index.d.ts", 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.cjs" 18 | }, 19 | "./Base": { 20 | "types": "./dist/Base.d.ts", 21 | "import": "./dist/Base.js", 22 | "require": "./dist/Base.cjs" 23 | }, 24 | "./Client": { 25 | "types": "./dist/Client.d.ts", 26 | "import": "./dist/Client.js", 27 | "require": "./dist/Client.cjs" 28 | }, 29 | "./Document": { 30 | "types": "./dist/Document.d.ts", 31 | "import": "./dist/Document.js", 32 | "require": "./dist/Document.cjs" 33 | }, 34 | "./Example": { 35 | "types": "./dist/Example.d.ts", 36 | "import": "./dist/Example.js", 37 | "require": "./dist/Example.cjs" 38 | }, 39 | "./Format": { 40 | "types": "./dist/Format.d.ts", 41 | "import": "./dist/Format.js", 42 | "require": "./dist/Format.cjs" 43 | }, 44 | "./Helpers": { 45 | "types": "./dist/Helpers.d.ts", 46 | "import": "./dist/Helpers.js", 47 | "require": "./dist/Helpers.cjs" 48 | }, 49 | "./Locale": { 50 | "types": "./dist/Locale.d.ts", 51 | "import": "./dist/Locale.js", 52 | "require": "./dist/Locale.cjs" 53 | }, 54 | "./User": { 55 | "types": "./dist/User.d.ts", 56 | "import": "./dist/User.js", 57 | "require": "./dist/User.cjs" 58 | } 59 | }, 60 | "dependencies": { 61 | "date-fns": "^4.1.0", 62 | "lodash": "^4.17.21", 63 | "luxon": "^3.7.2" 64 | }, 65 | "devDependencies": { 66 | "@repo/typescript-config": "workspace:*", 67 | "@types/lodash": "^4.17.20", 68 | "@types/sprintf-js": "^1.1.4", 69 | "tsup": "^8.5.0", 70 | "typescript": "latest" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /apps/frontend/components/Navigation/Settings.vue: -------------------------------------------------------------------------------- 1 | 56 | -------------------------------------------------------------------------------- /packages/common/src/Client.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { Helpers } from "./Helpers"; 3 | 4 | export interface ClientData { 5 | info: { 6 | vat: string; 7 | addition: string; 8 | }; 9 | address: { 10 | street: string; 11 | zip: string; 12 | city: string; 13 | country: string; 14 | }; 15 | contactPerson: { 16 | fullName: string; 17 | email: string; 18 | }; 19 | conditions: { 20 | earlyPayment: { 21 | discount: number | null; 22 | days: number | null; 23 | }; 24 | invoiceDueDays: number | null; 25 | rate: number | null; 26 | discount: { 27 | value: number | null; 28 | valueType: string; 29 | }; 30 | }; 31 | } 32 | 33 | class Client { 34 | id: string = ""; 35 | name: string = ""; 36 | number: string = ""; 37 | createdAt: Date = new Date(); 38 | updatedAt: Date = new Date(); 39 | data: ClientData = { 40 | address: { street: "", zip: "", city: "", country: "" }, 41 | info: { vat: "", addition: "" }, 42 | contactPerson: { fullName: "", email: "" }, 43 | conditions: { 44 | earlyPayment: { days: null, discount: null }, 45 | invoiceDueDays: null, 46 | rate: null, 47 | discount: { value: null, valueType: "percent" }, 48 | }, 49 | }; 50 | totalInvoices: number = 0; 51 | pendingInvoices: number = 0; 52 | totalOffers: number = 0; 53 | totalReminders: number = 0; 54 | pendingOffers: number = 0; 55 | 56 | public constructor(json?: any) { 57 | if (json) { 58 | Helpers.merge(this, json); 59 | if (json.updatedAt && json.createdAt) { 60 | this.updatedAt = new Date(Date.parse(json.updatedAt.toString())); 61 | this.createdAt = new Date(Date.parse(json.createdAt.toString())); 62 | } 63 | } 64 | } 65 | 66 | public errors(): string[] { 67 | const e: string[] = []; 68 | if (this.name === "") { 69 | e.push("Name is required"); 70 | } 71 | 72 | if (this.data.contactPerson.email == "") { 73 | e.push("E-mail is required"); 74 | } 75 | 76 | return e; 77 | } 78 | 79 | public toJSON() { 80 | return { ...this }; 81 | } 82 | } 83 | 84 | export { Client }; 85 | -------------------------------------------------------------------------------- /apps/frontend/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Organization } from "~~/models/organization"; 2 | import _ from "lodash"; 3 | import type { IBase } from "@repo/common"; 4 | import { UserRole } from "@repo/common"; 5 | import { Helpers } from "@repo/common"; 6 | interface UserData { 7 | username: string; 8 | fullName: string; 9 | avatar: string; 10 | } 11 | 12 | class Token { 13 | id: string | null = null; 14 | name: string = ""; 15 | token: string = ""; 16 | expiresAt: null | Date = null; 17 | createdAt: Date = new Date(); 18 | 19 | constructor(json?: any) { 20 | if (json) { 21 | Helpers.merge(this, json); 22 | if (json.expiresAt) { 23 | this.expiresAt = new Date(Date.parse(json.expiresAt.toString())); 24 | } 25 | if (json.createdAt) { 26 | this.createdAt = new Date(Date.parse(json.createdAt.toString())); 27 | } 28 | } 29 | } 30 | } 31 | 32 | class User implements IBase { 33 | id: string | null = null; 34 | role: UserRole = UserRole.VIEWER; 35 | password: string | null = null; 36 | createdAt: Date = new Date(); 37 | updatedAt: Date = new Date(); 38 | email: string = ""; 39 | net: number = 0; 40 | data: UserData = { 41 | username: "", 42 | fullName: "", 43 | avatar: "", 44 | }; 45 | duration?: string; 46 | initials = () => { 47 | const s = this.data.fullName.split(" "); 48 | return s.length > 1 49 | ? s[0].charAt(0).toUpperCase() + s[1].charAt(0).toUpperCase() 50 | : s[0].charAt(0).toUpperCase() + s[0].charAt(1).toUpperCase(); 51 | }; 52 | organization: Organization = new Organization(); 53 | 54 | constructor(json?: any) { 55 | if (json) { 56 | Helpers.merge(this, json); 57 | this.organization = new Organization(json.organization); 58 | if (json.updatedAt && json.createdAt) { 59 | this.updatedAt = new Date(Date.parse(json.updatedAt.toString())); 60 | this.createdAt = new Date(Date.parse(json.createdAt.toString())); 61 | } 62 | } 63 | } 64 | public toJSON() { 65 | return { ...this }; 66 | } 67 | 68 | public errors(): [] { 69 | return []; 70 | } 71 | } 72 | 73 | export { User, Token }; 74 | export type { UserData }; 75 | -------------------------------------------------------------------------------- /apps/frontend/models/template.ts: -------------------------------------------------------------------------------- 1 | import { Helpers } from "@repo/common"; 2 | import _ from "lodash"; 3 | interface TemplateData { 4 | columns: { 5 | first: string; 6 | second: string; 7 | third: string; 8 | fourth: string; 9 | }; 10 | texts: { 11 | beforeTable: string; 12 | afterTable: string; 13 | }; 14 | colors: { 15 | border: string; 16 | primary: string; 17 | bodyText: string; 18 | secondary: string; 19 | footerText: string; 20 | headerText: string; 21 | footerBackground: string; 22 | headerBackground: string; 23 | tableOddBackground: string; 24 | tableEvenBackground: string; 25 | }; 26 | } 27 | 28 | class Template { 29 | id: string = ""; 30 | title: string = ""; 31 | createdAt: Date = new Date(); 32 | updatedAt: Date = new Date(); 33 | thumbnail: string = ""; 34 | isGlobal: boolean = false; 35 | data: TemplateData = { 36 | columns: { 37 | first: "", 38 | second: "", 39 | third: "", 40 | fourth: "", 41 | }, 42 | texts: { 43 | beforeTable: "", 44 | afterTable: "", 45 | }, 46 | colors: { 47 | border: "#E6E9EF", 48 | primary: "#7287FD", 49 | bodyText: "#1E1E2E", 50 | secondary: "#DC8A78", 51 | footerText: "#CDD6F4", 52 | headerText: "#1E1E2E", 53 | footerBackground: "#1E1E2E", 54 | headerBackground: "#E6E9EF", 55 | tableOddBackground: "#FFFFFF", 56 | tableEvenBackground: "#E6E9EF", 57 | }, 58 | }; 59 | 60 | html: string = ""; 61 | default: boolean = false; 62 | premium: boolean = false; 63 | 64 | public constructor(json?: any) { 65 | if (json) { 66 | Helpers.merge(this, json); 67 | if (json.updatedAt && json.createdAt) { 68 | this.updatedAt = new Date(Date.parse(json.updatedAt.toString())); 69 | this.createdAt = new Date(Date.parse(json.createdAt.toString())); 70 | } 71 | } 72 | } 73 | 74 | public errors(): string[] { 75 | const e: string[] = []; 76 | if (this.title === "") { 77 | e.push("Name is required"); 78 | } 79 | 80 | return e; 81 | } 82 | 83 | public toJSON() { 84 | return { ...this }; 85 | } 86 | } 87 | 88 | export { Template }; 89 | -------------------------------------------------------------------------------- /apps/frontend/components/Client/Form/Address.vue: -------------------------------------------------------------------------------- 1 | 4 | 68 | -------------------------------------------------------------------------------- /apps/frontend/composables/useTemplate.ts: -------------------------------------------------------------------------------- 1 | import { Template } from "~~/models/template"; 2 | import _ from "lodash"; 3 | import Base from "./_base"; 4 | 5 | class TemplateStore extends Base