From 0825fb9d43e484ad4b250abea890cb7ba8b00009 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 27 Jan 2026 17:06:24 +0100 Subject: [PATCH 1/7] feat: added payment selector system --- bun.lock | 5 +- locales/en.json | 12 +- package.json | 4 +- src/client/index.ts | 44 +++++- src/common/typings/index.ts | 1 + src/common/typings/payselector.ts | 22 +++ src/server/index.ts | 80 ++++++++++- src/web/src/App.tsx | 4 +- src/web/src/layouts/dev/DeveloperDrawer.tsx | 4 +- .../paymentselector/PaymentSelector.tsx | 126 ++++++++++++++++++ .../components/AccountSelector.tsx | 90 +++++++++++++ .../components/PaymentDetails.tsx | 22 +++ .../components/PaymentStatus.tsx | 42 ++++++ src/web/src/state/visibility.ts | 6 +- 14 files changed, 447 insertions(+), 15 deletions(-) create mode 100644 src/common/typings/payselector.ts create mode 100644 src/web/src/layouts/paymentselector/PaymentSelector.tsx create mode 100644 src/web/src/layouts/paymentselector/components/AccountSelector.tsx create mode 100644 src/web/src/layouts/paymentselector/components/PaymentDetails.tsx create mode 100644 src/web/src/layouts/paymentselector/components/PaymentStatus.tsx diff --git a/bun.lock b/bun.lock index d1e0417..65f2818 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ox_banking", @@ -45,7 +46,7 @@ "fast-printf": "^1.6.9", "jotai": "^2.10.0", "jotai-tanstack-query": "^0.7.2", - "lucide-react": "^0.428.0", + "lucide-react": "^0.563.0", "prettier": "^3.3.3", "react": "^18.3.1", "react-day-picker": "8.10.1", @@ -562,7 +563,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.428.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-rGrzslfEcgqwh+TLBC5qJ8wvVIXhLvAIXVFKNHndYyb1utSxxn9rXOC+1CNJLi6yNOooyPqIs6+3YCp6uSiEvg=="], + "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], "mariadb": ["mariadb@3.4.2", "", { "dependencies": { "@types/geojson": "^7946.0.14", "@types/node": "^22.5.4", "denque": "^2.1.0", "iconv-lite": "^0.6.3", "lru-cache": "^10.3.0" } }, "sha512-B17vhYRHDMQ1XXvhSWsvKJbpw3Q8B6py93ThBEXZXSgxIbqnKqoHK1RzoPLbIxoEzWN3jA86ZaMMc3IG6L5wsw=="], diff --git a/locales/en.json b/locales/en.json index 005f8ae..1f890f2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -158,9 +158,19 @@ "no_permission": "You are not authorised to perform this action.", "invalid_input": "Invalid input.", "insufficient_funds": "Insufficient funds", + "insufficient_balance": "Insufficient funds", "no_balance": "No balance", "no_access": "No access", + "time_out": "Action timed out", "something_went_wrong": "Unknown error, something went wrong", "account_id_not_exists": "No account with such id found", - "same_account_transfer": "Cannot transfer money to the same account" + "same_account_transfer": "Cannot transfer money to the same account", + "pay_selector_title": "Select Payment Method", + "pay_selector_reason": "Purchase", + "pay_selector_no_reason": "Amount", + "pay_selector_amount": "Amount", + "pay_selector_status": "Payment Status", + "pay_selector_awaiting_payment": "Awaiting Payment...", + "pay_selector_processing": "Processing", + "pay_selector_denied": "Action denied for this account" } diff --git a/package.json b/package.json index a530d66..4f9d7c8 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "fast-printf": "^1.6.9", "jotai": "^2.10.0", "jotai-tanstack-query": "^0.7.2", - "lucide-react": "^0.428.0", + "lucide-react": "^0.563.0", "prettier": "^3.3.3", "react": "^18.3.1", "react-day-picker": "8.10.1", @@ -77,4 +77,4 @@ "engines": { "node": ">=16.9.1" } -} \ No newline at end of file +} diff --git a/src/client/index.ts b/src/client/index.ts index 8ea7156..0334648 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,7 +1,15 @@ import { Config, LoadJsonFile, Locale } from '@common/.'; import { OxAccountPermissions, OxAccountRole } from '@communityox/ox_core'; -import { cache, getLocales, hideTextUI, requestAnimDict, sleep, waitFor } from '@communityox/ox_lib/client'; -import type { Character } from '../common/typings'; +import { + cache, + getLocales, + hideTextUI, + onServerCallback, + requestAnimDict, + sleep, + waitFor, +} from '@communityox/ox_lib/client'; +import type { Character, NuiPaySelectionData, NuiPaySelectResponse, PaySelectNuiData } from '../common/typings'; import { SendTypedNUIMessage, serverNuiCallback } from './utils'; let hasLoadedUi = false; @@ -44,10 +52,9 @@ const openAtm = async ({ entity }: { entity: number }) => { const [cX, cY, cZ] = GetEntityCoords(entity, false); const [pX, pY, pZ] = GetEntityCoords(cache.ped, false); - const doAnim = (entity && DoesEntityExist(entity) && Math.abs((cX - cY) + (cZ - pX) + (pY - pZ)) < 5.0) + const doAnim = entity && DoesEntityExist(entity) && Math.abs(cX - cY + (cZ - pX) + (pY - pZ)) < 5.0; - if (doAnim) - { + if (doAnim) { const [x, y, z] = GetOffsetFromEntityInWorldCoords(entity, 0, -0.7, 1); const heading = GetEntityHeading(entity); const sequence = OpenSequenceTask(0) as unknown as number; @@ -156,6 +163,33 @@ on('ox_inventory:itemCount', (itemName: string, count: number) => { SendTypedNUIMessage('refreshCharacter', { cash: count }); }); +let currentPaymentPromise: Function | null = null; +onServerCallback( + 'ox_banking:usePaySelector', + async (amount: PaySelectNuiData['amount'], reason: PaySelectNuiData['reason']) => { + setupUi(); + SetNuiFocus(true, true); + SendTypedNUIMessage('openPaySelector', { amount, reason }); + + return new Promise((resolve) => { + currentPaymentPromise = (data: NuiPaySelectionData) => resolve(data); + }); + } +); + +RegisterNuiCallback('confirmPayment', async (data: NuiPaySelectionData, cb: Function) => { + if (currentPaymentPromise) { + currentPaymentPromise(data); + currentPaymentPromise = null; + } + + cb({ received: true }); +}); + +onNet('ox_banking:paySelectorResponse', (response: { success: boolean; message: string }) => { + SendTypedNUIMessage('paySelectorResult', response); +}); + serverNuiCallback('getDashboardData'); serverNuiCallback('transferOwnership'); serverNuiCallback('manageUser'); diff --git a/src/common/typings/index.ts b/src/common/typings/index.ts index ab09ded..adc3ba2 100644 --- a/src/common/typings/index.ts +++ b/src/common/typings/index.ts @@ -2,3 +2,4 @@ export * from './game'; export * from './accounts'; export * from './character'; export * from './nui'; +export * from './payselector'; diff --git a/src/common/typings/payselector.ts b/src/common/typings/payselector.ts new file mode 100644 index 0000000..d10e904 --- /dev/null +++ b/src/common/typings/payselector.ts @@ -0,0 +1,22 @@ +export type NuiPaySelectResponse = + | { + success: true; + } + | { + success: false; + message: string; + }; + +export type NuiPaySelectionData = + | { + cancelled: false; + accountId: number; + } + | { + cancelled: true; + }; + +export interface PaySelectNuiData { + reason: string; + amount: number; +} diff --git a/src/server/index.ts b/src/server/index.ts index 9c49af0..f9ed4fa 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,12 @@ import type { OxAccountRole, OxAccountUserMetadata } from '@communityox/ox_core'; import { CreateAccount, GetAccount, GetCharacterAccount, GetPlayer } from '@communityox/ox_core/server'; -import { onClientCallback, versionCheck, checkDependency } from '@communityox/ox_lib/server'; +import { + onClientCallback, + versionCheck, + checkDependency, + triggerClientCallback, + locale, +} from '@communityox/ox_lib/server'; import { oxmysql } from '@communityox/oxmysql'; import type { DateRange } from 'react-day-picker'; import type { @@ -11,6 +17,7 @@ import type { Invoice, InvoicesFilters, LogsFilters, + NuiPaySelectionData, RawLogItem, Transaction, } from '../common/typings'; @@ -137,7 +144,7 @@ onClientCallback( if (!hasPermission) return; - target = typeof(target) == "string" ? Number.parseInt(target) : target + target = typeof target == 'string' ? Number.parseInt(target) : target; let targetAccountId = 0; try { @@ -820,3 +827,72 @@ function sanitizeSearch(search: string) { return search === '+*' ? null : search; } + +let usingSelector: number[] = []; +async function usePaySelector(playerId: number, amount: number, reason: string = '') { + if (usingSelector.includes(playerId)) return { success: false, message: 'already_using' }; + + const player = GetPlayer(playerId); + if (!player) return { success: false, message: 'no_player' }; + + const closeSelector = (success: boolean, message?: string | null) => { + usingSelector = usingSelector.filter((id) => id !== playerId); + return { success, message }; + }; + + usingSelector.push(playerId); + + let response: NuiPaySelectionData | void; + try { + response = await triggerClientCallback('ox_banking:usePaySelector', playerId, amount, reason); + } catch { + // doubt much would error excluding a time out + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale('time_out'), + }); + + return closeSelector(false, 'time_out'); + } + + if (!response || response.cancelled === true) { + return closeSelector(false, !response ? 'failed_to_send' : 'payment_cancelled'); + } + + const account = await GetAccount(response.accountId); + const hasPermission = await account.playerHasPermission(playerId, 'payInvoice'); + const hasFunds = await account.get('balance'); + + if (!hasPermission) { + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale('pay_selector_denied'), + }); + return closeSelector(false, 'unauthorised'); + } + + if (hasFunds > amount) { + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale('pay_selector_denied'), + }); + return closeSelector(false, 'insufficient_funds'); + } + + const actionResponse = await account.removeBalance({ + amount, + overdraw: false, + message: `Payment by ${player.get('firstName')} ${player.get('lastName')} for ${reason}`, + }); + + if (!actionResponse.success) { + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale(actionResponse.message), + }); + } + + return closeSelector(actionResponse.success, actionResponse.message); +} + +exports('usePaySelector', usePaySelector); diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index a7e6156..708647e 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -7,6 +7,7 @@ import DeveloperDrawer from './layouts/dev/DeveloperDrawer'; import locales, { setLocale } from './locales'; import { Permissions, setPermissions } from './permissions'; import { isEnvBrowser } from './utils/misc'; +import PaymentSelector from './layouts/paymentselector/PaymentSelector'; const App: React.FC = () => { useNuiEvent('setInitData', (data: { locales: typeof locales; permissions: Permissions }) => { @@ -18,8 +19,9 @@ const App: React.FC = () => {
- {isEnvBrowser() && } + + {isEnvBrowser() && }
); diff --git a/src/web/src/layouts/dev/DeveloperDrawer.tsx b/src/web/src/layouts/dev/DeveloperDrawer.tsx index 6ae3cb2..1c1ff69 100644 --- a/src/web/src/layouts/dev/DeveloperDrawer.tsx +++ b/src/web/src/layouts/dev/DeveloperDrawer.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { Wrench } from 'lucide-react'; import { Button } from '../../components/ui/button'; -import { useAtmVisibilityState, useBankVisibilityState } from '../../state/visibility'; +import { useAtmVisibilityState, useBankVisibilityState, usePaySelectorVisibilityState } from '../../state/visibility'; import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; const DeveloperDrawer: React.FC = () => { const [open, setOpen] = React.useState(false); const [bankVisibility, setBankVisibility] = useBankVisibilityState(); const [atmVisibility, setAtmVisibility] = useAtmVisibilityState(); + const [paySelectorVisibility, setPaySelectorVisibility] = usePaySelectorVisibilityState(); return ( <> @@ -24,6 +25,7 @@ const DeveloperDrawer: React.FC = () => { + diff --git a/src/web/src/layouts/paymentselector/PaymentSelector.tsx b/src/web/src/layouts/paymentselector/PaymentSelector.tsx new file mode 100644 index 0000000..619d915 --- /dev/null +++ b/src/web/src/layouts/paymentselector/PaymentSelector.tsx @@ -0,0 +1,126 @@ +import React, { Suspense } from 'react'; +import { usePaySelectorVisibilityState } from '@/state/visibility'; +import { Check, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useExitListener } from '@/hooks/useExitListener'; +import { useNuiEvent } from '@/hooks/useNuiEvent'; +import AccountSelector from './components/AccountSelector'; +import { fetchNui } from '@/utils/fetchNui'; +import SpinningLoader from '@/components/SpinningLoader'; +import locales from '@/locales'; +import { Account, PaySelectNuiData } from '~/src/common/typings'; +import { delay } from '@/utils/misc'; +import PaymentDetails from './components/PaymentDetails'; +import PaymentStatus from './components/PaymentStatus'; + +const PaymentSelector: React.FC = () => { + const [visible, setVisible] = usePaySelectorVisibilityState(); + const [errorMessage, setErrorMessage] = React.useState(null); + const [isProcessing, setIsProcessing] = React.useState(false); + const [paymentDetails, setPayDetails] = React.useState({ amount: 0, reason: '' }); + const [selectedAccount, setSelectedAccount] = React.useState({ + id: 0, + balance: 0, + type: 'personal', + label: '', + role: 'viewer', + }); + + const [shouldRender, setShouldRender] = React.useState(false); + + React.useEffect(() => { + if (visible) setShouldRender(true); + }, [visible]); + + useExitListener((visible) => { + setVisible(visible); + confirmPayment(true); + }); + + useNuiEvent('openPaySelector', (data: PaySelectNuiData) => { + setErrorMessage(null); + setPayDetails(data); + setIsProcessing(false); + setVisible(true); + }); + + useNuiEvent('paySelectorResult', (data: { success: boolean, message: string }) => { + setIsProcessing(false); + if (data.success) { + handleClose(); + } else { + setErrorMessage(data.message); + } + }); + + const confirmPayment = async (cancelled: boolean) => { + return await fetchNui( + 'confirmPayment', + cancelled + ? { cancelled: true } + : { cancelled: false, accountId: selectedAccount?.id } + ); + } + + const handleClose = () => { + confirmPayment(true); + fetchNui('exit').then(); + setVisible(false); + } + + const handlePayment = React.useCallback( + async () => { + setIsProcessing(true); + await delay(500); + await confirmPayment(false); + }, + [selectedAccount] + ); + + return ( + <> + {shouldRender && ( +
!visible && setShouldRender(false)} + data-state={visible ? 'open' : 'closed'} + className="bg-background fill-mode-forwards data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-bottom relative flex min-w-[400px] max-w-lg flex-col gap-2 rounded-lg p-2" + > + + +
+ } + > + <> + + + +
+ + +
+ + + + )} + + ); +}; + +export default PaymentSelector; diff --git a/src/web/src/layouts/paymentselector/components/AccountSelector.tsx b/src/web/src/layouts/paymentselector/components/AccountSelector.tsx new file mode 100644 index 0000000..fbcaf1b --- /dev/null +++ b/src/web/src/layouts/paymentselector/components/AccountSelector.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { useAccounts } from '@/state/accounts'; +import locales from '../../../locales'; +import { ChevronDown, ChevronUp, CreditCard } from 'lucide-react'; +import { formatNumber } from '@/utils/formatNumber'; +import { Button } from '@/components/ui/button'; +import BaseCard from '../../bank/components/BaseCard'; +import { Carousel, CarouselApi, CarouselContent, CarouselItem } from '@/components/ui/carousel'; +import { Account } from '@/typings'; + +interface Props { + setSelectedAccount: React.Dispatch>; +} + +const AccountSelector: React.FC = ({ setSelectedAccount }) => { + const { accounts } = useAccounts(); + const [api, setApi] = React.useState(); + const [current, setCurrent] = React.useState(0); + + React.useEffect(() => { + if (!api) { + return; + } + + setCurrent(api.selectedScrollSnap()); + setSelectedAccount(accounts[api.selectedScrollSnap()]); + + api.on('select', () => { + setCurrent(api.selectedScrollSnap()); + setSelectedAccount(accounts[api.selectedScrollSnap()]); + }); + }, [api]); + + return ( + +
+ + + {accounts.map((account) => ( + +
+
+

{account.label}

+

+ {account.type === 'personal' + ? locales.personal_account + : account.type === 'shared' + ? account.role === 'owner' + ? locales.shared_account + : account.owner + : locales.group_account} +

+
+ +
+

{formatNumber(account.balance)}

+

{account.id}

+
+
+
+ ))} +
+
+
+ + +
+
+

+ {current + 1} / {accounts.length} +

+
+ ); +}; + +export default AccountSelector; diff --git a/src/web/src/layouts/paymentselector/components/PaymentDetails.tsx b/src/web/src/layouts/paymentselector/components/PaymentDetails.tsx new file mode 100644 index 0000000..ebb73d5 --- /dev/null +++ b/src/web/src/layouts/paymentselector/components/PaymentDetails.tsx @@ -0,0 +1,22 @@ +import BaseCard from "@/layouts/bank/components/BaseCard" +import locales from "@/locales"; +import { formatNumber } from "@/utils/formatNumber"; +import { Banknote } from "lucide-react" + +interface Props { + reason: string; + amount: number; +} + +const PaymentDetails: React.FC = ({ reason, amount }) => { + const formattedReason = reason.trim() !== '' ? reason.trim() : locales.pay_selector_no_reason; + + return ( + +

{locales.pay_selector_reason}: {formattedReason}

+

{locales.pay_selector_amount}: {formatNumber(amount)}

+
+ ); +} + +export default PaymentDetails; diff --git a/src/web/src/layouts/paymentselector/components/PaymentStatus.tsx b/src/web/src/layouts/paymentselector/components/PaymentStatus.tsx new file mode 100644 index 0000000..300c8bd --- /dev/null +++ b/src/web/src/layouts/paymentselector/components/PaymentStatus.tsx @@ -0,0 +1,42 @@ +import SpinningLoader from "@/components/SpinningLoader"; +import BaseCard from "@/layouts/bank/components/BaseCard"; +import locales from "@/locales"; +import { AlertCircle, Clock, ReceiptText } from "lucide-react"; + +interface Props { + processing: boolean; + error: string | null; +} + +const PaymentStatus: React.FC = ({ processing, error }) => { + return ( + +
+ + {processing ? ( +
+ +

+ {locales.pay_selector_processing} +

+
+ ) : error ? ( +
+ +

{error}

+
+ ) : ( +
+ +

+ {locales.pay_selector_awaiting_payment} +

+
+ )} + +
+
+ ); +}; + +export default PaymentStatus; diff --git a/src/web/src/state/visibility.ts b/src/web/src/state/visibility.ts index ff1cda7..eecc221 100644 --- a/src/web/src/state/visibility.ts +++ b/src/web/src/state/visibility.ts @@ -1,8 +1,8 @@ import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { isEnvBrowser } from '@/utils/misc'; const bankVisibilityAtom = atom(false); const atmVisibilityAtom = atom(false); +const paySelectorVisibilityAtom = atom(false); export const useSetBankVisibility = () => useSetAtom(bankVisibilityAtom); export const useBankVisibility = () => useAtomValue(bankVisibilityAtom); @@ -11,3 +11,7 @@ export const useBankVisibilityState = () => useAtom(bankVisibilityAtom); export const useSetAtmVisibility = () => useSetAtom(atmVisibilityAtom); export const useAtmVisibility = () => useAtomValue(atmVisibilityAtom); export const useAtmVisibilityState = () => useAtom(atmVisibilityAtom); + +export const useSetPaySelectorVisibility = () => useSetAtom(paySelectorVisibilityAtom); +export const usePaySelectorVisibility = () => useAtomValue(paySelectorVisibilityAtom); +export const usePaySelectorVisibilityState = () => useAtom(paySelectorVisibilityAtom); From eda0e7264005a2c97a43764390f5cd7e599ae67e Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 27 Jan 2026 20:02:27 +0100 Subject: [PATCH 2/7] refactor(server): allow user to pay with cash --- src/server/index.ts | 73 ++++++++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index f9ed4fa..634ead1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -859,40 +859,57 @@ async function usePaySelector(playerId: number, amount: number, reason: string = return closeSelector(false, !response ? 'failed_to_send' : 'payment_cancelled'); } - const account = await GetAccount(response.accountId); - const hasPermission = await account.playerHasPermission(playerId, 'payInvoice'); - const hasFunds = await account.get('balance'); + // response from client will have accountId set to -1 if paying with cash + if (response.accountId > 0) { + const account = await GetAccount(response.accountId); + const hasPermission = await account.playerHasPermission(playerId, 'payInvoice'); + const hasFunds = await account.get('balance'); + + if (!hasPermission) { + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale('pay_selector_denied'), + }); + return closeSelector(false, 'unauthorised'); + } - if (!hasPermission) { - player.emit('ox_banking:paySelectorResponse', { - success: false, - message: locale('pay_selector_denied'), - }); - return closeSelector(false, 'unauthorised'); - } + if (hasFunds > amount) { + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale('pay_selector_denied'), + }); + return closeSelector(false, 'insufficient_funds'); + } - if (hasFunds > amount) { - player.emit('ox_banking:paySelectorResponse', { - success: false, - message: locale('pay_selector_denied'), + const actionResponse = await account.removeBalance({ + amount, + overdraw: false, + message: `Payment by ${player.get('firstName')} ${player.get('lastName')} for ${reason}`, }); - return closeSelector(false, 'insufficient_funds'); - } - const actionResponse = await account.removeBalance({ - amount, - overdraw: false, - message: `Payment by ${player.get('firstName')} ${player.get('lastName')} for ${reason}`, - }); + if (!actionResponse.success) { + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale(actionResponse.message), + }); + } - if (!actionResponse.success) { - player.emit('ox_banking:paySelectorResponse', { - success: false, - message: locale(actionResponse.message), - }); - } + return closeSelector(actionResponse.success, actionResponse.message); + } else { + const hasFunds = exports.ox_inventory.GetItemCount(playerId, 'money') as number; - return closeSelector(actionResponse.success, actionResponse.message); + if (hasFunds > amount) { + player.emit('ox_banking:paySelectorResponse', { + success: false, + message: locale('pay_selector_denied'), + }); + return closeSelector(false, 'insufficient_funds'); + } + + exports.ox_inventory.RemoveItem(playerId, 'money', amount); + + return closeSelector(true); + } } exports('usePaySelector', usePaySelector); From cbcd0898bf7830f0654d8fee3f453dd991b7bb7a Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 27 Jan 2026 20:03:06 +0100 Subject: [PATCH 3/7] refactor(web/paymentselector): added cash option to AccountSelector with id -1 --- .../components/AccountSelector.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/web/src/layouts/paymentselector/components/AccountSelector.tsx b/src/web/src/layouts/paymentselector/components/AccountSelector.tsx index fbcaf1b..47af569 100644 --- a/src/web/src/layouts/paymentselector/components/AccountSelector.tsx +++ b/src/web/src/layouts/paymentselector/components/AccountSelector.tsx @@ -7,12 +7,14 @@ import { Button } from '@/components/ui/button'; import BaseCard from '../../bank/components/BaseCard'; import { Carousel, CarouselApi, CarouselContent, CarouselItem } from '@/components/ui/carousel'; import { Account } from '@/typings'; +import { useCharacter } from '@/state/character'; interface Props { setSelectedAccount: React.Dispatch>; } const AccountSelector: React.FC = ({ setSelectedAccount }) => { + const { cash } = useCharacter(); const { accounts } = useAccounts(); const [api, setApi] = React.useState(); const [current, setCurrent] = React.useState(0); @@ -22,12 +24,22 @@ const AccountSelector: React.FC = ({ setSelectedAccount }) => { return; } + const carouselIndex = api.selectedScrollSnap() + const account: Account = carouselIndex === 0 + ? { + id: -1, + label: locales.cash, + type: 'personal', + role: 'owner', + } as Account + : accounts[carouselIndex - 1]; + setCurrent(api.selectedScrollSnap()); - setSelectedAccount(accounts[api.selectedScrollSnap()]); + setSelectedAccount(account); api.on('select', () => { setCurrent(api.selectedScrollSnap()); - setSelectedAccount(accounts[api.selectedScrollSnap()]); + setSelectedAccount(account); }); }, [api]); @@ -36,6 +48,18 @@ const AccountSelector: React.FC = ({ setSelectedAccount }) => {
+ +
+
+

{locales.cash}

+

{locales.personal_account}

+
+ +
+

{formatNumber(cash)}

+
+
+
{accounts.map((account) => (
From 24b218d836cf76fdfbd24d6ba5dabe7d6b529e9d Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 27 Jan 2026 20:03:31 +0100 Subject: [PATCH 4/7] chore(locales/en): added cash locale field --- locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/locales/en.json b/locales/en.json index 1f890f2..bbe6220 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3,6 +3,7 @@ "date_format": "yyyy/MM/dd HH:mm", "currency": "USD", "bank": "Bank", + "cash": "Cash", "target_access_bank": "Access bank", "target_access_atm": "Access ATM", "text_ui_access_bank": "[E] - Access bank", From 84f1dce0630b1311b8a2a4655a564f7a6ae686e9 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 27 Jan 2026 21:41:43 +0100 Subject: [PATCH 5/7] tweak(locales/en): pay selector title --- locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/en.json b/locales/en.json index bbe6220..6ae4345 100644 --- a/locales/en.json +++ b/locales/en.json @@ -166,7 +166,7 @@ "something_went_wrong": "Unknown error, something went wrong", "account_id_not_exists": "No account with such id found", "same_account_transfer": "Cannot transfer money to the same account", - "pay_selector_title": "Select Payment Method", + "pay_selector_title": "Payment Method", "pay_selector_reason": "Purchase", "pay_selector_no_reason": "Amount", "pay_selector_amount": "Amount", From eb0af7505be905434ea4f73fd2070022dbdfe9c2 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 27 Jan 2026 21:42:10 +0100 Subject: [PATCH 6/7] tweak(web/layouts): PaymentStatus styling --- .../paymentselector/components/PaymentStatus.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/web/src/layouts/paymentselector/components/PaymentStatus.tsx b/src/web/src/layouts/paymentselector/components/PaymentStatus.tsx index 300c8bd..676b30a 100644 --- a/src/web/src/layouts/paymentselector/components/PaymentStatus.tsx +++ b/src/web/src/layouts/paymentselector/components/PaymentStatus.tsx @@ -11,29 +11,27 @@ interface Props { const PaymentStatus: React.FC = ({ processing, error }) => { return ( -
- +
{processing ? (
-

+

{locales.pay_selector_processing}

) : error ? ( -
- +
+

{error}

) : ( -
- +
+

{locales.pay_selector_awaiting_payment}

)} -
); From c5772840cbbd46bc24764da4be0cf51b18aa41e2 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 27 Jan 2026 21:55:35 +0100 Subject: [PATCH 7/7] fix(web/layouts): AccountSelector pagination value being off by 1 value --- .../src/layouts/paymentselector/components/AccountSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/src/layouts/paymentselector/components/AccountSelector.tsx b/src/web/src/layouts/paymentselector/components/AccountSelector.tsx index 47af569..01b060d 100644 --- a/src/web/src/layouts/paymentselector/components/AccountSelector.tsx +++ b/src/web/src/layouts/paymentselector/components/AccountSelector.tsx @@ -105,7 +105,7 @@ const AccountSelector: React.FC = ({ setSelectedAccount }) => {

- {current + 1} / {accounts.length} + {current + 1} / {accounts.length + 1}

);