├── .yarn.installed ├── src ├── .gitignore ├── tsconfig.json ├── jest.setup.js ├── nodemon.json ├── server │ ├── utils │ │ ├── errors.ts │ │ ├── test.ts │ │ ├── server-config.ts │ │ ├── constants.ts │ │ ├── withProfile.ts │ │ ├── pool.ts │ │ ├── i18n.ts │ │ ├── dbUtils.ts │ │ ├── frameworkIntegration.ts │ │ ├── __tests__ │ │ │ └── misc.test.ts │ │ └── misc.ts │ ├── services │ │ ├── controllers.ts │ │ ├── timestamps.model.ts │ │ ├── user │ │ │ ├── user.module.ts │ │ │ ├── user.controller.ts │ │ │ └── user.service.ts │ │ ├── boot │ │ │ ├── boot.controller.ts │ │ │ └── boot.service.ts │ │ ├── Bank.ts │ │ ├── accountShared │ │ │ ├── sharedAccount.model.ts │ │ │ └── sharedAccount.db.ts │ │ ├── cash │ │ │ ├── cash.db.ts │ │ │ ├── cash.model.ts │ │ │ └── cash.controller.ts │ │ ├── accountExternal │ │ │ ├── externalAccount.model.ts │ │ │ └── externalAccount.db.ts │ │ ├── transaction │ │ │ ├── transaction.model.ts │ │ │ └── transaction.controller.ts │ │ ├── associations.ts │ │ ├── auth │ │ │ └── auth.service.ts │ │ ├── broadcast │ │ │ └── broadcast.controller.ts │ │ ├── invoice │ │ │ ├── invoice.db.ts │ │ │ └── invoice.model.ts │ │ └── account │ │ │ └── account.model.ts │ ├── lib │ │ ├── promise.types.ts │ │ └── onNetPromise.ts │ ├── decorators │ │ ├── Controller.ts │ │ ├── NetPromise.ts │ │ ├── Export.ts │ │ └── Event.ts │ ├── tsconfig.json │ ├── logform.js │ └── sv_logger.ts ├── utils │ └── fivem.ts ├── client │ ├── cl_config.ts │ ├── tsconfig.json │ ├── cl_exports.ts │ ├── i18n.ts │ ├── lua │ │ └── interaction.lua │ ├── cl_integrations.ts │ ├── functions.ts │ └── cl_blips.ts ├── scripts │ ├── build_client.js │ ├── build_server.js │ ├── watch_client.js │ └── watch_server.js ├── jest.config.js ├── .eslintrc.json └── package.json ├── web ├── src │ ├── bootstrapApp.ts │ ├── react-app-env.d.ts │ ├── bg.png │ ├── bootstrapMobile.ts │ ├── globals.d.ts │ ├── data │ │ ├── resourceConfig.ts │ │ ├── externalAccounts.ts │ │ ├── invoices.ts │ │ ├── transactions.ts │ │ └── accounts.ts │ ├── hooks │ │ ├── usePrevious.ts │ │ ├── useConfig.ts │ │ ├── useExitListener.ts │ │ ├── useGlobalSettings.tsx │ │ ├── useFetchNui.ts │ │ ├── useBroadcasts.ts │ │ ├── useNuiEvent.ts │ │ └── useI18n.ts │ ├── config │ │ └── default.json │ ├── index.css │ ├── components │ │ ├── ui │ │ │ ├── Fields │ │ │ │ ├── BaseField.styles.ts │ │ │ │ ├── PriceField.tsx │ │ │ │ └── TextField.tsx │ │ │ ├── Typography │ │ │ │ ├── BodyText.tsx │ │ │ │ └── Headings.tsx │ │ │ ├── Count.tsx │ │ │ ├── BadgeAtom.tsx │ │ │ ├── NewBalance.tsx │ │ │ ├── Checkbox.tsx │ │ │ ├── Button.tsx │ │ │ ├── Select.tsx │ │ │ └── Status.tsx │ │ ├── BankContainer.tsx │ │ ├── IconTextField.tsx │ │ ├── Modals │ │ │ ├── BaseDialog.tsx │ │ │ └── RemoveUser.tsx │ │ ├── TotalBalance.tsx │ │ ├── IconLabelButton.tsx │ │ ├── UserSelect.tsx │ │ ├── Summary.tsx │ │ ├── Layout.tsx │ │ └── DebugBar.tsx │ ├── utils │ │ ├── misc.ts │ │ ├── api.ts │ │ ├── i18nResourceHelpers.ts │ │ ├── account.ts │ │ ├── fetchNui.ts │ │ ├── i18n.ts │ │ ├── currency.ts │ │ ├── theme.ts │ │ └── test.tsx │ ├── views │ │ ├── transfer │ │ │ └── Transfer.tsx │ │ ├── Mobile │ │ │ ├── mobile.module.css │ │ │ ├── i18n.ts │ │ │ ├── Routes.tsx │ │ │ └── views │ │ │ │ ├── Accounts │ │ │ │ └── MobileAccountsView.tsx │ │ │ │ ├── Invoices │ │ │ │ └── MobileInvoicesView.tsx │ │ │ │ └── Dashboard │ │ │ │ └── MobileDashboardView.tsx │ │ └── dashboard │ │ │ ├── components │ │ │ ├── PendingInvoices.tsx │ │ │ ├── Transactions.tsx │ │ │ ├── __tests__ │ │ │ │ └── AccountCards.test.tsx │ │ │ └── Summary.tsx │ │ │ └── Dashboard.tsx │ ├── App.css │ ├── BankIcon.tsx │ ├── icons │ │ ├── CheckIcon.tsx │ │ ├── MasterCardIcon.tsx │ │ └── svgProvider.tsx │ ├── index.tsx │ └── mobileDevelopmentContainer.tsx ├── jest.setup.ts ├── scripts │ └── build_ui.js ├── index.html ├── .gitignore ├── npwd.config.ts ├── jest.config.js ├── public │ └── index.html ├── tsconfig.json ├── .eslintrc.json ├── vite.config.ts ├── README.md ├── webpack.config.js └── webpack.mobile.config.js ├── .gitignore ├── .husky ├── pre-commit └── commit-msg ├── .prettierignore ├── commitlint.config.js ├── shared └── utils │ └── regexes.ts ├── typings ├── common.ts ├── Cash.ts ├── user.ts ├── http.ts ├── Errors.ts ├── exports.ts ├── Invoice.ts ├── Transaction.ts ├── exports │ └── server.ts ├── config.ts ├── Events.ts └── Account.ts ├── .prettierrc ├── localazy.json ├── fxmanifest.lua ├── scripts ├── release.sh ├── prerelease.sh └── generateLocales.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── translations.yml │ ├── web.yml │ ├── server.yml │ ├── release.yml │ └── prerelease.yml ├── README.md ├── nx.json ├── package.json └── webpack.config.js /.yarn.installed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /web/src/bootstrapApp.ts: -------------------------------------------------------------------------------- 1 | import('./index'); 2 | export {}; 3 | -------------------------------------------------------------------------------- /web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "**/node_modules"], 3 | } -------------------------------------------------------------------------------- /web/src/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-error/pefcl/HEAD/web/src/bg.png -------------------------------------------------------------------------------- /web/src/bootstrapMobile.ts: -------------------------------------------------------------------------------- 1 | import('./mobileDevelopmentContainer'); 2 | export {}; 3 | -------------------------------------------------------------------------------- /src/jest.setup.js: -------------------------------------------------------------------------------- 1 | require('./server/globals.server'); 2 | require('reflect-metadata'); 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | sv_pefcl.log 5 | locales 6 | temp 7 | yarn-error.log -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | *.json 4 | *.lock 5 | dist 6 | coverage 7 | *.md 8 | .idea -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /web/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: string; 3 | export = value; 4 | } 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "" 5 | -------------------------------------------------------------------------------- /web/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import './src/utils/i18n'; 3 | 4 | jest.mock('@utils/fetchNui'); 5 | -------------------------------------------------------------------------------- /shared/utils/regexes.ts: -------------------------------------------------------------------------------- 1 | export const regexExternalNumber = /^(\d{3}, ?\d{4}-\d{4}-\d{4})$/; 2 | export const regexAlphaNumeric = /^[a-zA-Z0-9_åäöÅÄÖ ]+$/; 3 | -------------------------------------------------------------------------------- /typings/common.ts: -------------------------------------------------------------------------------- 1 | export enum DIToken { 2 | Controller = 'server-controller', 3 | } 4 | 5 | export interface IController { 6 | name: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "watch": ["dist/*.js"], 4 | "ext": "ts,mjs,js,json,graphql", 5 | "exec": "node ./dist/server.js -v" 6 | } 7 | -------------------------------------------------------------------------------- /src/server/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export class ServerError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'ServerError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/server/utils/test.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, ResourceConfig } from '@typings/config'; 2 | 3 | export const createMockedConfig = (config: DeepPartial) => config; 4 | -------------------------------------------------------------------------------- /src/utils/fivem.ts: -------------------------------------------------------------------------------- 1 | // https://forum.cfx.re/t/typescript-vs-lua-questions/612483/11 2 | export const Delay = (ms: number): Promise => 3 | new Promise((resolve) => setTimeout(resolve, ms)); 4 | -------------------------------------------------------------------------------- /typings/Cash.ts: -------------------------------------------------------------------------------- 1 | export interface Cash { 2 | id: number; 3 | amount: number; 4 | ownerIdentifier: string; 5 | } 6 | 7 | export interface ChangeCashInput { 8 | source: number; 9 | amount: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/client/cl_config.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, ResourceConfig } from '@typings/config'; 2 | 3 | export default JSON.parse( 4 | LoadResourceFile(GetCurrentResourceName(), 'config.json'), 5 | ) as DeepPartial; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "useTabs": false, 4 | "semi": true, 5 | "bracketSpacing": true, 6 | "jsxSingleQuote": false, 7 | "singleQuote": true, 8 | "tabWidth": 2, 9 | "trailingComma": "all" 10 | } 11 | -------------------------------------------------------------------------------- /web/src/data/resourceConfig.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | import { ResourceConfig } from '@typings/config'; 3 | import { getConfig } from '../utils/api'; 4 | 5 | export const configAtom = atom>(async () => { 6 | return await getConfig(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/server/utils/server-config.ts: -------------------------------------------------------------------------------- 1 | // Setup and export config loaded at runtime 2 | import { DeepPartial, ResourceConfig } from '@typings/config'; 3 | 4 | export const config: DeepPartial = JSON.parse( 5 | LoadResourceFile(GetCurrentResourceName(), 'config.json'), 6 | ); 7 | -------------------------------------------------------------------------------- /web/src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export const usePrevious = (value: unknown) => { 4 | const ref = useRef(); 5 | 6 | useEffect(() => { 7 | ref.current = value; 8 | }, [value]); 9 | 10 | return ref.current; 11 | }; 12 | -------------------------------------------------------------------------------- /typings/user.ts: -------------------------------------------------------------------------------- 1 | export type UserDTO = { 2 | name?: string; 3 | source: number; 4 | identifier?: string; 5 | }; 6 | 7 | export interface User { 8 | name: string; 9 | identifier: string; 10 | } 11 | 12 | export interface OnlineUser extends User { 13 | source: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/scripts/build_client.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | 3 | esbuild 4 | .build({ 5 | entryPoints: ['client/client.ts'], 6 | bundle: true, 7 | loader: { '.ts': 'ts' }, 8 | minify: true, 9 | outfile: 'dist/client.js', 10 | }) 11 | .catch(() => process.exit(1)); 12 | -------------------------------------------------------------------------------- /web/src/hooks/useConfig.ts: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai'; 2 | import { ResourceConfig } from '../../../typings/config'; 3 | import { configAtom } from '../data/resourceConfig'; 4 | 5 | export const useConfig = (): ResourceConfig => { 6 | const [config] = useAtom(configAtom); 7 | return config; 8 | }; 9 | -------------------------------------------------------------------------------- /src/server/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const resourceName = 'pefcl'; 2 | export const DATABASE_PREFIX = 'pefcl_'; 3 | export const DEFAULT_CLEARING_NUMBER = 920; 4 | 5 | export const MS_ONE_DAY = 1000 * 60 * 60 * 24; 6 | export const MS_ONE_WEEK = MS_ONE_DAY * 7; 7 | export const MS_TWO_WEEKS = MS_ONE_WEEK * 2; 8 | -------------------------------------------------------------------------------- /web/src/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "exports": { 3 | "resourceName": "my-resource" 4 | }, 5 | "general": { 6 | "useFrameworkIntegration": false 7 | }, 8 | "database": { 9 | "profileQueries": true 10 | }, 11 | "debug": { 12 | "level": "silly" 13 | }, 14 | "locale": "en" 15 | } -------------------------------------------------------------------------------- /src/server/services/controllers.ts: -------------------------------------------------------------------------------- 1 | import './boot/boot.controller'; 2 | import './user/user.controller'; 3 | import './cash/cash.controller'; 4 | import './invoice/invoice.controller'; 5 | import './account/account.controller'; 6 | import './transaction/transaction.controller'; 7 | import './broadcast/broadcast.controller'; 8 | -------------------------------------------------------------------------------- /src/server/services/timestamps.model.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { DataTypes } from 'sequelize'; 3 | 4 | export const timestamps = { 5 | createdAt: { 6 | type: DataTypes.DATE, 7 | get() { 8 | return new Date(this.getDataValue('createdAt') ?? '').getTime(); 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600&display=swap'); 2 | 3 | * { 4 | box-sizing: border-box; 5 | font-family: 'Montserrat', sans-serif; 6 | } 7 | 8 | body { 9 | margin: 0; 10 | height: 100vh; 11 | } 12 | 13 | #root { 14 | height: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /web/src/components/ui/Fields/BaseField.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import theme from '../../../utils/theme'; 3 | 4 | export default styled.div` 5 | display: flex; 6 | padding: 1rem; 7 | border-radius: ${theme.spacing(1)}; 8 | background-color: ${theme.palette.background.dark12}; 9 | 10 | & > div { 11 | flex: 1; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /localazy.json: -------------------------------------------------------------------------------- 1 | { 2 | "writeKey": "", 3 | "readKey": "a8249505652440216172-ad821901358cfd858cada94c9bf33d543d82cb994303c2a73b133e11b058ee71", 4 | 5 | "upload": { 6 | "type": "json", 7 | "files": ["locales/en/default.json"] 8 | }, 9 | 10 | "download": { 11 | "includeSourceLang": true, 12 | "files": "locales/${lang}/default.json" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | invokeNative(): void; 4 | GetParentResourceName?: () => string; 5 | } 6 | } 7 | 8 | // and not CEF 9 | export const isEnvBrowser = (): boolean => !window.invokeNative; 10 | export const getResourceName = () => 'pefcl'; 11 | 12 | // Basic no operation function 13 | export const noop = () => {}; 14 | -------------------------------------------------------------------------------- /src/server/lib/promise.types.ts: -------------------------------------------------------------------------------- 1 | import { ServerPromiseResp } from '../../../typings/http'; 2 | 3 | export interface PromiseRequest { 4 | data: T; 5 | source: number; 6 | } 7 | 8 | export type PromiseEventResp = (returnData: ServerPromiseResp) => void; 9 | 10 | export type CBSignature = (reqObj: PromiseRequest, resp: PromiseEventResp

) => void; 11 | -------------------------------------------------------------------------------- /web/src/views/transfer/Transfer.tsx: -------------------------------------------------------------------------------- 1 | import Layout from '@components/Layout'; 2 | import TransferFunds from '@components/TransferFunds'; 3 | import { t } from 'i18next'; 4 | import React from 'react'; 5 | 6 | const Transfer = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default Transfer; 15 | -------------------------------------------------------------------------------- /web/src/views/Mobile/mobile.module.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600&display=swap'); 2 | 3 | * { 4 | box-sizing: border-box; 5 | font-family: 'Montserrat', sans-serif; 6 | } 7 | 8 | body, 9 | html { 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | text-rendering: optimizeLegibility; 13 | } 14 | -------------------------------------------------------------------------------- /web/scripts/build_ui.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | 3 | esbuild 4 | .build({ 5 | entryPoints: ['src/index.tsx'], 6 | bundle: true, 7 | minify: true, 8 | target: 'es2015', 9 | loader: { 10 | '.tsx': 'tsx', 11 | '.ts': 'ts', 12 | '.js': 'js', 13 | '.jsx': 'jsx', 14 | }, 15 | outdir: 'build', 16 | }) 17 | .catch(() => process.exit(1)); 18 | -------------------------------------------------------------------------------- /web/src/views/Mobile/i18n.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from 'i18next'; 2 | import { getI18nResources, Language } from '@utils/i18nResourceHelpers'; 3 | 4 | export const loadPefclResources = (i18n: i18n) => { 5 | const resources = getI18nResources(); 6 | 7 | Object.keys(resources).forEach((lng) => { 8 | i18n.addResourceBundle(lng, 'pefcl', resources[lng as Language]); 9 | }); 10 | 11 | return i18n; 12 | }; 13 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | PEFCL 8 | 9 | 10 |

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/.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 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /web/src/components/ui/Typography/BodyText.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import theme from '../../../utils/theme'; 3 | 4 | const BaseText = styled.span` 5 | font-family: ${theme.typography.fontFamily}; 6 | font-weight: ${theme.typography.fontWeightLight}; 7 | `; 8 | 9 | export const PreHeading = styled(BaseText)` 10 | font-size: 0.9rem; 11 | `; 12 | 13 | export const BodyText = styled(BaseText)` 14 | font-size: 1rem; 15 | `; 16 | -------------------------------------------------------------------------------- /web/src/components/BankContainer.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const BankWrapper = styled('div')({ 4 | height: '100%', 5 | display: 'flex', 6 | justifyContent: 'center', 7 | alignItems: 'center', 8 | }); 9 | 10 | export const BankContainer = styled('div')({ 11 | width: '1400px', 12 | height: '800px', 13 | borderRadius: 7, 14 | backgroundColor: '#212529', 15 | display: 'flex', 16 | flexDirection: 'row', 17 | }); 18 | -------------------------------------------------------------------------------- /src/server/decorators/Controller.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from 'tsyringe'; 2 | import { constructor } from 'tsyringe/dist/typings/types'; 3 | import { Bank } from '../services/Bank'; 4 | import { DIToken } from '@typings/common'; 5 | 6 | export function Controller(name: string) { 7 | return function (target: constructor) { 8 | target.prototype.name = name; 9 | 10 | singleton()(target); 11 | Bank.container.registerSingleton(DIToken.Controller, target); 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/scripts/build_server.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const { esbuildDecorators } = require('@anatine/esbuild-decorators'); 3 | 4 | esbuild 5 | .build({ 6 | entryPoints: ['server/server.ts'], 7 | bundle: true, 8 | platform: 'node', 9 | target: 'node16', 10 | outfile: 'dist/server.js', 11 | plugins: [ 12 | esbuildDecorators({ 13 | tsconfig: 'server/tsconfig.json', 14 | }), 15 | ], 16 | }) 17 | .catch(() => process.exit(1)); 18 | -------------------------------------------------------------------------------- /typings/http.ts: -------------------------------------------------------------------------------- 1 | interface ServerResponseSuccess { 2 | status: 'ok'; 3 | data?: T; 4 | } 5 | interface ServerResponseError { 6 | status: 'error'; 7 | errorMsg?: string; 8 | errorName?: string; 9 | } 10 | 11 | export type ServerPromiseResp = ServerResponseSuccess | ServerResponseError; 12 | 13 | export interface Request { 14 | data: T; 15 | source: number; 16 | } 17 | 18 | export type Response = (returnData: ServerPromiseResp) => void; 19 | -------------------------------------------------------------------------------- /web/src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import { ResourceConfig } from '../../../typings/config'; 2 | import { getResourceName, isEnvBrowser } from './misc'; 3 | import defaultConfig from '../../../config.json'; 4 | 5 | export const getConfig = async (): Promise => { 6 | if (isEnvBrowser()) { 7 | return defaultConfig; 8 | } 9 | 10 | const resourceName = getResourceName(); 11 | const config = await fetch(`https://cfx-nui-${resourceName}/config.json`).then((res) => 12 | res.json(), 13 | ); 14 | 15 | return config; 16 | }; 17 | -------------------------------------------------------------------------------- /src/scripts/watch_client.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | 3 | esbuild 4 | .build({ 5 | entryPoints: ['client/client.ts'], 6 | bundle: true, 7 | loader: { '.ts': 'ts' }, 8 | minify: true, 9 | outfile: 'dist/client.js', 10 | watch: { 11 | onRebuild(error) { 12 | if (error) console.error('[CLIENT] watch build failed:', error); 13 | else console.log('[CLIENT] watch build succeeded.'); 14 | }, 15 | }, 16 | }) 17 | .then(() => { 18 | console.log('Watching client'); 19 | }); 20 | -------------------------------------------------------------------------------- /src/server/services/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { OnlineUser } from '@typings/user'; 2 | 3 | export class UserModule { 4 | private readonly _source: number; 5 | private readonly _identifier: string; 6 | public readonly name: string; 7 | 8 | constructor(user: OnlineUser) { 9 | this._source = user.source; 10 | this._identifier = user.identifier; 11 | this.name = user.name; 12 | } 13 | 14 | getSource() { 15 | return this._source; 16 | } 17 | 18 | getIdentifier() { 19 | return this._identifier; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /web/src/components/IconTextField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { InputAdornment, StandardTextFieldProps, TextField } from '@mui/material'; 3 | 4 | interface IconTextFieldProps extends StandardTextFieldProps { 5 | icon: JSX.Element; 6 | } 7 | 8 | const IconTextField: React.FC = ({ icon, ...props }) => ( 9 | <> 10 | {icon} }} 13 | /> 14 | 15 | ); 16 | 17 | export default IconTextField; 18 | -------------------------------------------------------------------------------- /src/jest.config.js: -------------------------------------------------------------------------------- 1 | const tsconfig = require('./server/tsconfig.json'); 2 | const moduleNameMapper = require('tsconfig-paths-jest')(tsconfig); 3 | 4 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 5 | module.exports = { 6 | preset: 'ts-jest', 7 | testEnvironment: 'node', 8 | rootDir: 'server', 9 | moduleNameMapper, 10 | testMatch: ['**/**/*.test.ts'], 11 | testPathIgnorePatterns: ['/utils/test.ts'], 12 | setupFiles: ['/../jest.setup.js'], 13 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], 14 | }; 15 | -------------------------------------------------------------------------------- /web/npwd.config.ts: -------------------------------------------------------------------------------- 1 | import App from './src/views/Mobile/Mobile'; 2 | import BankIcon from './src/BankIcon'; 3 | 4 | // const defaultLanguage = 'en'; 5 | // const localizedAppName = { 6 | // en: 'APPS_BANK', 7 | // }; 8 | 9 | // interface Settings { 10 | // language: 'en'; 11 | // } 12 | 13 | export const externalAppConfig = () => ({ 14 | id: 'BANK', 15 | nameLocale: 'BANK', 16 | color: '#fff', 17 | backgroundColor: '#264f82', 18 | path: '/bank', 19 | icon: BankIcon, 20 | notificationIcon: BankIcon, 21 | app: App, 22 | }); 23 | 24 | export default externalAppConfig; 25 | -------------------------------------------------------------------------------- /fxmanifest.lua: -------------------------------------------------------------------------------- 1 | fx_version "cerulean" 2 | 3 | description "Financing resource. Accounts, Cash, Invoices, Transactions." 4 | author "Project Error" 5 | version '1.0.0' 6 | repository 'https://github.com/project-error/fivem-react-boilerplate-lua' 7 | 8 | lua54 'yes' 9 | 10 | games { 11 | "gta5", 12 | "rdr3" 13 | } 14 | 15 | ui_page 'web/dist/index.html' 16 | 17 | client_script { 18 | "src/dist/client.js", 19 | "src/client/lua/interaction.lua" 20 | } 21 | server_script "src/dist/server.js" 22 | 23 | files { 24 | 'web/dist/index.html', 25 | 'web/dist/**/*', 26 | 'config.json' 27 | } 28 | -------------------------------------------------------------------------------- /web/src/views/dashboard/components/PendingInvoices.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai'; 2 | import React from 'react'; 3 | import { unpaidInvoicesAtom } from '../../../data/invoices'; 4 | import InvoiceItem from '@components/InvoiceItem'; 5 | import { Stack } from '@mui/material'; 6 | 7 | const PendingInvoices: React.FC = () => { 8 | const [invoices] = useAtom(unpaidInvoicesAtom); 9 | 10 | return ( 11 | 12 | {invoices.map((invoice) => ( 13 | 14 | ))} 15 | 16 | ); 17 | }; 18 | 19 | export default PendingInvoices; 20 | -------------------------------------------------------------------------------- /src/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es6", 5 | "module": "commonjs", 6 | "resolveJsonModule": true, 7 | "lib": ["ES2017"], 8 | "allowJs": true, 9 | "noEmit": true, 10 | "strictNullChecks": true, 11 | "types": ["@citizenfx/client", "@types/node"], 12 | "esModuleInterop": true, 13 | "moduleResolution": "node", 14 | "paths": { 15 | "@typings/*": ["../../typings/*"], 16 | "@locales/*": ["../../locales/*"] 17 | } 18 | }, 19 | "include": ["./**/*", "../../typings/*"], 20 | "exclude": ["**/node_modules", "**/__tests__/*"] 21 | } -------------------------------------------------------------------------------- /web/jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const tsconfig = require('./tsconfig.json'); 3 | const moduleNameMapper = require('tsconfig-paths-jest')(tsconfig); 4 | 5 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 6 | module.exports = { 7 | preset: 'ts-jest', 8 | testEnvironment: 'jsdom', 9 | rootDir: '.', 10 | resetMocks: false, 11 | moduleNameMapper: moduleNameMapper, 12 | testMatch: ['**/*.test.*'], 13 | setupFiles: ['jest-localstorage-mock'], 14 | setupFilesAfterEnv: ['/jest.setup.ts'], 15 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], 16 | }; 17 | -------------------------------------------------------------------------------- /web/src/views/dashboard/components/Transactions.tsx: -------------------------------------------------------------------------------- 1 | import TransactionItem from '@components/TransactionItem'; 2 | import { Stack } from '@mui/material'; 3 | import { useAtom } from 'jotai'; 4 | import React from 'react'; 5 | import { transactionsAtom } from '../../../data/transactions'; 6 | 7 | const Transactions = () => { 8 | const [transactions] = useAtom(transactionsAtom); 9 | 10 | return ( 11 | 12 | {transactions.slice(0, 3).map((transaction) => ( 13 | 14 | ))} 15 | 16 | ); 17 | }; 18 | 19 | export default Transactions; 20 | -------------------------------------------------------------------------------- /src/server/services/boot/boot.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '../../decorators/Controller'; 2 | import { Event, EventListener } from '../../decorators/Event'; 3 | import { BootService } from './boot.service'; 4 | 5 | @Controller('Boot') 6 | @EventListener() 7 | export class BootController { 8 | _bootService: BootService; 9 | constructor(bootService: BootService) { 10 | this._bootService = bootService; 11 | } 12 | 13 | @Event('onServerResourceStart') 14 | async onServerResourceStart(resource: string) { 15 | if (resource !== GetCurrentResourceName()) { 16 | return; 17 | } 18 | 19 | this._bootService.handleResourceStart(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /web/src/hooks/useExitListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { fetchNui } from '../utils/fetchNui'; 3 | import { isEnvBrowser } from '@utils/misc'; 4 | import { GeneralEvents } from '@typings/Events'; 5 | 6 | const LISTENED_KEYS = ['Escape']; 7 | 8 | export const useExitListener = () => { 9 | useEffect(() => { 10 | const keyHandler = (e: KeyboardEvent) => { 11 | if (LISTENED_KEYS.includes(e.code) && !isEnvBrowser()) { 12 | fetchNui(GeneralEvents.CloseUI); 13 | } 14 | }; 15 | 16 | window.addEventListener('keydown', keyHandler); 17 | 18 | return () => window.removeEventListener('keydown', keyHandler); 19 | }, []); 20 | }; 21 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PREFIX="[PEFCL]" 4 | RESOURCE="pefcl" 5 | 6 | echo "$PREFIX Creating release" 7 | ## Create temporary folder and move files into it. Keeping same structure according to fxmanifest.lua 8 | mkdir -p ./temp/$RESOURCE/src/client 9 | mkdir -p ./temp/$RESOURCE/web 10 | cp LICENSE README.md config.json import.sql fxmanifest.lua ./temp/$RESOURCE 11 | cp -r ./src/dist ./temp/$RESOURCE/src/dist # Copy resource files 12 | cp -r ./src/client/lua/ ./temp/$RESOURCE/src/client/lua # Copy Lua files 13 | cp -r ./web/dist ./temp/$RESOURCE/web/dist # Copy web files 14 | 15 | echo "$PREFIX Zipping it up: $RESOURCE.zip" 16 | 17 | cd temp && zip -r $RESOURCE.zip ./$RESOURCE 18 | -------------------------------------------------------------------------------- /web/src/hooks/useGlobalSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | const GlobalSettingsContext = React.createContext<{ isMobile: boolean }>({ isMobile: false }); 3 | 4 | export const useGlobalSettings = () => { 5 | const context = useContext(GlobalSettingsContext); 6 | 7 | return context; 8 | }; 9 | 10 | interface GlobalSettingsProviderProps { 11 | isMobile: boolean; 12 | children: React.ReactNode; 13 | } 14 | export const GlobalSettingsProvider = (props: GlobalSettingsProviderProps) => { 15 | const { children, ...rest } = props; 16 | return ( 17 | {children} 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /scripts/prerelease.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PREFIX="[PEFCL]" 4 | RESOURCE="pefcl" 5 | 6 | echo "$PREFIX Creating pre-release" 7 | ## Create temporary folder and move files into it. Keeping same structure according to fxmanifest.lua 8 | mkdir -p ./temp/$RESOURCE/src/client 9 | mkdir -p ./temp/$RESOURCE/web 10 | cp LICENSE README.md config.json import.sql fxmanifest.lua ./temp/$RESOURCE 11 | cp -r ./src/dist ./temp/$RESOURCE/src/dist # Copy resource files 12 | cp -r ./src/client/lua/ ./temp/$RESOURCE/src/client/lua # Copy Lua files 13 | cp -r ./web/dist ./temp/$RESOURCE/web/dist # Copy web files 14 | 15 | echo "$PREFIX Zipping it up: $RESOURCE-pre-$GITHUB_SHA_SHORT.zip" 16 | 17 | cd temp && zip -r $RESOURCE-pre-$GITHUB_SHA_SHORT.zip ./$RESOURCE 18 | -------------------------------------------------------------------------------- /typings/Errors.ts: -------------------------------------------------------------------------------- 1 | export enum GenericErrors { 2 | BadInput = 'BadInput', 3 | NotFound = 'NotFound', 4 | UserNotFound = 'UserNotFound', 5 | MissingDefaultAccount = 'MissingDefaultAccount', 6 | } 7 | 8 | export enum ExternalAccountErrors { 9 | AccountIsYours = 'AccountIsYours', 10 | } 11 | 12 | export enum AccountErrors { 13 | NotFound = 'AccountNotFound', 14 | AlreadyExists = 'AccountAlreadyExists', 15 | UserAlreadyExists = 'UserAlreadyExists', 16 | SameAccount = 'SameAccount', 17 | } 18 | 19 | export enum UserErrors { 20 | NotFound = 'UserNotFound', 21 | } 22 | 23 | export enum BalanceErrors { 24 | InsufficentFunds = 'InsufficentFunds', 25 | } 26 | 27 | export enum AuthorizationErrors { 28 | Forbidden = 'Forbidden', 29 | } 30 | -------------------------------------------------------------------------------- /web/src/components/Modals/BaseDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useGlobalSettings } from '@hooks/useGlobalSettings'; 2 | import { Dialog, DialogProps } from '@mui/material'; 3 | import React, { ReactNode } from 'react'; 4 | 5 | interface BaseDialogProps extends DialogProps { 6 | children: ReactNode; 7 | } 8 | const BaseDialog = (props: BaseDialogProps) => { 9 | const { isMobile } = useGlobalSettings(); 10 | 11 | return ( 12 | 20 | {props.children} 21 | 22 | ); 23 | }; 24 | 25 | export default BaseDialog; 26 | -------------------------------------------------------------------------------- /typings/exports.ts: -------------------------------------------------------------------------------- 1 | /* Exports used with framework integrations */ 2 | 3 | import { OnlineUser } from './user'; 4 | 5 | export interface FrameworkIntegrationExports { 6 | /* Cash exports */ 7 | getCash(source: number): number; 8 | addCash: (source: number, amount: number) => void; 9 | removeCash: (source: number, amount: number) => void; 10 | /** 11 | * 12 | * Move the bank balance from existing bank upon initial account creation. 13 | * The point is to move balance from framework to PEFCL, as an initial solution. 14 | * 15 | * This export should probably remove old bank balance as well. 16 | */ 17 | getBank: (source: number) => number; 18 | } 19 | 20 | export type FrameworkIntegrationFunction = keyof FrameworkIntegrationExports; 21 | -------------------------------------------------------------------------------- /web/src/utils/i18nResourceHelpers.ts: -------------------------------------------------------------------------------- 1 | import languages from '@locales/index'; 2 | 3 | export type Namespace = 'translation' | 'pefcl'; 4 | export type LanguageContent = typeof languages['en']; 5 | export type Language = keyof typeof languages; 6 | export type Locale = Record; 7 | export type Resource = Record>; 8 | 9 | export const getI18nResources = () => { 10 | return languages; 11 | }; 12 | 13 | export const getI18nResourcesNamespaced = (namespace: Namespace) => { 14 | return Object.keys(languages).reduce((prev, key) => { 15 | return { 16 | ...prev, 17 | [key]: { 18 | [namespace]: languages[key as Language], 19 | }, 20 | }; 21 | }, {} as Resource); 22 | }; 23 | -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | -webkit-font-smoothing: antialiased; 3 | -moz-osx-font-smoothing: grayscale; 4 | text-rendering: optimizeLegibility; 5 | } 6 | 7 | .nui-wrapper { 8 | text-align: center; 9 | height: 100%; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | pre { 16 | counter-reset: line-numbering; 17 | background: #2c3e50; 18 | padding: 12px 0px 14px 0; 19 | color: #ecf0f1; 20 | line-height: 140%; 21 | } 22 | 23 | ::-webkit-scrollbar { 24 | width: 0.25rem; 25 | height: 0.25rem; 26 | } 27 | 28 | ::-webkit-scrollbar-track { 29 | background-color: #222; 30 | border-radius: 2rem; 31 | } 32 | 33 | ::-webkit-scrollbar-thumb { 34 | border-radius: 2rem; 35 | background-color: #80cae24a; 36 | } 37 | -------------------------------------------------------------------------------- /src/server/services/Bank.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | import { DIToken, IController } from '@typings/common'; 3 | import { mainLogger } from '../sv_logger'; 4 | 5 | const baseLogger = mainLogger.child({ module: 'base' }); 6 | 7 | export class Bank { 8 | static container = container; 9 | 10 | bootstrap() { 11 | Bank.container.beforeResolution(DIToken.Controller, () => { 12 | baseLogger.debug('Initializing...'); 13 | }); 14 | 15 | Bank.container.afterResolution(DIToken.Controller, (_t, controllers: IController[]) => { 16 | for (const controller of controllers) { 17 | baseLogger.debug(`Initialized ${controller.name} controller`); 18 | } 19 | }); 20 | 21 | Bank.container.resolveAll(DIToken.Controller); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/components/ui/Count.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import theme from '@utils/theme'; 3 | import React from 'react'; 4 | 5 | const Total = styled.div` 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | text-align: center; 10 | 11 | height: 2rem; 12 | padding: 0 0.73rem; 13 | 14 | border-radius: ${theme.spacing(1)}; 15 | font-weight: ${theme.typography.fontWeightBold}; 16 | background-color: ${theme.palette.background.light4}; 17 | `; 18 | 19 | interface CountProps extends React.HTMLAttributes { 20 | amount: string | number; 21 | } 22 | const Count = ({ amount, ...props }: CountProps) => { 23 | return ( 24 |
25 | {amount} 26 |
27 | ); 28 | }; 29 | 30 | export default Count; 31 | -------------------------------------------------------------------------------- /web/src/components/ui/BadgeAtom.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, BadgeProps } from '@mui/material'; 2 | import { Atom, useAtom } from 'jotai'; 3 | import React, { ReactNode } from 'react'; 4 | 5 | interface BadgeAtomProps extends BadgeProps { 6 | children: ReactNode; 7 | countAtom: Atom; 8 | } 9 | 10 | const BadgeAtomContent = ({ countAtom, children }: BadgeAtomProps) => { 11 | const [amount] = useAtom(countAtom); 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | 20 | const BadgeAtom = (props: BadgeAtomProps) => { 21 | return ( 22 | }> 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default BadgeAtom; 29 | -------------------------------------------------------------------------------- /web/src/components/TotalBalance.tsx: -------------------------------------------------------------------------------- 1 | import { totalBalanceAtom } from '@data/accounts'; 2 | import { useConfig } from '@hooks/useConfig'; 3 | import { Stack } from '@mui/material'; 4 | import { formatMoney } from '@utils/currency'; 5 | import { useAtom } from 'jotai'; 6 | import React from 'react'; 7 | import { useTranslation } from 'react-i18next'; 8 | import { PreHeading } from './ui/Typography/BodyText'; 9 | import { Heading1 } from './ui/Typography/Headings'; 10 | 11 | const TotalBalance = () => { 12 | const config = useConfig(); 13 | const { t } = useTranslation(); 14 | const [totalBalance] = useAtom(totalBalanceAtom); 15 | 16 | return ( 17 | 18 | {t('Total balance')} 19 | {formatMoney(totalBalance, config.general)} 20 | 21 | ); 22 | }; 23 | 24 | export default TotalBalance; 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Material-UI logo 3 |
4 |

PEFCL

5 | 6 |
7 | 8 | [![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC_BY--NC--SA_4.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) 9 | ![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord) 10 | 11 |
12 | 13 | ## Installation 14 | 15 | https://projecterror.dev/docs/pefcl/installation 16 | 17 | 18 | 19 | ## Configuration 20 | 21 | https://projecterror.dev/docs/pefcl/configuration 22 | 23 | 24 | 25 | ## Developers 26 | 27 | https://projecterror.dev/docs/pefcl/developers/introduction 28 | 29 | 30 | ## Additional Notes 31 | 32 | Need further support? Join our [Discord](https://discord.gg/DwKrMwCHX3)! 33 | -------------------------------------------------------------------------------- /web/src/components/ui/Typography/Headings.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import theme from '../../../utils/theme'; 3 | 4 | const BaseHeading = styled.span` 5 | font-family: ${theme.typography.fontFamily}; 6 | font-weight: ${theme.typography.fontWeightBold}; 7 | `; 8 | 9 | export const Heading1 = styled(BaseHeading)` 10 | font-size: 2.5rem; 11 | `; 12 | 13 | export const Heading2 = styled(BaseHeading)` 14 | font-size: 2rem; 15 | `; 16 | 17 | export const Heading3 = styled(BaseHeading)` 18 | font-size: 1.5rem; 19 | `; 20 | export const Heading4 = styled(BaseHeading)` 21 | font-size: 1.25rem; 22 | `; 23 | 24 | export const Heading5 = styled(BaseHeading)` 25 | font-size: 1rem; 26 | color: ${theme.palette.text.secondary}; 27 | `; 28 | 29 | export const Heading6 = styled(BaseHeading)` 30 | font-size: 0.75rem; 31 | color: ${theme.palette.text.secondary}; 32 | `; 33 | -------------------------------------------------------------------------------- /scripts/generateLocales.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { writeFileSync, readdirSync } = require('fs'); 4 | 5 | const output = 'locales/index.ts'; 6 | const input = 'locales'; 7 | 8 | const rawLanguages = readdirSync(input); 9 | const languages = rawLanguages.filter((lng) => lng !== 'index.ts'); 10 | 11 | const importData = []; 12 | const exportData = []; 13 | 14 | /* Add export line */ 15 | exportData.push('export default {'); 16 | 17 | languages.forEach((lng) => { 18 | const language = lng.replace('-', ''); 19 | 20 | importData.push(`import ${language} from './${lng}/default.json';`); 21 | exportData.push(` ${language},`); 22 | }); 23 | 24 | /* Add spacing after imports */ 25 | importData.push(`\n`); 26 | 27 | /* Add closing bracket for exports & ending line */ 28 | exportData.push('};'); 29 | exportData.push(''); 30 | 31 | writeFileSync(output, importData.join('\n') + exportData.join('\n')); 32 | -------------------------------------------------------------------------------- /src/server/utils/withProfile.ts: -------------------------------------------------------------------------------- 1 | import { mainLogger } from '../sv_logger'; 2 | 3 | const profilerLog = mainLogger.child({ module: 'profiler' }); 4 | 5 | const RESOURCE_NAME = GetCurrentResourceName(); 6 | 7 | // Simple higher order function profiler 8 | // currently targeted towards ms as unit and db queries 9 | // but can be altered to be generic in future 10 | export const withProfile = async (fn: CallableFunction, ...args: any[]) => { 11 | const startTime = process.hrtime.bigint(); 12 | 13 | // https://forum.cfx.re/t/node-mysql2-question-about-performance-process-nexttick/4550064/2?u=taso 14 | ScheduleResourceTick(RESOURCE_NAME); 15 | const res = await fn(...args); 16 | 17 | const endTime = process.hrtime.bigint(); 18 | 19 | const timeMs = Number(endTime - startTime) / 1e6; 20 | 21 | profilerLog.info(`Executed '${fn.name} (${[...args].join(', ')}) in ${timeMs}ms'`); 22 | 23 | return res; 24 | }; 25 | -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | NUI React Boilerplate 8 | 9 | 10 | 11 |
12 |
13 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/client/cl_exports.ts: -------------------------------------------------------------------------------- 1 | import { setBankIsOpen, setAtmIsOpen } from 'client'; 2 | import { createInvoice, depositMoney, giveCash, withdrawMoney } from 'functions'; 3 | 4 | const exp = global.exports; 5 | 6 | exp('openBank', async () => { 7 | setBankIsOpen(true); 8 | }); 9 | 10 | exp('closeBank', async () => { 11 | setBankIsOpen(false); 12 | }); 13 | 14 | exp('openAtm', async () => { 15 | setAtmIsOpen(true); 16 | }); 17 | 18 | exp('closeAtm', async () => { 19 | setAtmIsOpen(false); 20 | }); 21 | 22 | exp('giveNearestPlayerCash', (amount: number) => { 23 | giveCash(0, [amount.toString()]); 24 | }); 25 | 26 | exp('createInvoiceForNearestPlayer', (amount: number, message: string) => { 27 | createInvoice(0, [amount.toString(), message]); 28 | }); 29 | 30 | exp('depositMoney', (amount: number) => { 31 | depositMoney(amount); 32 | }); 33 | 34 | exp('withdrawMoney', (amount: number) => { 35 | withdrawMoney(amount); 36 | }); 37 | -------------------------------------------------------------------------------- /web/src/utils/account.ts: -------------------------------------------------------------------------------- 1 | import { Account, AccountRole, AccountType } from '@typings/Account'; 2 | 3 | export const getIsAdmin = (account: Account) => { 4 | return [AccountRole.Admin, AccountRole.Owner].includes(account.role); 5 | }; 6 | 7 | export const getIsOwner = (account: Account) => { 8 | return [AccountRole.Owner].includes(account.role); 9 | }; 10 | 11 | export const getIsShared = (account: Account) => { 12 | return account.type === AccountType.Shared; 13 | }; 14 | 15 | export const updateAccount = (accounts: Account[], updatedAccount: Account): Account[] => { 16 | const existingAccount = accounts.find((acc) => acc.id === updatedAccount.id); 17 | if (!existingAccount) { 18 | return accounts; 19 | } 20 | 21 | const newAccounts = [...accounts]; 22 | const index = accounts.findIndex((acc) => acc.id === existingAccount.id); 23 | newAccounts.splice(index, 1, updatedAccount); 24 | 25 | return newAccounts; 26 | }; 27 | -------------------------------------------------------------------------------- /web/src/hooks/useFetchNui.ts: -------------------------------------------------------------------------------- 1 | import { fetchNui } from '@utils/fetchNui'; 2 | import { useEffect, useState } from 'react'; 3 | import { usePrevious } from './usePrevious'; 4 | 5 | export const useFetchNui = (event: string, options?: object) => { 6 | const [error, setError] = useState(''); 7 | const [isLoading, setIsLoading] = useState(false); 8 | const [data, setData] = useState(); 9 | 10 | const previous = usePrevious(JSON.stringify(options)); 11 | const hasChanged = JSON.stringify(options) !== previous; 12 | 13 | useEffect(() => { 14 | setIsLoading(true); 15 | fetchNui(event, options) 16 | .then(setData) 17 | .catch((error) => { 18 | setError(error); 19 | }) 20 | .finally(() => { 21 | setIsLoading(false); 22 | }); 23 | 24 | // eslint-disable-next-line react-hooks/exhaustive-deps 25 | }, [event, hasChanged]); 26 | 27 | return { isLoading, data, error }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "module": "commonjs", 5 | "target": "es2018", 6 | "allowJs": false, 7 | "lib": ["es2018", "DOM"], 8 | "types": ["@citizenfx/server", "@types/node", "@types/jest"], 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "strictNullChecks": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "noEmit": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@shared/*": ["./../../shared/*"], 19 | "@server/*": ["./*"], 20 | "@utils/*": ["utils/*"], 21 | "@services/*": ["services/*"], 22 | "@decorators/*": ["decorators/*"], 23 | "@locales/*": ["../../locales/*"], 24 | "@typings/*": ["../../typings/*"] 25 | } 26 | }, 27 | "include": ["./**/*", "../../shared/**/*"], 28 | "exclude": ["**/node_modules"] 29 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a bug report to help solve an issue 4 | title: 'Bug: TITLE' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the issue** 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | **Expected behavior** 15 | 16 | A clear and concise description of what you expected to happen. 17 | 18 | **To Reproduce** 19 | 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | 27 | **Media** 28 | 29 | If applicable, add a screenshot or a video to help explain your problem. 30 | 31 | **Needed information (please complete the following information):** 32 | - **Client Version:**: [e.g. Canary or Release] 33 | - **Template Version**: [e.g. 3486] Don't know?~~Check the version in your package.json~~ 34 | 35 | **Additional context** 36 | Add any other context about the issue here. 37 | -------------------------------------------------------------------------------- /src/server/utils/pool.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize'; 2 | import { CONNECTION_STRING, parseUri } from './dbUtils'; 3 | 4 | const mysqlConnectionString = GetConvar(CONNECTION_STRING, 'none'); 5 | 6 | if (mysqlConnectionString === 'none') { 7 | throw new Error( 8 | `No connection string provided. make sure "${CONNECTION_STRING}" is set in server.cfg`, 9 | ); 10 | } 11 | 12 | const config = parseUri(mysqlConnectionString); 13 | 14 | export const sequelize = new Sequelize({ 15 | dialect: 'mysql', 16 | dialectModule: require('mysql2'), 17 | logging: false, 18 | host: config.host, 19 | port: typeof config.port === 'string' ? parseInt(config.port, 10) : config.port, 20 | username: config.user, 21 | password: config.password, 22 | database: config.database, 23 | pool: { 24 | max: 5, 25 | min: 0, 26 | acquire: 30000, 27 | idle: 60000, 28 | }, 29 | sync: { 30 | alter: true, 31 | force: true, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /web/src/components/ui/NewBalance.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Stack, Typography } from '@mui/material'; 3 | import { formatMoney } from '@utils/currency'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { useConfig } from '@hooks/useConfig'; 6 | 7 | interface NewBalanceProps { 8 | amount: number; 9 | isValid: boolean; 10 | newBalanceText?: string; 11 | } 12 | 13 | const NewBalance = ({ amount, isValid, newBalanceText }: NewBalanceProps) => { 14 | const { t } = useTranslation(); 15 | const { general } = useConfig(); 16 | 17 | return ( 18 | 19 | 20 | {newBalanceText ?? t('New balance')} 21 | 22 | 23 | : {formatMoney(amount, general)} 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default NewBalance; 30 | -------------------------------------------------------------------------------- /web/src/utils/fetchNui.ts: -------------------------------------------------------------------------------- 1 | import { getResourceName } from './misc'; 2 | import { ServerPromiseResp } from '@typings/http'; 3 | 4 | const isDevelopment = process.env.NODE_ENV === 'development'; 5 | 6 | export const fetchNui = async ( 7 | eventName: string, 8 | data?: I, 9 | ): Promise => { 10 | const resourceName = getResourceName(); 11 | const url = isDevelopment 12 | ? `http://localhost:3005/${eventName.replace(':', '-')}` 13 | : `https://${resourceName}/${eventName}`; 14 | 15 | const options = { 16 | method: 'post', 17 | headers: { 18 | 'Content-Type': 'application/json; charset=UTF-8', 19 | }, 20 | body: JSON.stringify(data), 21 | }; 22 | 23 | const res = await fetch(url, options); 24 | const response: ServerPromiseResp = await res.json(); 25 | 26 | if (response.status === 'error') { 27 | throw new Error(response.errorMsg); 28 | } 29 | 30 | return response.data; 31 | }; 32 | -------------------------------------------------------------------------------- /web/src/BankIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const BankIcon = () => ( 4 | 5 | 9 | 10 | ); 11 | 12 | export default BankIcon; 13 | -------------------------------------------------------------------------------- /src/server/services/accountShared/sharedAccount.model.ts: -------------------------------------------------------------------------------- 1 | import { AccountRole, SharedAccount } from '@typings/Account'; 2 | import { DATABASE_PREFIX } from '@utils/constants'; 3 | import { DataTypes, Model, Optional } from 'sequelize'; 4 | import { sequelize } from '../../utils/pool'; 5 | import { timestamps } from '../timestamps.model'; 6 | 7 | export class SharedAccountModel extends Model< 8 | SharedAccount, 9 | Optional 10 | > {} 11 | 12 | SharedAccountModel.init( 13 | { 14 | id: { 15 | type: DataTypes.INTEGER, 16 | autoIncrement: true, 17 | primaryKey: true, 18 | }, 19 | userIdentifier: { 20 | type: DataTypes.STRING, 21 | }, 22 | name: { 23 | type: DataTypes.STRING, 24 | }, 25 | role: { 26 | type: DataTypes.STRING, 27 | defaultValue: AccountRole.Contributor, 28 | }, 29 | ...timestamps, 30 | }, 31 | { sequelize: sequelize, tableName: DATABASE_PREFIX + 'shared_accounts', paranoid: true }, 32 | ); 33 | -------------------------------------------------------------------------------- /src/server/services/cash/cash.db.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from 'sequelize/types'; 2 | import { singleton } from 'tsyringe'; 3 | import { CashModel } from './cash.model'; 4 | 5 | @singleton() 6 | export class CashDB { 7 | async getCashByIdentifier(ownerIdentifier: string): Promise { 8 | return await CashModel.findOne({ where: { ownerIdentifier } }); 9 | } 10 | 11 | async createInitial(ownerIdentifier: string): Promise { 12 | const existing = await CashModel.findOne({ where: { ownerIdentifier } }); 13 | return existing ?? (await CashModel.create({ ownerIdentifier })); 14 | } 15 | 16 | async decrement(cash: CashModel, amount: number, transaction?: Transaction) { 17 | await cash?.update({ amount: cash.getDataValue('amount') - amount }, { transaction }); 18 | } 19 | 20 | async increment(cash: CashModel, amount: number, transaction?: Transaction) { 21 | await cash?.update({ amount: cash.getDataValue('amount') + amount }, { transaction }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/views/Mobile/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { useGlobalSettings } from '@hooks/useGlobalSettings'; 2 | import React from 'react'; 3 | import { Route } from 'react-router-dom'; 4 | import MobileAccountsView from './views/Accounts/MobileAccountsView'; 5 | import MobileDashboardView from './views/Dashboard/MobileDashboardView'; 6 | import MobileInvoicesView from './views/Invoices/MobileInvoicesView'; 7 | import MobileTransferView from './views/Transfer/MobileTransferView'; 8 | 9 | const MobileRoutes = () => { 10 | const settings = useGlobalSettings(); 11 | const prefix = settings.isMobile ? '/bank' : ''; 12 | 13 | return ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default MobileRoutes; 24 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "browser": false, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "plugins": [ 13 | "@typescript-eslint" 14 | ], 15 | "ignorePatterns": "logform.js", 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "no-undef": "off", 19 | "react/prop-types": "off", 20 | "no-unused-prop-types": 0, 21 | "@typescript-eslint/no-empty-function": 0, 22 | "no-empty-function": 0, 23 | "@typescript-eslint/explicit-module-boundary-types": 0, 24 | "@typescript-eslint/no-explicit-any": 0 25 | }, 26 | "overrides": [ 27 | { 28 | "files": ["*.js"], 29 | "rules": { 30 | "@typescript-eslint/no-var-requires": "off", 31 | "no-var-requires": "off" 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/server/services/accountExternal/externalAccount.model.ts: -------------------------------------------------------------------------------- 1 | import { ExternalAccount } from '@typings/Account'; 2 | import { DATABASE_PREFIX } from '@utils/constants'; 3 | import { DataTypes, Model, Optional } from 'sequelize'; 4 | import { sequelize } from '../../utils/pool'; 5 | import { timestamps } from '../timestamps.model'; 6 | 7 | export class ExternalAccountModel extends Model> {} 8 | 9 | ExternalAccountModel.init( 10 | { 11 | id: { 12 | type: DataTypes.INTEGER, 13 | autoIncrement: true, 14 | primaryKey: true, 15 | }, 16 | number: { 17 | type: DataTypes.STRING, 18 | }, 19 | name: { 20 | type: DataTypes.STRING, 21 | }, 22 | userId: { 23 | type: DataTypes.STRING, 24 | }, 25 | ...timestamps, 26 | }, 27 | { 28 | sequelize: sequelize, 29 | tableName: DATABASE_PREFIX + 'external_accounts', 30 | indexes: [ 31 | { 32 | unique: true, 33 | fields: ['userId', 'number'], 34 | }, 35 | ], 36 | }, 37 | ); 38 | -------------------------------------------------------------------------------- /src/server/services/transaction/transaction.model.ts: -------------------------------------------------------------------------------- 1 | import { DATABASE_PREFIX } from '@utils/constants'; 2 | import { DataTypes, Model, Optional } from 'sequelize'; 3 | import { singleton } from 'tsyringe'; 4 | import { Transaction, TransactionType } from '../../../../typings/Transaction'; 5 | import { sequelize } from '../../utils/pool'; 6 | import { timestamps } from '../timestamps.model'; 7 | 8 | @singleton() 9 | export class TransactionModel extends Model< 10 | Transaction, 11 | Optional 12 | > {} 13 | 14 | TransactionModel.init( 15 | { 16 | id: { 17 | type: DataTypes.INTEGER, 18 | autoIncrement: true, 19 | primaryKey: true, 20 | }, 21 | message: { 22 | type: DataTypes.STRING, 23 | }, 24 | amount: { 25 | type: DataTypes.INTEGER, 26 | defaultValue: 0, 27 | }, 28 | type: { 29 | type: DataTypes.STRING, 30 | defaultValue: TransactionType.Outgoing, 31 | }, 32 | ...timestamps, 33 | }, 34 | { sequelize: sequelize, tableName: DATABASE_PREFIX + 'transactions' }, 35 | ); 36 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "strictNullChecks": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx", 23 | "jsxImportSource": "@emotion/react", 24 | "baseUrl": ".", 25 | "paths": { 26 | "@components/*": ["src/components/*"], 27 | "@ui/*": ["src/components/ui/*"], 28 | "@hooks/*": ["src/hooks/*"], 29 | "@data/*": ["src/data/*"], 30 | "@utils/*": ["src/utils/*"], 31 | "@locales/*": ["../locales/*"], 32 | "@typings/*": ["../typings/*"], 33 | "@shared/*": ["../shared/*"] 34 | } 35 | }, 36 | "include": [ 37 | "src/**/*" 38 | ] 39 | } -------------------------------------------------------------------------------- /web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": "latest", 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "react", 21 | "@typescript-eslint", 22 | "react-hooks" 23 | ], 24 | "ignorePatterns": "webpack*", 25 | "rules": { 26 | "no-unused-vars": "off", 27 | "no-undef": "off", 28 | "react-hooks/rules-of-hooks": "error", 29 | "react-hooks/exhaustive-deps": "warn", 30 | "react/prop-types": "off", 31 | "no-unused-prop-types": 0, 32 | "@typescript-eslint/no-empty-function": 0, 33 | "no-empty-function": 0, 34 | "@typescript-eslint/explicit-module-boundary-types": 0, 35 | "@typescript-eslint/no-explicit-any": 1 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/components/ui/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { CheckboxProps, Checkbox as BaseCheckbox } from '@mui/material'; 3 | import React from 'react'; 4 | import CheckIcon from '../../icons/CheckIcon'; 5 | import theme from '../../utils/theme'; 6 | 7 | const BaseBox = styled.div` 8 | width: 2rem; 9 | height: 2rem; 10 | 11 | border-radius: ${theme.spacing(0.5)}; 12 | background-color: ${theme.palette.background.dark12}; 13 | 14 | display: flex; 15 | align-items: center; 16 | justify-content: center; 17 | 18 | svg { 19 | width: inherit; 20 | height: inherit; 21 | } 22 | `; 23 | 24 | const CheckedBox = styled(BaseBox)` 25 | background-color: ${theme.palette.background.light4}; 26 | `; 27 | 28 | const Checkbox: React.FC = (props) => { 29 | return ( 30 |
31 | 36 | 37 | 38 | } 39 | icon={} 40 | /> 41 |
42 | ); 43 | }; 44 | 45 | export default Checkbox; 46 | -------------------------------------------------------------------------------- /src/server/services/associations.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '../utils/pool'; 2 | import { config } from '@utils/server-config'; 3 | import { AccountModel } from './account/account.model'; 4 | import { TransactionModel } from './transaction/transaction.model'; 5 | import './invoice/invoice.model'; 6 | import { SharedAccountModel } from './accountShared/sharedAccount.model'; 7 | 8 | /* This is so annoying. Next time choose something with TS support. */ 9 | declare module './accountShared/sharedAccount.model' { 10 | interface SharedAccountModel { 11 | setAccount(id: number): Promise; 12 | } 13 | } 14 | 15 | declare module './transaction/transaction.model' { 16 | interface TransactionModel { 17 | setFromAccount(id?: number): Promise; 18 | setToAccount(id?: number): Promise; 19 | } 20 | } 21 | 22 | TransactionModel.belongsTo(AccountModel, { 23 | as: 'toAccount', 24 | }); 25 | 26 | TransactionModel.belongsTo(AccountModel, { 27 | as: 'fromAccount', 28 | }); 29 | 30 | SharedAccountModel.belongsTo(AccountModel, { 31 | as: 'account', 32 | }); 33 | 34 | if (config?.database?.shouldSync) { 35 | sequelize.sync(); 36 | } 37 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "nx/presets/npm.json", 3 | "tasksRunnerOptions": { 4 | "default": { 5 | "runner": "nx/tasks-runners/default", 6 | "options": { 7 | "cacheableOperations": [ 8 | "setup", 9 | "dev", 10 | "dev:ingame", 11 | "build", 12 | "lint", 13 | "watch", 14 | "watch:client", 15 | "test", 16 | "test:watch", 17 | "tsc", 18 | "tsc:client", 19 | "build:esbuild", 20 | "eject" 21 | ] 22 | } 23 | } 24 | }, 25 | "targetDefaults": { 26 | "setup": { 27 | "dependsOn": [ 28 | "^setup" 29 | ] 30 | }, 31 | "dev": { 32 | "dependsOn": [ 33 | "setup" 34 | ] 35 | }, 36 | "dev:ingame": { 37 | "dependsOn": [ 38 | "setup" 39 | ] 40 | }, 41 | "dev:mobile": { 42 | "dependsOn": [ 43 | "setup" 44 | ] 45 | }, 46 | "build": { 47 | "dependsOn": [ 48 | "setup", 49 | "^build" 50 | ] 51 | } 52 | }, 53 | "affected": { 54 | "defaultBase": "main" 55 | } 56 | } -------------------------------------------------------------------------------- /web/src/components/IconLabelButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, ButtonProps } from '@mui/material'; 3 | import styled from '@emotion/styled'; 4 | 5 | interface IconLabelButtonProps extends ButtonProps { 6 | icon: JSX.Element; 7 | } 8 | 9 | interface IconButtonProps extends React.ButtonHTMLAttributes { 10 | icon: JSX.Element; 11 | } 12 | 13 | const IconLabelButton: React.FC = ({ children, icon, ...props }) => ( 14 | 17 | ); 18 | 19 | const IconButtonWrapper = styled('button')({ 20 | background: '#d84e4b', 21 | display: 'inline', 22 | alignItems: 'center', 23 | justifyContent: 'center', 24 | color: '#fff', 25 | border: 'none', 26 | borderRadius: 5, 27 | padding: '3px 20px', 28 | fontWeight: 500, 29 | fontSize: 16, 30 | }); 31 | 32 | export const IconButton: React.FC = ({ children, icon, ...props }) => { 33 | return ( 34 | 35 | {children} 36 | {icon} 37 | 38 | ); 39 | }; 40 | 41 | export default IconLabelButton; 42 | -------------------------------------------------------------------------------- /src/scripts/watch_server.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const { esbuildDecorators } = require('@anatine/esbuild-decorators'); 3 | 4 | const args = process.argv.slice(2); 5 | 6 | const modeArg = args.find((arg) => { 7 | const [key] = arg.split('='); 8 | return key === '--mode'; 9 | }); 10 | 11 | const mode = modeArg?.split('=')[1]; 12 | const NODE_ENV = mode === 'ingame' ? 'ingame' : 'mocking'; 13 | 14 | esbuild 15 | .build({ 16 | entryPoints: ['server/server.ts'], 17 | bundle: true, 18 | watch: { 19 | onRebuild(error) { 20 | if (error) console.error('watch build failed:', error); 21 | else console.log('watch build succeeded:'); 22 | }, 23 | }, 24 | platform: 'node', 25 | target: 'node16', 26 | outfile: 'dist/server.js', 27 | define: { 28 | 'process.env.NODE_ENV': `"${NODE_ENV}"`, // Mocking is value that's checked for mocking globals etc when running the server outside FiveM 29 | }, 30 | plugins: [ 31 | esbuildDecorators({ 32 | tsconfig: 'server/tsconfig.json', 33 | }), 34 | ], 35 | }) 36 | .then(() => { 37 | console.log('Watching server'); 38 | }); 39 | -------------------------------------------------------------------------------- /src/server/logform.js: -------------------------------------------------------------------------------- 1 | const createLogger = require('winston/dist/winston/create-logger'); 2 | const transports = require('winston/dist/winston/transports/index'); 3 | 4 | const format = require('logform/format.js'); 5 | const levels = require('logform/levels.js'); 6 | 7 | format.align = require('logform/align.js'); 8 | format.errors = require('logform/errors.js'); 9 | format.cli = require('logform/cli.js'); 10 | format.combine = require('logform/combine.js'); 11 | format.colorize = require('logform/colorize.js'); 12 | format.json = require('logform/json.js'); 13 | format.label = require('logform/label.js'); 14 | format.logstash = require('logform/logstash.js'); 15 | format.metadata = require('logform/metadata.js'); 16 | format.ms = require('logform/ms.js'); 17 | format.padLevels = require('logform/pad-levels.js'); 18 | format.prettyPrint = require('logform/pretty-print.js'); 19 | format.printf = require('logform/printf.js'); 20 | format.simple = require('logform/simple.js'); 21 | format.splat = require('logform/splat.js'); 22 | format.timestamp = require('logform/timestamp.js'); 23 | format.uncolorize = require('logform/uncolorize.js'); 24 | 25 | module.exports = { createLogger, transports, format, levels }; 26 | -------------------------------------------------------------------------------- /web/src/data/externalAccounts.ts: -------------------------------------------------------------------------------- 1 | import { ExternalAccount } from '@typings/Account'; 2 | import { ExternalAccountEvents } from '@typings/Events'; 3 | import { fetchNui } from '@utils/fetchNui'; 4 | import { isEnvBrowser } from '@utils/misc'; 5 | import { atom } from 'jotai'; 6 | 7 | const getExternalAccounts = async (): Promise => { 8 | try { 9 | const res = await fetchNui(ExternalAccountEvents.Get); 10 | return res ?? []; 11 | } catch (e) { 12 | if (isEnvBrowser()) { 13 | return [ 14 | { 15 | id: 1, 16 | name: 'Bossman', 17 | number: '803, 5800-6000-7000', 18 | }, 19 | ]; 20 | } 21 | console.error(e); 22 | return []; 23 | } 24 | }; 25 | 26 | const rawExternalAccountsAtom = atom([]); 27 | export const externalAccountsAtom = atom( 28 | async (get) => { 29 | const accounts = 30 | get(rawExternalAccountsAtom).length === 0 31 | ? await getExternalAccounts() 32 | : get(rawExternalAccountsAtom); 33 | return accounts; 34 | }, 35 | async (_get, set) => { 36 | return set(rawExternalAccountsAtom, await getExternalAccounts()); 37 | }, 38 | ); 39 | -------------------------------------------------------------------------------- /src/server/services/cash/cash.model.ts: -------------------------------------------------------------------------------- 1 | import { DATABASE_PREFIX } from '@utils/constants'; 2 | import { DataTypes, Model, Optional } from 'sequelize'; 3 | import { Cash } from '@typings/Cash'; 4 | import { sequelize } from '../../utils/pool'; 5 | import { config } from '@utils/server-config'; 6 | import { timestamps } from '../timestamps.model'; 7 | import { CashEvents } from '@server/../../typings/Events'; 8 | 9 | export class CashModel extends Model> {} 10 | 11 | CashModel.init( 12 | { 13 | id: { 14 | type: DataTypes.INTEGER, 15 | autoIncrement: true, 16 | primaryKey: true, 17 | }, 18 | amount: { 19 | type: DataTypes.INTEGER, 20 | defaultValue: config?.cash?.startAmount ?? 0, 21 | }, 22 | ownerIdentifier: { 23 | type: DataTypes.STRING, 24 | unique: true, 25 | }, 26 | ...timestamps, 27 | }, 28 | { 29 | sequelize: sequelize, 30 | tableName: DATABASE_PREFIX + 'cash', 31 | hooks: { 32 | afterSave: (instance, options) => { 33 | if (options.fields?.includes('amount')) { 34 | emit(CashEvents.NewCash, instance.toJSON()); 35 | } 36 | }, 37 | }, 38 | }, 39 | ); 40 | -------------------------------------------------------------------------------- /web/src/icons/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const CheckIcon = () => { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | }; 13 | 14 | export default CheckIcon; 15 | -------------------------------------------------------------------------------- /web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx: -------------------------------------------------------------------------------- 1 | import { AccountCard } from '@components/Card'; 2 | import TotalBalance from '@components/TotalBalance'; 3 | import { Heading5 } from '@components/ui/Typography/Headings'; 4 | import { accountsAtom } from '@data/accounts'; 5 | import { Stack } from '@mui/material'; 6 | import { Box } from '@mui/system'; 7 | import { useAtom } from 'jotai'; 8 | import React from 'react'; 9 | import { useTranslation } from 'react-i18next'; 10 | 11 | const MobileAccountsView = () => { 12 | const { t } = useTranslation(); 13 | const [accounts] = useAtom(accountsAtom); 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | {accounts.map((account) => { 22 | return ; 23 | })} 24 | 25 | 26 | {accounts.length <= 1 && ( 27 | 28 | {t('You can create more accounts by visiting the nearest bank ..')} 29 | 30 | )} 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default MobileAccountsView; 37 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import { ThemeProvider } from '@mui/material'; 6 | import theme from './utils/theme'; 7 | import { HashRouter } from 'react-router-dom'; 8 | import i18n from './utils/i18n'; 9 | import { SnackbarProvider } from 'notistack'; 10 | import { NuiProvider } from 'react-fivem-hooks'; 11 | import { I18nextProvider } from 'react-i18next'; 12 | import { GlobalSettingsProvider } from '@hooks/useGlobalSettings'; 13 | 14 | ReactDOM.render( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Fetching app}> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | , 32 | document.getElementById('root'), 33 | ); 34 | -------------------------------------------------------------------------------- /src/client/i18n.ts: -------------------------------------------------------------------------------- 1 | import languages from '@locales/index'; 2 | 3 | export type Namespace = 'translation' | 'pefcl'; 4 | export type LanguageContent = typeof languages['en']; 5 | export type Language = keyof typeof languages; 6 | export type Locale = Record; 7 | export type Resource = Record>; 8 | 9 | import cl_config from 'cl_config'; 10 | import i18next from 'i18next'; 11 | 12 | export const getI18nResourcesNamespaced = (namespace: Namespace) => { 13 | return Object.keys(languages).reduce((prev, key) => { 14 | return { 15 | ...prev, 16 | [key]: { 17 | [namespace]: languages[key as Language], 18 | }, 19 | }; 20 | }, {} as Resource); 21 | }; 22 | 23 | const language = cl_config.general?.language; 24 | export const load = async () => { 25 | console.debug('Loading language from config: ' + language); 26 | const resources = getI18nResourcesNamespaced('translation'); 27 | 28 | await i18next 29 | .init({ 30 | resources, 31 | lng: language, 32 | fallbackLng: 'en', 33 | }) 34 | .catch((r) => console.error(r)); 35 | }; 36 | 37 | load(); 38 | 39 | export const translations = i18next; 40 | 41 | export default i18next; 42 | -------------------------------------------------------------------------------- /web/src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | // import styled from '@emotion/styled'; 2 | import styled from '@emotion/styled'; 3 | import { ButtonProps, css } from '@mui/material'; 4 | import { Button as ButtonBase } from '@mui/material'; 5 | import React from 'react'; 6 | import theme from '../../utils/theme'; 7 | 8 | const colors = { 9 | inherit: '', 10 | secondary: '', 11 | success: '', 12 | info: '', 13 | warning: '', 14 | error: css` 15 | color: ${theme.palette.error.main}; 16 | background-color: rgba(255, 77, 77, 0.14); 17 | `, 18 | primary: css` 19 | color: ${theme.palette.primary.main}; 20 | `, 21 | }; 22 | 23 | const StyledButtonBase = styled(ButtonBase)` 24 | font-weight: 200; 25 | box-shadow: none; 26 | border-radius: ${theme.spacing(1)}; 27 | background-color: #1d2a3a; 28 | padding: 0.4rem 2rem; 29 | 30 | ${({ color }) => colors[color ?? 'primary']}; 31 | 32 | :disabled { 33 | opacity: 0.25; 34 | ${({ color }) => colors[color ?? 'primary']}; 35 | } 36 | `; 37 | 38 | export const Button: React.FC = ({ children, ...props }) => { 39 | return ( 40 | 41 | {children} 42 | 43 | ); 44 | }; 45 | 46 | export default Button; 47 | -------------------------------------------------------------------------------- /web/src/components/ui/Select.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { InputBase, Select as BaseSelect, SelectProps as BaseSelectProps } from '@mui/material'; 3 | import styled from '@emotion/styled'; 4 | import theme from '@utils/theme'; 5 | import { ArrowDropDownRounded } from '@mui/icons-material'; 6 | 7 | const StyledInput = styled(InputBase)` 8 | border-radius: ${theme.spacing(1)}; 9 | color: ${theme.palette.text.primary}; 10 | background: ${theme.palette.background.dark12}; 11 | 12 | & > div { 13 | padding: 0.75rem 1rem; 14 | } 15 | `; 16 | 17 | const SelectIcon = styled(ArrowDropDownRounded)` 18 | color: ${theme.palette.text.primary}; 19 | color: white !important; 20 | margin-right: 0.5rem; 21 | `; 22 | 23 | interface SelectProps extends BaseSelectProps { 24 | label?: string; 25 | } 26 | const Select = (props: SelectProps) => { 27 | return ( 28 | } 33 | sx={{ width: '100%' }} 34 | IconComponent={(props) => { 35 | return ; 36 | }} 37 | > 38 | ); 39 | }; 40 | 41 | export default Select; 42 | -------------------------------------------------------------------------------- /.github/workflows/translations.yml: -------------------------------------------------------------------------------- 1 | name: Translations 2 | on: [pull_request] 3 | 4 | jobs: 5 | test: 6 | name: Pushing translations 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | - name: Setup node environment 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 18.x 15 | - name: Get yarn cache directory path 16 | id: yarn-cache-dir-path 17 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 18 | - uses: actions/cache@v2 19 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 20 | with: 21 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 22 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-yarn- 25 | - name: Install deps (Translations) 26 | run: yarn --frozen-lockfile 27 | - name: Generate translations 28 | working-directory: . 29 | env: 30 | LOCALAZY_WRITE_KEY: ${{ secrets.LOCALAZY_WRITE_KEY }} 31 | LOCALAZY_READ_KEY: ${{ secrets.LOCALAZY_READ_KEY }} 32 | run: yarn translations:generate && yarn translations:push 33 | -------------------------------------------------------------------------------- /src/server/decorators/NetPromise.ts: -------------------------------------------------------------------------------- 1 | import { onNetPromise } from '../lib/onNetPromise'; 2 | 3 | export const NetPromise = (eventName: string) => { 4 | return function (target: object, key: string) { 5 | if (!Reflect.hasMetadata('promiseEvents', target)) { 6 | Reflect.defineMetadata('promiseEvents', [], target); 7 | } 8 | 9 | const promiseEvents = Reflect.getMetadata('promiseEvents', target); 10 | 11 | promiseEvents.push({ 12 | eventName, 13 | key, 14 | }); 15 | 16 | Reflect.defineMetadata('promiseEvents', promiseEvents, target); 17 | }; 18 | }; 19 | 20 | export const PromiseEventListener = () => { 21 | return function (ctr: T) { 22 | return class extends ctr { 23 | constructor(...args: any[]) { 24 | super(...args); 25 | 26 | if (!Reflect.hasMetadata('promiseEvents', this)) { 27 | Reflect.defineMetadata('promiseEvents', [], this); 28 | } 29 | 30 | const promiseEvents: any[] = Reflect.getMetadata('promiseEvents', this); 31 | 32 | for (const { eventName, key } of promiseEvents) { 33 | onNetPromise(eventName, async (...args: any[]) => { 34 | this[key](...args); 35 | }); 36 | } 37 | } 38 | }; 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /web/src/views/Mobile/views/Invoices/MobileInvoicesView.tsx: -------------------------------------------------------------------------------- 1 | import InvoiceItem from '@components/InvoiceItem'; 2 | import { Heading2, Heading5 } from '@components/ui/Typography/Headings'; 3 | import { invoicesAtom } from '@data/invoices'; 4 | import { Box, Stack, Typography } from '@mui/material'; 5 | import { useAtom } from 'jotai'; 6 | import React from 'react'; 7 | import { useTranslation } from 'react-i18next'; 8 | 9 | const MobileInvoicesView = () => { 10 | const { t } = useTranslation(); 11 | const [invoices] = useAtom(invoicesAtom); 12 | 13 | const hasInvoices = (invoices?.invoices?.length ?? 0) > 0; 14 | 15 | return ( 16 | 17 | 18 | {t('Invoices')} 19 | {t('Handle your unpaid invoices')} 20 | 21 | 22 | {!hasInvoices && ( 23 | 24 | {t('There is nothing to see here.')} 25 | 26 | )} 27 | 28 | 29 | {invoices.invoices.map((invoice) => ( 30 | 31 | ))} 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default MobileInvoicesView; 38 | -------------------------------------------------------------------------------- /typings/Invoice.ts: -------------------------------------------------------------------------------- 1 | export enum InvoiceStatus { 2 | PENDING = 'PENDING', 3 | PAID = 'PAID', 4 | } 5 | 6 | export interface CreateInvoiceInput { 7 | to: string; 8 | from: string; 9 | amount: number; 10 | message: string; 11 | toIdentifier: string; 12 | fromIdentifier: string; 13 | receiverAccountIdentifier?: string; 14 | expiresAt?: string; 15 | } 16 | 17 | export interface InvoiceOnlineInput { 18 | source: number; 19 | amount: number; 20 | message: string; 21 | } 22 | 23 | export interface Invoice { 24 | id: number; 25 | amount: number; 26 | message: string; 27 | 28 | /* Displayed information, on invoice pages and such. */ 29 | to: string; 30 | from: string; 31 | 32 | /* Personal identifiers */ 33 | toIdentifier: string; 34 | fromIdentifier: string; 35 | 36 | /* Optional to insert balance to specific account. */ 37 | receiverAccountIdentifier?: string; 38 | 39 | status: InvoiceStatus; 40 | expiresAt?: string; 41 | createdAt?: string; 42 | } 43 | 44 | export interface PayInvoiceInput { 45 | invoiceId: number; 46 | fromAccountId: number; 47 | } 48 | 49 | export interface GetInvoicesInput { 50 | limit: number; 51 | offset: number; 52 | } 53 | 54 | export interface GetInvoicesResponse extends GetInvoicesInput { 55 | total: number; 56 | totalUnpaid: number; 57 | invoices: Invoice[]; 58 | } 59 | -------------------------------------------------------------------------------- /src/server/sv_logger.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@utils/server-config'; 2 | import path from 'path'; 3 | import winston from 'winston'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const { createLogger, transports, format } = require('./logform'); 7 | 8 | // Needed to manually apply a color to componenent property of log 9 | const manualColorize = (strToColor: string): string => `[\x1b[35m${strToColor}\x1b[0m]`; 10 | 11 | // Format handler passed to winston 12 | const formatLogs = (log: any): string => { 13 | if (log.module) 14 | return `${log.label} ${manualColorize(log.module)} [${log.level}]: ${log.message}`; 15 | 16 | return `${log.label} [${log.level}]: ${log.message}`; 17 | }; 18 | 19 | const findLogPath = () => `${path.join(GetResourcePath(GetCurrentResourceName()), 'sv_pefcl.log')}`; 20 | 21 | export const mainLogger: winston.Logger = createLogger({ 22 | level: config?.debug?.level, 23 | transports: [ 24 | new transports.File({ 25 | filename: findLogPath(), 26 | format: format.combine(format.errors({ stack: true }), format.timestamp(), format.json()), 27 | }), 28 | new transports.Console({ 29 | format: format.combine( 30 | format.label({ label: '[PEFCL]' }), 31 | format.colorize({ all: true }), 32 | format.printf(formatLogs), 33 | ), 34 | }), 35 | ], 36 | }); 37 | -------------------------------------------------------------------------------- /src/server/services/accountExternal/externalAccount.db.ts: -------------------------------------------------------------------------------- 1 | import { ExternalAccountInput } from '@typings/Account'; 2 | import { singleton } from 'tsyringe'; 3 | import { ExternalAccountModel } from './externalAccount.model'; 4 | 5 | export interface RemoveFromSharedAccountInput { 6 | accountId: number; 7 | identifier: string; 8 | } 9 | 10 | @singleton() 11 | export class ExternalAccountDB { 12 | async getAccounts(): Promise { 13 | return await ExternalAccountModel.findAll(); 14 | } 15 | 16 | async getAccountById(accountId: number): Promise { 17 | return await ExternalAccountModel.findOne({ where: { id: accountId } }); 18 | } 19 | 20 | async getAccountByNumber(number: string): Promise { 21 | return await ExternalAccountModel.findOne({ where: { number } }); 22 | } 23 | 24 | async getAccountsByUserId(userId: string): Promise { 25 | return await ExternalAccountModel.findAll({ where: { userId } }); 26 | } 27 | 28 | async getExistingAccount(userId: string, number: string): Promise { 29 | return await ExternalAccountModel.findOne({ where: { userId, number } }); 30 | } 31 | 32 | async create(input: ExternalAccountInput): Promise { 33 | return await ExternalAccountModel.create(input); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/src/views/dashboard/components/__tests__/AccountCards.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { screen, waitFor } from '@testing-library/react'; 3 | import AccountCards from '../AccountCards'; 4 | import { renderWithProviders } from '@utils/test'; 5 | import { mockedAccounts } from '@utils/constants'; 6 | 7 | jest.mock('@utils/fetchNui', () => ({ 8 | fetchNui: () => [mockedAccounts[0], mockedAccounts[1]], 9 | })); 10 | 11 | const Loading = () => { 12 | return
; 13 | }; 14 | describe('Component: ', () => { 15 | test('should display add card button', async () => { 16 | renderWithProviders( 17 | }> 18 | 19 | , 20 | ); 21 | 22 | expect(screen.getByTestId('loading')).toBeInTheDocument(); 23 | await waitFor(() => expect(screen.queryByTestId('loading')).not.toBeInTheDocument()); 24 | expect(screen.getByTitle('create-account')).toBeInTheDocument(); 25 | }); 26 | 27 | test('should display cards', async () => { 28 | renderWithProviders( 29 | }> 30 | 31 | , 32 | ); 33 | 34 | await waitFor(() => expect(screen.queryByTestId('loading')).not.toBeInTheDocument()); 35 | expect(screen.getByText(mockedAccounts[0].accountName)).toBeInTheDocument(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /web/src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import i18n from 'i18next'; 3 | import 'dayjs/locale/sv'; 4 | import { initReactI18next } from 'react-i18next'; 5 | 6 | import { getConfig } from '@utils/api'; 7 | import updateLocale from 'dayjs/plugin/updateLocale'; 8 | import localizedFormat from 'dayjs/plugin/localizedFormat'; 9 | import { getI18nResourcesNamespaced } from './i18nResourceHelpers'; 10 | 11 | dayjs.extend(updateLocale); 12 | dayjs.extend(localizedFormat); 13 | 14 | const load = async () => { 15 | const config = await getConfig(); 16 | const language = config.general.language ?? 'en'; 17 | const resources = getI18nResourcesNamespaced('translation'); 18 | 19 | await i18n 20 | .use(initReactI18next) 21 | .init({ 22 | resources, 23 | lng: config.general.language, 24 | fallbackLng: 'en', 25 | }) 26 | .then(() => {}) 27 | .catch((r) => console.error(r)); 28 | 29 | dayjs.locale(language); 30 | dayjs.updateLocale(language, { 31 | calendar: { 32 | lastDay: i18n.t('calendar.lastDay'), 33 | sameDay: i18n.t('calendar.sameDay'), 34 | nextDay: i18n.t('calendar.nextDay'), 35 | lastWeek: i18n.t('calendar.lastWeek'), 36 | nextWeek: i18n.t('calendar.nextWeek'), 37 | sameElse: i18n.t('calendar.sameElse'), 38 | }, 39 | }); 40 | }; 41 | 42 | load(); 43 | 44 | export type TranslateFunction = typeof i18n['t']; 45 | 46 | export default i18n; 47 | -------------------------------------------------------------------------------- /web/src/components/ui/Status.tsx: -------------------------------------------------------------------------------- 1 | import { SerializedStyles } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { ChipProps, css } from '@mui/material'; 4 | import theme from '@utils/theme'; 5 | import React from 'react'; 6 | import { BodyText } from './Typography/BodyText'; 7 | 8 | type Color = Exclude; 9 | 10 | const colors: Record = { 11 | default: css` 12 | color: red; 13 | `, 14 | primary: css``, 15 | secondary: css``, 16 | error: css``, 17 | info: css``, 18 | success: css` 19 | color: ${theme.palette.success.contrastText}; 20 | background-color: ${theme.palette.primary.dark}; 21 | `, 22 | warning: css``, 23 | }; 24 | 25 | const Container = styled.div<{ color: Color }>` 26 | text-transform: uppercase; 27 | padding: 0.35rem 1.25rem; 28 | border-radius: ${theme.spacing(1)}; 29 | 30 | color: ${theme.palette.primary.main}; 31 | background-color: ${theme.palette.background.light2}; 32 | 33 | span { 34 | font-size: 0.875rem; 35 | font-weight: ${theme.typography.fontWeightBold}; 36 | } 37 | 38 | ${({ color }) => colors[color]} 39 | `; 40 | 41 | interface StatusProps { 42 | label: string; 43 | color: Color; 44 | } 45 | const Status: React.FC = (props) => { 46 | return ( 47 | 48 | {props.label} 49 | 50 | ); 51 | }; 52 | 53 | export default Status; 54 | -------------------------------------------------------------------------------- /web/src/utils/currency.ts: -------------------------------------------------------------------------------- 1 | import { ResourceConfig } from '../../../typings/config'; 2 | 3 | type FormatMoneyOptions = { 4 | currency: string; 5 | language: string; 6 | }; 7 | 8 | export const formatMoney = (amount: number, options: FormatMoneyOptions) => { 9 | const formatter = new Intl.NumberFormat(options.language, { 10 | style: 'currency', 11 | currency: options.currency, 12 | minimumFractionDigits: 0, 13 | maximumFractionDigits: 0, 14 | }); 15 | 16 | return formatter.format(amount); 17 | }; 18 | 19 | export const formatMoneyWithoutCurrency = (amount: number, language: string) => { 20 | const formatter = new Intl.NumberFormat(language); 21 | return formatter.format(amount); 22 | }; 23 | 24 | export const getSignLocation = (config: ResourceConfig): 'before' | 'after' => { 25 | const formatter = new Intl.NumberFormat(config?.general?.language, { 26 | style: 'currency', 27 | currency: config.general.currency, 28 | }); 29 | 30 | const result = formatter.format(0); 31 | const isBefore = result.charAt(0) !== '0'; 32 | 33 | return isBefore ? 'before' : 'after'; 34 | }; 35 | 36 | export const getCurrencySign = (config: ResourceConfig): string => { 37 | const formatter = new Intl.NumberFormat(config.general.language, { 38 | style: 'currency', 39 | currency: config.general.currency, 40 | }); 41 | 42 | const [result] = formatter.formatToParts(0).filter((part) => part.type === 'currency'); 43 | 44 | return result.value; 45 | }; 46 | -------------------------------------------------------------------------------- /typings/Transaction.ts: -------------------------------------------------------------------------------- 1 | import { Account } from './Account'; 2 | 3 | export enum TransactionType { 4 | Outgoing = 'Outgoing', 5 | Incoming = 'Incoming', 6 | Transfer = 'Transfer', 7 | } 8 | 9 | export enum TransferType { 10 | Internal = 'Internal', 11 | External = 'External', 12 | } 13 | export interface Transaction { 14 | id: number; 15 | toAccount?: Account; 16 | fromAccount?: Account; 17 | 18 | amount: number; 19 | message: string; 20 | type: TransactionType; 21 | 22 | updatedAt?: string | number | Date; 23 | createdAt?: string | number | Date; 24 | } 25 | 26 | export interface GetTransactionsInput { 27 | limit: number; 28 | offset: number; 29 | } 30 | 31 | export interface GetTransactionsResponse extends GetTransactionsInput { 32 | total: number; 33 | transactions: Transaction[]; 34 | } 35 | 36 | export interface TransactionInput { 37 | type: Transaction['type']; 38 | amount: Transaction['amount']; 39 | message: Transaction['message']; 40 | toAccount?: Transaction['toAccount']; 41 | fromAccount?: Transaction['fromAccount']; 42 | } 43 | 44 | export interface CreateTransferInput { 45 | number?: string; 46 | toAccountId: number; 47 | fromAccountId: number; 48 | message: string; 49 | amount: number; 50 | type: TransferType; 51 | } 52 | 53 | export type IncomeExpense = { income: number; expenses: number }; 54 | export interface GetTransactionHistoryResponse { 55 | income: number; 56 | expenses: number; 57 | lastWeek: Record; 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/web.yml: -------------------------------------------------------------------------------- 1 | name: Tests - Web, UI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | 7 | jobs: 8 | test: 9 | name: Testing 10 | runs-on: ubuntu-latest 11 | defaults: 12 | run: 13 | working-directory: web 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Setup node environment 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 18.x 22 | - name: Get yarn cache directory path 23 | id: yarn-cache-dir-path 24 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 25 | - uses: actions/cache@v2 26 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 27 | with: 28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | - name: Install deps (Translations) 33 | working-directory: . 34 | run: yarn --frozen-lockfile 35 | - name: Generate translations 36 | working-directory: . 37 | run: | 38 | yarn translations:generate 39 | yarn translations:generate-index 40 | - name: Install deps 41 | run: yarn --frozen-lockfile 42 | - name: Linting 43 | run: yarn lint 44 | - name: Type checking 45 | run: yarn tsc 46 | - name: Tests 47 | run: yarn test 48 | -------------------------------------------------------------------------------- /.github/workflows/server.yml: -------------------------------------------------------------------------------- 1 | name: Tests - Server & Client 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | 7 | jobs: 8 | test: 9 | name: Testing 10 | runs-on: ubuntu-latest 11 | defaults: 12 | run: 13 | working-directory: src 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Setup node environment 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 18.x 22 | - name: Get yarn cache directory path 23 | id: yarn-cache-dir-path 24 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 25 | - uses: actions/cache@v2 26 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 27 | with: 28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | - name: Install deps (Translations) 33 | working-directory: . 34 | run: yarn --frozen-lockfile 35 | - name: Generate translations 36 | working-directory: . 37 | run: | 38 | yarn translations:generate 39 | yarn translations:generate-index 40 | - name: Install deps 41 | run: yarn --frozen-lockfile 42 | - name: Linting 43 | run: yarn lint 44 | - name: Type checking 45 | run: yarn tsc 46 | - name: Tests 47 | run: yarn test 48 | -------------------------------------------------------------------------------- /src/server/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import 'dayjs/locale/sv'; 2 | import dayjs from 'dayjs'; 3 | import i18next from 'i18next'; 4 | import { config } from '@utils/server-config'; 5 | import { mainLogger } from '@server/sv_logger'; 6 | import languages from '@locales/index'; 7 | 8 | const language = config?.general?.language ?? 'en'; 9 | const logger = mainLogger.child({ module: 'i18n' }); 10 | 11 | export type Namespace = 'translation' | 'pefcl'; 12 | export type LanguageContent = typeof languages['en']; 13 | export type Language = keyof typeof languages; 14 | export type Locale = Record; 15 | export type Resource = Record>; 16 | 17 | export const getI18nResources = () => { 18 | return languages; 19 | }; 20 | 21 | export const getI18nResourcesNamespaced = (namespace: Namespace) => { 22 | return Object.keys(languages).reduce((prev, key) => { 23 | return { 24 | ...prev, 25 | [key]: { 26 | [namespace]: languages[key as Language], 27 | }, 28 | }; 29 | }, {} as Resource); 30 | }; 31 | 32 | dayjs.locale(language); 33 | 34 | export const load = async () => { 35 | logger.debug('Loading language from config: ' + language); 36 | const resources = getI18nResourcesNamespaced('translation'); 37 | 38 | await i18next 39 | .init({ 40 | resources, 41 | lng: language, 42 | fallbackLng: 'en', 43 | }) 44 | .catch((r) => console.error(r)); 45 | }; 46 | 47 | export type TranslateFunction = typeof i18next['t']; 48 | 49 | export default i18next; 50 | -------------------------------------------------------------------------------- /web/src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material'; 2 | 3 | const theme = createTheme({ 4 | palette: { 5 | background: { 6 | light2: 'rgba(255, 255, 255, 0.02)', 7 | light4: 'rgba(255, 255, 255, 0.04)', 8 | light8: 'rgba(255, 255, 255, 0.08)', 9 | dark4: 'rgba(0, 0, 0, 0.04)', 10 | dark12: 'rgba(0, 0, 0, 0.12)', 11 | primary20: 'rgba(60, 142, 169, 0.2)', 12 | paper: '#131E2A', 13 | default: 'linear-gradient(179.77deg, #152333 0.2%, #17212C 99.8%);', 14 | }, 15 | primary: { 16 | light: '#56CCF2', 17 | main: '#56CCF2', 18 | contrastText: '#fff', 19 | }, 20 | secondary: { 21 | light: '#fff', 22 | main: '#6FCF97', 23 | contrastText: '#fff', 24 | }, 25 | divider: '#2c3036', 26 | common: { 27 | white: '#2c3036', 28 | }, 29 | text: { 30 | primary: '#fff', 31 | secondary: 'rgba(255,255,255, 0.54)', 32 | }, 33 | }, 34 | typography: { 35 | fontFamily: "'Open Sans', 'sans-serif'", 36 | fontWeightBold: 600, 37 | }, 38 | components: { 39 | MuiListItem: { 40 | styleOverrides: { 41 | root: { 42 | height: 60, 43 | borderRadius: 1, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }); 49 | 50 | declare module '@mui/material/styles' { 51 | interface TypeBackground { 52 | light2: string; 53 | light4: string; 54 | light8: string; 55 | dark4: string; 56 | dark12: string; 57 | primary20: string; 58 | } 59 | } 60 | 61 | export default theme; 62 | -------------------------------------------------------------------------------- /src/server/utils/dbUtils.ts: -------------------------------------------------------------------------------- 1 | export const CONNECTION_STRING = 'mysql_connection_string'; 2 | const regex = new RegExp( 3 | '^(?:([^:/?#.]+):)?(?://(?:([^/?#]*)@)?([\\w\\d\\-\\u0100-\\uffff.%]*)(?::([0-9]+))?)?([^?#]+)?(?:\\?([^#]*))?$', 4 | ); 5 | 6 | export const parseUri = (connectionUri: string) => { 7 | const splitMatchGroups = connectionUri.match(regex); 8 | 9 | if (!splitMatchGroups) { 10 | throw new Error('Invalid connection string'); 11 | } 12 | 13 | // Handle parsing for optional password auth 14 | const authTgt = splitMatchGroups[2] ? splitMatchGroups[2].split(':') : []; 15 | 16 | const removeForwardSlash = (str: string) => str.replace(/^\/+/, ''); 17 | 18 | if (connectionUri.includes('mysql://')) 19 | return { 20 | driver: splitMatchGroups[1], 21 | user: authTgt[0] || undefined, 22 | password: authTgt[1] || undefined, 23 | host: splitMatchGroups[3], 24 | port: parseInt(splitMatchGroups[4], 10), 25 | database: removeForwardSlash(splitMatchGroups[5]), 26 | params: splitMatchGroups[6], 27 | }; 28 | 29 | return connectionUri 30 | .replace(/(?:host(?:name)|ip|server|data\s?source|addr(?:ess)?)=/gi, 'host=') 31 | .replace(/(?:user\s?(?:id|name)?|uid)=/gi, 'user=') 32 | .replace(/(?:pwd|pass)=/gi, 'password=') 33 | .replace(/(?:db)=/gi, 'database=') 34 | .split(';') 35 | .reduce>((connectionInfo, parameter) => { 36 | const [key, value] = parameter.split('='); 37 | connectionInfo[key] = value; 38 | return connectionInfo; 39 | }, {}); 40 | }; 41 | -------------------------------------------------------------------------------- /web/src/components/UserSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, FormHelperText, Stack } from '@mui/material'; 2 | import { User } from '@typings/user'; 3 | import React, { SyntheticEvent, useState } from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import TextField from './ui/Fields/TextField'; 6 | 7 | interface SelectableUser extends User { 8 | isDisabled?: boolean; 9 | } 10 | interface UserSelectProps { 11 | users: SelectableUser[]; 12 | isDisabled?: boolean; 13 | selectedId?: string; 14 | onSelect(user?: SelectableUser): void; 15 | } 16 | 17 | const UserSelect = ({ users, onSelect }: UserSelectProps) => { 18 | const { t } = useTranslation(); 19 | const [value, setValue] = useState(''); 20 | 21 | const handleChange = (_event: SyntheticEvent, value: string | null) => { 22 | const selectedUser = users.find((user) => user.name === value); 23 | if (!selectedUser || !value) { 24 | return; 25 | } 26 | setValue(value); 27 | onSelect(selectedUser); 28 | }; 29 | 30 | return ( 31 | 32 | } 37 | disableClearable 38 | sx={{ width: '100%' }} 39 | options={users.map((user) => user.name)} 40 | /> 41 | {users.length === 0 && {t('No users found.')}} 42 | 43 | ); 44 | }; 45 | 46 | export default UserSelect; 47 | -------------------------------------------------------------------------------- /web/src/hooks/useBroadcasts.ts: -------------------------------------------------------------------------------- 1 | import { accountsAtom } from '@data/accounts'; 2 | import { invoicesAtom } from '@data/invoices'; 3 | import { transactionBaseAtom } from '@data/transactions'; 4 | import { Account } from '@typings/Account'; 5 | import { Broadcasts } from '@typings/Events'; 6 | import { updateAccount } from '@utils/account'; 7 | import { useAtom, useSetAtom } from 'jotai'; 8 | import { useNuiEvent } from '@hooks/useNuiEvent'; 9 | 10 | export const useBroadcasts = () => { 11 | const updateInvoices = useSetAtom(invoicesAtom); 12 | const updateTransactions = useSetAtom(transactionBaseAtom); 13 | const [accounts, updateAccounts] = useAtom(accountsAtom); 14 | 15 | useNuiEvent('PEFCL', Broadcasts.NewTransaction, () => { 16 | updateTransactions(); 17 | }); 18 | 19 | useNuiEvent('PEFCL', Broadcasts.NewAccount, (account: Account) => { 20 | updateAccounts([...accounts, account]); 21 | }); 22 | 23 | useNuiEvent('PEFCL', Broadcasts.UpdatedAccount, () => { 24 | updateAccounts(); 25 | }); 26 | 27 | useNuiEvent('PEFCL', Broadcasts.NewAccountBalance, (account: Account) => { 28 | updateAccounts(updateAccount(accounts, account)); 29 | }); 30 | 31 | useNuiEvent('PEFCL', Broadcasts.NewInvoice, () => { 32 | updateInvoices(); 33 | }); 34 | 35 | useNuiEvent('PEFCL', Broadcasts.NewSharedUser, () => { 36 | updateAccounts(); 37 | }); 38 | 39 | useNuiEvent('PEFCL', Broadcasts.RemovedSharedUser, () => { 40 | updateAccounts(); 41 | }); 42 | }; 43 | 44 | export const BroadcastsWrapper = () => { 45 | useBroadcasts(); 46 | return null; 47 | }; 48 | -------------------------------------------------------------------------------- /src/server/services/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from 'tsyringe'; 2 | import { UserService } from '../user/user.service'; 3 | import { mainLogger } from '../../sv_logger'; 4 | import { AccountDB } from '@services/account/account.db'; 5 | import { AccountRole } from '@typings/Account'; 6 | import { ServerError } from '@utils/errors'; 7 | import { GenericErrors } from '@typings/Errors'; 8 | import { SharedAccountDB } from '@services/accountShared/sharedAccount.db'; 9 | 10 | const logger = mainLogger.child({ module: 'auth' }); 11 | 12 | @singleton() 13 | export class AuthService { 14 | _accountDB: AccountDB; 15 | _userService: UserService; 16 | _sharedAccountDB: SharedAccountDB; 17 | 18 | constructor(accountDB: AccountDB, userService: UserService, sharedAccountDB: SharedAccountDB) { 19 | this._accountDB = accountDB; 20 | this._userService = userService; 21 | this._sharedAccountDB = sharedAccountDB; 22 | } 23 | 24 | async isAuthorizedAccount( 25 | accountId: number, 26 | source: number, 27 | roles: AccountRole[], 28 | ): Promise { 29 | const user = this._userService.getUser(source); 30 | const identifier = user.getIdentifier(); 31 | 32 | logger.debug(`Authorizing user ${identifier} for account: ${accountId}`); 33 | 34 | const account = 35 | (await this._accountDB.getAuthorizedAccountById(accountId, identifier)) ?? 36 | (await this._sharedAccountDB.getAuthorizedSharedAccountById(accountId, identifier, roles)); 37 | 38 | if (!account) { 39 | throw new ServerError(GenericErrors.NotFound); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/server/decorators/Export.ts: -------------------------------------------------------------------------------- 1 | import { Request } from '@typings/http'; 2 | export const Export = (name: string) => { 3 | return function (target: object, key: string) { 4 | if (!Reflect.hasMetadata('exports', target)) { 5 | Reflect.defineMetadata('exports', [], target); 6 | } 7 | 8 | const _exports = Reflect.getMetadata('exports', target); 9 | 10 | _exports.push({ 11 | name, 12 | key, 13 | }); 14 | 15 | Reflect.defineMetadata('exports', _exports, target); 16 | }; 17 | }; 18 | 19 | const exp = global.exports; 20 | 21 | export const ExportListener = () => { 22 | return function (ctr: T) { 23 | return class extends ctr { 24 | constructor(...args: any[]) { 25 | super(...args); 26 | 27 | if (!Reflect.hasMetadata('exports', this)) { 28 | Reflect.defineMetadata('exports', [], this); 29 | } 30 | 31 | const _exports: any[] = Reflect.getMetadata('exports', this); 32 | 33 | _exports.forEach(({ name, key }) => { 34 | exp(name, async (source: number, data: unknown, cb: (data: unknown) => void) => { 35 | const payload: Request = { 36 | data, 37 | source, 38 | }; 39 | 40 | const result = await new Promise((resolve) => { 41 | return this[key](payload, resolve); 42 | }); 43 | 44 | cb?.(result); 45 | 46 | return new Promise((resolve) => { 47 | resolve(result); 48 | }); 49 | }); 50 | }); 51 | } 52 | }; 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /web/src/components/Summary.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Divider } from '@mui/material'; 3 | import { fontWeight } from '@mui/system'; 4 | import React from 'react'; 5 | import { useTranslation } from 'react-i18next'; 6 | import { useConfig } from '../hooks/useConfig'; 7 | import { formatMoney } from '../utils/currency'; 8 | import { BodyText } from './ui/Typography/BodyText'; 9 | import { Heading6 } from './ui/Typography/Headings'; 10 | 11 | const Row = styled.li` 12 | display: flex; 13 | justify-content: space-between; 14 | margin: 0.5rem 0; 15 | `; 16 | 17 | const Label = styled(BodyText)``; 18 | const Amount = styled(BodyText)` 19 | ${fontWeight} 20 | `; 21 | 22 | interface SummaryRowProps { 23 | label: string; 24 | amount: number; 25 | } 26 | const SummaryRow: React.FC = ({ label, amount }) => { 27 | const config = useConfig(); 28 | return ( 29 | 30 | 31 | {formatMoney(amount, config.general)} 32 | 33 | ); 34 | }; 35 | 36 | interface SummaryProps { 37 | balance: number; 38 | payment: number; 39 | } 40 | 41 | const Summary: React.FC = ({ balance, payment }) => { 42 | const { t } = useTranslation(); 43 | 44 | return ( 45 |
46 | {t('Summary')} 47 | 48 | 49 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | export default Summary; 56 | -------------------------------------------------------------------------------- /web/src/components/ui/Fields/PriceField.tsx: -------------------------------------------------------------------------------- 1 | import { InputAdornment, InputBase, InputBaseProps } from '@mui/material'; 2 | import React, { ChangeEventHandler } from 'react'; 3 | import { useConfig } from '@hooks/useConfig'; 4 | import { formatMoneyWithoutCurrency, getCurrencySign, getSignLocation } from '@utils/currency'; 5 | import BaseFieldStyles from './BaseField.styles'; 6 | 7 | const Input: React.FC = (props) => { 8 | const config = useConfig(); 9 | const currencySignLocation = getSignLocation(config); 10 | const isLocationBefore = currencySignLocation === 'before'; 11 | const currencySign = getCurrencySign(config); 12 | 13 | const handleChange: ChangeEventHandler = (event) => { 14 | const value = event.target.value.replace(/\D/g, ''); 15 | const formattedValue = formatMoneyWithoutCurrency(Number(value), config.general.language); 16 | 17 | if (!value) { 18 | props.onChange?.(event); 19 | return; 20 | } 21 | 22 | const formattedEvent = { 23 | ...event, 24 | target: { ...event.target, value: formattedValue }, 25 | }; 26 | 27 | props.onChange?.(formattedEvent); 28 | }; 29 | 30 | return ( 31 | 32 | {currencySign} 38 | ) 39 | } 40 | endAdornment={ 41 | isLocationBefore ? null : {currencySign} 42 | } 43 | /> 44 | 45 | ); 46 | }; 47 | 48 | export default Input; 49 | -------------------------------------------------------------------------------- /web/src/hooks/useNuiEvent.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect, useRef } from 'react'; 2 | import { noop } from '../utils/misc'; 3 | 4 | interface NuiMessageData { 5 | method: string; 6 | data: T; 7 | app: string; 8 | } 9 | 10 | type NuiHandlerSignature = (data: T) => void; 11 | 12 | /** 13 | * A hook that manage events listeners for receiving data from the client scripts 14 | * @param app 15 | * @param action The specific `action` that should be listened for. 16 | * @param handler The callback function that will handle data relayed by this hook 17 | * 18 | * @example 19 | * useNuiEvent<{visibility: true, wasVisible: 'something'}>('setVisible', (data) => { 20 | * // whatever logic you want 21 | * }) 22 | * 23 | **/ 24 | 25 | export const useNuiEvent = (app: string, action: string, handler: (data: T) => void) => { 26 | const savedHandler: MutableRefObject> = useRef(noop); 27 | 28 | // When handler value changes set mutable ref to handler val 29 | useEffect(() => { 30 | savedHandler.current = handler; 31 | }, [handler]); 32 | 33 | useEffect(() => { 34 | const eventListener = (event: MessageEvent>) => { 35 | const { method: eventAction, app: tgtApp, data } = event.data; 36 | 37 | if (savedHandler.current && savedHandler.current.call) { 38 | if (eventAction === action && tgtApp === app) { 39 | savedHandler.current(data); 40 | } 41 | } 42 | }; 43 | 44 | window.addEventListener('message', eventListener); 45 | // Remove Event Listener on component cleanup 46 | return () => window.removeEventListener('message', eventListener); 47 | }, [action, app]); 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pefcl", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/project-error/pefcl", 6 | "author": "projecterror ", 7 | "license": "MIT", 8 | "scripts": { 9 | "format": "prettier --write .", 10 | "prepare": "husky install", 11 | "postinstall": "husky install && yarn setup", 12 | "translations:generate": "yarn i18next", 13 | "translations:generate-index": "node ./scripts/generateLocales.js", 14 | "translations:pull": "localazy download", 15 | "translations:push": "localazy upload -w $LOCALAZY_WRITE_KEY -r $LOCALAZY_READ_KEY", 16 | "setup": "yarn nx run-many --target=setup --all && yarn translations:pull && yarn translations:generate-index", 17 | "build": "yarn nx run-many --target=build --all", 18 | "lint": "yarn nx run-many --target=lint --all", 19 | "dev": "yarn nx run-many --target=dev --all", 20 | "tsc": "yarn nx run-many --target=tsc --all", 21 | "dev:ingame": "yarn nx run-many --target=dev:ingame --all", 22 | "dev:mobile": "yarn nx run-many --target=dev:mobile --all", 23 | "pre-release": "yarn build && sh ./scripts/prerelease.sh", 24 | "release": "yarn build && sh ./scripts/release.sh" 25 | }, 26 | "devDependencies": { 27 | "@citizenfx/client": "^2.0.5754-1", 28 | "@commitlint/cli": "^16.0.2", 29 | "@commitlint/config-conventional": "^16.0.0", 30 | "@localazy/cli": "^1.6.0", 31 | "@types/node": "^18.6.1", 32 | "axios": "^0.26.1", 33 | "husky": "^7.0.4", 34 | "nodemon": "^2.0.15", 35 | "nx": "14.4.3", 36 | "prettier": "^2.5.1", 37 | "pretty-quick": "^3.1.3" 38 | }, 39 | "dependencies": { 40 | "i18next-parser": "^6.0.0" 41 | } 42 | } -------------------------------------------------------------------------------- /src/server/services/broadcast/broadcast.controller.ts: -------------------------------------------------------------------------------- 1 | import { Account } from '@server/../../typings/Account'; 2 | import { Cash } from '@server/../../typings/Cash'; 3 | import { AccountEvents, CashEvents, TransactionEvents } from '@server/../../typings/Events'; 4 | import { Transaction } from '@server/../../typings/Transaction'; 5 | import { Controller } from '@server/decorators/Controller'; 6 | import { Event, EventListener } from '@server/decorators/Event'; 7 | import { BroadcastService } from './broadcast.service'; 8 | 9 | @Controller('Broadcast') 10 | @EventListener() 11 | export class BroadcastController { 12 | broadcastService: BroadcastService; 13 | constructor(broadcastService: BroadcastService) { 14 | this.broadcastService = broadcastService; 15 | } 16 | 17 | @Event(AccountEvents.NewBalance) 18 | async onNewBalance(account: Account) { 19 | this.broadcastService.broadcastNewDefaultAccountBalance(account); 20 | } 21 | 22 | @Event(AccountEvents.NewBalance) 23 | async onNewAccountBalance(account: Account) { 24 | this.broadcastService.broadcastNewAccountBalance(account); 25 | } 26 | 27 | @Event(AccountEvents.NewAccountCreated) 28 | async onNewAccountCreation(account: Account) { 29 | this.broadcastService.broadcastUpdatedAccount(account); 30 | } 31 | 32 | @Event(AccountEvents.AccountDeleted) 33 | async onAccountDeleted(account: Account) { 34 | this.broadcastService.broadcastUpdatedAccount(account); 35 | } 36 | 37 | @Event(CashEvents.NewCash) 38 | async onNewCash(cash: Cash) { 39 | this.broadcastService.broadcastNewCash(cash); 40 | } 41 | 42 | @Event(TransactionEvents.NewTransaction) 43 | async onNewTransaction(transaction: Transaction) { 44 | this.broadcastService.broadcastTransaction(transaction); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/src/data/invoices.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | import { mockedInvoices } from '@utils/constants'; 3 | import { InvoiceEvents } from '@typings/Events'; 4 | import { GetInvoicesInput, GetInvoicesResponse, InvoiceStatus } from '../../../typings/Invoice'; 5 | import { fetchNui } from '../utils/fetchNui'; 6 | import { isEnvBrowser } from '../utils/misc'; 7 | 8 | const initialState: GetInvoicesResponse = { 9 | total: 0, 10 | offset: 0, 11 | limit: 10, 12 | totalUnpaid: 0, 13 | invoices: [], 14 | }; 15 | 16 | const getInvoices = async (input: GetInvoicesInput): Promise => { 17 | try { 18 | const res = await fetchNui(InvoiceEvents.Get, input); 19 | return res ?? initialState; 20 | } catch (e) { 21 | if (isEnvBrowser()) { 22 | return { 23 | ...initialState, 24 | invoices: mockedInvoices, 25 | }; 26 | } 27 | console.error(e); 28 | return initialState; 29 | } 30 | }; 31 | 32 | const invoicesAtomRaw = atom(initialState); 33 | export const invoicesAtom = atom( 34 | async (get) => { 35 | const hasTransactions = get(invoicesAtomRaw).invoices.length > 0; 36 | return hasTransactions ? get(invoicesAtomRaw) : await getInvoices({ ...initialState }); 37 | }, 38 | async (get, set) => { 39 | const currentSettings = get(invoicesAtomRaw); 40 | return set(invoicesAtomRaw, await getInvoices(currentSettings)); 41 | }, 42 | ); 43 | 44 | export const unpaidInvoicesAtom = atom((get) => { 45 | return get(invoicesAtom).invoices.filter((invoice) => invoice.status === InvoiceStatus.PENDING); 46 | }); 47 | 48 | export const totalInvoicesAtom = atom((get) => get(invoicesAtom).total); 49 | export const totalUnpaidInvoicesAtom = atom((get) => get(invoicesAtom).totalUnpaid); 50 | -------------------------------------------------------------------------------- /src/server/lib/onNetPromise.ts: -------------------------------------------------------------------------------- 1 | import { getSource } from '../utils/misc'; 2 | import { mainLogger } from '../sv_logger'; 3 | import { CBSignature, PromiseEventResp, PromiseRequest } from './promise.types'; 4 | import { ServerPromiseResp } from '../../../typings/http'; 5 | 6 | const netEventLogger = mainLogger.child({ module: 'events' }); 7 | 8 | export function onNetPromise(eventName: string, cb: CBSignature): void { 9 | onNet(eventName, async (respEventName: string, data: T) => { 10 | const startTime = process.hrtime.bigint(); 11 | const src = getSource(); 12 | 13 | if (!respEventName) { 14 | return netEventLogger.warn( 15 | `Promise event (${eventName}) was called with wrong struct by ${src} (maybe originator wasn't a promiseEvent)`, 16 | ); 17 | } 18 | 19 | const promiseRequest: PromiseRequest = { 20 | source: src, 21 | data, 22 | }; 23 | 24 | netEventLogger.silly(`netPromise > ${eventName} > RequestObj`); 25 | netEventLogger.silly(promiseRequest); 26 | 27 | const promiseResp: PromiseEventResp

= (data: ServerPromiseResp

) => { 28 | const endTime = process.hrtime.bigint(); 29 | const totalTime = Number(endTime - startTime) / 1e6; 30 | emitNet(respEventName, src, data); 31 | netEventLogger.silly(`Response Promise Event ${respEventName} (${totalTime}ms), Data >>`); 32 | netEventLogger.silly(data); 33 | }; 34 | 35 | // In case the cb is a promise, we use Promise.resolve 36 | Promise.resolve(cb(promiseRequest, promiseResp)).catch((e) => { 37 | netEventLogger.error( 38 | `An error occured for a onNetPromise (${eventName}), Error: ${e.message}`, 39 | ); 40 | 41 | promiseResp({ status: 'error', errorMsg: 'UNKNOWN_ERROR' }); 42 | }); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /web/src/utils/test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import React from 'react'; 3 | import { render } from '@testing-library/react'; 4 | import { Resource } from 'i18next'; 5 | import { ReactElement, ReactNode, Suspense } from 'react'; 6 | import { HashRouter, Router } from 'react-router-dom'; 7 | import { createMemoryHistory, MemoryHistory } from 'history'; 8 | import { createTheme, ThemeProvider } from '@mui/material'; 9 | import { SnackbarProvider } from 'notistack'; 10 | 11 | const theme = createTheme({ 12 | palette: { 13 | mode: 'dark', 14 | }, 15 | }); 16 | 17 | const renderWithRouter = (history: MemoryHistory) => (ui: ReactNode) => { 18 | return {ui}; 19 | }; 20 | 21 | const renderWithTheme = (ui: ReactNode) => { 22 | return {ui}; 23 | }; 24 | 25 | const renderWithSuspense = (ui: ReactNode) => { 26 | return loading..

}>{ui}; 27 | }; 28 | 29 | const renderWithSnackbar = (ui: ReactNode) => { 30 | return {ui}; 31 | }; 32 | 33 | type RenderWithProvidersOptions = { 34 | resources?: Resource; 35 | router?: Partial; 36 | history?: MemoryHistory; 37 | }; 38 | export const renderWithProviders = (ui: ReactElement, options?: RenderWithProvidersOptions) => { 39 | const history = options?.history ?? createMemoryHistory(); 40 | 41 | /* From bottom, to top. Lowest = rendered furthest out. */ 42 | const providers = [ 43 | renderWithRouter(history), 44 | renderWithSnackbar, 45 | renderWithSuspense, 46 | renderWithTheme, 47 | ]; 48 | 49 | const renderedElement = providers.reduce((prevUi, provider) => { 50 | return provider(prevUi); 51 | }, ui); 52 | 53 | return render(renderedElement); 54 | }; 55 | -------------------------------------------------------------------------------- /src/server/decorators/Event.ts: -------------------------------------------------------------------------------- 1 | export const Event = (eventName: string) => { 2 | return function (target: object, key: string): void { 3 | if (!Reflect.hasMetadata('events', target)) { 4 | Reflect.defineMetadata('events', [], target); 5 | } 6 | 7 | const netEvents = Reflect.getMetadata('events', target) as Array; 8 | 9 | netEvents.push({ 10 | eventName, 11 | key: key, 12 | net: false, 13 | }); 14 | 15 | Reflect.defineMetadata('events', netEvents, target); 16 | }; 17 | }; 18 | 19 | export const NetEvent = (eventName: string) => { 20 | return function (target: any, key: string): void { 21 | if (!Reflect.hasMetadata('events', target)) { 22 | Reflect.defineMetadata('events', [], target); 23 | } 24 | 25 | const netEvents = Reflect.getMetadata('events', target) as Array; 26 | 27 | netEvents.push({ 28 | eventName, 29 | key: key, 30 | net: true, 31 | }); 32 | 33 | Reflect.defineMetadata('events', netEvents, target); 34 | }; 35 | }; 36 | 37 | export const EventListener = function () { 38 | return function (constructor: T) { 39 | return class extends constructor { 40 | constructor(...args: any[]) { 41 | super(...args); 42 | 43 | if (!Reflect.hasMetadata('events', this)) { 44 | Reflect.defineMetadata('events', [], this); 45 | } 46 | 47 | const events = Reflect.getMetadata('events', this) as Array; 48 | 49 | for (const { net, eventName, key } of events) { 50 | if (net) 51 | onNet(eventName, (...args: any[]) => { 52 | this[key](...args); 53 | }); 54 | else 55 | on(eventName, (...args: any[]) => { 56 | this[key](...args); 57 | }); 58 | } 59 | } 60 | }; 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /src/client/lua/interaction.lua: -------------------------------------------------------------------------------- 1 | local config = json.decode(LoadResourceFile(GetCurrentResourceName(), "config.json")) 2 | local bank_coords = config.bankBlips.coords 3 | local atm_props = config.atms.props 4 | 5 | function display_help_text(text) 6 | BeginTextCommandDisplayHelp("STRING") 7 | AddTextComponentString(text) 8 | EndTextCommandDisplayHelp(0, false, false, -1) 9 | end 10 | 11 | CreateThread(function () 12 | 13 | if not config.target.enabled then 14 | while true do 15 | local player_id = PlayerPedId() 16 | local player_coords = GetEntityCoords(player_id) 17 | local sleep = 1000 18 | 19 | for i = 1, #bank_coords do 20 | local pos = bank_coords[i] 21 | 22 | local distBank = #(player_coords - vector3(pos.x, pos.y, pos.z)) 23 | if distBank <= 10.0 then 24 | DrawMarker(2, pos.x, pos.y, pos.z, 0.0, 0.0, 0.0, 0.0, 180.0, 0.0, 0.5, 0.5, 0.3, 255, 255, 255, 50, false, true, 2, nil, nil, false) 25 | 26 | if distBank <= 3.5 then 27 | display_help_text("Open bank: ~INPUT_PICKUP~") 28 | 29 | 30 | if IsControlJustReleased(0, 38) then 31 | exports["pefcl"]:openBank() 32 | end 33 | end 34 | 35 | sleep = 0 36 | end 37 | end 38 | 39 | for i = 1, #atm_props do 40 | local prop = GetClosestObjectOfType(player_coords, 5.0, joaat(atm_props[i]), false, false, false) 41 | local pos = GetEntityCoords(prop) 42 | local distAtm = #(player_coords - pos) 43 | 44 | if distAtm <= 2.0 then 45 | display_help_text("Open atm: ~INPUT_PICKUP~") 46 | 47 | if IsControlJustReleased(0, 38) then 48 | exports["pefcl"]:openAtm() 49 | end 50 | 51 | sleep = 0 52 | end 53 | end 54 | 55 | Wait(sleep) 56 | end 57 | end 58 | end) 59 | -------------------------------------------------------------------------------- /web/src/hooks/useI18n.ts: -------------------------------------------------------------------------------- 1 | import { Language } from '@utils/i18nResourceHelpers'; 2 | import { i18n } from 'i18next'; 3 | import updateLocale from 'dayjs/plugin/updateLocale'; 4 | import localizedFormat from 'dayjs/plugin/localizedFormat'; 5 | import dayjs from 'dayjs'; 6 | import { useCallback, useEffect, useState } from 'react'; 7 | import { loadPefclResources } from 'src/views/Mobile/i18n'; 8 | 9 | dayjs.extend(updateLocale); 10 | dayjs.extend(localizedFormat); 11 | 12 | export const useI18n = (initialI18n: i18n, language: Language) => { 13 | const [i18n, setI18n] = useState(); 14 | 15 | const changeLanguage = useCallback( 16 | (language: Language) => { 17 | if (!i18n) { 18 | throw new Error('Cannot change language before i18n has been loaded.'); 19 | } 20 | 21 | /* Change language for i18n */ 22 | i18n.changeLanguage(language); 23 | 24 | /* Import locale for DayJS, then update translations & set locale */ 25 | import(`dayjs/locale/${language}.js`).then(() => { 26 | dayjs.locale(language); 27 | dayjs.updateLocale(language, { 28 | calendar: { 29 | lastDay: i18n.t('calendar.lastDay'), 30 | sameDay: i18n.t('calendar.sameDay'), 31 | nextDay: i18n.t('calendar.nextDay'), 32 | lastWeek: i18n.t('calendar.lastWeek'), 33 | nextWeek: i18n.t('calendar.nextWeek'), 34 | sameElse: i18n.t('calendar.sameElse'), 35 | }, 36 | }); 37 | }); 38 | }, 39 | [i18n], 40 | ); 41 | 42 | useEffect(() => { 43 | if (i18n) { 44 | changeLanguage(language); 45 | } 46 | }, [changeLanguage, i18n, language]); 47 | 48 | useEffect(() => { 49 | const instance = initialI18n.cloneInstance(); 50 | loadPefclResources(instance); 51 | setI18n(instance); 52 | }, [initialI18n]); 53 | 54 | return { i18n: i18n, changeLanguage }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/server/services/invoice/invoice.db.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from 'tsyringe'; 2 | import { CreateInvoiceInput, GetInvoicesInput, InvoiceStatus } from '@typings/Invoice'; 3 | import { InvoiceModel } from './invoice.model'; 4 | import { MS_TWO_WEEKS } from '@utils/constants'; 5 | import { Transaction } from 'sequelize/types'; 6 | 7 | @singleton() 8 | export class InvoiceDB { 9 | async getAllInvoices(): Promise { 10 | return await InvoiceModel.findAll(); 11 | } 12 | 13 | async getAllReceivingInvoices( 14 | identifier: string, 15 | pagination: GetInvoicesInput, 16 | ): Promise { 17 | return await InvoiceModel.findAll({ 18 | where: { toIdentifier: identifier }, 19 | ...pagination, 20 | order: [['createdAt', 'DESC']], 21 | }); 22 | } 23 | 24 | async getReceivedInvoicesCount(identifier: string): Promise { 25 | return await InvoiceModel.count({ where: { toIdentifier: identifier } }); 26 | } 27 | 28 | async getUnpaidInvoicesCount(identifier: string): Promise { 29 | return await InvoiceModel.count({ 30 | where: { toIdentifier: identifier, status: InvoiceStatus.PENDING }, 31 | }); 32 | } 33 | 34 | async getInvoiceById(id: number, transaction: Transaction): Promise { 35 | return await InvoiceModel.findOne({ where: { id }, transaction }); 36 | } 37 | 38 | async createInvoice(input: CreateInvoiceInput): Promise { 39 | const expiresAt = input.expiresAt 40 | ? input.expiresAt 41 | : new Date(Date.now() + MS_TWO_WEEKS).toString(); 42 | 43 | return await InvoiceModel.create({ ...input, expiresAt }); 44 | } 45 | 46 | async payInvoice(invoiceId: number): Promise { 47 | const [result] = await InvoiceModel.update( 48 | { status: InvoiceStatus.PAID }, 49 | { where: { id: invoiceId } }, 50 | ); 51 | return result; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/client/cl_integrations.ts: -------------------------------------------------------------------------------- 1 | import { setBankIsOpen, setAtmIsOpen } from 'client'; 2 | import cl_config from 'cl_config'; 3 | import { translations } from 'i18n'; 4 | const exp = global.exports; 5 | 6 | const isTargetEnabled = cl_config.target?.enabled ?? false; 7 | const targetType = cl_config.target?.type ?? 'qtarget'; 8 | const isTargetDebugEnabled = cl_config.target?.debug ?? false; 9 | const isTargetAvailable = GetResourceState(targetType) === 'started'; 10 | 11 | if (isTargetEnabled && isTargetAvailable) { 12 | const bankZones = cl_config.target?.bankZones ?? []; 13 | const atmModels = cl_config.atms?.props ?? []; 14 | 15 | atmModels.forEach((model) => { 16 | exp[targetType]['AddTargetModel'](model, { 17 | options: [ 18 | { 19 | event: 'pefcl:open:atm', 20 | icon: 'fas fa-money-bill-1-wave', 21 | label: 'ATM', 22 | }, 23 | ], 24 | }); 25 | }); 26 | 27 | bankZones.forEach((zone, index) => { 28 | const name = 'bank_' + index; 29 | 30 | if (!zone) { 31 | throw new Error('Missing zone. Check your "qtarget.bankZones" config.'); 32 | } 33 | 34 | exp[targetType]['AddBoxZone']( 35 | name, 36 | zone.position, 37 | zone.length, 38 | zone.width, 39 | { 40 | name, 41 | heading: zone.heading, 42 | debugPoly: isTargetDebugEnabled, 43 | minZ: zone.minZ, 44 | maxZ: zone.maxZ, 45 | }, 46 | { 47 | options: [ 48 | { 49 | event: 'pefcl:open:bank', 50 | icon: 'fas fa-building-columns', 51 | label: translations.t('Open bank'), 52 | }, 53 | ], 54 | distance: 1.5, 55 | }, 56 | ); 57 | }); 58 | 59 | AddEventHandler('pefcl:open:atm', () => { 60 | setAtmIsOpen(true); 61 | }); 62 | 63 | AddEventHandler('pefcl:open:bank', () => { 64 | setBankIsOpen(true); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/client/functions.ts: -------------------------------------------------------------------------------- 1 | import { BalanceErrors } from '@typings/Errors'; 2 | import API from 'cl_api'; 3 | import { getNearestPlayer, validateAmount } from 'cl_utils'; 4 | 5 | export const giveCash = async (_source: number, args: string[]) => { 6 | const [amount] = args; 7 | 8 | const isValid = validateAmount(amount); 9 | if (!isValid) { 10 | console.log('Invalid amount'); 11 | return; 12 | } 13 | 14 | const nearestPlayer = getNearestPlayer(5); 15 | if (!nearestPlayer) { 16 | console.log('No player nearby.'); 17 | return; 18 | } 19 | 20 | await API.giveCash(nearestPlayer.source, Number(amount)).catch((error: Error) => { 21 | if (error.message === BalanceErrors.InsufficentFunds) { 22 | console.log('You are too poor'); 23 | return; 24 | } 25 | 26 | console.log(error); 27 | }); 28 | }; 29 | 30 | export const createInvoice = async (_source: number, args: string[]) => { 31 | const [amount, message] = args; 32 | const isValid = validateAmount(amount); 33 | 34 | if (!isValid) { 35 | console.log('Invalid amount'); 36 | return; 37 | } 38 | 39 | const nearestPlayer = getNearestPlayer(5); 40 | if (!nearestPlayer) { 41 | console.log('No player nearby.'); 42 | return; 43 | } 44 | 45 | await API.createInvoice({ 46 | amount: Number(amount), 47 | message, 48 | source: nearestPlayer.source, 49 | }); 50 | }; 51 | 52 | export const depositMoney = async (amount: number) => { 53 | const isValid = validateAmount(amount); 54 | 55 | if (!isValid) { 56 | console.log('Invalid amount'); 57 | return; 58 | } 59 | 60 | return await API.depositMoney(Number(amount)); 61 | }; 62 | 63 | export const withdrawMoney = async (amount: number) => { 64 | const isValid = validateAmount(amount); 65 | 66 | if (!isValid) { 67 | console.log('Invalid amount'); 68 | return; 69 | } 70 | 71 | return await API.withdrawMoney(Number(amount)); 72 | }; 73 | -------------------------------------------------------------------------------- /web/src/data/transactions.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | import { TransactionEvents } from '@typings/Events'; 3 | import { GetTransactionsInput, GetTransactionsResponse } from '@typings/Transaction'; 4 | import { mockedTransactions } from '../utils/constants'; 5 | import { fetchNui } from '../utils/fetchNui'; 6 | import { isEnvBrowser } from '../utils/misc'; 7 | 8 | const initialState: GetTransactionsResponse = { 9 | total: 0, 10 | offset: 0, 11 | limit: 10, 12 | transactions: [], 13 | }; 14 | 15 | const getTransactions = async (input: GetTransactionsInput): Promise => { 16 | try { 17 | const res = await fetchNui(TransactionEvents.Get, input); 18 | return res ?? initialState; 19 | } catch (e) { 20 | if (isEnvBrowser()) { 21 | return mockedTransactions; 22 | } 23 | console.error(e); 24 | return initialState; 25 | } 26 | }; 27 | 28 | export const rawTransactionsAtom = atom(initialState); 29 | 30 | export const transactionBaseAtom = atom( 31 | async (get) => { 32 | const hasTransactions = get(rawTransactionsAtom).transactions.length > 0; 33 | return hasTransactions ? get(rawTransactionsAtom) : await getTransactions({ ...initialState }); 34 | }, 35 | async (get, set, by: Partial | undefined) => { 36 | const currentSettings = get(rawTransactionsAtom); 37 | return set(rawTransactionsAtom, await getTransactions({ ...currentSettings, ...by })); 38 | }, 39 | ); 40 | 41 | export const transactionsAtom = atom(async (get) => { 42 | const transactions = get(transactionBaseAtom).transactions; 43 | return transactions; 44 | }); 45 | 46 | export const transactionsTotalAtom = atom((get) => get(transactionBaseAtom).total); 47 | export const transactionsLimitAtom = atom((get) => get(transactionBaseAtom).limit); 48 | export const transactionsOffsetAtom = atom((get) => get(transactionBaseAtom).offset); 49 | -------------------------------------------------------------------------------- /web/src/components/ui/Fields/TextField.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { InputBase, InputBaseProps, StandardTextFieldProps, Typography } from '@mui/material'; 3 | import React from 'react'; 4 | import theme from '../../../utils/theme'; 5 | import { Heading5 } from '../Typography/Headings'; 6 | 7 | const Container = styled.div` 8 | display: flex; 9 | padding: 0.75rem 1rem; 10 | border-radius: ${theme.spacing(1)}; 11 | background-color: ${theme.palette.background.dark12}; 12 | 13 | & > div { 14 | flex: 1; 15 | } 16 | 17 | input:-webkit-autofill, 18 | input:-webkit-autofill:hover, 19 | input:-webkit-autofill:focus, 20 | input:-webkit-autofill:active { 21 | color: white !important; 22 | -webkit-text-fill-color: white !important; 23 | box-shadow: 0 0 0 30px rgb(16 26 37) inset !important; 24 | -webkit-box-shadow: 0 0 0 30px rgb(16 26 37) inset !important; 25 | } 26 | `; 27 | 28 | const LabelWrapper = styled.div` 29 | display: flex; 30 | flex-direction: column; 31 | `; 32 | 33 | const Label = styled(Heading5)` 34 | margin-bottom: 0.5rem; 35 | `; 36 | 37 | const HelperText = styled(Typography)` 38 | margin-top: 0.5rem; 39 | `; 40 | 41 | interface Props extends InputBaseProps { 42 | label?: string; 43 | helperText?: string; 44 | InputProps?: StandardTextFieldProps['InputProps']; 45 | InputLabelProps?: StandardTextFieldProps['InputLabelProps']; 46 | } 47 | const TextField = ({ InputProps, InputLabelProps, helperText, ...props }: Props) => { 48 | return ( 49 | 50 | {props.label && } 51 | 52 | 53 | 54 | 55 | {helperText && ( 56 | 57 | {helperText} 58 | 59 | )} 60 | 61 | ); 62 | }; 63 | 64 | export default TextField; 65 | -------------------------------------------------------------------------------- /src/server/utils/frameworkIntegration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FrameworkIntegrationExports, 3 | FrameworkIntegrationFunction, 4 | } from '@server/../../typings/exports'; 5 | import { mainLogger } from '@server/sv_logger'; 6 | import { getExports } from './misc'; 7 | import { config } from './server-config'; 8 | 9 | const log = mainLogger.child({ module: 'frameworkIntegration' }); 10 | 11 | const frameworkIntegrationKeys: FrameworkIntegrationFunction[] = [ 12 | 'addCash', 13 | 'removeCash', 14 | 'getCash', 15 | 'getBank', 16 | ]; 17 | 18 | export const validateResourceExports = (resourceExports: FrameworkIntegrationExports): boolean => { 19 | let isValid = true; 20 | frameworkIntegrationKeys.forEach((key: FrameworkIntegrationFunction) => { 21 | if (typeof resourceExports[key] === 'undefined') { 22 | log.error(`Framework integration export ${key} is missing.`); 23 | isValid = false; 24 | return; 25 | } 26 | 27 | if (typeof resourceExports[key] !== 'function') { 28 | log.error(`Framework integration export ${key} is not a function.`); 29 | isValid = false; 30 | } 31 | }); 32 | 33 | return isValid; 34 | }; 35 | 36 | export const getFrameworkExports = (): FrameworkIntegrationExports => { 37 | const exps = getExports(); 38 | const resourceName = config?.frameworkIntegration?.resource; 39 | const resourceExports: FrameworkIntegrationExports = exps[resourceName ?? '']; 40 | 41 | log.debug(`Checking exports from resource: ${resourceName}`); 42 | 43 | if (!resourceName) { 44 | log.error(`Missing resourceName in the config for framework integration`); 45 | throw new Error('Framework integration failed'); 46 | } 47 | 48 | if (!resourceExports) { 49 | log.error( 50 | `No resource found with name: ${resourceName}. Make sure you have the correct resource name in the config.`, 51 | ); 52 | throw new Error('Framework integration failed'); 53 | } 54 | 55 | return resourceExports; 56 | }; 57 | -------------------------------------------------------------------------------- /src/server/services/account/account.model.ts: -------------------------------------------------------------------------------- 1 | import { DATABASE_PREFIX } from '@utils/constants'; 2 | import { DataTypes, Model, Optional } from 'sequelize'; 3 | import { config } from '@utils/server-config'; 4 | import { Account, AccountRole, AccountType } from '@typings/Account'; 5 | import { sequelize } from '@utils/pool'; 6 | import { generateAccountNumber } from '@utils/misc'; 7 | import { timestamps } from '../timestamps.model'; 8 | import { AccountEvents } from '@server/../../typings/Events'; 9 | 10 | export class AccountModel extends Model< 11 | Account, 12 | Optional 13 | > {} 14 | 15 | AccountModel.init( 16 | { 17 | id: { 18 | type: DataTypes.INTEGER, 19 | autoIncrement: true, 20 | primaryKey: true, 21 | }, 22 | number: { 23 | type: DataTypes.STRING, 24 | unique: true, 25 | defaultValue: generateAccountNumber, 26 | }, 27 | accountName: { 28 | type: DataTypes.STRING, 29 | validate: { 30 | max: 25, 31 | min: 1, 32 | }, 33 | }, 34 | isDefault: { 35 | type: DataTypes.BOOLEAN, 36 | defaultValue: false, 37 | }, 38 | ownerIdentifier: { 39 | type: DataTypes.STRING, 40 | }, 41 | role: { 42 | type: DataTypes.STRING, 43 | defaultValue: AccountRole.Owner, 44 | }, 45 | balance: { 46 | type: DataTypes.INTEGER, 47 | defaultValue: config?.accounts?.otherAccountStartBalance ?? 0, 48 | }, 49 | type: { 50 | type: DataTypes.STRING, 51 | defaultValue: AccountType.Personal, 52 | }, 53 | ...timestamps, 54 | }, 55 | { 56 | sequelize: sequelize, 57 | tableName: DATABASE_PREFIX + 'accounts', 58 | paranoid: true, 59 | hooks: { 60 | afterSave: (instance, options) => { 61 | if (options.fields?.includes('balance')) { 62 | emit(AccountEvents.NewBalance, instance.toJSON()); 63 | } 64 | }, 65 | }, 66 | }, 67 | ); 68 | -------------------------------------------------------------------------------- /web/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { CircularProgress } from '@mui/material'; 3 | import { Box } from '@mui/system'; 4 | import React from 'react'; 5 | import { useTranslation } from 'react-i18next'; 6 | import Sidebar from './Sidebar'; 7 | import { Heading2, Heading5 } from './ui/Typography/Headings'; 8 | import { AnimatePresence, motion } from 'framer-motion'; 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | position: relative; 13 | height: 100%; 14 | `; 15 | 16 | const Content = styled(motion.div)` 17 | padding: 2rem; 18 | flex: 1; 19 | height: 100%; 20 | overflow: hidden; 21 | `; 22 | 23 | const LoadingContainer = styled.div` 24 | display: flex; 25 | flex-direction: column; 26 | padding: 2rem; 27 | `; 28 | 29 | const pageVariants = { 30 | initial: { 31 | x: 0, 32 | y: 400, 33 | }, 34 | in: { 35 | x: 0, 36 | y: 0, 37 | }, 38 | out: { 39 | x: 100, 40 | y: -200, 41 | }, 42 | }; 43 | 44 | const Layout: React.FC<{ title?: string }> = ({ children, title }) => { 45 | const { t } = useTranslation(); 46 | return ( 47 | 48 | 49 | 50 | 57 | {title} 58 | 61 | {t('Loading {{name}} view ..', { name: title })} 62 | 63 | 64 | 65 | 66 | } 67 | > 68 | {children} 69 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default Layout; 77 | -------------------------------------------------------------------------------- /typings/exports/server.ts: -------------------------------------------------------------------------------- 1 | import { ServerPromiseResp } from '../http'; 2 | 3 | type ExportResponse = ServerPromiseResp; 4 | type ExportCallback = (result: ExportResponse) => void; 5 | 6 | export enum ServerExports { 7 | GetCash = 'getCash', 8 | AddCash = 'addCash', 9 | RemoveCash = 'removeCash', 10 | DepositCash = 'depositCash', 11 | WithdrawCash = 'withdrawCash', 12 | 13 | GetTotalBankBalance = 'getTotalBankBalance', 14 | GetTotalBankBalanceByIdentifier = 'getTotalBankBalanceByIdentifier', 15 | GetDefaultAccountBalance = 'getDefaultAccountBalance', 16 | GetBankBalanceByIdentifier = 'getBankBalanceByIdentifier', 17 | SetBankBalance = 'setBankBalance', 18 | SetBankBalanceByIdentifier = 'setBankBalanceByIdentifier', 19 | AddBankBalance = 'addBankBalance', 20 | AddBankBalanceByIdentifier = 'addBankBalanceByIdentifier', 21 | AddBankBalanceByNumber = 'addBankBalanceByNumber', 22 | RemoveBankBalance = 'removeBankBalance', 23 | RemoveBankBalanceByIdentifier = 'removeBankBalanceByIdentifier', 24 | RemoveBankBalanceByNumber = 'removeBankBalanceByNumber', 25 | 26 | PayInvoice = 'payInvoice', 27 | GetInvoices = 'getInvoices', 28 | CreateInvoice = 'createInvoice', 29 | GetUnpaidInvoices = 'getUnpaidInvoices', 30 | 31 | LoadPlayer = 'loadPlayer', 32 | UnloadPlayer = 'unloadPlayer', 33 | 34 | GetAccounts = 'getAccounts', 35 | GetAccountsByIdentifier = 'getAccountsByIdentifier', 36 | 37 | /* Can be utilised by jobs or similar */ 38 | CreateUniqueAccount = 'createUniqueAccount', 39 | GetUniqueAccount = 'getUniqueAccount', 40 | AddUserToUniqueAccount = 'addUserToUniqueAccount', 41 | RemoveUserFromUniqueAccount = 'removeUserFromUniqueAccount', 42 | } 43 | 44 | export type WithdrawMoneyExport = ( 45 | source: number, 46 | amount: number, 47 | callback: ExportCallback, 48 | ) => Promise; 49 | 50 | export type DepositMoneyExport = ( 51 | source: number, 52 | amount: number, 53 | callback: ExportCallback, 54 | ) => Promise; 55 | -------------------------------------------------------------------------------- /src/server/utils/__tests__/misc.test.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CLEARING_NUMBER } from '@utils/constants'; 2 | import { generateAccountNumber, getClearingNumber } from '@utils/misc'; 3 | import { createMockedConfig } from '@utils/test'; 4 | import { regexExternalNumber } from '@shared/utils/regexes'; 5 | 6 | const defaultValue = DEFAULT_CLEARING_NUMBER.toString(); 7 | const clearingNumberConfig = (input: any) => { 8 | return createMockedConfig({ accounts: { clearingNumber: input } }); 9 | }; 10 | 11 | describe('Helper: getClearingNumber', () => { 12 | test('should take clearing number from config', () => { 13 | const config = clearingNumberConfig('900'); 14 | expect(getClearingNumber(config)).toBe('900'); 15 | }); 16 | 17 | test('should handle number', () => { 18 | const config = clearingNumberConfig(900); 19 | expect(getClearingNumber(config)).toBe('900'); 20 | }); 21 | 22 | test('should default to 920', () => { 23 | expect(getClearingNumber()).toBe(defaultValue); 24 | }); 25 | 26 | describe('error handling:', () => { 27 | test('Too long', () => { 28 | const config = clearingNumberConfig(9000); 29 | expect(getClearingNumber(config)).toBe(defaultValue); 30 | }); 31 | 32 | test('Too short', () => { 33 | const config = clearingNumberConfig(90); 34 | expect(getClearingNumber(config)).toBe(defaultValue); 35 | }); 36 | 37 | test('object', () => { 38 | const config = clearingNumberConfig({}); 39 | expect(getClearingNumber(config)).toBe(defaultValue); 40 | }); 41 | 42 | test('array', () => { 43 | const config = clearingNumberConfig([]); 44 | expect(getClearingNumber(config)).toBe(defaultValue); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('Helper: generateAccountNumber', () => { 50 | test('should pass regex test', () => { 51 | for (let i = 0; i < 100; i++) { 52 | const accountNumber = generateAccountNumber(); 53 | expect(regexExternalNumber.test(accountNumber)).toBe(true); 54 | } 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /typings/config.ts: -------------------------------------------------------------------------------- 1 | export type DeepPartial = T extends object 2 | ? { 3 | [P in keyof T]?: DeepPartial; 4 | } 5 | : T; 6 | 7 | export type IdentifierType = 'license' | 'xbox' | 'discord' | 'steam'; 8 | 9 | export interface PolyZone { 10 | position: { 11 | x: number; 12 | y: number; 13 | z: number; 14 | }; 15 | length: number; 16 | width: number; 17 | heading: number; 18 | minZ: number; 19 | maxZ: number; 20 | } 21 | 22 | interface BlipCoords { 23 | x: number; 24 | y: number; 25 | z: number; 26 | } 27 | export interface ResourceConfig { 28 | general: { 29 | language: string; 30 | currency: string; 31 | identifierType: string; 32 | }; 33 | frameworkIntegration: { 34 | enabled: boolean; 35 | resource: string; 36 | syncInitialBankBalance: boolean; 37 | }; 38 | database: { 39 | profileQueries: boolean; 40 | shouldSync?: boolean; 41 | }; 42 | prices: { 43 | newAccount: number; 44 | }; 45 | accounts: { 46 | firstAccountStartBalance: number; 47 | otherAccountStartBalance: number; 48 | clearingNumber: string | number; 49 | maximumNumberOfAccounts: number; 50 | }; 51 | cash: { 52 | startAmount: number; 53 | }; 54 | atms: { 55 | distance: number; 56 | props: number[]; 57 | withdrawOptions: number[]; 58 | }; 59 | bankBlips: { 60 | enabled: boolean; 61 | name: string; 62 | colour: number; 63 | icon: number; 64 | scale: number; 65 | display: number; 66 | shortRange: boolean; 67 | coords: BlipCoords[]; 68 | }; 69 | atmBlips: { 70 | enabled: boolean; 71 | name: string; 72 | colour: number; 73 | icon: number; 74 | scale: number; 75 | display: number; 76 | shortRange: boolean; 77 | coords: BlipCoords[]; 78 | }; 79 | target: { 80 | enabled: boolean; 81 | bankZones: PolyZone[]; 82 | type: string; 83 | debug: boolean; 84 | }; 85 | debug: { 86 | level: string; 87 | mockLicenses: boolean; 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/server/services/transaction/transaction.controller.ts: -------------------------------------------------------------------------------- 1 | import { TransactionEvents } from '@typings/Events'; 2 | import { Request, Response } from '@typings/http'; 3 | import { 4 | GetTransactionHistoryResponse, 5 | GetTransactionsInput, 6 | GetTransactionsResponse, 7 | CreateTransferInput, 8 | } from '@typings/Transaction'; 9 | import { Controller } from '../../decorators/Controller'; 10 | import { NetPromise, PromiseEventListener } from '../../decorators/NetPromise'; 11 | import { TransactionService } from './transaction.service'; 12 | 13 | @Controller('Transaction') 14 | @PromiseEventListener() 15 | export class TransactionController { 16 | private readonly _transactionService: TransactionService; 17 | 18 | constructor(transactionService: TransactionService) { 19 | this._transactionService = transactionService; 20 | } 21 | 22 | @NetPromise(TransactionEvents.Get) 23 | async getTransactions( 24 | req: Request, 25 | res: Response, 26 | ) { 27 | try { 28 | const transactions = await this._transactionService.handleGetMyTransactions(req); 29 | res({ status: 'ok', data: transactions }); 30 | } catch (err) { 31 | res({ status: 'error', errorMsg: err.message }); 32 | } 33 | } 34 | 35 | @NetPromise(TransactionEvents.CreateTransfer) 36 | async createTransfer(req: Request, res: Response) { 37 | try { 38 | await this._transactionService.handleTransfer(req); 39 | res({ status: 'ok', data: {} }); 40 | } catch (err) { 41 | res({ status: 'error', errorMsg: err.message }); 42 | } 43 | } 44 | 45 | @NetPromise(TransactionEvents.GetHistory) 46 | async getHistory(req: Request, res: Response) { 47 | try { 48 | const history = await this._transactionService.handleGetHistory(req); 49 | res({ status: 'ok', data: history }); 50 | } catch (err) { 51 | res({ status: 'error', errorMsg: err.message }); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/src/icons/MasterCardIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const MasterCardIcon: React.FC = (props) => { 4 | return ( 5 | 13 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import topLevelAwait from 'vite-plugin-top-level-await'; 4 | import federation from '@originjs/vite-plugin-federation'; 5 | import path from 'path'; 6 | const packageJson = require('./package.json'); 7 | const { dependencies, name } = packageJson; 8 | 9 | delete dependencies['@emotion/styled']; 10 | delete dependencies['@mui/material']; 11 | delete dependencies['@mui/styles']; 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | plugins: [ 16 | react(), 17 | federation({ 18 | name, 19 | filename: 'remoteEntry.js', 20 | exposes: { 21 | './config': './npwd.config.ts', 22 | }, 23 | shared: ['react', 'react-dom', '@emotion/react', 'react-router-dom', 'jotai'], 24 | }), 25 | topLevelAwait({ 26 | // The export name of top-level await promise for each chunk module 27 | promiseExportName: '__tla', 28 | // The function to generate import names of top-level await promise in each chunk module 29 | promiseImportName: (i) => `__tla_${i}`, 30 | }), 31 | ], 32 | resolve: { 33 | alias: { 34 | '@hooks': path.resolve(__dirname, './src/hooks/'), 35 | '@components': path.resolve(__dirname, './src/components/'), 36 | '@ui': path.resolve(__dirname, './src/components/ui/'), 37 | '@utils': path.resolve(__dirname, './src/utils/'), 38 | '@typings': path.resolve(__dirname, '../typings/'), 39 | src: path.resolve(__dirname, './src/'), 40 | '@locales': path.resolve(__dirname, '../locales/'), 41 | '@data': path.resolve(__dirname, './src/data/'), 42 | '@shared': path.resolve(__dirname, '../shared'), 43 | 'npwd.config': path.resolve(__dirname, './npwd.config.ts'), 44 | }, 45 | }, 46 | base: './', 47 | define: { 48 | process: { 49 | env: { 50 | VITE_REACT_APP_IN_GAME: process.env.VITE_REACT_APP_IN_GAME, 51 | }, 52 | }, 53 | }, 54 | server: { 55 | port: 3002, 56 | }, 57 | build: { 58 | outDir: 'dist', 59 | emptyOutDir: true, 60 | modulePreload: false, 61 | assetsDir: '', 62 | }, 63 | }); 64 | -------------------------------------------------------------------------------- /src/server/services/invoice/invoice.model.ts: -------------------------------------------------------------------------------- 1 | import { DATABASE_PREFIX, MS_TWO_WEEKS } from '@utils/constants'; 2 | import { DataTypes, Model, Optional } from 'sequelize'; 3 | import { singleton } from 'tsyringe'; 4 | import { Invoice, InvoiceStatus } from '../../../../typings/Invoice'; 5 | import { sequelize } from '../../utils/pool'; 6 | import { timestamps } from '../timestamps.model'; 7 | 8 | @singleton() 9 | export class InvoiceModel extends Model< 10 | Invoice, 11 | Optional 12 | > {} 13 | 14 | InvoiceModel.init( 15 | { 16 | id: { 17 | type: DataTypes.INTEGER, 18 | autoIncrement: true, 19 | primaryKey: true, 20 | }, 21 | to: { 22 | defaultValue: 'unknown', 23 | type: DataTypes.STRING, 24 | validate: { 25 | max: 80, 26 | min: 1, 27 | }, 28 | }, 29 | from: { 30 | type: DataTypes.STRING, 31 | allowNull: false, 32 | validate: { 33 | max: 80, 34 | min: 1, 35 | }, 36 | }, 37 | message: { 38 | type: DataTypes.STRING, 39 | allowNull: false, 40 | validate: { 41 | max: 80, 42 | min: 1, 43 | }, 44 | }, 45 | fromIdentifier: { 46 | type: DataTypes.STRING, 47 | allowNull: false, 48 | }, 49 | toIdentifier: { 50 | type: DataTypes.STRING, 51 | allowNull: false, 52 | }, 53 | receiverAccountIdentifier: { 54 | type: DataTypes.STRING, 55 | allowNull: true, 56 | }, 57 | amount: { 58 | type: DataTypes.INTEGER, 59 | defaultValue: 0, 60 | validate: { 61 | min: 0, 62 | }, 63 | }, 64 | status: { 65 | type: DataTypes.STRING, 66 | defaultValue: InvoiceStatus.PENDING, 67 | }, 68 | expiresAt: { 69 | type: DataTypes.DATE, 70 | allowNull: false, 71 | get() { 72 | return new Date(this.getDataValue('expiresAt') ?? '').getTime(); 73 | }, 74 | defaultValue: () => new Date(Date.now() + MS_TWO_WEEKS).toString(), 75 | }, 76 | ...timestamps, 77 | }, 78 | { 79 | sequelize: sequelize, 80 | tableName: DATABASE_PREFIX + 'invoices', 81 | }, 82 | ); 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Tagged Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | create-tagged-release: 8 | name: 'Build & Create Release' 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout source code 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | ref: ${{ github.ref }} 17 | 18 | - name: Setup Node and Yarn Cache 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 18.x 22 | cache: 'yarn' 23 | 24 | - name: Get variables 25 | id: get_vars 26 | run: | 27 | echo "GITHUB_SHA_SHORT=$(echo $GITHUB_SHA | cut -c 1-7)" >> $GITHUB_ENV 28 | echo "::set-output name=SHORT_SHA::$(git rev-parse --short HEAD)" 29 | echo "::set-output name=BRANCH_NAME::$(echo ${GITHUB_REF#refs/heads/})" 30 | echo "::set-output name=VERSION_TAG::$(echo ${GITHUB_REF/refs\/tags\//})" 31 | 32 | - name: Install main deps 33 | run: yarn --frozen-lockfile --ignore-scripts 34 | 35 | - name: Translations 36 | env: 37 | LOCALAZY_READ_KEY: a8269809765126758267-f01743c76d6e9e434d7b4c6322938eefce8d82a559b57efc2acfcf4531d46089 38 | run: yarn translations:pull 39 | 40 | - name: Generate translations 41 | run: yarn translations:generate-index 42 | 43 | - name: Install src deps 44 | working-directory: src 45 | run: yarn --frozen-lockfile 46 | 47 | - name: Install web deps 48 | working-directory: web 49 | run: yarn --frozen-lockfile 50 | 51 | - name: Create release 52 | run: | 53 | chmod +x "./scripts/release.sh" 54 | yarn release 55 | 56 | - name: Create Release & Changelog 57 | uses: 'marvinpinto/action-automatic-releases@v1.2.1' 58 | id: auto_release 59 | with: 60 | repo_token: '${{ secrets.GITHUB_TOKEN }}' 61 | title: PEFCL Release | (${{ steps.get_vars.outputs.VERSION_TAG }}) 62 | prerelease: false 63 | files: ./temp/pefcl.zip 64 | 65 | env: 66 | CI: false 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | -------------------------------------------------------------------------------- /web/src/mobileDevelopmentContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import { HashRouter } from 'react-router-dom'; 5 | import styled from 'styled-components'; 6 | import image from './bg.png'; 7 | import { NuiProvider } from 'react-fivem-hooks'; 8 | import MobileApp from './views/Mobile/Mobile'; 9 | import i18n from '@utils/i18n'; 10 | import { IPhoneSettings } from '@project-error/npwd-types'; 11 | 12 | const Container = styled.div` 13 | position: relative; 14 | width: 500px; 15 | height: 1000px; 16 | `; 17 | const Background = styled.div<{ src: string }>` 18 | z-index: 10; 19 | background: url(${({ src }) => src}); 20 | position: absolute; 21 | width: 500px; 22 | height: 1000px; 23 | pointer-events: none; 24 | `; 25 | 26 | const AppContainer = styled.div` 27 | z-index: 2; 28 | position: absolute; 29 | bottom: 100px; 30 | left: 50px; 31 | right: 50px; 32 | top: 100px; 33 | display: flex; 34 | flex-direction: column; 35 | background-position: center; 36 | background-size: cover; 37 | background-repeat: no-repeat; 38 | border-radius: 20px; 39 | `; 40 | 41 | const mockedSetting = { 42 | label: 'idk', 43 | value: 'idk', 44 | }; 45 | const mockedSettings: IPhoneSettings = { 46 | language: mockedSetting, 47 | iconSet: mockedSetting, 48 | wallpaper: mockedSetting, 49 | frame: mockedSetting, 50 | theme: mockedSetting, 51 | zoom: mockedSetting, 52 | streamerMode: false, 53 | ringtone: mockedSetting, 54 | callVolume: 0, 55 | notiSound: mockedSetting, 56 | TWITTER_notiFilter: mockedSetting, 57 | TWITTER_notiSound: mockedSetting, 58 | TWITTER_notiSoundVol: 0, 59 | TWITTER_notifyNewFeedTweet: false, 60 | MARKETPLACE_notifyNewListing: false, 61 | }; 62 | 63 | const Root = () => ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | 78 | ReactDOM.render(, document.getElementById('mobile-app')); 79 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /.github/workflows/prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Prerelease Publisher 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | 9 | jobs: 10 | pre-release-build: 11 | name: 'Build & Create Pre-Release' 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout source code 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | ref: ${{ github.ref }} 20 | 21 | - name: Setup Node and Yarn Cache 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 18.x 25 | cache: 'yarn' 26 | 27 | - name: Get variables 28 | id: get_vars 29 | run: | 30 | echo "GITHUB_SHA_SHORT=$(echo $GITHUB_SHA | cut -c 1-7)" >> $GITHUB_ENV 31 | echo "::set-output name=SHORT_SHA::$(git rev-parse --short HEAD)" 32 | echo "::set-output name=BRANCH_NAME::$(echo ${GITHUB_REF#refs/heads/})" 33 | 34 | - name: Install main deps 35 | run: yarn --frozen-lockfile 36 | 37 | - name: Translations 38 | env: 39 | LOCALAZY_READ_KEY: ${{ secrets.LOCALAZY_READ_KEY }} 40 | LOCALAZY_WRITE_KEY: ${{ secrets.LOCALAZY_WRITE_KEY }} 41 | run: | 42 | yarn translations:pull 43 | yarn translations:generate-index 44 | 45 | - name: Install src deps 46 | working-directory: src 47 | run: yarn --frozen-lockfile 48 | 49 | - name: Install web deps 50 | working-directory: web 51 | run: yarn --frozen-lockfile 52 | 53 | - name: Create pre-release 54 | run: | 55 | chmod +x "./scripts/prerelease.sh" 56 | yarn pre-release 57 | 58 | - name: Create Release & Changelog 59 | uses: 'marvinpinto/action-automatic-releases@v1.2.1' 60 | with: 61 | repo_token: '${{ secrets.GITHUB_TOKEN }}' 62 | title: Pre-Release Build (${{ steps.get_vars.outputs.SHORT_SHA }}) 63 | prerelease: true 64 | automatic_release_tag: unstable-${{ steps.get_vars.outputs.BRANCH_NAME }} 65 | files: ./temp/pefcl-pre-${{ steps.get_vars.outputs.SHORT_SHA }}.zip 66 | 67 | env: 68 | CI: false 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | -------------------------------------------------------------------------------- /src/server/services/boot/boot.service.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from 'tsyringe'; 2 | import { sequelize } from '../../utils/pool'; 3 | import { mainLogger } from '../../sv_logger'; 4 | import { UserService } from '../user/user.service'; 5 | import { getFrameworkExports, validateResourceExports } from '@server/utils/frameworkIntegration'; 6 | import { config } from '@server/utils/server-config'; 7 | import { GeneralEvents } from '@server/../../typings/Events'; 8 | import { resourceName } from '@server/utils/constants'; 9 | 10 | const logger = mainLogger.child({ module: 'boot' }); 11 | 12 | @singleton() 13 | export class BootService { 14 | _userService: UserService; 15 | isReady = false; 16 | onReady: Promise; 17 | 18 | constructor(userService: UserService) { 19 | this._userService = userService; 20 | } 21 | 22 | private checkExports() { 23 | const resourceExports = getFrameworkExports(); 24 | 25 | if (!validateResourceExports(resourceExports)) { 26 | throw new Error('Framework integration failed'); 27 | } 28 | } 29 | 30 | async handleResourceStart() { 31 | logger.debug('Checking database connection ..'); 32 | await sequelize.authenticate(); 33 | logger.debug('Connected to database.'); 34 | 35 | if (config?.frameworkIntegration?.enabled) { 36 | logger.info('Framework integration is enabled.'); 37 | 38 | try { 39 | logger.debug('Verifying exports ..'); 40 | this.checkExports(); 41 | } catch (error: unknown | Error) { 42 | logger.error('Stopping resource due to framework integration error. Reason:'); 43 | logger.error(error); 44 | 45 | if (error instanceof Error && error.message.includes('No such export')) { 46 | logger.error( 47 | 'Check your starting order. The framework integration library needs to be started before PEFCL!', 48 | ); 49 | } 50 | 51 | this.handleResourceStop(); 52 | return; 53 | } 54 | } 55 | 56 | logger.info(`Starting ${resourceName}.`); 57 | emit(GeneralEvents.ResourceStarted); 58 | } 59 | 60 | async handleResourceStop() { 61 | logger.info(`Stopping ${resourceName}.`); 62 | emit(GeneralEvents.ResourceStopped); 63 | StopResource(resourceName); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/client/cl_blips.ts: -------------------------------------------------------------------------------- 1 | import cl_config from 'cl_config'; 2 | 3 | const isBankBlipsEnabled = cl_config.bankBlips?.enabled ?? false; 4 | const isAtmBlipsEnabled = cl_config.atmBlips?.enabled ?? false; 5 | 6 | if (isBankBlipsEnabled) { 7 | const blipName = cl_config.bankBlips?.name ?? 'Bank'; 8 | const blipColor = cl_config.bankBlips?.colour ?? 3; 9 | const blipIcon = cl_config.bankBlips?.icon ?? 4; 10 | const blipScale = cl_config.bankBlips?.scale ?? 1; 11 | const blipCoords = cl_config.bankBlips?.coords ?? []; 12 | const blipDisplayType = cl_config.bankBlips?.display ?? 4; 13 | const blipShortRange = cl_config.bankBlips?.shortRange ?? true; 14 | 15 | blipCoords.forEach((coord) => { 16 | if (!coord || !coord.x || !coord.y || !coord.z) { 17 | throw new Error('Missing blip coords. Check your "blips.coords" config.'); 18 | } 19 | 20 | const blip = AddBlipForCoord(coord.x, coord.y, coord.z); 21 | SetBlipDisplay(blip, blipDisplayType); 22 | SetBlipSprite(blip, blipIcon); 23 | SetBlipScale(blip, blipScale); 24 | SetBlipColour(blip, blipColor); 25 | SetBlipAsShortRange(blip, blipShortRange); 26 | BeginTextCommandSetBlipName('STRING'); 27 | AddTextComponentString(blipName); 28 | EndTextCommandSetBlipName(blip); 29 | }); 30 | } 31 | 32 | if (isAtmBlipsEnabled) { 33 | const blipName = cl_config.atmBlips?.name ?? 'ATM'; 34 | const blipColor = cl_config.atmBlips?.colour ?? 3; 35 | const blipIcon = cl_config.atmBlips?.icon ?? 4; 36 | const blipScale = cl_config.atmBlips?.scale ?? 1; 37 | const blipCoords = cl_config.atmBlips?.coords ?? []; 38 | const blipDisplayType = cl_config.atmBlips?.display ?? 4; 39 | const blipShortRange = cl_config.atmBlips?.shortRange ?? true; 40 | 41 | blipCoords.forEach((coord) => { 42 | if (!coord || !coord.x || !coord.y || !coord.z) { 43 | throw new Error('Missing blip coords. Check your "blips.coords" config.'); 44 | } 45 | 46 | const blip = AddBlipForCoord(coord.x, coord.y, coord.z); 47 | SetBlipDisplay(blip, blipDisplayType); 48 | SetBlipSprite(blip, blipIcon); 49 | SetBlipScale(blip, blipScale); 50 | SetBlipColour(blip, blipColor); 51 | SetBlipAsShortRange(blip, blipShortRange); 52 | BeginTextCommandSetBlipName('STRING'); 53 | AddTextComponentString(blipName); 54 | EndTextCommandSetBlipName(blip); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /web/src/icons/svgProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const AccountIcons = { 4 | searchIcon: ( 5 | 13 | 19 | 20 | ), 21 | masterCardIcon: ( 22 | 23 | 27 | 28 | ), 29 | }; 30 | -------------------------------------------------------------------------------- /src/server/services/accountShared/sharedAccount.db.ts: -------------------------------------------------------------------------------- 1 | import { AccountRole, SharedAccountInput } from '@typings/Account'; 2 | import { AuthorizationErrors } from '@typings/Errors'; 3 | import { ServerError } from '@utils/errors'; 4 | import { AccountModel } from '@services/account/account.model'; 5 | import { singleton } from 'tsyringe'; 6 | import { SharedAccountModel } from './sharedAccount.model'; 7 | import { Transaction } from 'sequelize/types'; 8 | 9 | const include = [{ model: AccountModel, as: 'account' }]; 10 | 11 | @singleton() 12 | export class SharedAccountDB { 13 | async getSharedAccountsById(id: number): Promise { 14 | return await SharedAccountModel.findAll({ 15 | where: { accountId: id }, 16 | include, 17 | }); 18 | } 19 | 20 | async getSharedAccountsByIdentifier(identifier: string): Promise { 21 | return await SharedAccountModel.findAll({ 22 | where: { userIdentifier: identifier }, 23 | include, 24 | }); 25 | } 26 | 27 | async getAuthorizedSharedAccountById( 28 | id: number, 29 | identifier: string, 30 | roles: AccountRole[], 31 | transaction?: Transaction, 32 | ): Promise { 33 | const sharedAccount = await SharedAccountModel.findOne({ 34 | where: { accountId: id, userIdentifier: identifier }, 35 | include, 36 | transaction, 37 | lock: Boolean(transaction), 38 | }); 39 | 40 | const role = sharedAccount?.getDataValue('role'); 41 | if (role && !roles.includes(role) && role !== 'owner') { 42 | throw new ServerError(AuthorizationErrors.Forbidden); 43 | } 44 | 45 | return sharedAccount?.getDataValue('account') as unknown as AccountModel; 46 | } 47 | 48 | async createSharedAccount( 49 | input: SharedAccountInput, 50 | transaction: Transaction, 51 | ): Promise { 52 | const account = await SharedAccountModel.create(input, { transaction }); 53 | await account.setAccount(input.accountId); 54 | return account; 55 | } 56 | 57 | async deleteSharedAccount(id: number) { 58 | return await SharedAccountModel.findOne({ where: { id } }); 59 | } 60 | 61 | async getSharedAccountByIds(userIdentifier: string, accountId: number) { 62 | return await SharedAccountModel.findOne({ where: { userIdentifier, accountId } }); 63 | } 64 | 65 | async deleteSharedAccountsByAccountId(accountId: number) { 66 | return await SharedAccountModel.destroy({ where: { accountId } }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /web/src/components/DebugBar.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Settings } from '@mui/icons-material'; 3 | import { Fab, Stack, Typography } from '@mui/material'; 4 | import theme from '@utils/theme'; 5 | import { AnimatePresence, motion } from 'framer-motion'; 6 | import React, { useEffect, useState } from 'react'; 7 | import Button from './ui/Button'; 8 | 9 | const Container = styled(motion.div)` 10 | width: 100%; 11 | padding: 1rem; 12 | position: absolute; 13 | background: ${theme.palette.background.default}; 14 | color: #fefefe; 15 | `; 16 | 17 | const FabContainer = styled.div` 18 | position: absolute; 19 | bottom: 1rem; 20 | right: 1rem; 21 | `; 22 | 23 | const Devbar = () => { 24 | const [isOpen, setIsOpen] = useState(false); 25 | 26 | const [isBankOpen, setIsBankOpen] = useState(false); 27 | const [isAtmOpen, setIsAtmOpen] = useState(false); 28 | 29 | useEffect(() => { 30 | if (isBankOpen) { 31 | window.postMessage({ type: 'setVisible', payload: true }); 32 | window.postMessage({ type: 'setVisibleATM', payload: false }); 33 | } else { 34 | window.postMessage({ type: 'setVisible', payload: false }); 35 | } 36 | }, [isBankOpen]); 37 | 38 | useEffect(() => { 39 | if (isAtmOpen) { 40 | window.postMessage({ type: 'setVisible', payload: false }); 41 | window.postMessage({ type: 'setVisibleATM', payload: true }); 42 | } else { 43 | window.postMessage({ type: 'setVisibleATM', payload: false }); 44 | } 45 | }, [isAtmOpen]); 46 | 47 | return ( 48 | <> 49 | 50 | {isOpen && ( 51 | 52 | 53 | Devbar 54 | 55 | 58 | 61 | 62 | 63 | 64 | )} 65 | 66 | 67 | 68 | setIsOpen((prev) => !prev)}> 69 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default Devbar; 77 | -------------------------------------------------------------------------------- /src/server/services/cash/cash.controller.ts: -------------------------------------------------------------------------------- 1 | import { Export, ExportListener } from '@decorators/Export'; 2 | import { NetPromise, PromiseEventListener } from '@decorators/NetPromise'; 3 | import { OnlineUser } from '@server/../../typings/user'; 4 | import { config } from '@server/utils/server-config'; 5 | import { ChangeCashInput } from '@typings/Cash'; 6 | import { CashEvents, UserEvents } from '@typings/Events'; 7 | import { ServerExports } from '@typings/exports/server'; 8 | import { Request, Response } from '@typings/http'; 9 | import { Controller } from '../../decorators/Controller'; 10 | import { Event, EventListener } from '../../decorators/Event'; 11 | import { CashService } from './cash.service'; 12 | 13 | @Controller('Cash') 14 | @EventListener() 15 | @ExportListener() 16 | @PromiseEventListener() 17 | export class CashController { 18 | _cashService: CashService; 19 | constructor(cashService: CashService) { 20 | this._cashService = cashService; 21 | } 22 | 23 | @NetPromise(CashEvents.Give) 24 | async giveCash(req: Request, res: Response) { 25 | try { 26 | const result = await this._cashService.giveCash(req); 27 | res({ status: 'ok', data: result }); 28 | } catch (error) { 29 | res({ status: 'error', errorMsg: error.message }); 30 | } 31 | } 32 | 33 | @Export(ServerExports.AddCash) 34 | async addCash(req: Request, res: Response) { 35 | try { 36 | await this._cashService.handleAddCash(req.source, req.data); 37 | res({ status: 'ok', data: true }); 38 | } catch (error) { 39 | res({ status: 'error', errorMsg: error.message }); 40 | } 41 | } 42 | 43 | @Export(ServerExports.RemoveCash) 44 | async removeCash(req: Request, res: Response) { 45 | try { 46 | await this._cashService.handleRemoveCash(req.source, req.data); 47 | res({ status: 'ok', data: true }); 48 | } catch (error) { 49 | res({ status: 'error', errorMsg: error.message }); 50 | } 51 | } 52 | 53 | @Export(ServerExports.GetCash) 54 | @NetPromise(CashEvents.GetMyCash) 55 | async getMyCash(req: Request, res: Response) { 56 | const result = await this._cashService.getMyCash(req.source); 57 | res({ status: 'ok', data: result }); 58 | return; 59 | } 60 | 61 | /* When starting the resource / new player joining. We should handle the default account. */ 62 | @Event(UserEvents.Loaded) 63 | async onUserLoaded(user: OnlineUser) { 64 | if (config.frameworkIntegration?.enabled) return; 65 | this._cashService.createInitialCash(user.source); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-and-client", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "setup": "yarn install", 8 | "dev": "yarn concurrently \"yarn nodemon\" \"node ./scripts/watch_server.js\"", 9 | "dev:ingame": "yarn build:ingame && yarn concurrently \"node ./scripts/watch_client.js\" \"node ./scripts/watch_server.js --mode=ingame\"", 10 | "dev:mobile": "yarn dev", 11 | "build": "node ./scripts/build_server.js && node ./scripts/build_client.js", 12 | "build:ingame": "node ./scripts/build_server.js && node ./scripts/build_client.js", 13 | "lint": "eslint server && eslint client", 14 | "watch": "concurrently \"yarn watch:server\" \"yarn watch:client\"", 15 | "watch:client": "esbuild client/client.ts --bundle --target=es6 --watch --outfile=dist/client.js", 16 | "watch:server": "node scripts/watch_server.js", 17 | "test": "NODE_ENV=test yarn jest --silent", 18 | "test:watch": "NODE_ENV=test yarn jest --watch", 19 | "tsc": "yarn tsc:client && yarn tsc:server", 20 | "tsc:client": "tsc --project client/tsconfig.json", 21 | "tsc:server": "tsc --project server/tsconfig.json" 22 | }, 23 | "devDependencies": { 24 | "@anatine/esbuild-decorators": "^0.2.18", 25 | "@citizenfx/client": "^2.0.5208-1", 26 | "@citizenfx/server": "^2.0.5208-1", 27 | "@types/cors": "^2.8.12", 28 | "@types/express": "^4.17.13", 29 | "@types/jest": "^27.4.1", 30 | "@types/node": "^17.0.8", 31 | "@types/reflect-metadata": "^0.1.0", 32 | "@typescript-eslint/eslint-plugin": "^5.10.0", 33 | "@typescript-eslint/parser": "^5.10.0", 34 | "concurrently": "^7.0.0", 35 | "cors": "^2.8.5", 36 | "esbuild-node-tsc": "^1.8.3", 37 | "express": "^4.17.3", 38 | "jest": "^27.5.1", 39 | "jest-watch-typeahead": "^1.0.0", 40 | "nodemon": "^2.0.15", 41 | "remove-files-webpack-plugin": "^1.5.0", 42 | "ts-jest": "^27.1.3", 43 | "ts-loader": "^9.2.6", 44 | "tsconfig-paths-jest": "^0.0.1", 45 | "typescript": "^4.5.4" 46 | }, 47 | "dependencies": { 48 | "@project-error/npwd-types": "^1.3.4", 49 | "@project-error/pe-utils": "^0.2.7", 50 | "body-parser": "^1.19.2", 51 | "dayjs": "^1.10.8", 52 | "esbuild": "^0.14.25", 53 | "eslint": "^8.11.0", 54 | "i18next": "^21.9.1", 55 | "logform": "^2.3.2", 56 | "mysql2": "^2.3.3", 57 | "pg": "^8.7.3", 58 | "pg-hstore": "^2.3.4", 59 | "prettier": "^2.5.1", 60 | "reflect-metadata": "^0.1.13", 61 | "sequelize": "^6.17.0", 62 | "tsyringe": "^4.6.0", 63 | "winston": "^3.4.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /web/src/components/Modals/RemoveUser.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@components/ui/Button'; 2 | import { Heading6 } from '@components/ui/Typography/Headings'; 3 | import UserSelect from '@components/UserSelect'; 4 | import { Dialog, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'; 5 | import { SharedAccountEvents } from '@typings/Events'; 6 | import { AccountRole, SharedAccountUser } from '@typings/Account'; 7 | import { OnlineUser } from '@typings/user'; 8 | import { fetchNui } from '@utils/fetchNui'; 9 | import React, { useEffect, useState } from 'react'; 10 | import { useTranslation } from 'react-i18next'; 11 | 12 | interface SelectUserModalProps { 13 | isOpen: boolean; 14 | onClose(): void; 15 | accountId: number; 16 | onSelect(identifier: string): void; 17 | } 18 | const RemoveUserModal = ({ isOpen, onSelect, onClose, accountId }: SelectUserModalProps) => { 19 | const { t } = useTranslation(); 20 | const [selectedUserIdentifier, setSelectedUserIdentifier] = useState(''); 21 | const [users, setUsers] = useState([]); 22 | const handleUserSelect = (user: OnlineUser) => { 23 | setSelectedUserIdentifier(user.identifier); 24 | }; 25 | 26 | const handleSubmit = () => { 27 | onSelect(selectedUserIdentifier); 28 | }; 29 | 30 | useEffect(() => { 31 | if (isOpen) { 32 | fetchNui(SharedAccountEvents.GetUsers, { accountId }).then((users) => 33 | setUsers(users ?? []), 34 | ); 35 | } 36 | }, [accountId, isOpen]); 37 | 38 | const filteredUsers = users 39 | .map((user) => ({ 40 | name: user.name ?? '', 41 | identifier: user.userIdentifier, 42 | isDisabled: [AccountRole.Owner].includes(user.role), 43 | })) 44 | .filter((user) => !user.isDisabled); 45 | 46 | return ( 47 | 48 | 49 | {t('Remove user from a shared account')} 50 | 51 | 52 | 53 | 54 | {t('Select a user')} 55 | 56 | 57 | 58 | 59 | 60 | 63 | 66 | 67 | 68 | ); 69 | }; 70 | 71 | export default RemoveUserModal; 72 | -------------------------------------------------------------------------------- /web/src/data/accounts.ts: -------------------------------------------------------------------------------- 1 | import { Account } from '@typings/Account'; 2 | import { AccountEvents } from '@typings/Events'; 3 | import { atom } from 'jotai'; 4 | import { mockedAccounts } from '../utils/constants'; 5 | import { fetchNui } from '../utils/fetchNui'; 6 | import { isEnvBrowser } from '../utils/misc'; 7 | 8 | const getAccounts = async (): Promise => { 9 | try { 10 | const res = await fetchNui(AccountEvents.GetAccounts); 11 | return res ?? []; 12 | } catch (e) { 13 | if (isEnvBrowser()) { 14 | return mockedAccounts; 15 | } 16 | console.error(e); 17 | return []; 18 | } 19 | }; 20 | 21 | export const rawAccountAtom = atom([]); 22 | export const accountsAtom = atom, Account[] | undefined, Promise>( 23 | async (get) => { 24 | const accounts = get(rawAccountAtom).length === 0 ? await getAccounts() : get(rawAccountAtom); 25 | return accounts; 26 | }, 27 | async (_get, set, by) => { 28 | return set(rawAccountAtom, by ?? (await getAccounts())); 29 | }, 30 | ); 31 | 32 | export const totalBalanceAtom = atom((get) => 33 | get(accountsAtom).reduce((prev, curr) => prev + curr.balance, 0), 34 | ); 35 | 36 | export const activeAccountAtomId = atom(0); 37 | export const activeAccountAtom = atom( 38 | (get) => get(accountsAtom).find((account) => account.id === get(activeAccountAtomId)), 39 | (_get, set, str: number) => set(activeAccountAtomId, str), 40 | ); 41 | 42 | export const defaultAccountAtom = atom((get) => 43 | get(accountsAtom).find((account) => account.isDefault), 44 | ); 45 | 46 | export const defaultAccountBalance = atom((get) => get(defaultAccountAtom)?.balance); 47 | 48 | /* Saved order for cards */ 49 | type OrderedAccounts = Record; 50 | const accountOrderAtom = atom(localStorage.getItem('order') ?? ''); 51 | 52 | export const orderedAccountsAtom = atom( 53 | (get) => { 54 | const accounts = get(accountsAtom); 55 | const storageOrder = get(accountOrderAtom); 56 | 57 | try { 58 | JSON.parse(storageOrder); 59 | } catch { 60 | return accounts; 61 | } 62 | 63 | const order = JSON.parse(storageOrder); 64 | 65 | const sorted = accounts.sort((a, b) => { 66 | const aIndex = order?.[a.id] ?? 0; 67 | const bIndex = order?.[b.id] ?? 0; 68 | 69 | return aIndex > bIndex ? 1 : -1; 70 | }); 71 | 72 | return sorted; 73 | }, 74 | (_get, set, by: OrderedAccounts) => { 75 | set(accountOrderAtom, JSON.stringify(by)); 76 | localStorage.setItem('order', JSON.stringify(by)); 77 | }, 78 | ); 79 | -------------------------------------------------------------------------------- /src/server/services/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@decorators/Controller'; 2 | import { EventListener, Event } from '@decorators/Event'; 3 | import { NetPromise, PromiseEventListener } from '@decorators/NetPromise'; 4 | import { ServerExports } from '@server/../../typings/exports/server'; 5 | import { Export, ExportListener } from '@server/decorators/Export'; 6 | import { config } from '@server/utils/server-config'; 7 | import { GeneralEvents, UserEvents } from '@typings/Events'; 8 | import { Request, Response } from '@typings/http'; 9 | import { OnlineUser } from '@typings/user'; 10 | import { UserService } from './user.service'; 11 | 12 | @Controller('User') 13 | @ExportListener() 14 | @PromiseEventListener() 15 | @EventListener() 16 | export class UserController { 17 | private readonly _userService: UserService; 18 | 19 | constructor(userService: UserService) { 20 | this._userService = userService; 21 | } 22 | 23 | @Export(ServerExports.LoadPlayer) 24 | async loadPlayer(req: Request, res: Response) { 25 | this._userService.loadPlayer(req.data); 26 | res({ status: 'ok' }); 27 | } 28 | 29 | @Export(ServerExports.UnloadPlayer) 30 | async unloadPlayer(req: Request, res: Response) { 31 | this._userService.unloadPlayer(req.source); 32 | res({ status: 'ok' }); 33 | } 34 | 35 | @NetPromise(UserEvents.GetUsers) 36 | async getUsers(_req: Request, res: Response) { 37 | await new Promise((resolve) => { 38 | setImmediate(resolve); 39 | }); 40 | 41 | const users = this._userService.getAllUsers(); 42 | const list: OnlineUser[] = Array.from(users.values()).map((user) => ({ 43 | name: user.name, 44 | source: user.getSource(), 45 | identifier: user.getIdentifier(), 46 | })); 47 | 48 | res({ status: 'ok', data: list }); 49 | } 50 | 51 | @Event('playerJoining') 52 | playerJoining() { 53 | if (config.frameworkIntegration?.enabled) return; 54 | 55 | const _source = global.source; 56 | this._userService.loadStandalonePlayer({ source: _source }); 57 | } 58 | 59 | @Event('playerDropped') 60 | playerDropped() { 61 | if (config.frameworkIntegration?.enabled) return; 62 | const _source = global.source; 63 | this._userService.deletePlayer(_source); 64 | } 65 | 66 | @Event(GeneralEvents.ResourceStarted) 67 | async onServerResourceStart() { 68 | if (config.frameworkIntegration?.enabled) return; 69 | 70 | const players = getPlayers(); 71 | players.forEach((player) => { 72 | this._userService.loadStandalonePlayer({ source: parseInt(player, 10) }); 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /typings/Events.ts: -------------------------------------------------------------------------------- 1 | export enum GeneralEvents { 2 | CloseUI = 'pefcl:closeNui', 3 | ResourceStarted = 'pefcl:resourceStarted', 4 | ResourceStopped = 'pefcl:resourceStopped', 5 | } 6 | 7 | export enum UserEvents { 8 | GetUsers = 'pefcl:userEventsGetUsers', 9 | Loaded = 'pefcl:userLoaded', 10 | Unloaded = 'pefcl:userUnloaded', 11 | } 12 | 13 | export enum NUIEvents { 14 | Loaded = 'pefcl:nuiHasLoaded', 15 | Unloaded = 'pefcl:nuiHasUnloaded', 16 | } 17 | 18 | export enum AccountEvents { 19 | GetAccounts = 'pefcl:getAccounts', 20 | CreateAccount = 'pefcl:createAccount', 21 | RenameAccount = 'pefcl:renameAccount', 22 | CreateAccountResponse = 'pefcl:createAccountResponse', 23 | SetDefaultAccount = 'pefcl:setDefaultAccount', 24 | ChangedDefaultAccount = 'pefcl:changedDefaultAccount', 25 | DeleteAccount = 'pefcl:deleteAccount', 26 | DepositMoney = 'pefcl:depositMoney', 27 | WithdrawMoney = 'pefcl:withdrawMoney', 28 | NewBalance = 'pefcl:newAccountBalance', 29 | NewAccountCreated = 'pefcl:newAccountCreated', 30 | AccountDeleted = 'pefcl:accountDeleted', 31 | } 32 | 33 | export enum SharedAccountEvents { 34 | GetUsers = 'pefcl:sharedAccountsGetUsers', 35 | AddUser = 'pefcl:sharedAccountsAddUser', 36 | RemoveUser = 'pefcl:sharedAccountsRemoveUser', 37 | } 38 | 39 | export enum ExternalAccountEvents { 40 | Add = 'pefcl:addExternalAccount', 41 | Get = 'pefcl:getExternalAccount', 42 | } 43 | 44 | export enum Broadcasts { 45 | UpdatedAccount = 'pefcl:updatedAccountBroadcast', 46 | NewTransaction = 'pefcl:newTransactionBroadcast', 47 | NewInvoice = 'pefcl:newInvoiceBroadcast', 48 | NewSharedUser = 'pefcl:newSharedUser', 49 | RemovedSharedUser = 'pefcl:removedSharedUser', 50 | NewAccount = 'pefcl:newAccountBroadcast', 51 | NewAccountBalance = 'pefcl:newAccountBalanceBroadcast', 52 | NewDefaultAccountBalance = 'pefcl:newDefaultAccountBalance', 53 | NewCashAmount = 'pefcl:newCashAmount', 54 | } 55 | 56 | export enum TransactionEvents { 57 | Get = 'pefcl:getTransactions', 58 | GetHistory = 'pefcl:getTransactionsHistory', 59 | CreateTransfer = 'pefcl:createTransfer', 60 | NewTransaction = 'pefcl:newTransaction', 61 | } 62 | 63 | export enum InvoiceEvents { 64 | Get = 'pefcl:getInvoices', 65 | CountUnpaid = 'pefcl:countUnpaid', 66 | CreateInvoice = 'pefcl:createInvoice', 67 | CreateOnlineInvoice = 'pefcl:createOnlineInvoice', 68 | PayInvoice = 'pefcl:payInvoice', 69 | } 70 | 71 | export enum CashEvents { 72 | GetMyCash = 'pefcl:getMyCash', 73 | Give = 'pefcl:giveCash', 74 | NewCash = 'pefcl:newCash', 75 | } 76 | 77 | export enum BalanceEvents { 78 | UpdateCashBalance = 'pefcl:updateCashBalance', 79 | } 80 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const { ModuleFederationPlugin } = webpack.container; 4 | const deps = require('./package.json').dependencies; 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | // HMR 8 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 9 | const ReactRefreshTypeScript = require('react-refresh-typescript'); 10 | const isDevelopment = process.env.NODE_ENV === 'development'; 11 | 12 | module.exports = { 13 | entry: './src/bootstrap.ts', 14 | mode: isDevelopment ? 'development' : 'production', 15 | devtool: 'inline-source-map', 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.[jt]sx?$/, 20 | exclude: /node_modules/, 21 | use: [ 22 | { 23 | loader: require.resolve('ts-loader'), 24 | options: { 25 | getCustomTransformers: () => ({ 26 | before: [isDevelopment && ReactRefreshTypeScript()].filter(Boolean), 27 | }), 28 | transpileOnly: isDevelopment, 29 | }, 30 | }, 31 | ], 32 | }, 33 | { 34 | test: /\.(png|jpe?g|gif)$/i, 35 | exclude: /node_modules/, 36 | use: [ 37 | { 38 | loader: 'file-loader', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | resolve: { 45 | extensions: ['.tsx', '.ts', '.js'], 46 | }, 47 | output: { 48 | path: path.resolve(__dirname, 'release'), 49 | publicPath: 'auto', 50 | clean: true, 51 | }, 52 | plugins: [ 53 | new ModuleFederationPlugin({ 54 | name: 'pefcl', 55 | filename: 'remoteEntry.js', 56 | remotes: { 57 | npwd: 'layout@https://cfx-nui-npwd/phone/dist/remoteEntry.js', 58 | }, 59 | exposes: { 60 | './config': './npwd.config', 61 | }, 62 | shared: { 63 | ...deps, 64 | react: { 65 | singleton: true, 66 | requiredVersion: deps.react, 67 | }, 68 | 'react-dom': { 69 | singleton: true, 70 | requiredVersion: deps['react-dom'], 71 | }, 72 | }, 73 | }), 74 | new HtmlWebpackPlugin({ 75 | cache: false, 76 | template: './src/index.html', 77 | }), 78 | isDevelopment && new ReactRefreshWebpackPlugin(), 79 | ].filter(Boolean), 80 | 81 | devServer: { 82 | port: 3007, 83 | headers: { 84 | 'Access-Control-Allow-Origin': '*', 85 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 86 | 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', 87 | }, 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx: -------------------------------------------------------------------------------- 1 | import { AccountCard } from '@components/Card'; 2 | import InvoiceItem from '@components/InvoiceItem'; 3 | import TotalBalance from '@components/TotalBalance'; 4 | import TransactionItem from '@components/TransactionItem'; 5 | import { Heading4, Heading5 } from '@components/ui/Typography/Headings'; 6 | import { unpaidInvoicesAtom } from '@data/invoices'; 7 | import { useFetchNui } from '@hooks/useFetchNui'; 8 | import { Stack } from '@mui/material'; 9 | import { Box } from '@mui/system'; 10 | import { Account } from '@typings/Account'; 11 | import { AccountEvents, TransactionEvents } from '@typings/Events'; 12 | import { Transaction } from '@typings/Transaction'; 13 | import { fetchNui } from '@utils/fetchNui'; 14 | import { useAtom } from 'jotai'; 15 | import React, { useEffect, useState } from 'react'; 16 | import { useTranslation } from 'react-i18next'; 17 | 18 | const MobileDashboardView = () => { 19 | const { t } = useTranslation(); 20 | const [defaultAccount, setDefaultAccount] = useState(); 21 | 22 | const [invoices] = useAtom(unpaidInvoicesAtom); 23 | 24 | useEffect(() => { 25 | fetchNui(AccountEvents.GetAccounts).then((accounts) => { 26 | const defaultAccount = accounts?.find((account) => account.isDefault); 27 | setDefaultAccount(defaultAccount); 28 | }); 29 | }, []); 30 | 31 | const options = { 32 | offset: 0, 33 | limit: 5, 34 | }; 35 | const { data } = useFetchNui<{ total: number; transactions: Transaction[] }>( 36 | TransactionEvents.Get, 37 | options, 38 | ); 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | {t('Default account')} 47 | {defaultAccount && } 48 | 49 | 50 | {t('Latest transactions')} 51 | 52 | {data?.transactions?.map((transaction) => ( 53 | 54 | ))} 55 | 56 | 57 | {t('Unpaid invoices')} 58 | 59 | {invoices.map((invoice) => ( 60 | 61 | ))} 62 | 63 | {invoices.length <= 0 && ( 64 | {t('There are currently no unpaid invoices!')} 65 | )} 66 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default MobileDashboardView; 73 | -------------------------------------------------------------------------------- /web/src/views/dashboard/components/Summary.tsx: -------------------------------------------------------------------------------- 1 | import { Heading5, Heading6 } from '@components/ui/Typography/Headings'; 2 | import WeekGraph from '@components/WeekGraph'; 3 | import styled from '@emotion/styled'; 4 | import { useConfig } from '@hooks/useConfig'; 5 | import { Divider, Stack } from '@mui/material'; 6 | import { red } from '@mui/material/colors'; 7 | import { Box } from '@mui/system'; 8 | import { TransactionEvents } from '@typings/Events'; 9 | import { GetTransactionHistoryResponse } from '@typings/Transaction'; 10 | import { formatMoney } from '@utils/currency'; 11 | import { fetchNui } from '@utils/fetchNui'; 12 | import theme from '@utils/theme'; 13 | import React, { useEffect, useState } from 'react'; 14 | import { useTranslation } from 'react-i18next'; 15 | 16 | const Title = styled(Heading5)` 17 | color: ${theme.palette.primary.dark}; 18 | `; 19 | 20 | const Container = styled.div` 21 | padding: ${theme.spacing(3)}; 22 | border-radius: ${theme.spacing(2)}; 23 | background-color: ${theme.palette.background.paper}; 24 | `; 25 | 26 | const ExpensesIncomeContainer = styled(Box)` 27 | margin-top: 1rem; 28 | border-radius: ${theme.spacing(2)}; 29 | border: 1px dashed ${theme.palette.background.light8}; 30 | `; 31 | 32 | const Income = styled(Heading5)` 33 | color: ${theme.palette.primary.main}; 34 | `; 35 | 36 | const Expense = styled(Heading5)` 37 | color: ${red.A200}; 38 | `; 39 | 40 | const DashboardSummary = () => { 41 | const { t } = useTranslation(); 42 | const config = useConfig(); 43 | const [data, setData] = useState(); 44 | 45 | useEffect(() => { 46 | fetchNui(TransactionEvents.GetHistory).then(setData); 47 | }, []); 48 | 49 | return ( 50 | 51 | 52 | {t('Weekly summary')} 53 | 54 | 55 | 56 | 57 | {t('Income')} 58 | {formatMoney(data?.income ?? 0, config.general)} 59 | 60 | 61 | 62 | 63 | 64 | {t('Expenses')} 65 | {formatMoney(data?.expenses ?? 0, config.general)} 66 | 67 | 68 | 69 | 70 | {t('Report')} 71 | 72 | 73 | 74 | ); 75 | }; 76 | 77 | export default DashboardSummary; 78 | -------------------------------------------------------------------------------- /src/server/services/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { GenericErrors } from '@typings/Errors'; 2 | import { ServerError } from '@utils/errors'; 3 | import { mainLogger } from '@server/sv_logger'; 4 | import { singleton } from 'tsyringe'; 5 | import { OnlineUser, UserDTO } from '../../../../typings/user'; 6 | import { getPlayerIdentifier, getPlayerName } from '../../utils/misc'; 7 | import { UserModule } from './user.module'; 8 | import { UserEvents } from '@server/../../typings/Events'; 9 | 10 | const logger = mainLogger.child({ module: 'user' }); 11 | 12 | @singleton() 13 | export class UserService { 14 | private readonly usersBySource: Map; // Player class 15 | constructor() { 16 | this.usersBySource = new Map(); 17 | } 18 | 19 | getAllUsers() { 20 | return this.usersBySource; 21 | } 22 | 23 | getUser(source: number): UserModule { 24 | const user = this.usersBySource.get(source); 25 | 26 | if (!user) { 27 | throw new ServerError(GenericErrors.UserNotFound); 28 | } 29 | 30 | return user; 31 | } 32 | 33 | getUserByIdentifier(identifier: string): UserModule | undefined { 34 | let user: UserModule | undefined; 35 | 36 | this.getAllUsers().forEach((onlineUser) => { 37 | user = onlineUser.getIdentifier() === identifier ? onlineUser : user; 38 | }); 39 | 40 | return user; 41 | } 42 | 43 | /** 44 | * Used when the player is unloaded or dropped. 45 | * @param source 46 | */ 47 | deletePlayer(source: number) { 48 | this.usersBySource.delete(source); 49 | } 50 | 51 | async loadPlayer(data: OnlineUser) { 52 | logger.debug('Loading player for pefcl.'); 53 | logger.debug(data); 54 | 55 | const user = new UserModule(data); 56 | this.usersBySource.set(user.getSource(), user); 57 | 58 | logger.debug(`Player loaded. Emitting: ${UserEvents.Loaded}`); 59 | 60 | setImmediate(() => { 61 | emit(UserEvents.Loaded, data); 62 | emitNet(UserEvents.Loaded, data.source, data); 63 | }); 64 | } 65 | 66 | async unloadPlayer(source: number) { 67 | logger.debug('Unloading player for pefcl with export'); 68 | logger.debug(source); 69 | 70 | this.deletePlayer(source); 71 | 72 | logger.debug('Player unloaded, emitting: ' + UserEvents.Unloaded); 73 | emit(UserEvents.Unloaded, source); 74 | emitNet(UserEvents.Unloaded, source); 75 | } 76 | 77 | async loadStandalonePlayer(userDTO: UserDTO) { 78 | const identifier = getPlayerIdentifier(userDTO.source); 79 | const name = getPlayerName(userDTO.source); 80 | 81 | const user = { 82 | name, 83 | identifier, 84 | source: userDTO.source, 85 | }; 86 | 87 | return this.loadPlayer(user); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/server/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CLEARING_NUMBER } from './constants'; 2 | import { config } from './server-config'; 3 | 4 | const isMocking = process.env.NODE_ENV === 'mocking'; 5 | 6 | export const getExports = () => { 7 | if (!isMocking) { 8 | return global.exports; 9 | } 10 | 11 | return typeof global.exports === 'function' ? global.exports() : global.exports; 12 | }; 13 | 14 | export const getSource = (): number => global.source; 15 | 16 | export const getPlayerIdentifier = (source: number): string => { 17 | const identifiers = getPlayerIdentifiers(source.toString()); 18 | 19 | if (config.debug?.mockLicenses) { 20 | return `license:${source}`; 21 | } 22 | 23 | const identifierType = config.general?.identifierType ?? 'license'; 24 | const identifier = identifiers.find((identifier) => identifier.includes(`${identifierType}:`)); 25 | 26 | if (!identifier) { 27 | throw new Error('Failed to get identifier for player' + source); 28 | } 29 | 30 | return identifier; 31 | }; 32 | 33 | export const getPlayerName = (source: number): string => { 34 | return GetPlayerName(source.toString()); 35 | }; 36 | 37 | export const getClearingNumber = (initialConfig = config): string => { 38 | const configValue = initialConfig?.accounts?.clearingNumber ?? DEFAULT_CLEARING_NUMBER; 39 | const confValue = typeof configValue === 'string' ? configValue : configValue.toString(); 40 | 41 | if (confValue.length !== 3) { 42 | return DEFAULT_CLEARING_NUMBER.toString(); 43 | } 44 | 45 | return confValue; 46 | }; 47 | 48 | export const generateAccountNumber = (clearingNumber = getClearingNumber()): string => { 49 | const initialNumber = clearingNumber; 50 | 51 | let uuid = `${initialNumber},`; 52 | for (let i = 0; i < 12; i++) { 53 | switch (i) { 54 | case 8: 55 | uuid += '-'; 56 | uuid += ((Math.random() * 4) | 0).toString(); 57 | break; 58 | case 4: 59 | uuid += '-'; 60 | uuid += ((Math.random() * 4) | 0).toString(); 61 | break; 62 | default: 63 | uuid += ((Math.random() * 9) | 0).toString(10); 64 | } 65 | } 66 | 67 | return uuid; 68 | }; 69 | 70 | // Credits to d0p3t 71 | // https://github.com/d0p3t/fivem-js/blob/master/src/utils/UUIDV4.ts 72 | export const uuidv4 = (): string => { 73 | let uuid = ''; 74 | for (let ii = 0; ii < 32; ii += 1) { 75 | switch (ii) { 76 | case 8: 77 | case 20: 78 | uuid += '-'; 79 | uuid += ((Math.random() * 16) | 0).toString(16); 80 | break; 81 | case 12: 82 | uuid += '-'; 83 | uuid += '4'; 84 | break; 85 | case 16: 86 | uuid += '-'; 87 | uuid += ((Math.random() * 4) | 8).toString(16); 88 | break; 89 | default: 90 | uuid += ((Math.random() * 16) | 0).toString(16); 91 | } 92 | } 93 | return uuid; 94 | }; 95 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 4 | const port = process.env.PORT ?? 3004; 5 | 6 | const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); 7 | const deps = require('./package.json').dependencies; 8 | 9 | /* TODO: Fix for real */ 10 | /* Probably bad way of fixing this */ 11 | delete deps['@emotion/react']; 12 | delete deps['@emotion/styled']; 13 | delete deps['@mui/material']; 14 | delete deps['@mui/styles']; 15 | 16 | module.exports = (env, options) => ({ 17 | entry: { 18 | main: './src/bootstrapApp.ts', 19 | }, 20 | mode: 'development', 21 | output: { 22 | publicPath: 'auto', 23 | filename: '[name].js', 24 | }, 25 | devServer: { 26 | port, 27 | hot: true, 28 | headers: { 29 | 'Access-Control-Allow-Origin': '*', 30 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 31 | 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', 32 | }, 33 | }, 34 | devtool: 'eval-source-map', 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.tsx?$/, 39 | exclude: /node_modules/, 40 | use: { 41 | loader: 'ts-loader', 42 | options: { 43 | transpileOnly: true, 44 | }, 45 | }, 46 | }, 47 | { 48 | test: /\.(js|jsx)$/, 49 | exclude: /node_modules/, 50 | use: { 51 | loader: 'babel-loader', 52 | }, 53 | }, 54 | { 55 | test: /\.(css|s[ac]ss)$/i, 56 | use: ['style-loader', 'css-loader'], 57 | }, 58 | { 59 | test: /\.(png|jpe?g|gif)$/i, 60 | exclude: /node_modules/, 61 | use: [ 62 | { 63 | loader: 'file-loader', 64 | }, 65 | ], 66 | }, 67 | ], 68 | }, 69 | plugins: [ 70 | new ModuleFederationPlugin({ 71 | name: 'pefcl', 72 | filename: 'remoteEntry.js', 73 | exposes: { 74 | './config': './npwd.config.ts', 75 | }, 76 | shared: { 77 | ...deps, 78 | react: { 79 | singleton: true, 80 | requiredVersion: deps.react, 81 | }, 82 | 'react-dom': { 83 | singleton: true, 84 | requiredVersion: deps['react-dom'], 85 | }, 86 | }, 87 | }), 88 | new HtmlWebpackPlugin({ 89 | template: './public/index.html', 90 | chunks: ['main'], 91 | }), 92 | new webpack.DefinePlugin({ 93 | process: { env: {} }, 94 | }), 95 | ], 96 | resolve: { 97 | extensions: ['.ts', '.tsx', '.js', 'jsx'], 98 | plugins: [new TsconfigPathsPlugin()], 99 | }, 100 | }); 101 | -------------------------------------------------------------------------------- /web/webpack.mobile.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 4 | const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); 5 | const deps = require('./package.json').dependencies; 6 | const port = process.env.PORT ?? 3002; 7 | 8 | /* TODO: Fix for real */ 9 | /* Probably bad way of fixing this */ 10 | delete deps['@emotion/styled']; 11 | delete deps['@mui/material']; 12 | delete deps['@mui/styles']; 13 | 14 | module.exports = { 15 | entry: './src/bootstrapMobile.ts', 16 | mode: 'development', 17 | output: { 18 | publicPath: 'auto', 19 | filename: 'main.js', 20 | }, 21 | devServer: { 22 | port, 23 | hot: true, 24 | headers: { 25 | 'Access-Control-Allow-Origin': '*', 26 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 27 | 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', 28 | }, 29 | }, 30 | devtool: 'eval-source-map', 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.tsx?$/, 35 | exclude: /node_modules/, 36 | use: { 37 | loader: 'ts-loader', 38 | options: { 39 | transpileOnly: true, 40 | }, 41 | }, 42 | }, 43 | { 44 | test: /\.(png|jpe?g|gif)$/i, 45 | exclude: /node_modules/, 46 | use: [ 47 | { 48 | loader: 'file-loader', 49 | }, 50 | ], 51 | }, 52 | { 53 | test: /\.(js|jsx)$/, 54 | exclude: /node_modules/, 55 | use: { 56 | loader: 'babel-loader', 57 | }, 58 | }, 59 | { 60 | test: /\.(css|s[ac]ss)$/i, 61 | use: ['style-loader', 'css-loader'], 62 | }, 63 | ], 64 | }, 65 | plugins: [ 66 | new ModuleFederationPlugin({ 67 | name: 'pefcl', 68 | filename: 'remoteEntry.js', 69 | exposes: { 70 | './config': './npwd.config.ts', 71 | }, 72 | shared: { 73 | ...deps, 74 | react: { 75 | singleton: true, 76 | requiredVersion: deps.react, 77 | }, 78 | '@emotion/react': { 79 | singleton: true, 80 | requiredVersion: deps['@emotion/react'], 81 | }, 82 | 'react-dom': { 83 | singleton: true, 84 | requiredVersion: deps['react-dom'], 85 | }, 86 | }, 87 | }), 88 | new HtmlWebpackPlugin({ 89 | template: './public/index.html', 90 | }), 91 | new webpack.DefinePlugin({ 92 | process: { env: {} }, 93 | }), 94 | ], 95 | resolve: { 96 | extensions: ['.ts', '.tsx', '.js', 'jsx'], 97 | plugins: [new TsconfigPathsPlugin()], 98 | }, 99 | }; 100 | -------------------------------------------------------------------------------- /web/src/views/dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Skeleton, Stack } from '@mui/material'; 3 | import React from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { transactionsTotalAtom } from 'src/data/transactions'; 6 | import Layout from '../../components/Layout'; 7 | import theme from '../../utils/theme'; 8 | import DashboardContainer, { DashboardContainerFallback } from './components/DashboardContainer'; 9 | import PendingInvoices from './components/PendingInvoices'; 10 | import Transactions from './components/Transactions'; 11 | import DashboardSummary from './components/Summary'; 12 | import AccountCards, { LoadingCards } from './components/AccountCards'; 13 | import TotalBalance from '@components/TotalBalance'; 14 | import { PreHeading } from '@components/ui/Typography/BodyText'; 15 | import { Heading1 } from '@components/ui/Typography/Headings'; 16 | import { totalUnpaidInvoicesAtom } from '@data/invoices'; 17 | 18 | const Lists = styled.section` 19 | display: grid; 20 | grid-template-columns: 1fr 1fr 1.25fr; 21 | margin-top: ${theme.spacing(4)}; 22 | grid-column-gap: ${theme.spacing(4)}; 23 | `; 24 | 25 | const Dashboard = () => { 26 | const { t } = useTranslation(); 27 | 28 | return ( 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | } 42 | > 43 | 44 | 45 | 46 | }> 47 | 48 | 49 | 50 | 51 | 52 | }> 53 | 54 | 55 | 56 | }> 57 | 62 | 63 | 64 | 65 | 66 | }> 67 | 72 | 73 | 74 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | export default Dashboard; 81 | -------------------------------------------------------------------------------- /typings/Account.ts: -------------------------------------------------------------------------------- 1 | export enum AccountType { 2 | Personal = 'personal', 3 | Shared = 'shared', 4 | } 5 | 6 | export enum AccountRole { 7 | Owner = 'owner', 8 | Admin = 'admin', 9 | Contributor = 'contributor', 10 | } 11 | 12 | export type PreDBAccount = { 13 | fromAccountId: number; 14 | accountName: string; 15 | isDefault?: boolean; 16 | isShared?: boolean; 17 | }; 18 | 19 | export type CreateBasicAccountInput = { 20 | name: string; 21 | type: AccountType; 22 | identifier: string; 23 | number?: string; 24 | }; 25 | 26 | export type RenameAccountInput = { 27 | accountId: number; 28 | name: string; 29 | }; 30 | 31 | export interface Account { 32 | id: number; 33 | number: string; 34 | balance: number; 35 | isDefault: boolean; 36 | accountName: string; 37 | ownerIdentifier: string; 38 | role: AccountRole; 39 | type: AccountType; 40 | createdAt?: string; 41 | } 42 | 43 | export interface CreateAccountInput { 44 | accountName: string; 45 | ownerIdentifier: string; 46 | type: AccountType; 47 | isDefault?: boolean; 48 | number?: string; 49 | balance?: number; 50 | } 51 | 52 | export interface SharedAccount { 53 | id: number; 54 | userIdentifier: string; 55 | role: AccountRole; 56 | name?: string; 57 | account?: Account; 58 | accountId?: number; 59 | setAccount?(): void; 60 | } 61 | export type SharedAccountUser = Pick; 62 | export interface SharedAccountInput { 63 | userIdentifier: string; 64 | name?: string; 65 | accountId: number; 66 | role?: AccountRole; 67 | } 68 | export interface AddToSharedAccountInput { 69 | name: string; 70 | identifier: string; 71 | accountId: number; 72 | role?: AccountRole; 73 | } 74 | 75 | export interface AddToUniqueAccountInput { 76 | role?: AccountRole; 77 | source?: number; 78 | userIdentifier?: string; 79 | accountIdentifier: string; 80 | } 81 | 82 | export interface RemoveFromUniqueAccountInput { 83 | source?: number; 84 | userIdentifier?: string; 85 | accountIdentifier: string; 86 | } 87 | 88 | export interface RemoveFromSharedAccountInput { 89 | accountId: number; 90 | identifier: string; 91 | } 92 | 93 | export type TransactionAccount = Pick; 94 | 95 | export interface ATMInput { 96 | amount: number; 97 | message: string; 98 | accountId?: number; 99 | } 100 | 101 | export interface ExternalAccount { 102 | id: number; 103 | name: string; 104 | number: string; 105 | userId?: string; 106 | } 107 | 108 | export interface ExternalAccountInput { 109 | name: string; 110 | number: string; 111 | userId: string; 112 | } 113 | 114 | export interface UpdateBankBalanceInput { 115 | amount: number; 116 | message: string; 117 | identifier?: string; 118 | } 119 | 120 | export interface UpdateBankBalanceByNumberInput { 121 | amount: number; 122 | message: string; 123 | accountNumber: string; 124 | } 125 | --------------------------------------------------------------------------------