diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index b5de8d81..4295c18d 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -40,7 +40,6 @@ jobs: LOCALAZY_WRITE_KEY: ${{ secrets.LOCALAZY_WRITE_KEY }} run: | yarn translations:pull - yarn translations:generate-index - name: Install src deps working-directory: src diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73a42cad..2729f403 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,9 +37,6 @@ jobs: LOCALAZY_READ_KEY: a8269809765126758267-f01743c76d6e9e434d7b4c6322938eefce8d82a559b57efc2acfcf4531d46089 run: yarn translations:pull - - name: Generate translations - run: yarn translations:generate-index - - name: Install src deps working-directory: src run: yarn --frozen-lockfile diff --git a/README.md b/README.md index 2b0490f7..f61d2c42 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
[![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/) -![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord) +[![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord)](https://discord.gg/HYwBjTbAY5)
diff --git a/config.json b/config.json index fd502769..def3219b 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,8 @@ "frameworkIntegration": { "enabled": false, "resource": "your-resource", - "syncInitialBankBalance": true + "syncInitialBankBalance": true, + "isCardsEnabled": false }, "database": { "profileQueries": true @@ -24,6 +25,10 @@ "clearingNumber": 920, "maximumNumberOfAccounts": 3 }, + "cards": { + "cost": 4500, + "maxCardsPerAccount": 2 + }, "atms": { "distance": 5.0, "props": [-870868698, -1126237515, 506770882, -1364697528], diff --git a/package.json b/package.json index 9a346dc7..bf905681 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "postinstall": "husky install && yarn setup", "translations:generate": "yarn i18next", "translations:generate-index": "node ./scripts/generateLocales.js", - "translations:pull": "localazy download", + "translations:pull": "localazy download && node ./scripts/generateLocales.js", "translations:push": "localazy upload -w $LOCALAZY_WRITE_KEY -r $LOCALAZY_READ_KEY", "setup": "yarn nx run-many --target=setup --all && yarn translations:pull && yarn translations:generate-index", "build": "yarn nx run-many --target=build --all", "lint": "yarn nx run-many --target=lint --all", - "dev": "yarn nx run-many --target=dev --all", "tsc": "yarn nx run-many --target=tsc --all", + "dev": "yarn nx run-many --target=dev --all", "dev:ingame": "yarn nx run-many --target=dev:ingame --all", "dev:mobile": "yarn nx run-many --target=dev:mobile --all", "pre-release": "yarn build && sh ./scripts/prerelease.sh", @@ -39,4 +39,4 @@ "dependencies": { "i18next-parser": "^6.0.0" } -} \ No newline at end of file +} diff --git a/shared/constants.ts b/shared/constants.ts new file mode 100644 index 00000000..d21fe449 --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1,2 @@ +export const PIN_CODE_LENGTH = 4; +export const CHECK_PLAYER_LOADED_INTERVAL = 15; // ms; diff --git a/src/client/cl_api.ts b/src/client/cl_api.ts index cc59d204..13928acc 100644 --- a/src/client/cl_api.ts +++ b/src/client/cl_api.ts @@ -3,6 +3,7 @@ import { ATMInput } from '@typings/Account'; import { AccountEvents, CashEvents, InvoiceEvents } from '@typings/Events'; import { ServerPromiseResp } from '@typings/http'; import { Invoice, InvoiceOnlineInput } from '@typings/Invoice'; +import { translations } from 'i18n'; export class Api { utils: ClientUtils; @@ -83,7 +84,9 @@ export class Api { try { const payload: ATMInput = { amount, - message: 'Deposition', + message: translations.t('Successfully deposited {{amount}} into selected account.', { + amount, + }), }; const response = await this.utils.emitNetPromise(AccountEvents.DepositMoney, payload); console.log({ response }); @@ -97,7 +100,9 @@ export class Api { try { const payload: ATMInput = { amount, - message: 'Withdrawal', + message: translations.t('Withdrew {{amount}} from an ATM.', { + amount, + }), }; const response = await this.utils.emitNetPromise(AccountEvents.WithdrawMoney, payload); diff --git a/src/client/cl_events.ts b/src/client/cl_events.ts index fdf8d714..1ee0dcd1 100644 --- a/src/client/cl_events.ts +++ b/src/client/cl_events.ts @@ -11,9 +11,11 @@ import { Broadcasts, NUIEvents, CashEvents, + CardEvents, } from '@typings/Events'; import { Invoice } from '@typings/Invoice'; import { Transaction } from '@typings/Transaction'; +import { OnlineUser } from '@typings/user'; import { RegisterNuiProxy } from 'cl_utils'; import API from './cl_api'; import config from './cl_config'; @@ -23,6 +25,8 @@ const npwdExports = global.exports['npwd']; const useFrameworkIntegration = config.frameworkIntegration?.enabled; let hasNUILoaded = false; +emitNet(UserEvents.LoadClient); + RegisterNuiCB(NUIEvents.Loaded, () => { console.debug('NUI has loaded.'); hasNUILoaded = true; @@ -84,7 +88,7 @@ onNet(Broadcasts.RemovedSharedUser, () => { SendBankUIMessage('PEFCL', Broadcasts.RemovedSharedUser, {}); }); -onNet(UserEvents.Loaded, async () => { +onNet(UserEvents.Loaded, async (user: OnlineUser) => { console.debug('Waiting for NUI to load ..'); await waitForNUILoaded(); console.debug('Loaded. Emitting data to NUI.'); @@ -124,10 +128,20 @@ RegisterNuiProxy(SharedAccountEvents.GetUsers); RegisterNuiProxy(ExternalAccountEvents.Add); RegisterNuiProxy(ExternalAccountEvents.Get); +RegisterNuiProxy(AccountEvents.GetAtmAccount); RegisterNuiProxy(AccountEvents.WithdrawMoney); RegisterNuiProxy(AccountEvents.DepositMoney); RegisterNuiProxy(CashEvents.GetMyCash); +// Cards +RegisterNuiProxy(CardEvents.Get); +RegisterNuiProxy(CardEvents.Block); +RegisterNuiProxy(CardEvents.Delete); +RegisterNuiProxy(CardEvents.OrderPersonal); +RegisterNuiProxy(CardEvents.OrderShared); +RegisterNuiProxy(CardEvents.UpdatePin); +RegisterNuiProxy(CardEvents.GetInventoryCards); + RegisterCommand( 'bank-force-load', async () => { diff --git a/src/client/cl_exports.ts b/src/client/cl_exports.ts index a5655341..123b8d77 100644 --- a/src/client/cl_exports.ts +++ b/src/client/cl_exports.ts @@ -1,10 +1,12 @@ +import { NUIEvents } from '@typings/Events'; import { setBankIsOpen, setAtmIsOpen } from 'client'; import { createInvoice, depositMoney, giveCash, withdrawMoney } from 'functions'; const exp = global.exports; -exp('openBank', async () => { +exp('openBank', async (accountId: number) => { setBankIsOpen(true); + SendNUIMessage({ type: NUIEvents.SetCardId, payload: accountId }); }); exp('closeBank', async () => { diff --git a/src/server/globals.server.ts b/src/server/globals.server.ts index 76793829..f2fe2e65 100644 --- a/src/server/globals.server.ts +++ b/src/server/globals.server.ts @@ -7,7 +7,7 @@ export const mockedResourceName = 'pefcl'; // TODO: Move this into package const convars = { - mysql_connection_string: 'mysql://root:bruv@localhost/dev', + mysql_connection_string: 'mysql://root:root@127.0.0.1/QBCoreFramework_E05901?charset=utf8mb4', }; const players: any = { @@ -26,6 +26,10 @@ if (isMocking) { const ServerEmitter = new EventEmitter().setMaxListeners(25); const NetEmitter = new EventEmitter().setMaxListeners(25); + global.RegisterCommand = (cmd: string) => { + console.log('Registered command', cmd); + }; + global.LoadResourceFile = (_resourceName: string, fileName: string) => { const file = readFileSync(`${baseDir}/${fileName}`, 'utf-8'); return file; @@ -64,7 +68,7 @@ if (isMocking) { 'your-resource': { addCash: () => { console.log('global.server.ts: Adding cash ..'); - throw new Error('no funds'); + throw new Error('adding cash'); }, getCash: () => { console.log('global.server.ts: Getting cash ..'); @@ -72,7 +76,15 @@ if (isMocking) { }, removeCash: () => { console.log('global.server.ts: Removing cash ..'); - throw new Error('no funds'); + throw new Error('could not remove cash'); + }, + giveCard: () => { + console.log('global.server.ts: Giving card ..'); + throw new Error('giving card'); + }, + getCards: () => { + console.log('global.server.ts: Getting cards ..'); + return []; }, }, }); diff --git a/src/server/server.ts b/src/server/server.ts index e49cfbb0..1b38b5b5 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2,6 +2,7 @@ import './globals.server'; import { ServerPromiseResp } from '@project-error/pe-utils'; import { AccountEvents, + CardEvents, CashEvents, ExternalAccountEvents, GeneralEvents, @@ -28,6 +29,7 @@ import { mockedResourceName } from './globals.server'; import { config } from './utils/server-config'; import { UserService } from './services/user/user.service'; import { container } from 'tsyringe'; +import { CardService } from './services/card/card.service'; const hotReloadConfig = { resourceName: GetCurrentResourceName(), @@ -76,6 +78,7 @@ if (isMocking) { app.post(...createEndpoint(UserEvents.GetUsers)); app.post(...createEndpoint(AccountEvents.GetAccounts)); + app.post(...createEndpoint(AccountEvents.GetAtmAccount)); app.post(...createEndpoint(AccountEvents.DeleteAccount)); app.post(...createEndpoint(AccountEvents.SetDefaultAccount)); app.post(...createEndpoint(AccountEvents.CreateAccount)); @@ -96,6 +99,14 @@ if (isMocking) { app.post(...createEndpoint(ExternalAccountEvents.Get)); app.post(...createEndpoint(CashEvents.GetMyCash)); + // Cards + app.post(...createEndpoint(CardEvents.Get)); + app.post(...createEndpoint(CardEvents.OrderPersonal)); + app.post(...createEndpoint(CardEvents.UpdatePin)); + app.post(...createEndpoint(CardEvents.Block)); + app.post(...createEndpoint(CardEvents.Delete)); + app.post(...createEndpoint(CardEvents.GetInventoryCards)); + app.listen(port, async () => { mainLogger.child({ module: 'server' }).debug(`[MOCKSERVER]: listening on port: ${port}`); @@ -186,6 +197,33 @@ const debug = async () => { // }, // source: 0, // }); + + RegisterCommand( + 'card', + async (src: number) => { + const exps = exports; + const QBCore = await exps['qb-core']?.GetCoreObject(); + + await exps['qb-core'].RemoveItem('bank_card'); + + const item = { + name: 'bank_card', + label: 'Bank card', + weight: 1, + type: 'item', + image: 'visacard.png', + }; + + await exps['qb-core'].AddItem('bank_card', item); + + const cardService = container.resolve(CardService); + + const res = await cardService.giveCard(src, QBCore); + + console.log(res); + }, + false, + ); }; on(GeneralEvents.ResourceStarted, debug); diff --git a/src/server/services/account/account.controller.ts b/src/server/services/account/account.controller.ts index d75b89e7..201ddd4c 100644 --- a/src/server/services/account/account.controller.ts +++ b/src/server/services/account/account.controller.ts @@ -125,11 +125,7 @@ export class AccountController { @Export(ServerExports.WithdrawCash) @NetPromise(AccountEvents.WithdrawMoney) async withdrawMoney(req: Request, res: Response) { - const accountId = req.data.accountId; - try { - accountId && - (await this._auth.isAuthorizedAccount(accountId, req.source, [AccountRole.Admin])); await this._accountService.handleWithdrawMoney(req); res({ status: 'ok', data: {} }); } catch (err) { diff --git a/src/server/services/account/account.model.ts b/src/server/services/account/account.model.ts index 271e9843..10d03da9 100644 --- a/src/server/services/account/account.model.ts +++ b/src/server/services/account/account.model.ts @@ -3,7 +3,7 @@ import { DataTypes, Model, Optional } from 'sequelize'; import { config } from '@utils/server-config'; import { Account, AccountRole, AccountType } from '@typings/Account'; import { sequelize } from '@utils/pool'; -import { generateAccountNumber } from '@utils/misc'; +import { generateClearingNumber } from '@utils/misc'; import { timestamps } from '../timestamps.model'; import { AccountEvents } from '@server/../../typings/Events'; @@ -22,7 +22,7 @@ AccountModel.init( number: { type: DataTypes.STRING, unique: true, - defaultValue: generateAccountNumber, + defaultValue: generateClearingNumber, }, accountName: { type: DataTypes.STRING, diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts index 815dafea..f19a07f4 100644 --- a/src/server/services/account/account.service.ts +++ b/src/server/services/account/account.service.ts @@ -31,6 +31,7 @@ import { AccountErrors, AuthorizationErrors, BalanceErrors, + CardErrors, GenericErrors, UserErrors, } from '@typings/Errors'; @@ -38,14 +39,20 @@ import { SharedAccountDB } from '@services/accountShared/sharedAccount.db'; import { AccountEvents, Broadcasts } from '@server/../../typings/Events'; import { getFrameworkExports } from '@server/utils/frameworkIntegration'; import { Transaction } from 'sequelize/types'; +import { CardDB } from '../card/card.db'; const logger = mainLogger.child({ module: 'accounts' }); -const { enabled = false, syncInitialBankBalance = false } = config.frameworkIntegration ?? {}; +const { + enabled = false, + syncInitialBankBalance = false, + isCardsEnabled = false, +} = config.frameworkIntegration ?? {}; const { firstAccountStartBalance } = config.accounts ?? {}; const isFrameworkIntegrationEnabled = enabled; @singleton() export class AccountService { + _cardDB: CardDB; _accountDB: AccountDB; _sharedAccountDB: SharedAccountDB; _cashService: CashService; @@ -58,7 +65,9 @@ export class AccountService { userService: UserService, cashService: CashService, transactionService: TransactionService, + cardDB: CardDB, ) { + this._cardDB = cardDB; this._accountDB = accountDB; this._sharedAccountDB = sharedAccountDB; this._cashService = cashService; @@ -83,7 +92,7 @@ export class AccountService { /* Override role by the shared one. */ return { - ...acc.toJSON(), + ...acc?.toJSON(), role: sharedAcc.role, }; }); @@ -460,25 +469,39 @@ export class AccountService { } async handleWithdrawMoney(req: Request) { - logger.silly(`"${req.source}" withdrawing "${req.data.amount}".`); - const amount = req.data.amount; + const { accountId, amount, cardId, cardPin } = req.data; + logger.silly(`"${req.source}" withdrawing "${amount}".`); if (amount <= 0) { throw new ServerError(GenericErrors.BadInput); } - /* Only run the export when account is the default(?). Not sure about this. */ const t = await sequelize.transaction(); try { - const targetAccount = req.data.accountId - ? await this._accountDB.getAccountById(req.data.accountId) + /* If framework is enabled, do a card check, otherwise continue. */ + if (isFrameworkIntegrationEnabled && isCardsEnabled && cardId) { + const exports = getFrameworkExports(); + const cards = exports.getCards(req.source); + const selectedCard = cards?.find((card) => card.id === cardId); + + if (!selectedCard) { + throw new Error('User does not have selected card in inventory.'); + } + + const card = await this._cardDB.getById(selectedCard.id); + if (card?.getDataValue('pin') !== cardPin) { + throw new Error(CardErrors.InvalidPin); + } + } + + const targetAccount = accountId + ? await this._accountDB.getAccountById(accountId) : await this.getDefaultAccountBySource(req.source); if (!targetAccount) { throw new ServerError(GenericErrors.NotFound); } - const accountId = targetAccount.getDataValue('id') ?? 0; const currentAccountBalance = targetAccount.getDataValue('balance'); if (currentAccountBalance < amount) { @@ -859,7 +882,6 @@ export class AccountService { type, accountName: name, ownerIdentifier: identifier, - isDefault: true, }); const json = account.toJSON(); diff --git a/src/server/services/associations.ts b/src/server/services/associations.ts index faeb29b2..20285b20 100644 --- a/src/server/services/associations.ts +++ b/src/server/services/associations.ts @@ -4,6 +4,7 @@ import { AccountModel } from './account/account.model'; import { TransactionModel } from './transaction/transaction.model'; import './invoice/invoice.model'; import { SharedAccountModel } from './accountShared/sharedAccount.model'; +import { CardModel } from './card/card.model'; /* This is so annoying. Next time choose something with TS support. */ declare module './accountShared/sharedAccount.model' { @@ -19,6 +20,12 @@ declare module './transaction/transaction.model' { } } +declare module './card/card.model' { + interface CardModel { + setAccount(id?: number): Promise; + } +} + TransactionModel.belongsTo(AccountModel, { as: 'toAccount', }); @@ -31,6 +38,10 @@ SharedAccountModel.belongsTo(AccountModel, { as: 'account', }); +CardModel.belongsTo(AccountModel, { + as: 'account', +}); + if (config?.database?.shouldSync) { sequelize.sync(); } diff --git a/src/server/services/boot/boot.service.ts b/src/server/services/boot/boot.service.ts index ca911a33..b4aaf896 100644 --- a/src/server/services/boot/boot.service.ts +++ b/src/server/services/boot/boot.service.ts @@ -43,14 +43,27 @@ export class BootService { logger.error(error); if (error instanceof Error && error.message.includes('No such export')) { - logger.error( - 'Check your starting order. The framework integration library needs to be started before PEFCL!', - ); + if (config.frameworkIntegration.isCardsEnabled && error.message.includes('Card')) { + logger.error(' '); + logger.error( + 'This framework does not seem to support cards. Make sure your resource is exporting the required exports!', + ); + logger.error( + 'Check the documentation for correct setup: https://projecterror.dev/docs/pefcl/developers/framework_integration', + ); + logger.error(' '); + } else { + logger.error( + 'Check your starting order. The framework integration library needs to be started before PEFCL!', + ); + } } this.handleResourceStop(); return; } + + logger.info('Successfully verified exports.'); } logger.info(`Starting ${resourceName}.`); @@ -61,5 +74,8 @@ export class BootService { logger.info(`Stopping ${resourceName}.`); emit(GeneralEvents.ResourceStopped); StopResource(resourceName); + StopResource(resourceName); + StopResource(resourceName); + StopResource(resourceName); } } diff --git a/src/server/services/broadcast/broadcast.controller.ts b/src/server/services/broadcast/broadcast.controller.ts index e34981b0..c99ad577 100644 --- a/src/server/services/broadcast/broadcast.controller.ts +++ b/src/server/services/broadcast/broadcast.controller.ts @@ -1,11 +1,18 @@ import { Account } from '@server/../../typings/Account'; +import { Card } from '@server/../../typings/BankCard'; import { Cash } from '@server/../../typings/Cash'; -import { AccountEvents, CashEvents, TransactionEvents } from '@server/../../typings/Events'; import { Transaction } from '@server/../../typings/Transaction'; import { Controller } from '@server/decorators/Controller'; import { Event, EventListener } from '@server/decorators/Event'; import { BroadcastService } from './broadcast.service'; +import { + AccountEvents, + CardEvents, + CashEvents, + TransactionEvents, +} from '@server/../../typings/Events'; + @Controller('Broadcast') @EventListener() export class BroadcastController { @@ -43,4 +50,9 @@ export class BroadcastController { async onNewTransaction(transaction: Transaction) { this.broadcastService.broadcastTransaction(transaction); } + + @Event(CardEvents.NewCard) + async onNewCard(card: Card) { + this.broadcastService.broadcastNewCard(card); + } } diff --git a/src/server/services/broadcast/broadcast.service.ts b/src/server/services/broadcast/broadcast.service.ts index 3da23e94..cca8d3b1 100644 --- a/src/server/services/broadcast/broadcast.service.ts +++ b/src/server/services/broadcast/broadcast.service.ts @@ -7,6 +7,7 @@ import { TransactionDB } from '../transaction/transaction.db'; import { Account, AccountType } from '@server/../../typings/Account'; import { Cash } from '@server/../../typings/Cash'; import { AccountService } from '../account/account.service'; +import { Card } from '@server/../../typings/BankCard'; const logger = mainLogger.child({ module: 'broadcastService' }); @@ -36,6 +37,16 @@ export class BroadcastService { emitNet(Broadcasts.UpdatedAccount, user?.getSource(), account); } + async broadcastNewCard(card: Card) { + logger.silly(`Broadcasted new card:`); + logger.silly(JSON.stringify(card)); + + const user = this._userService.getUserByIdentifier(card.holderCitizenId); + if (!user) return; + + emitNet(Broadcasts.NewCard, user?.getSource(), card); + } + async broadcastTransaction(transaction: Transaction) { logger.silly(`Broadcasted transaction:`); logger.silly(JSON.stringify(transaction)); diff --git a/src/server/services/card/card.controller.ts b/src/server/services/card/card.controller.ts new file mode 100644 index 00000000..ed13a187 --- /dev/null +++ b/src/server/services/card/card.controller.ts @@ -0,0 +1,97 @@ +import { NetPromise, PromiseEventListener } from '@decorators/NetPromise'; +import { GetATMAccountInput, GetATMAccountResponse } from '@server/../../typings/Account'; +import { + BlockCardInput, + Card, + CreateCardInput, + DeleteCardInput, + GetCardInput, + InventoryCard, + UpdateCardPinInput, +} from '@server/../../typings/BankCard'; +import { AccountEvents, CardEvents } from '@typings/Events'; +import { Request, Response } from '@typings/http'; +import { Controller } from '../../decorators/Controller'; +import { EventListener } from '../../decorators/Event'; +import { CardService } from './card.service'; + +@Controller('Card') +@EventListener() +@PromiseEventListener() +export class CardController { + cardService: CardService; + constructor(cardService: CardService) { + this.cardService = cardService; + } + + @NetPromise(AccountEvents.GetAtmAccount) + async getAtmAccount(req: Request, res: Response) { + try { + const result = await this.cardService.getAccountByCard(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.OrderPersonal) + async orderPersonalAccount(req: Request, res: Response) { + try { + const result = await this.cardService.orderPersonalCard(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.Block) + async blockCard(req: Request, res: Response) { + try { + const isUpdated = await this.cardService.blockCard(req); + res({ status: 'ok', data: isUpdated }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.Delete) + async deleteCard(req: Request, res: Response) { + try { + const isDeleted = await this.cardService.blockCard(req); + res({ status: 'ok', data: isDeleted }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.UpdatePin) + async updatePin(req: Request, res: Response) { + try { + const isUpdated = await this.cardService.updateCardPin(req); + res({ status: 'ok', data: isUpdated }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.Get) + async getCards(req: Request, res: Response) { + try { + const result = await this.cardService.getCards(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + /* Return cards from player inventory to be selected at ATM */ + @NetPromise(CardEvents.GetInventoryCards) + async getInventoryCards(req: Request, res: Response) { + try { + const result = await this.cardService.getInventoryCards(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } +} diff --git a/src/server/services/card/card.db.ts b/src/server/services/card/card.db.ts new file mode 100644 index 00000000..a0767df2 --- /dev/null +++ b/src/server/services/card/card.db.ts @@ -0,0 +1,22 @@ +import { Transaction } from 'sequelize/types'; +import { singleton } from 'tsyringe'; +import { CardModel, CardModelCreate } from './card.model'; + +@singleton() +export class CardDB { + async getAll(): Promise { + return await CardModel.findAll(); + } + + async getById(cardId: number, transaction?: Transaction): Promise { + return await CardModel.findOne({ where: { id: cardId }, transaction }); + } + + async getByAccountId(accountId: number): Promise { + return await CardModel.findAll({ where: { accountId: accountId } }); + } + + async create(data: CardModelCreate, transaction: Transaction): Promise { + return await CardModel.create(data, { transaction }); + } +} diff --git a/src/server/services/card/card.model.ts b/src/server/services/card/card.model.ts new file mode 100644 index 00000000..c4675bcc --- /dev/null +++ b/src/server/services/card/card.model.ts @@ -0,0 +1,44 @@ +import { Card } from '@server/../../typings/BankCard'; +import { generateCardNumber } from '@server/utils/misc'; +import { DATABASE_PREFIX } from '@utils/constants'; +import { DataTypes, Model, Optional } from 'sequelize'; +import { singleton } from 'tsyringe'; +import { sequelize } from '../../utils/pool'; +import { timestamps } from '../timestamps.model'; + +export type CardModelCreate = Optional< + Card, + 'id' | 'number' | 'pin' | 'isBlocked' | 'createdAt' | 'updatedAt' +>; + +@singleton() +export class CardModel extends Model {} +CardModel.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + holder: { + type: DataTypes.STRING, + }, + holderCitizenId: { + type: DataTypes.STRING, + }, + isBlocked: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + number: { + type: DataTypes.STRING, + defaultValue: generateCardNumber, + }, + pin: { + type: DataTypes.INTEGER, + defaultValue: 1234, + }, + ...timestamps, + }, + { sequelize: sequelize, tableName: DATABASE_PREFIX + 'cards' }, +); diff --git a/src/server/services/card/card.service.ts b/src/server/services/card/card.service.ts new file mode 100644 index 00000000..3284ffec --- /dev/null +++ b/src/server/services/card/card.service.ts @@ -0,0 +1,286 @@ +import { singleton } from 'tsyringe'; +import { config } from '@utils/server-config'; +import { mainLogger } from '../../sv_logger'; +import { UserService } from '../user/user.service'; +import { Request } from '@typings/http'; +import { CardDB } from './card.db'; +import { sequelize } from '@server/utils/pool'; +import { AccountService } from '../account/account.service'; +import { + AuthorizationErrors, + BalanceErrors, + CardErrors, + GenericErrors, + UserErrors, +} from '@server/../../typings/Errors'; +import i18next from '@utils/i18n'; +import { + BlockCardInput, + Card, + CreateCardInput, + InventoryCard, + UpdateCardPinInput, +} from '@server/../../typings/BankCard'; +import { AccountDB } from '../account/account.db'; +import { PIN_CODE_LENGTH } from '@shared/constants'; +import { GetATMAccountResponse, GetATMAccountInput } from '@server/../../typings/Account'; +import { CardEvents } from '@server/../../typings/Events'; +import { getFrameworkExports } from '@server/utils/frameworkIntegration'; + +const logger = mainLogger.child({ module: 'card' }); +const isFrameworkIntegrationEnabled = config?.frameworkIntegration?.enabled; + +@singleton() +export class CardService { + cardDB: CardDB; + accountDB: AccountDB; + userService: UserService; + accountService: AccountService; + + constructor( + CardDB: CardDB, + userService: UserService, + accountService: AccountService, + accountDB: AccountDB, + ) { + this.cardDB = CardDB; + this.accountDB = accountDB; + this.userService = userService; + this.accountService = accountService; + } + + validateCardsConfig() { + if (!isFrameworkIntegrationEnabled) { + logger.error('Could not give card to player.'); + throw new Error('Phsyical cards are not available without FrameworkIntegration enabled.'); + } + + if (!config.frameworkIntegration?.isCardsEnabled) { + logger.error('Cards are not enabled in the config.'); + throw new Error('Cards are not enabled in the config.'); + } + } + + async getCards(req: Request<{ accountId: number }>) { + const cards = await this.cardDB.getByAccountId(req.data.accountId); + return cards.map((card) => card.toJSON()); + } + + async getInventoryCards(req: Request): Promise { + this.validateCardsConfig(); + + const user = this.userService.getUser(req.source); + + if (!user) { + throw new Error(UserErrors.NotFound); + } + + const exports = getFrameworkExports(); + return exports.getCards(user.getSource()); + } + + async getAccountByCard(req: Request): Promise { + this.validateCardsConfig(); + + logger.silly('Getting account by card.'); + + const { cardId, pin } = req.data; + const card = await this.cardDB.getById(cardId); + + if (!card) { + logger.error('Card not found'); + throw new Error(GenericErrors.NotFound); + } + + if (card.getDataValue('isBlocked')) { + logger.error('The card is blocked'); + throw new Error(CardErrors.Blocked); + } + + if (pin !== card.getDataValue('pin')) { + logger.error('Invalid pin'); + throw new Error(CardErrors.InvalidPin); + } + + const account = await this.accountDB.getAccountById(card.getDataValue('accountId') ?? -1); + + if (!account) { + logger.error('Card is not bound to any account'); + throw new Error(GenericErrors.NotFound); + } + + logger.error('Returning account!'); + return { account: account?.toJSON(), card: card.toJSON() }; + } + + async giveCard(src: number, card: Card) { + this.validateCardsConfig(); + + const exports = getFrameworkExports(); + return exports.giveCard(src, card); + } + + async blockCard(req: Request) { + this.validateCardsConfig(); + logger.silly('Blocking card ..'); + logger.silly(req.data); + + const user = this.userService.getUser(req.source); + const { cardId } = req.data; + + const t = await sequelize.transaction(); + const card = await this.cardDB.getById(cardId, t); + + if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) { + throw new Error(AuthorizationErrors.Forbidden); + } + + if (!card) { + throw new Error(GenericErrors.NotFound); + } + + try { + await card.update({ isBlocked: true }); + t.commit(); + logger.silly('Blocked card.'); + return true; + } catch (error: unknown) { + t.rollback(); + logger.error(error); + return false; + } + } + + async deleteCard(req: Request) { + this.validateCardsConfig(); + logger.silly('Deleting card ..'); + logger.silly(req.data); + const user = this.userService.getUser(req.source); + const { cardId } = req.data; + + const t = await sequelize.transaction(); + const card = await this.cardDB.getById(cardId, t); + + if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) { + throw new Error(AuthorizationErrors.Forbidden); + } + + if (!card) { + throw new Error(GenericErrors.NotFound); + } + + try { + await card.destroy(); + t.commit(); + logger.silly('Deleted card.'); + return true; + } catch (error: unknown) { + t.rollback(); + logger.error(error); + return false; + } + } + + async updateCardPin(req: Request): Promise { + this.validateCardsConfig(); + logger.silly('Updating pin for card ..'); + logger.silly(req.data); + + const user = this.userService.getUser(req.source); + const { cardId, newPin } = req.data; + + const t = await sequelize.transaction(); + const card = await this.cardDB.getById(cardId, t); + + if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) { + throw new Error(AuthorizationErrors.Forbidden); + } + + if (!card) { + throw new Error(GenericErrors.NotFound); + } + + try { + await card.update({ pin: newPin }, { transaction: t }); + t.commit(); + logger.silly('Updated pin.'); + return true; + } catch (error) { + logger.error(error); + t.rollback(); + return false; + } + } + + async orderPersonalCard(req: Request): Promise { + this.validateCardsConfig(); + logger.silly('Ordering new card ..'); + logger.silly(req.data); + + const user = this.userService.getUser(req.source); + const { accountId, paymentAccountId, pin } = req.data; + + const newCardCost = config.cards?.cost; + + if (!newCardCost) { + logger.error('Missing "cards.cost" in config.json'); + throw new Error(CardErrors.MissingConfigCost); + } + + if (pin.toString().length !== PIN_CODE_LENGTH) { + logger.error('Pin is wrong length, should be: ' + PIN_CODE_LENGTH); + throw new Error(GenericErrors.BadInput); + } + + const t = await sequelize.transaction(); + try { + const account = await this.accountService.getAuthorizedAccount(req.source, accountId); + const paymentAccount = await this.accountService.getAuthorizedAccount( + req.source, + paymentAccountId, + ); + + if (!account || !paymentAccount) { + throw new Error(GenericErrors.NotFound); + } + + const card = await this.cardDB.create( + { + pin: pin, + holder: user.name, + holderCitizenId: user.getIdentifier(), + accountId: account.getDataValue('id'), + }, + t, + ); + + if (paymentAccount.getDataValue('balance') < newCardCost) { + throw new Error(BalanceErrors.InsufficentFunds); + } + + this.accountService.removeMoneyByAccountNumber({ + ...req, + data: { + amount: newCardCost, + message: i18next.t('Ordered new card'), + accountNumber: paymentAccount.getDataValue('number'), + }, + }); + + t.afterCommit(() => { + logger.silly(`Emitting ${CardEvents.NewCard}`); + emit(CardEvents.NewCard, { ...card.toJSON() }); + }); + + this.giveCard(req.source, card.toJSON()); + + t.commit(); + logger.silly('Ordered new card.'); + return card.toJSON(); + } catch (error: unknown) { + logger.error(error); + t.rollback(); + throw new Error(i18next.t('Failed to create new account')); + } + } +} diff --git a/src/server/services/controllers.ts b/src/server/services/controllers.ts index 70a2c640..c5484128 100644 --- a/src/server/services/controllers.ts +++ b/src/server/services/controllers.ts @@ -5,3 +5,4 @@ import './invoice/invoice.controller'; import './account/account.controller'; import './transaction/transaction.controller'; import './broadcast/broadcast.controller'; +import './card/card.controller'; diff --git a/src/server/services/transaction/transaction.service.ts b/src/server/services/transaction/transaction.service.ts index 0e99e236..0f3b9a9d 100644 --- a/src/server/services/transaction/transaction.service.ts +++ b/src/server/services/transaction/transaction.service.ts @@ -54,12 +54,18 @@ export class TransactionService { const user = this._userService.getUser(req.source); const accounts = await this._accountDB.getAccountsByIdentifier(user.getIdentifier()); + const sharedAccounts = await this._sharedAccountDB.getSharedAccountsByIdentifier( + user.getIdentifier(), + ); + const sharedAccountIds = sharedAccounts.map( + (account) => account.getDataValue('accountId') ?? 0, + ); const accountIds = accounts.map((account) => account.getDataValue('id') ?? 0); const transactions = await this._transactionDB.getTransactionFromAccounts({ ...req.data, - accountIds, + accountIds: [...sharedAccountIds, ...accountIds], }); const total = await this._transactionDB.getTotalTransactionsFromAccounts(accountIds); diff --git a/src/server/services/user/user.controller.ts b/src/server/services/user/user.controller.ts index bb3a0668..cfd05f48 100644 --- a/src/server/services/user/user.controller.ts +++ b/src/server/services/user/user.controller.ts @@ -1,5 +1,5 @@ import { Controller } from '@decorators/Controller'; -import { EventListener, Event } from '@decorators/Event'; +import { EventListener, Event, NetEvent } from '@decorators/Event'; import { NetPromise, PromiseEventListener } from '@decorators/NetPromise'; import { ServerExports } from '@server/../../typings/exports/server'; import { Export, ExportListener } from '@server/decorators/Export'; @@ -48,6 +48,12 @@ export class UserController { res({ status: 'ok', data: list }); } + @NetEvent(UserEvents.LoadClient) + async loadClient() { + const src = source; + this._userService.loadClient(src); + } + @Event('playerJoining') playerJoining() { if (config.frameworkIntegration?.enabled) return; diff --git a/src/server/services/user/user.module.ts b/src/server/services/user/user.module.ts index 4165dddd..2dc6df30 100644 --- a/src/server/services/user/user.module.ts +++ b/src/server/services/user/user.module.ts @@ -4,6 +4,7 @@ export class UserModule { private readonly _source: number; private readonly _identifier: string; public readonly name: string; + public isClientLoaded: boolean; constructor(user: OnlineUser) { this._source = user.source; @@ -18,4 +19,8 @@ export class UserModule { getIdentifier() { return this._identifier; } + + loadClient() { + this.isClientLoaded = true; + } } diff --git a/src/server/services/user/user.service.ts b/src/server/services/user/user.service.ts index 56969f95..c52513da 100644 --- a/src/server/services/user/user.service.ts +++ b/src/server/services/user/user.service.ts @@ -6,16 +6,25 @@ import { OnlineUser, UserDTO } from '../../../../typings/user'; import { getPlayerIdentifier, getPlayerName } from '../../utils/misc'; import { UserModule } from './user.module'; import { UserEvents } from '@server/../../typings/Events'; +import { CHECK_PLAYER_LOADED_INTERVAL } from '@shared/constants'; const logger = mainLogger.child({ module: 'user' }); @singleton() export class UserService { private readonly usersBySource: Map; // Player class + private loadedSources: number[]; + constructor() { + this.loadedSources = []; this.usersBySource = new Map(); } + loadClient(source: number) { + logger.debug('Loaded client for source: ' + source); + this.loadedSources.push(source); + } + getAllUsers() { return this.usersBySource; } @@ -55,12 +64,8 @@ export class UserService { const user = new UserModule(data); this.usersBySource.set(user.getSource(), user); - logger.debug(`Player loaded. Emitting: ${UserEvents.Loaded}`); - - setImmediate(() => { - emit(UserEvents.Loaded, data); - emitNet(UserEvents.Loaded, data.source, data); - }); + logger.debug('Player loaded on server.'); + this.emitLoadedPlayer(user, data); } async unloadPlayer(source: number) { @@ -86,4 +91,16 @@ export class UserService { return this.loadPlayer(user); } + + emitLoadedPlayer(user: UserModule, data: OnlineUser) { + const interval = setInterval(() => { + const isLoaded = this.loadedSources.includes(user.getSource()); + if (isLoaded) { + logger.debug(`Player loaded on client. Emitting: ${UserEvents.Loaded}`); + emit(UserEvents.Loaded, data); + emitNet(UserEvents.Loaded, data.source, data); + clearInterval(interval); + } + }, CHECK_PLAYER_LOADED_INTERVAL); + } } diff --git a/src/server/utils/__tests__/misc.test.ts b/src/server/utils/__tests__/misc.test.ts index 7df4f059..04c96513 100644 --- a/src/server/utils/__tests__/misc.test.ts +++ b/src/server/utils/__tests__/misc.test.ts @@ -1,5 +1,5 @@ import { DEFAULT_CLEARING_NUMBER } from '@utils/constants'; -import { generateAccountNumber, getClearingNumber } from '@utils/misc'; +import { generateClearingNumber, getClearingNumber } from '@utils/misc'; import { createMockedConfig } from '@utils/test'; import { regexExternalNumber } from '@shared/utils/regexes'; @@ -49,7 +49,7 @@ describe('Helper: getClearingNumber', () => { describe('Helper: generateAccountNumber', () => { test('should pass regex test', () => { for (let i = 0; i < 100; i++) { - const accountNumber = generateAccountNumber(); + const accountNumber = generateClearingNumber(); expect(regexExternalNumber.test(accountNumber)).toBe(true); } }); diff --git a/src/server/utils/frameworkIntegration.ts b/src/server/utils/frameworkIntegration.ts index f72406d2..6135954f 100644 --- a/src/server/utils/frameworkIntegration.ts +++ b/src/server/utils/frameworkIntegration.ts @@ -6,7 +6,7 @@ import { mainLogger } from '@server/sv_logger'; import { getExports } from './misc'; import { config } from './server-config'; -const log = mainLogger.child({ module: 'frameworkIntegration' }); +const logger = mainLogger.child({ module: 'frameworkIntegration' }); const frameworkIntegrationKeys: FrameworkIntegrationFunction[] = [ 'addCash', @@ -15,17 +15,23 @@ const frameworkIntegrationKeys: FrameworkIntegrationFunction[] = [ 'getBank', ]; +if (config?.frameworkIntegration?.isCardsEnabled) { + frameworkIntegrationKeys.push('giveCard'); + frameworkIntegrationKeys.push('getCards'); +} + export const validateResourceExports = (resourceExports: FrameworkIntegrationExports): boolean => { let isValid = true; frameworkIntegrationKeys.forEach((key: FrameworkIntegrationFunction) => { + logger.silly(`Verifying export: ${key}`); if (typeof resourceExports[key] === 'undefined') { - log.error(`Framework integration export ${key} is missing.`); + logger.error(`Framework integration export ${key} is missing.`); isValid = false; return; } if (typeof resourceExports[key] !== 'function') { - log.error(`Framework integration export ${key} is not a function.`); + logger.error(`Framework integration export ${key} is not a function.`); isValid = false; } }); @@ -38,15 +44,15 @@ export const getFrameworkExports = (): FrameworkIntegrationExports => { const resourceName = config?.frameworkIntegration?.resource; const resourceExports: FrameworkIntegrationExports = exps[resourceName ?? '']; - log.debug(`Checking exports from resource: ${resourceName}`); + logger.debug(`Checking exports from resource: ${resourceName}`); if (!resourceName) { - log.error(`Missing resourceName in the config for framework integration`); + logger.error(`Missing resourceName in the config for framework integration`); throw new Error('Framework integration failed'); } if (!resourceExports) { - log.error( + logger.error( `No resource found with name: ${resourceName}. Make sure you have the correct resource name in the config.`, ); throw new Error('Framework integration failed'); diff --git a/src/server/utils/misc.ts b/src/server/utils/misc.ts index ce55c696..c39c5f7c 100644 --- a/src/server/utils/misc.ts +++ b/src/server/utils/misc.ts @@ -45,7 +45,7 @@ export const getClearingNumber = (initialConfig = config): string => { return confValue; }; -export const generateAccountNumber = (clearingNumber = getClearingNumber()): string => { +export const generateClearingNumber = (clearingNumber = getClearingNumber()): string => { const initialNumber = clearingNumber; let uuid = `${initialNumber},`; @@ -67,6 +67,26 @@ export const generateAccountNumber = (clearingNumber = getClearingNumber()): str return uuid; }; +export const generateCardNumber = (): string => { + let uuid = `5160 `; + for (let i = 0; i < 12; i++) { + switch (i) { + case 8: + uuid += ' '; + uuid += ((Math.random() * 4) | 0).toString(); + break; + case 4: + uuid += ' '; + uuid += ((Math.random() * 4) | 0).toString(); + break; + default: + uuid += ((Math.random() * 9) | 0).toString(10); + } + } + + return uuid; +}; + // Credits to d0p3t // https://github.com/d0p3t/fivem-js/blob/master/src/utils/UUIDV4.ts export const uuidv4 = (): string => { diff --git a/typings/Account.ts b/typings/Account.ts index 9ebc3382..8b48bbeb 100644 --- a/typings/Account.ts +++ b/typings/Account.ts @@ -1,3 +1,5 @@ +import { Card } from './BankCard'; + export enum AccountType { Personal = 'personal', Shared = 'shared', @@ -40,6 +42,15 @@ export interface Account { createdAt?: string; } +export interface GetATMAccountInput { + pin: number; + cardId: number; +} + +export interface GetATMAccountResponse { + card: Card; + account: Account; +} export interface CreateAccountInput { accountName: string; ownerIdentifier: string; @@ -96,6 +107,8 @@ export interface ATMInput { amount: number; message: string; accountId?: number; + cardId?: number; + cardPin?: number; } export interface ExternalAccount { diff --git a/typings/BankCard.ts b/typings/BankCard.ts new file mode 100644 index 00000000..f6c32de4 --- /dev/null +++ b/typings/BankCard.ts @@ -0,0 +1,42 @@ +import { Account } from './Account'; + +export interface Card { + // Dynamic + id: number; + account?: Account; + accountId?: number; + + pin: number; + isBlocked: boolean; + + // Static + holder: string; + holderCitizenId: string; + number: string; + + // Timestamps + updatedAt?: string | number | Date; + createdAt?: string | number | Date; +} + +export type InventoryCard = Pick; + +export interface GetCardInput { + accountId: number; +} +export interface CreateCardInput { + pin: number; + accountId: number; + paymentAccountId: number; +} + +export interface BlockCardInput { + cardId: number; +} + +export type DeleteCardInput = BlockCardInput; + +export interface UpdateCardPinInput { + cardId: number; + newPin: number; +} diff --git a/typings/Errors.ts b/typings/Errors.ts index f9a9314c..9ef61c3e 100644 --- a/typings/Errors.ts +++ b/typings/Errors.ts @@ -16,6 +16,13 @@ export enum AccountErrors { SameAccount = 'SameAccount', } +export enum CardErrors { + MissingConfigCost = 'MissingConfigCost', + FailedToCreate = 'FailedToCreate', + InvalidPin = 'InvalidPin', + Blocked = 'Blocked', +} + export enum UserErrors { NotFound = 'UserNotFound', } diff --git a/typings/Events.ts b/typings/Events.ts index 4893f011..52dbb9ea 100644 --- a/typings/Events.ts +++ b/typings/Events.ts @@ -8,14 +8,18 @@ export enum UserEvents { GetUsers = 'pefcl:userEventsGetUsers', Loaded = 'pefcl:userLoaded', Unloaded = 'pefcl:userUnloaded', + LoadClient = 'pefcl:loadClient', } export enum NUIEvents { Loaded = 'pefcl:nuiHasLoaded', Unloaded = 'pefcl:nuiHasUnloaded', + SetCardId = 'pefcl:nuiSetCardId', + SetCards = 'pefcl:nuiSetCards', } export enum AccountEvents { + GetAtmAccount = 'pefcl:getAtmAccount', GetAccounts = 'pefcl:getAccounts', CreateAccount = 'pefcl:createAccount', RenameAccount = 'pefcl:renameAccount', @@ -51,6 +55,7 @@ export enum Broadcasts { NewAccountBalance = 'pefcl:newAccountBalanceBroadcast', NewDefaultAccountBalance = 'pefcl:newDefaultAccountBalance', NewCashAmount = 'pefcl:newCashAmount', + NewCard = 'pefcl:newCardBroadcast', } export enum TransactionEvents { @@ -77,3 +82,14 @@ export enum CashEvents { export enum BalanceEvents { UpdateCashBalance = 'pefcl:updateCashBalance', } + +export enum CardEvents { + Get = 'pefcl:getCards', + OrderShared = 'pefcl:orderSharedCard', + OrderPersonal = 'pefcl:orderPersonalCard', + Block = 'pefcl:blockCard', + Delete = 'pefcl:deleteCard', + UpdatePin = 'pefcl:updatePin', + NewCard = 'pefcl:newCard', + GetInventoryCards = 'pefcl:getInventoryCards', +} diff --git a/typings/config.ts b/typings/config.ts index e6f8300e..6b1d87ad 100644 --- a/typings/config.ts +++ b/typings/config.ts @@ -34,6 +34,7 @@ export interface ResourceConfig { enabled: boolean; resource: string; syncInitialBankBalance: boolean; + isCardsEnabled: boolean; }; database: { profileQueries: boolean; @@ -48,6 +49,10 @@ export interface ResourceConfig { clearingNumber: string | number; maximumNumberOfAccounts: number; }; + cards: { + cost: number; + maxCardsPerAccount: number; + }; cash: { startAmount: number; }; diff --git a/typings/exports.ts b/typings/exports.ts index 7004521a..5c236917 100644 --- a/typings/exports.ts +++ b/typings/exports.ts @@ -1,6 +1,6 @@ /* Exports used with framework integrations */ -import { OnlineUser } from './user'; +import { Card, InventoryCard } from './BankCard'; export interface FrameworkIntegrationExports { /* Cash exports */ @@ -15,6 +15,8 @@ export interface FrameworkIntegrationExports { * This export should probably remove old bank balance as well. */ getBank: (source: number) => number; + giveCard: (source: number, card: Card) => void; + getCards: (source: number) => InventoryCard[]; } export type FrameworkIntegrationFunction = keyof FrameworkIntegrationExports; diff --git a/web/src/App.tsx b/web/src/App.tsx index b0981f74..7f07b006 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,27 +1,31 @@ +import Devbar from '@components/DebugBar'; +import { accountsAtom, rawAccountAtom } from '@data/accounts'; +import { transactionBaseAtom, transactionInitialState } from '@data/transactions'; import styled from '@emotion/styled'; +import { BroadcastsWrapper } from '@hooks/useBroadcasts'; import { useExitListener } from '@hooks/useExitListener'; +import { useNuiEvent } from '@hooks/useNuiEvent'; +import { GeneralEvents, NUIEvents, UserEvents } from '@typings/Events'; +import { fetchNui } from '@utils/fetchNui'; import dayjs from 'dayjs'; -import updateLocale from 'dayjs/plugin/updateLocale'; import 'dayjs/locale/sv'; +import updateLocale from 'dayjs/plugin/updateLocale'; +import { useSetAtom } from 'jotai'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Route } from 'react-router-dom'; import './App.css'; import { useConfig } from './hooks/useConfig'; import theme from './utils/theme'; -import Accounts from './views/accounts/Accounts'; -import Dashboard from './views/dashboard/Dashboard'; -import Invoices from './views/Invoices/Invoices'; import ATM from './views/ATM/ATM'; -import { BroadcastsWrapper } from '@hooks/useBroadcasts'; -import Transfer from './views/transfer/Transfer'; -import Transactions from './views/transactions/Transactions'; -import Devbar from '@components/DebugBar'; -import { NUIEvents, UserEvents } from '@typings/Events'; import Deposit from './views/Deposit/Deposit'; -import { fetchNui } from '@utils/fetchNui'; +import Invoices from './views/Invoices/Invoices'; import Withdraw from './views/Withdraw/Withdraw'; -import { useNuiEvent } from '@hooks/useNuiEvent'; +import Accounts from './views/accounts/Accounts'; +import Dashboard from './views/dashboard/Dashboard'; +import Transactions from './views/transactions/Transactions'; +import Transfer from './views/transfer/Transfer'; +import CardsView from './views/Cards/CardsView'; dayjs.extend(updateLocale); @@ -46,12 +50,22 @@ const Content = styled.div` const App: React.FC = () => { const config = useConfig(); + const setRawAccounts = useSetAtom(rawAccountAtom); + const setAccounts = useSetAtom(accountsAtom); + const setTransactions = useSetAtom(transactionBaseAtom); const [hasLoaded, setHasLoaded] = useState(process.env.NODE_ENV === 'development'); const [isAtmVisible, setIsAtmVisible] = useState(false); const [isVisible, setIsVisible] = useState(false); useNuiEvent('PEFCL', UserEvents.Loaded, () => setHasLoaded(true)); - useNuiEvent('PEFCL', UserEvents.Unloaded, () => setHasLoaded(false)); + useNuiEvent('PEFCL', UserEvents.Unloaded, () => { + setHasLoaded(false); + setAccounts([]); + setRawAccounts([]); + setTransactions(); + fetchNui(GeneralEvents.CloseUI); + setTransactions(transactionInitialState); + }); useEffect(() => { fetchNui(NUIEvents.Loaded); @@ -64,7 +78,7 @@ const App: React.FC = () => { useNuiEvent('PEFCL', 'setVisibleATM', (data) => setIsAtmVisible(data)); const { i18n } = useTranslation(); - useExitListener(); + useExitListener(isVisible); useEffect(() => { i18n.changeLanguage(config?.general?.language).catch((e) => console.error(e)); @@ -93,6 +107,7 @@ const App: React.FC = () => { + )} diff --git a/web/src/components/Card.tsx b/web/src/components/AccountCard.tsx similarity index 80% rename from web/src/components/Card.tsx rename to web/src/components/AccountCard.tsx index d91efafc..18f95c0c 100644 --- a/web/src/components/Card.tsx +++ b/web/src/components/AccountCard.tsx @@ -12,7 +12,12 @@ import { IconButton, Skeleton, Stack } from '@mui/material'; import { ContentCopyRounded } from '@mui/icons-material'; import copy from 'copy-to-clipboard'; -const Container = styled.div<{ accountType: AccountType; selected: boolean }>` +interface ContainerProps { + isDisabled: boolean; + accountType: AccountType; + selected: boolean; +} +const Container = styled.div` user-select: none; width: 100%; padding: 1rem; @@ -40,8 +45,14 @@ const Container = styled.div<{ accountType: AccountType; selected: boolean }>` ${({ selected }) => selected && ` - border: 2px solid ${theme.palette.background.light8}; + border: 2px solid ${theme.palette.primary.light}; `}; + + ${({ isDisabled }) => + isDisabled && + ` + opacity: 0.5; + `} `; const Row = styled.div` @@ -77,15 +88,23 @@ const DefaultText = styled(Heading6)` type AccountCardProps = { account: Account; selected?: boolean; + withCopy?: boolean; + isDisabled?: boolean; }; -export const AccountCard = ({ account, selected = false, ...props }: AccountCardProps) => { +export const AccountCard = ({ + account, + selected = false, + withCopy = false, + isDisabled = false, + ...props +}: AccountCardProps) => { const { type, id, balance, isDefault, accountName, number } = account; const { t } = useTranslation(); const config = useConfig(); return ( - + {formatMoney(balance, config.general)} @@ -96,14 +115,16 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard {number} - copy(number)} - size="small" - color="inherit" - style={{ opacity: '0.45', marginTop: 0, marginLeft: '0.25rem' }} - > - - + {withCopy && ( + copy(number)} + size="small" + color="inherit" + style={{ opacity: '0.45', marginTop: 0, marginLeft: '0.25rem' }} + > + + + )} @@ -111,8 +132,6 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard {t('Account name')} {accountName} - - ); @@ -120,7 +139,7 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard export const LoadingAccountCard = () => { return ( - + diff --git a/web/src/components/BankCard.tsx b/web/src/components/BankCard.tsx new file mode 100644 index 00000000..a4cc97ad --- /dev/null +++ b/web/src/components/BankCard.tsx @@ -0,0 +1,68 @@ +import { Stack } from '@mui/material'; +import { Card, InventoryCard } from '@typings/BankCard'; +import theme from '@utils/theme'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { MasterCardIcon } from 'src/icons/MasterCardIcon'; +import styled from 'styled-components'; +import { BodyText } from './ui/Typography/BodyText'; +import { Heading4, Heading6 } from './ui/Typography/Headings'; + +const Container = styled.div<{ selected: boolean; blocked: boolean }>` + user-select: none; + width: 100%; + padding: 1rem; + background: ${({ blocked }) => + blocked + ? 'linear-gradient(90deg, #bcbcbc 0%, #b0b0b0 100%)' + : 'linear-gradient(90deg, #fc5f02 0%, #f43200 100%)'}; + + min-height: 7rem; + width: auto; + border-radius: ${theme.spacing(1)}; + + cursor: pointer; + transition: 250ms; + box-shadow: ${theme.shadows[4]}; + + :hover { + box-shadow: ${theme.shadows[6]}; + } + + transition: 200ms ease-in-out; + border: 2px solid transparent; + + ${({ selected }) => selected && `border: 2px solid ${theme.palette.text.primary}`} +`; + +const StyledIcon = styled(MasterCardIcon)` + color: rgba(255, 255, 255, 0.54); + align-self: flex-end; +`; + +interface BankCardProps { + card: Card | InventoryCard; + isBlocked?: boolean; + selected?: boolean; +} +const BankCard = ({ card, selected = false, isBlocked = false }: BankCardProps) => { + const { t } = useTranslation(); + + return ( + + + {card.number} + + + {t('Card holder')} + {card.holder} + + + + + + + ); +}; + +export default BankCard; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 624fa809..c4db56a0 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -14,6 +14,7 @@ const Container = styled.div` `; const Content = styled(motion.div)` + position: relative; padding: 2rem; flex: 1; height: 100%; diff --git a/web/src/components/Modals/BaseDialog.tsx b/web/src/components/Modals/BaseDialog.tsx index c48cf590..ada35818 100644 --- a/web/src/components/Modals/BaseDialog.tsx +++ b/web/src/components/Modals/BaseDialog.tsx @@ -1,5 +1,5 @@ import { useGlobalSettings } from '@hooks/useGlobalSettings'; -import { Dialog, DialogProps } from '@mui/material'; +import { Backdrop, Dialog, DialogProps } from '@mui/material'; import React, { ReactNode } from 'react'; interface BaseDialogProps extends DialogProps { diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 3f4c5c94..ec8937c0 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from 'react'; import { + AccountBalanceRounded, Add, CreditCardRounded, DashboardRounded, @@ -16,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import { Atom } from 'jotai'; import { totalUnpaidInvoicesAtom } from '@data/invoices'; import BadgeAtom from './ui/BadgeAtom'; +import { useConfig } from '@hooks/useConfig'; const List = styled.ul` margin: 0; @@ -96,11 +98,12 @@ const ListItem = ({ to, icon, label, amount, countAtom }: ListItemProps) => { const Sidebar = () => { const { t } = useTranslation(); + const config = useConfig(); return ( } label={t('Dashboard')} /> - } label={t('Accounts')} /> + } label={t('Accounts')} /> } label={t('Transfer')} /> } label={t('Transactions')} /> { /> } label={t('Deposit')} /> } label={t('Withdraw')} /> + + {config.frameworkIntegration.isCardsEnabled && ( + } label={t('Cards')} /> + )} ); }; diff --git a/web/src/components/ui/Count.tsx b/web/src/components/ui/Count.tsx index 02f4ed8d..f6d85106 100644 --- a/web/src/components/ui/Count.tsx +++ b/web/src/components/ui/Count.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import theme from '@utils/theme'; import React from 'react'; -const Total = styled.div` +const Total = styled.div<{ focus: boolean }>` display: flex; justify-content: center; align-items: center; @@ -14,15 +14,22 @@ const Total = styled.div` border-radius: ${theme.spacing(1)}; font-weight: ${theme.typography.fontWeightBold}; background-color: ${theme.palette.background.light4}; + + ${({ focus }) => + focus && + ` + background-color: ${theme.palette.background.light8}; + `} `; interface CountProps extends React.HTMLAttributes { amount: string | number; + focus?: boolean; } -const Count = ({ amount, ...props }: CountProps) => { +const Count = ({ amount, focus = false, ...props }: CountProps) => { return (
- {amount} + {amount}
); }; diff --git a/web/src/components/ui/Fields/PinField.tsx b/web/src/components/ui/Fields/PinField.tsx new file mode 100644 index 00000000..b16ca2b9 --- /dev/null +++ b/web/src/components/ui/Fields/PinField.tsx @@ -0,0 +1,71 @@ +import styled from '@emotion/styled'; +import { InputBaseProps, Stack, Typography } from '@mui/material'; +import { PIN_CODE_LENGTH } from '@shared/constants'; +import React, { ChangeEvent, useState } from 'react'; +import Count from '../Count'; + +const Container = styled.div` + position: relative; + display: grid; + grid-template-columns: ${`repeat(${PIN_CODE_LENGTH}, 1fr)`}; + grid-column-gap: 0.5rem; + width: ${`calc(${PIN_CODE_LENGTH} * 3rem)`}; +`; + +const InputField = styled.input` + position: absolute; + opacity: 0; + width: 100%; + height: 100%; +`; + +interface PinFieldProps { + label?: string; + value: string; + onChange: InputBaseProps['onChange']; +} + +const PinField = ({ onChange, value, label }: PinFieldProps) => { + const [hasFocus, setHasFocus] = useState(false); + + const handleChange = (event: ChangeEvent) => { + const newValue = event.target.value; + const newLength = newValue.length; + + if (newValue && isNaN(parseInt(newValue, 10))) { + return; + } + + if (newLength > PIN_CODE_LENGTH && value.length < newLength) { + return; + } + + onChange?.(event); + }; + + const codeLen = new Array(PIN_CODE_LENGTH).fill(''); + + return ( + + {label && ( + + {label} + + )} + + setHasFocus(false)} + onFocus={() => setHasFocus(true)} + /> + + {codeLen.map((_val, index) => ( + + ))} + + + ); +}; + +export default PinField; diff --git a/web/src/data/cards.ts b/web/src/data/cards.ts new file mode 100644 index 00000000..d8abd71b --- /dev/null +++ b/web/src/data/cards.ts @@ -0,0 +1,80 @@ +import { Card, GetCardInput } from '@typings/BankCard'; +import { CardEvents } from '@typings/Events'; +import { mockedAccounts } from '@utils/constants'; +import { fetchNui } from '@utils/fetchNui'; +import { isEnvBrowser } from '@utils/misc'; +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; + +const mockedCards: Card[] = [ + { + id: 1, + account: mockedAccounts[0], + isBlocked: false, + number: '4242 4220 1234 9000', + holder: 'Charles Carlsberg', + pin: 1234, + holderCitizenId: '1', + }, + { + id: 2, + account: mockedAccounts[0], + isBlocked: false, + number: '4242 4220 1234 9002', + holder: 'Charles Carlsberg', + pin: 1234, + holderCitizenId: '2', + }, + { + id: 3, + account: mockedAccounts[0], + isBlocked: false, + number: '4242 4220 1234 9003', + holder: 'Charles Carlsberg', + pin: 1234, + holderCitizenId: '3', + }, +]; + +const getCards = async (accountId: number): Promise => { + try { + const res = await fetchNui(CardEvents.Get, { accountId }); + return res ?? []; + } catch (e) { + if (isEnvBrowser()) { + return mockedCards; + } + + console.error(e); + return []; + } +}; + +export const selectedAccountIdAtom = atom(0); + +export const rawCardAtom = atomWithStorage>('rawCards', {}); +export const cardsAtom = atom( + async (get) => { + const selectedCardId = get(selectedAccountIdAtom); + const state = get(rawCardAtom); + + return state[selectedCardId] ?? []; + }, + async (get, set, by: Card | number) => { + const selectedCardId = get(selectedAccountIdAtom); + const state = get(rawCardAtom); + + if (typeof by === 'number') { + const cards = await getCards(by); + return set(rawCardAtom, { ...state, [selectedCardId]: cards }); + } + + if (!by) { + const cards = await getCards(selectedCardId); + return set(rawCardAtom, { ...state, [selectedCardId]: cards }); + } + + const cards = state[selectedCardId]; + return set(rawCardAtom, { ...state, [selectedCardId]: [...cards, by] }); + }, +); diff --git a/web/src/data/transactions.ts b/web/src/data/transactions.ts index f312d670..6f22f8bd 100644 --- a/web/src/data/transactions.ts +++ b/web/src/data/transactions.ts @@ -5,7 +5,7 @@ import { mockedTransactions } from '../utils/constants'; import { fetchNui } from '../utils/fetchNui'; import { isEnvBrowser } from '../utils/misc'; -const initialState: GetTransactionsResponse = { +export const transactionInitialState: GetTransactionsResponse = { total: 0, offset: 0, limit: 10, @@ -15,26 +15,31 @@ const initialState: GetTransactionsResponse = { const getTransactions = async (input: GetTransactionsInput): Promise => { try { const res = await fetchNui(TransactionEvents.Get, input); - return res ?? initialState; + return res ?? transactionInitialState; } catch (e) { if (isEnvBrowser()) { return mockedTransactions; } console.error(e); - return initialState; + return transactionInitialState; } }; -export const rawTransactionsAtom = atom(initialState); +export const rawTransactionsAtom = atom(transactionInitialState); -export const transactionBaseAtom = atom( +export const transactionBaseAtom = atom< + Promise, + GetTransactionsResponse | undefined +>( async (get) => { const hasTransactions = get(rawTransactionsAtom).transactions.length > 0; - return hasTransactions ? get(rawTransactionsAtom) : await getTransactions({ ...initialState }); + return hasTransactions + ? get(rawTransactionsAtom) + : await getTransactions({ ...transactionInitialState }); }, - async (get, set, by: Partial | undefined) => { + async (get, set, by?) => { const currentSettings = get(rawTransactionsAtom); - return set(rawTransactionsAtom, await getTransactions({ ...currentSettings, ...by })); + return set(rawTransactionsAtom, by ?? (await getTransactions({ ...currentSettings }))); }, ); diff --git a/web/src/hooks/useBroadcasts.ts b/web/src/hooks/useBroadcasts.ts index f77e3238..3cbf828c 100644 --- a/web/src/hooks/useBroadcasts.ts +++ b/web/src/hooks/useBroadcasts.ts @@ -1,4 +1,4 @@ -import { accountsAtom } from '@data/accounts'; +import { accountsAtom, rawAccountAtom } from '@data/accounts'; import { invoicesAtom } from '@data/invoices'; import { transactionBaseAtom } from '@data/transactions'; import { Account } from '@typings/Account'; @@ -10,6 +10,7 @@ import { useNuiEvent } from '@hooks/useNuiEvent'; export const useBroadcasts = () => { const updateInvoices = useSetAtom(invoicesAtom); const updateTransactions = useSetAtom(transactionBaseAtom); + const setRawAccounts = useSetAtom(rawAccountAtom); const [accounts, updateAccounts] = useAtom(accountsAtom); useNuiEvent('PEFCL', Broadcasts.NewTransaction, () => { @@ -17,7 +18,7 @@ export const useBroadcasts = () => { }); useNuiEvent('PEFCL', Broadcasts.NewAccount, (account: Account) => { - updateAccounts([...accounts, account]); + setRawAccounts([...accounts, account]) }); useNuiEvent('PEFCL', Broadcasts.UpdatedAccount, () => { @@ -25,7 +26,7 @@ export const useBroadcasts = () => { }); useNuiEvent('PEFCL', Broadcasts.NewAccountBalance, (account: Account) => { - updateAccounts(updateAccount(accounts, account)); + setRawAccounts(updateAccount(accounts, account)); }); useNuiEvent('PEFCL', Broadcasts.NewInvoice, () => { diff --git a/web/src/hooks/useExitListener.ts b/web/src/hooks/useExitListener.ts index afe423e7..f3149282 100644 --- a/web/src/hooks/useExitListener.ts +++ b/web/src/hooks/useExitListener.ts @@ -5,10 +5,10 @@ import { GeneralEvents } from '@typings/Events'; const LISTENED_KEYS = ['Escape']; -export const useExitListener = () => { +export const useExitListener = (enabled: boolean) => { useEffect(() => { const keyHandler = (e: KeyboardEvent) => { - if (LISTENED_KEYS.includes(e.code) && !isEnvBrowser()) { + if (LISTENED_KEYS.includes(e.code) && !isEnvBrowser() && enabled) { fetchNui(GeneralEvents.CloseUI); } }; @@ -16,5 +16,5 @@ export const useExitListener = () => { window.addEventListener('keydown', keyHandler); return () => window.removeEventListener('keydown', keyHandler); - }, []); + }, [enabled]); }; diff --git a/web/src/hooks/useKeyPress.ts b/web/src/hooks/useKeyPress.ts new file mode 100644 index 00000000..acd5254c --- /dev/null +++ b/web/src/hooks/useKeyPress.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; + +export const useKeyDown = (keys: string[], callback: () => void) => { + useEffect(() => { + const keyHandler = (e: KeyboardEvent) => { + if (keys.includes(e.code)) { + callback(); + } + }; + + window.addEventListener('keydown', keyHandler); + + return () => window.removeEventListener('keydown', keyHandler); + }, [keys, callback]); +}; diff --git a/web/src/utils/constants.ts b/web/src/utils/constants.ts index 5d7e4e34..be428c24 100644 --- a/web/src/utils/constants.ts +++ b/web/src/utils/constants.ts @@ -1,7 +1,7 @@ import { Account, AccountRole, AccountType } from '@typings/Account'; -import { GetTransactionsResponse, TransactionType } from '../../../typings/Transaction'; -import { Invoice, InvoiceStatus } from '../../../typings/Invoice'; import dayjs from 'dayjs'; +import { Invoice, InvoiceStatus } from '@typings/Invoice'; +import { GetTransactionsResponse, TransactionType } from '@typings/Transaction'; export const resourceDefaultName = 'pefcl'; diff --git a/web/src/views/ATM/ATM.tsx b/web/src/views/ATM/ATM.tsx index 6ff706a9..3c94a800 100644 --- a/web/src/views/ATM/ATM.tsx +++ b/web/src/views/ATM/ATM.tsx @@ -1,21 +1,29 @@ import Button from '@components/ui/Button'; -import { Heading2, Heading6 } from '@components/ui/Typography/Headings'; -import { accountsAtom, defaultAccountBalance } from '@data/accounts'; +import { Heading2, Heading4, Heading6 } from '@components/ui/Typography/Headings'; import styled from '@emotion/styled'; import { useConfig } from '@hooks/useConfig'; -import { Paper, Stack } from '@mui/material'; -import { ATMInput } from '@typings/Account'; -import { AccountEvents } from '@typings/Events'; +import { Alert, Paper, Stack } from '@mui/material'; +import { Account, ATMInput, GetATMAccountInput } from '@typings/Account'; +import { AccountEvents, CardEvents } from '@typings/Events'; import { defaultWithdrawOptions } from '@utils/constants'; import { formatMoney } from '@utils/currency'; import { fetchNui } from '@utils/fetchNui'; import theme from '@utils/theme'; import { AnimatePresence } from 'framer-motion'; -import { useAtom } from 'jotai'; -import React, { useState } from 'react'; +import React, { FormEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { useNuiEvent } from '@hooks/useNuiEvent'; +import { Card, InventoryCard } from '@typings/BankCard'; +import { useKeyDown } from '@hooks/useKeyPress'; +import { CardErrors } from '@typings/Errors'; +import { PIN_CODE_LENGTH } from '@shared/constants'; +import BankCard from '@components/BankCard'; +import { ErrorRounded } from '@mui/icons-material'; +import PinField from '@components/ui/Fields/PinField'; +import { useExitListener } from '@hooks/useExitListener'; +import { useAtomValue } from 'jotai'; +import { defaultAccountAtom } from '@data/accounts'; const AnimationContainer = styled.div` position: absolute; @@ -36,7 +44,7 @@ const AccountBalance = styled(Heading6)` `; const Header = styled(Stack)` - margin-bottom: ${theme.spacing(7)}; + margin-bottom: ${theme.spacing(5)}; `; const WithdrawText = styled(Heading6)` @@ -51,60 +59,321 @@ const WithdrawContainer = styled.div` grid-column-gap: ${theme.spacing(1.5)}; `; +const CardWrapper = styled.div` + min-width: 15rem; +`; + +type BankState = 'select-card' | 'enter-pin' | 'withdraw'; + const ATM = () => { const { t } = useTranslation(); const config = useConfig(); - const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions; + const { isCardsEnabled } = config.frameworkIntegration; + const defaultAccount = useAtomValue(defaultAccountAtom); + const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [accountBalance] = useAtom(defaultAccountBalance); - const [, updateAccounts] = useAtom(accountsAtom); - + const [account, setAccount] = useState(); const [isOpen, setIsOpen] = useState(false); useNuiEvent('PEFCL', 'setVisibleATM', (data) => setIsOpen(data)); + const initialStatus: BankState = isCardsEnabled ? 'select-card' : 'withdraw'; + + const [selectedCard, setSelectedCard] = useState(); + const [cards, setCards] = useState([]); + const [state, setState] = useState(initialStatus); + const [pin, setPin] = useState(''); + + useExitListener(state === 'withdraw' || state === initialStatus); + + const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions; + + const handleClose = () => { + setError(''); + setPin(''); + setAccount(undefined); + setState(initialStatus); + }; + + const handleBack = () => { + setError(''); + setPin(''); + if (state === 'enter-pin') { + setState('select-card'); + } + }; - const handleWithdraw = (amount: number) => { - const payload: ATMInput = { - amount, - message: 'Withdrew ' + amount + ' from an ATM.', + useKeyDown(['Escape'], handleBack); + + const handleVisibility = (isOpen: boolean) => { + setIsOpen(isOpen); + + if (!isOpen) { + handleClose(); + } + }; + + useEffect(() => { + const updateCards = async () => { + try { + const cards = await fetchNui(CardEvents.GetInventoryCards); + if (!cards) { + throw new Error('No cards available'); + } + setCards(cards); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } else { + setError(t('Something went wrong, please try again later.')); + } + } }; + isCardsEnabled && isOpen && updateCards(); + }, [t, isCardsEnabled, isOpen]); + + const input = { + cardId: selectedCard?.id ?? 0, + pin: parseInt(pin, 10), + }; + + const handleUpdateBalance = async () => { + setError(''); + const response = await fetchNui<{ account: Account; card: Card }, GetATMAccountInput>( + AccountEvents.GetAtmAccount, + input, + ); + + if (!response) { + return; + } + + const { card, account } = response; + setSelectedCard(card); + setAccount(account); + }; + + const handleWithdraw = async (amount: number) => { + const withdrawAccount = isCardsEnabled ? account : defaultAccount; + if (!withdrawAccount) { + return; + } + + const accountId = withdrawAccount.id; + + const payload: ATMInput = isCardsEnabled + ? { + amount, + cardId: selectedCard?.id, + cardPin: parseInt(pin, 10), + accountId, + message: t('Withdrew {{amount}} from an ATM with card {{cardNumber}}.', { + amount, + cardNumber: selectedCard?.number ?? 'unknown', + }), + } + : { + amount, + accountId, + message: t('Withdrew {{amount}} from an ATM.', { + amount, + }), + }; + setIsLoading(true); - // TODO: Update this with cards implementation - fetchNui(AccountEvents.WithdrawMoney, payload) - .then(() => updateAccounts()) - .finally(() => setIsLoading(false)); + + try { + setError(''); + await fetchNui(AccountEvents.WithdrawMoney, payload); + await handleUpdateBalance(); + } catch (error) { + if (error instanceof Error) { + if (error.message === CardErrors.InvalidPin) { + setError(t('Invalid pin')); + return; + } + + if (error.message === CardErrors.Blocked) { + setError(t('The card is blocked')); + return; + } + + setError(error.message); + } else { + setError(t('Something went wrong, please try again later.')); + } + } + + setIsLoading(false); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (!isCardsEnabled) { + return; + } + + if (pin.length === PIN_CODE_LENGTH && selectedCard?.id) { + try { + setError(''); + const response = await fetchNui<{ account: Account; card: Card }, GetATMAccountInput>( + AccountEvents.GetAtmAccount, + input, + ); + + if (!response) { + return; + } + + const { card, account } = response; + setSelectedCard(card); + setAccount(account); + setState('withdraw'); + } catch (error) { + if (error instanceof Error) { + if (error.message === CardErrors.InvalidPin) { + setError(t('Invalid pin')); + return; + } + + if (error.message === CardErrors.Blocked) { + setError(t('The card is blocked')); + return; + } + + setError(error.message); + } else { + setError(t('Something went wrong, please try again later.')); + } + } + } }; + const handleSelectCard = (card: InventoryCard) => { + setSelectedCard(card); + setState('enter-pin'); + }; + + const accountBalance = isCardsEnabled ? account?.balance ?? 0 : defaultAccount?.balance ?? 0; return ( - - {isOpen && ( - - - -
- {t('Account balance')} - {formatMoney(accountBalance ?? 0, config.general)} -
- - {t('Quick withdraw')} - - {withdrawOptions.map((value) => ( - + + + + {error && ( + } + color="error" + sx={{ margin: '0.5rem -1.5rem -1.5rem' }} > - {formatMoney(value, config.general)} - - ))} - -
-
-
- )} -
+ {error} + + )} +
+ + + )} + + + + {isOpen && state === 'withdraw' && ( + + + +
+ {t('Account balance')} + {formatMoney(accountBalance, config.general)} +
+ + {t('Quick withdraw')} + + {withdrawOptions.map((value) => ( + + ))} + + + {error && ( + } color="error" sx={{ marginTop: '1rem' }}> + {error} + + )} +
+
+
+ )} +
+ ); }; diff --git a/web/src/views/Cards/CardsView.tsx b/web/src/views/Cards/CardsView.tsx new file mode 100644 index 00000000..b1959848 --- /dev/null +++ b/web/src/views/Cards/CardsView.tsx @@ -0,0 +1,102 @@ +import { AccountCard } from '@components/AccountCard'; +import Layout from '@components/Layout'; +import { PreHeading } from '@components/ui/Typography/BodyText'; +import { Heading1 } from '@components/ui/Typography/Headings'; +import { accountsAtom } from '@data/accounts'; +import { Backdrop, Stack } from '@mui/material'; +import { useAtom } from 'jotai'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { AnimatePresence, motion } from 'framer-motion'; +import theme from '@utils/theme'; +import BankCards from './components/BankCards'; +import { selectedAccountIdAtom } from '@data/cards'; +import { AccountType } from '@typings/Account'; + +const Container = styled.div` + overflow: auto; + height: 100%; +`; + +const CardContainer = styled.div` + display: flex; + flex-wrap: wrap; + margin-top: 1rem; + margin-left: -0.5rem; + + & > div { + width: calc(33% - 0.5rem); + margin-left: 0.5rem; + margin-top: 0.5rem; + } +`; + +const Modal = styled(motion.div)` + z-index: 2; + padding: 2rem 3rem; + position: absolute; + width: calc(100% - 5rem); + height: 100%; + top: 0; + left: 5rem; + background-color: ${theme.palette.background.paper}; +`; + +const CardsView = () => { + const [selectedCardId, setSelectedCardId] = useState(0); + const [selectedAccountId, setSelectedAccountId] = useAtom(selectedAccountIdAtom); + const [accounts] = useAtom(accountsAtom); + const { t } = useTranslation(); + + return ( + + + + {t('Accounts')} + {t('Handle cards for your accounts')} + + + + {accounts.map((account) => ( +
+ account.type !== AccountType.Shared && setSelectedAccountId(account.id) + } + > + +
+ ))} +
+ + { + setSelectedAccountId(0); + setSelectedCardId(0); + }} + sx={{ position: 'absolute' }} + /> + + + {Boolean(selectedAccountId) && ( + + + + )} + +
+
+ ); +}; + +export default CardsView; diff --git a/web/src/views/Cards/components/BankCards.tsx b/web/src/views/Cards/components/BankCards.tsx new file mode 100644 index 00000000..97351f57 --- /dev/null +++ b/web/src/views/Cards/components/BankCards.tsx @@ -0,0 +1,253 @@ +import { PreHeading } from '@components/ui/Typography/BodyText'; +import { Heading1 } from '@components/ui/Typography/Headings'; +import React, { useEffect, useState } from 'react'; +import BankCard from '@components/BankCard'; +import { AddRounded, ErrorRounded, InfoRounded } from '@mui/icons-material'; +import { Alert, Backdrop, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'; +import { Card, CreateCardInput } from '@typings/BankCard'; +import theme from '@utils/theme'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import CardActions from './CardActions'; +import { useConfig } from '@hooks/useConfig'; +import BaseDialog from '@components/Modals/BaseDialog'; +import { fetchNui } from '@utils/fetchNui'; +import { CardEvents } from '@typings/Events'; +import { useAtom } from 'jotai'; +import { cardsAtom } from '@data/cards'; +import { AnimatePresence, motion } from 'framer-motion'; +import Button from '@components/ui/Button'; +import AccountSelect from '@components/AccountSelect'; +import Summary from '@components/Summary'; +import { accountsAtom } from '@data/accounts'; +import PinField from '@components/ui/Fields/PinField'; + +const CreateCard = styled.div` + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + border-radius: ${theme.spacing(1)}; + border: 1px dashed ${theme.palette.grey[500]}; + font-size: 1.75rem; + transition: 300ms; + + min-height: 7rem; + width: auto; + + :hover { + color: ${theme.palette.primary.main}; + border: 1px dashed ${theme.palette.primary.main}; + } + + svg { + font-size: 2.5rem; + } +`; + +const CardContainer = styled.div` + display: flex; + flex-wrap: wrap; + height: 100%; + overflow: auto; + margin-top: 1rem; + margin-left: -0.5rem; + + & > div { + width: calc(33% - 0.5rem); + margin-left: 0.5rem; + margin-top: 0.5rem; + } +`; + +const Modal = styled(motion.div)` + z-index: 2; + padding: 2rem 3rem; + position: absolute; + width: calc(100% - 5rem); + height: 100%; + top: 0; + left: 5rem; + background-color: ${theme.palette.background.paper}; +`; + +interface BankCardsProps { + accountId: number; + selectedCardId: number; + onSelectCardId(id: number): void; +} + +const BankCards = ({ onSelectCardId, selectedCardId, accountId }: BankCardsProps) => { + const { t } = useTranslation(); + const [accounts] = useAtom(accountsAtom); + const defaultAccount = accounts.find((account) => Boolean(account.isDefault)); + const initialAccountId = defaultAccount?.id ?? -1; + const [cards, updateCards] = useAtom(cardsAtom); + const [error, setError] = useState(''); + const [pin, setPin] = useState(''); + const [confirmPin, setConfirmPin] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isOrderingCard, setIsOrderingCard] = useState(false); + const [selectedAccountId, setSelectedAccountId] = useState(initialAccountId); + const { + cards: { cost, maxCardsPerAccount }, + } = useConfig(); + + useEffect(() => { + updateCards(accountId); + }, [accountId, updateCards]); + + const handleClose = () => { + setError(''); + setIsLoading(false); + setIsOrderingCard(false); + setPin(''); + setConfirmPin(''); + }; + + const handleOrderCard = async () => { + if (confirmPin !== pin) { + setError(t('Pins do not match')); + return; + } + + setError(''); + setIsLoading(true); + try { + const newCard = await fetchNui(CardEvents.OrderPersonal, { + accountId, + paymentAccountId: selectedAccountId, + pin: parseInt(pin, 10), + }); + + if (!newCard) { + return; + } + + updateCards(newCard); + handleClose(); + } catch (error: unknown) { + if (error instanceof Error) { + setError(error.message); + } + } + + setIsLoading(false); + }; + + const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId); + const selectedCard = cards.find((card) => card.id === selectedCardId); + const isAffordable = (selectedAccount?.balance ?? 0) > cost; + + return ( + + + + {t('Cards')} + {t('Select a card to handle, or order a new one.')} + + + + {cards.map((card) => ( +
{ + !card.isBlocked && onSelectCardId(card.id); + }} + > + +
+ ))} + + {cards.length < maxCardsPerAccount && ( + setIsOrderingCard(true)}> + + + + + )} +
+
+ + { + onSelectCardId(0); + }} + sx={{ position: 'absolute', left: '-2rem' }} + /> + + + {Boolean(selectedCardId) && ( + + { + updateCards(accountId); + onSelectCardId(0); + }} + onDelete={() => { + updateCards(accountId); + onSelectCardId(0); + }} + /> + + )} + + + + {t('Order a new card')} + + + + setPin(event.target.value)} + /> + + setConfirmPin(event.target.value)} + /> + + + + + + + + + + {error && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + + ); +}; + +export default BankCards; diff --git a/web/src/views/Cards/components/CardActions.tsx b/web/src/views/Cards/components/CardActions.tsx new file mode 100644 index 00000000..4ac9904b --- /dev/null +++ b/web/src/views/Cards/components/CardActions.tsx @@ -0,0 +1,251 @@ +import React, { useState } from 'react'; +import Button from '@components/ui/Button'; +import { + Alert, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, +} from '@mui/material'; +import { Heading1 } from '@components/ui/Typography/Headings'; +import { PreHeading } from '@components/ui/Typography/BodyText'; +import { useTranslation } from 'react-i18next'; +import BaseDialog from '@components/Modals/BaseDialog'; +import { fetchNui } from '@utils/fetchNui'; +import { CardEvents } from '@typings/Events'; +import { CheckRounded, ErrorRounded, InfoRounded } from '@mui/icons-material'; +import { BlockCardInput, DeleteCardInput, UpdateCardPinInput } from '@typings/BankCard'; +import PinField from '@components/ui/Fields/PinField'; + +interface CardActionsProps { + cardId: number; + isBlocked?: boolean; + onBlock?(): void; + onDelete?(): void; +} + +const CardActions = ({ cardId, onBlock, onDelete, isBlocked }: CardActionsProps) => { + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [dialog, setDialog] = useState<'none' | 'block' | 'update' | 'delete'>('none'); + const [newPin, setNewPin] = useState(''); + const [confirmNewPin, setConfirmNewPin] = useState(''); + const { t } = useTranslation(); + + const handleClose = () => { + setIsLoading(false); + setDialog('none'); + setError(''); + setNewPin(''); + setConfirmNewPin(''); + + setTimeout(() => { + setSuccess(''); + }, 2000); + }; + + const handleBlockCard = async () => { + try { + setSuccess(''); + setError(''); + setIsLoading(true); + + await fetchNui(CardEvents.Block, { + cardId, + }); + setSuccess(t('Successfully blocked the card.')); + handleClose(); + onBlock?.(); + } catch (err: unknown | Error) { + if (err instanceof Error) { + setError(err.message); + } + } + + setIsLoading(false); + }; + + const handleDeleteCard = async () => { + try { + setSuccess(''); + setError(''); + setIsLoading(true); + + await fetchNui(CardEvents.Delete, { + cardId, + }); + setSuccess(t('Successfully deleted the card.')); + handleClose(); + onDelete?.(); + } catch (err: unknown | Error) { + if (err instanceof Error) { + setError(err.message); + } + } + + setIsLoading(false); + }; + + const handleUpdatePin = async () => { + try { + setError(''); + setSuccess(''); + + if (confirmNewPin !== newPin) { + setError(t('Pins do not match')); + return; + } + + setIsLoading(true); + const data = { cardId, newPin: parseInt(newPin, 10) }; + await fetchNui(CardEvents.UpdatePin, data); + + setSuccess(t('Successfully updated pin.')); + handleClose(); + } catch (err: unknown | Error) { + if (err instanceof Error) { + setError(err.message); + } + } + + setIsLoading(false); + }; + + return ( + <> + + + + {t('Actions')} + {t('Block, update pin and more.')} + + + + + + + + + + {success && ( + } color="success"> + {success} + + )} + + + + + {t('Update pin')} + + + + setNewPin(event.target.value)} + /> + setConfirmNewPin(event.target.value)} + /> + + + {(Boolean(error) || isLoading) && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + + + + {t('Blocking card')} + + + + + {t('Are you sure you want to block this card? This action cannot be undone.')} + + + + {error && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + + + + {t('Deleting card')} + + + + + {t('Are you sure you want to delete this card? This action cannot be undone.')} + + + + {error && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + + + ); +}; + +export default CardActions; diff --git a/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx b/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx index 5d59bc9c..5d2b3951 100644 --- a/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx +++ b/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx @@ -1,11 +1,11 @@ -import { AccountCard } from '@components/Card'; +import React from 'react'; +import { AccountCard } from '@components/AccountCard'; import TotalBalance from '@components/TotalBalance'; import { Heading5 } from '@components/ui/Typography/Headings'; import { accountsAtom } from '@data/accounts'; import { Stack } from '@mui/material'; import { Box } from '@mui/system'; import { useAtom } from 'jotai'; -import React from 'react'; import { useTranslation } from 'react-i18next'; const MobileAccountsView = () => { diff --git a/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx b/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx index 6d2fbaea..029b6c3c 100644 --- a/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx +++ b/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx @@ -1,4 +1,4 @@ -import { AccountCard } from '@components/Card'; +import { AccountCard } from '@components/AccountCard'; import InvoiceItem from '@components/InvoiceItem'; import TotalBalance from '@components/TotalBalance'; import TransactionItem from '@components/TransactionItem'; diff --git a/web/src/views/Withdraw/Withdraw.tsx b/web/src/views/Withdraw/Withdraw.tsx index 3edd9498..eb33585b 100644 --- a/web/src/views/Withdraw/Withdraw.tsx +++ b/web/src/views/Withdraw/Withdraw.tsx @@ -42,6 +42,10 @@ const Withdraw = () => { }, [success]); const handleWithdrawal = () => { + if (!selectedAccountId) { + return; + } + const payload: ATMInput = { amount: value, message: t('Withdrew {{amount}} from account.', { amount: formatMoney(value, general) }), diff --git a/web/src/views/dashboard/components/AccountCards.tsx b/web/src/views/dashboard/components/AccountCards.tsx index 53cf9bb9..31e73825 100644 --- a/web/src/views/dashboard/components/AccountCards.tsx +++ b/web/src/views/dashboard/components/AccountCards.tsx @@ -1,4 +1,4 @@ -import { AccountCard, LoadingAccountCard } from '@components/Card'; +import { AccountCard, LoadingAccountCard } from '@components/AccountCard'; import CreateAccountModal from '@components/Modals/CreateAccount'; import { orderedAccountsAtom } from '@data/accounts'; import styled from '@emotion/styled'; @@ -89,7 +89,7 @@ const AccountCards = ({ onSelectAccount, selectedAccountId }: AccountCardsProps) {orderedAccounts.map((account) => ( onSelectAccount?.(account.id)}> - + ))} diff --git a/web/tsconfig.json b/web/tsconfig.json index bcd21407..e58dd45e 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es5", + "module": "esnext", "lib": [ "dom", "dom.iterable", @@ -13,7 +14,6 @@ "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "strictNullChecks": true, @@ -34,6 +34,7 @@ } }, "include": [ - "src/**/*" + "src/**/*", + "../shared/**/*" ] } \ No newline at end of file diff --git a/web/webpack.config.js b/web/webpack.config.js index e748f230..0d2bdc78 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -11,6 +11,7 @@ const deps = require('./package.json').dependencies; delete deps['@emotion/react']; delete deps['@emotion/styled']; delete deps['@mui/material']; +delete deps['@mui/icons-material']; delete deps['@mui/styles']; module.exports = (env, options) => ({ @@ -18,6 +19,7 @@ module.exports = (env, options) => ({ main: './src/bootstrapApp.ts', }, mode: 'development', + // devtool: 'none', output: { publicPath: 'auto', filename: '[name].js', @@ -31,7 +33,6 @@ module.exports = (env, options) => ({ 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', }, }, - devtool: 'eval-source-map', module: { rules: [ {