# esx_shops (/docs/addons/esx_shops)

esx_shops is the official store resource for ESX Legacy. It places 24/7, Rob's Liquor and LTD Gasoline stores on the map and opens a Svelte-based shopping cart UI where players pick items, choose cash or bank and pay. Use it as your general store system or as a template for your own item shops.

## Overview [#overview]

| | |
| --- | --- |
| Resource | `esx_shops` (version `2.0.0`) |
| Side | Client + server, NUI (`web/dist`) |
| Dependencies | `esx_lib`, `es_extended`, `oxmysql` |
| Optional | `esx_addonaccount` (tax collection), `ox_inventory` |
| SQL file | None |
| Config files | `shared/config/main.lua`, `shared/config/shops.lua` |

How a purchase works:

1. The player walks into a marker and presses the interaction key. The client asks the server for the player's tax rate and opens the NUI.
2. On checkout the client sends the cart through the `esx_shops:purchaseItems` callback.
3. The server rate-limits the request, checks the zone, the distance to the shop, the payment method, every item name, quantity and price against `Config.Zones`, and recomputes the total. The client total must match the server total within `Config.PriceTolerance`.
4. The server checks inventory weight, deducts money and adds the items. If adding an item fails, already added items are removed and the money is refunded.
5. The tax part of the price is deposited into `Config.TaxSocietyAccount` when tax collection is on.

## Installation [#installation]

<Steps>
<Step>
### Add the resource [#add-the-resource]

Place `esx_shops` in your resources folder. The repository ships a prebuilt UI in `web/dist`, so you only need Node.js if you change the Svelte source (`cd web && npm install && npm run build`).
</Step>
<Step>
### Ensure it after its dependencies [#ensure-it-after-its-dependencies]

```bash title="server.cfg"
ensure oxmysql
ensure esx_lib
ensure es_extended
ensure esx_addonaccount
ensure esx_shops
```
</Step>
<Step>
### Prepare the tax account (optional) [#prepare-the-tax-account-optional]

With `Config.EnableTaxCollection = true` the tax goes into the shared addon account named in `Config.TaxSocietyAccount` (`society_banker` by default). Create it if it does not exist:

```sql
INSERT IGNORE INTO `addon_account` (`name`, `label`, `shared`) VALUES ('society_banker', 'Bank', 1);
```
</Step>
</Steps>

With `Config.Inventory = 'esx'` the resource registers every configured shop item via `ESX.AddItems` on startup (label, `weight` or `Config.DefaultItemWeight`). You still want proper rows in your `items` table for items you use elsewhere.

## Configuration [#configuration]

### Main config (`shared/config/main.lua`) [#main-config-sharedconfigmainlua]

| Key | Default | Description |
| --- | --- | --- |
| `Config.Debug` | `false` | Prints validation warnings and errors to the console. |
| `Config.Inventory` | `'esx'` | `'esx'`, `'ox_inventory'` (or `'ox'`) or `'auto'`. Falls back to ESX if ox_inventory is configured but not started. |
| `Config.DefaultItemWeight` | `1` | Weight used when a shop item has no `weight`. |
| `Config.DefaultImagePath` | `"nui://esx_shops/web/images"` | Base path for item images. Set to `nil` or `''` to disable auto paths. |
| `Config.DefaultImageFormat` | `"png"` | File extension for auto-generated image paths. |
| `Config.TaxRate` | `0.19` | Tax share included in every price (19 %). |
| `Config.EnableTaxCollection` | `true` | Deposit the tax into the society account. `false` only shows it in the UI. |
| `Config.TaxSocietyAccount` | `'society_banker'` | Shared addon account that receives tax. |
| `Config.EnableTaxExemptions` | `true` | Lets jobs in `TaxExemptJobs` pay the net price. |
| `Config.TaxExemptJobs` | `{ 'police', 'ambulance' }` | Job names that do not pay tax. |
| `Config.DrawDistance` | `7.5` | Marker draw distance in meters. |
| `Config.MarkerSize` | `{x = 1.1, y = 0.7, z = 1.1}` | Marker scale. |
| `Config.MarkerType` | `29` | Marker type. |
| `Config.MarkerColor` | `{r = 50, g = 200, b = 50, a = 200}` | Marker color. |
| `Config.MovementThreshold` | `5.0` | Meters the player must move before nearby shops are recalculated. |
| `Config.SleepNear` / `SleepMedium` / `SleepFar` | `0` / `500` / `1500` | Marker thread sleep in ms (under 50 m, 50-100 m, further). |
| `Config.PurchaseCooldownMs` | `500` | Refill interval of the purchase rate limiter. |
| `Config.CooldownExpiryMs` | `10000` | Idle time after which limiter entries are cleaned up. |
| `Config.PurchaseRateLimitCapacity` | `3` | Token bucket size (burst of purchases allowed). |
| `Config.PurchaseRateLimitRefill` | `1` | Tokens added per interval. |
| `Config.PurchaseRateLimitIntervalMs` | `Config.PurchaseCooldownMs` | Interval for token refill. |
| `Config.MaxQuantityPerItem` | `999` | Maximum quantity per cart line. |
| `Config.MaxCartLines` | `25` | Maximum distinct items per purchase. |
| `Config.PriceTolerance` | `0.001` | Allowed difference between client and server total. |

### Shop zones (`shared/config/shops.lua`) [#shop-zones-sharedconfigshopslua]

`Config.Zones` ships with three zones: `TwentyFourSeven`, `RobsLiquor` and `LTDgasoline`. Each zone looks like this:

```lua title="shared/config/shops.lua"
Config.Zones.Hardware = {
    Items = {
        { name = 'repairkit', label = 'Repair Kit', price = 250, category = 'tools', weight = 2 },
        { name = 'bandage', label = 'Bandage', price = 40, category = 'medical' },
    },
    Categories = {
        { id = 'tools', label = 'Tools', icon = 'fa-solid fa-wrench' },
        { id = 'medical', label = 'Medical', icon = 'fa-solid fa-kit-medical' },
    },
    Pos = {
        vector3(2748.0, 3473.0, 55.7),
    },
    Size = 0.8,     -- blip scale
    Type = 59,      -- blip sprite
    Color = 25,     -- blip color
    ShowBlip = true,
    ShowMarker = true,
}
```

| Field | Type | Description |
| --- | --- | --- |
| `Items[].name` | `string` | Item name in your inventory. |
| `Items[].label` | `string` | Name shown in the UI. |
| `Items[].price` | `number` | Gross price per unit (tax included). Must be greater than 0. |
| `Items[].category` | `string?` | Category `id` for filtering. |
| `Items[].weight` | `number?` | Weight used when the item is auto-registered for the ESX inventory. |
| `Items[].image` | `string?` | Custom image URL. Otherwise `DefaultImagePath/<name>.<DefaultImageFormat>`. |
| `Items[].limit` | `number?` | Passed to the UI as a per-transaction limit. The server only enforces `Config.MaxQuantityPerItem`. |
| `Categories` | `table?` | `{ id, label, icon }` entries, icons are Font Awesome classes. |
| `Pos` | `vector3[]` | Every location of this shop. |
| `Size`, `Type`, `Color` | `number` | Blip scale, sprite and color. |
| `ShowBlip`, `ShowMarker` | `boolean` | Toggle blip and 3D marker. |

### Tax math [#tax-math]

Prices include tax. For a 100 item at a `TaxRate` of `0.19`, a normal player pays 100 and 15.97 goes to the society account. A tax-exempt player pays 84.03 and no tax is collected.

The UI colors follow the ESX theme convars (`esx:ui:*`) via `xLib.colors.getESXTheme`.

## Developer API [#developer-api]

### Server export `IsItemInShop` [#server-export-isiteminshop]

```lua
exports.esx_shops:IsItemInShop(itemName, zone)
```

| Name | Type | Description |
| --- | --- | --- |
| `itemName` | `string` | Item name to look up. |
| `zone` | `string` | Key in `Config.Zones`, e.g. `'TwentyFourSeven'`. |

Returns `true` when the item is sold in that zone, otherwise `false`.

```lua title="server/main.lua"
if exports.esx_shops:IsItemInShop('lockpick', 'TwentyFourSeven') then
    print('Lockpicks are sold in 24/7 stores')
end
```

### Shared exports `GetShopItems` / `GetShopCategories` [#shared-exports-getshopitems--getshopcategories]

Both are available on client and server and are marked as legacy. They return the `Items` or `Categories` table of a zone, or `{}` if the zone does not exist.

```lua
local items = exports.esx_shops:GetShopItems('RobsLiquor')
```

### Client exports [#client-exports]

| Export | Returns |
| --- | --- |
| `exports.esx_shops:IsShopOpen()` | `true` while the shop UI is open. |
| `exports.esx_shops:GetCurrentShop()` | The zone name of the open shop, or `nil`. |

### Client event `esx_shops:forceOpenShop` [#client-event-esx_shopsforceopenshop]

Opens the shop UI for a zone if it exists in `Config.Zones`.

```lua title="server/main.lua"
TriggerClientEvent('esx_shops:forceOpenShop', playerId, 'TwentyFourSeven')
```

Purchases still require the player to stand within 5 meters of one of that zone's `Pos` entries, so this is only useful near a real shop location (for example from a target or ped script).

### Callbacks and network events [#callbacks-and-network-events]

| Name | Type | Notes |
| --- | --- | --- |
| `esx_shops:purchaseItems` | Server callback | Used by the NUI. Arguments: `purchaseData` (`{ items, total, paymentMethod }`) and `zone`. |
| `esx_shops:getTaxRate` | Server callback | Returns the player's tax rate and an optional exemption message. |
| `esx_shops:legacyPurchase` | Server net event | Compatibility event: `(zone, items, paymentMethod)`. Answers with the client event `esx_shops:legacyPurchaseResponse(success, message)`. |
| `esx_shops:openShop` | Net event | Legacy placeholder. It only validates the argument and does nothing else. |

<Callout type="info" title="Client-triggerable">
`esx_shops:purchaseItems` and `esx_shops:legacyPurchase` can be called by any client. Both go through the same server pipeline (rate limit, distance check, server-side prices, inventory space, refund on failure), so clients cannot set prices. Keep `Config.Zones` the only source of prices and do not add your own purchase events that skip `ProcessPurchase`.
</Callout>

## Common issues [#common-issues]

- **"Not enough money" although the player has cash.** The payment method chosen in the UI decides between `money` and `bank`.
- **Tax is not deposited.** `Config.TaxSocietyAccount` does not exist in `addon_account`, or `esx_addonaccount` is not started. Enable `Config.Debug` to see the warning.
- **Item not added / inventory full.** With the ESX inventory, every item must be registered and fit the player's max weight. With ox_inventory, `CanCarryItem` must succeed.
- **Images missing.** Put a PNG named after the item into `web/images` or set `image` on the item.
- **Shop opens but purchases fail far from the marker.** See the distance check above.

## Related [#related]

- [esx_addonaccount](/docs/addons/esx_addonaccount)
- [esx_weaponshop](/docs/addons/esx_weaponshop)
- [Adding items](/docs/guides/add-items)
- [ox_inventory with ESX](/docs/guides/ox-inventory)
