> For the complete documentation index, see [llms.txt](https://docs.ohalee.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ohalee.com/products/networkeconomy/for-developers.md).

# For Developers

NetworkEconomy exposes a stable, fully asynchronous API plus two Bukkit events. Everything in the `com.ohalee.networkeconomy.api` package is the public surface; anything outside it is internal and may change.

## Getting the API

Depend on NetworkEconomy in your `plugin.yml` so load order is guaranteed:

```yaml
depend: [NetworkEconomy]      # or softdepend if the hook is optional
```

Then grab the instance from your own `onEnable`:

```java
NetworkEconomyAPI economy = NetworkEconomyProvider.get();
```

With `softdepend`, guard the call:

```java
if (NetworkEconomyProvider.isAvailable()) {
    NetworkEconomyAPI economy = NetworkEconomyProvider.get();
}
```

The API is also registered with the Bukkit `ServicesManager`. `get()` throws `IllegalStateException` if NetworkEconomy is not loaded yet, and the provider is cleared when the plugin disables.

## Threading model

{% hint style="warning" %}
**Every** querying and mutating method is asynchronous and returns a `CompletableFuture`. Futures complete on an internal worker thread, **never** on the main server thread — so any Bukkit call inside a callback must be scheduled back onto the main thread.
{% endhint %}

```java
economy.getBalance(uuid).thenAccept(balance -> {
    // off the main thread!
    Bukkit.getScheduler().runTask(plugin, () -> {
        player.sendMessage("You have " + balance);
    });
});
```

This is deliberate: all operations are backed by an authoritative SQL store using atomic conditional updates, which is exactly what makes concurrent writes from different network nodes safe.

### Fast reads for UI

For scoreboards, tab lists and anything that needs a number *now*, use the cached read. It performs no I/O and returns empty if the player is not cached (normally meaning they are not on this server).

```java
Optional<BigDecimal> cached = economy.getCachedBalance(uuid, economy.getDefaultCurrency());
```

## Currencies

```java
Currency def = economy.getDefaultCurrency();          // the Vault-exposed one
Optional<Currency> gems = economy.getCurrency("gems"); // by id, case-insensitive
Collection<Currency> all = economy.getCurrencies();
```

`Currency` is immutable and equality is by id. Useful members:

<table><thead><tr><th width="255">Member</th><th>Description</th></tr></thead><tbody><tr><td>getId()</td><td>Lowercase config key</td></tr><tr><td>getDisplayName() / getDisplayNamePlural()</td><td>Human-readable names</td></tr><tr><td>getSymbol() / isSymbolBefore()</td><td>Symbol and its position</td></tr><tr><td>getDecimals()</td><td>Fractional digits</td></tr><tr><td>getStartingBalance()</td><td>Balance for new accounts</td></tr><tr><td>isPayable()</td><td>Whether <code>/pay</code> accepts it</td></tr><tr><td>isDefault()</td><td>Whether this is the Vault-exposed currency</td></tr><tr><td>normalize(BigDecimal)</td><td>Round to this currency's precision (half-up)</td></tr><tr><td>format(BigDecimal)</td><td>Format with grouping and symbol, e.g. <code>$1,250.00</code></td></tr></tbody></table>

## Reading balances

```java
CompletableFuture<BigDecimal> getBalance(UUID player);                       // default currency
CompletableFuture<BigDecimal> getBalance(UUID player, Currency currency);
CompletableFuture<Boolean>    has(UUID player, Currency currency, BigDecimal amount);
Optional<BigDecimal>          getCachedBalance(UUID player, Currency currency);
```

## Mutating balances

```java
CompletableFuture<EconomyResult> deposit (UUID player, Currency c, BigDecimal amount, String reason);
CompletableFuture<EconomyResult> withdraw(UUID player, Currency c, BigDecimal amount, String reason);
CompletableFuture<EconomyResult> set     (UUID player, Currency c, BigDecimal amount, String reason);
CompletableFuture<EconomyResult> transfer(UUID from, UUID to, Currency c, BigDecimal amount, String reason);
```

`reason` is stored on the transaction row (up to 128 characters) and shown in the history GUI. Use something identifiable — `"shop:diamond_sword"`, `"quest:reward:12"` — so the audit log stays useful.

```java
economy.withdraw(uuid, economy.getDefaultCurrency(),
        new BigDecimal("50.00"), "shop:diamond_sword")
    .thenAccept(result -> {
        if (result.isSuccess()) {
            BigDecimal now = result.newBalance();
        } else if (result.status() == EconomyResult.Status.INSUFFICIENT_FUNDS) {
            // not enough money — nothing was charged
        }
    });
```

{% hint style="danger" %}
Never implement your own "check then withdraw": `has(...)` followed by `withdraw(...)` is a race, because another server can spend the money in between. `withdraw` already performs the sufficiency check and the debit in a **single atomic statement** — just call it and handle `INSUFFICIENT_FUNDS`.
{% endhint %}

`transfer` moves money inside one database transaction: either both sides apply or neither does. Use it instead of a withdraw-then-deposit pair.

### EconomyResult

<table><thead><tr><th width="255">Member</th><th>Description</th></tr></thead><tbody><tr><td>status()</td><td>The outcome — see below</td></tr><tr><td>isSuccess()</td><td><code>true</code> when the operation was persisted</td></tr><tr><td>newBalance()</td><td>Balance after the operation, or the current balance on failure</td></tr><tr><td>message()</td><td>Optional detail, <code>null</code> on success</td></tr></tbody></table>

<table><thead><tr><th width="255">Status</th><th>Meaning</th></tr></thead><tbody><tr><td>SUCCESS</td><td>Completed and persisted</td></tr><tr><td>INSUFFICIENT_FUNDS</td><td>Not enough money for a withdrawal or transfer</td></tr><tr><td>ACCOUNT_NOT_FOUND</td><td>The player has never joined the network</td></tr><tr><td>CANCELLED</td><td>A <code>PreTransactionEvent</code> handler vetoed it</td></tr><tr><td>ERROR</td><td>Unexpected failure (SQL, connectivity, …)</td></tr></tbody></table>

## History and listings

```java
CompletableFuture<List<TransactionRecord>> getHistory(UUID player, int limit, int offset);
CompletableFuture<List<BalanceEntry>>      getTopBalances(Currency currency, int limit, int offset);
CompletableFuture<Optional<UUID>>          lookupUuid(String name);
```

`getHistory` returns newest first. `getTopBalances` returns highest first and covers the whole network. `lookupUuid` resolves a name case-insensitively from the accounts table, so it works for any player who has ever joined — no Mojang API call, no main-thread stall.

`TransactionRecord` is a record with: `id`, `player`, `currencyId`, `type`, `amount` (always positive), `balanceAfter`, `counterparty` (nullable, transfers only), `reason`, `serverId` and `timestamp`.

`BalanceEntry` is a record with `uuid`, `name` (nullable) and `balance`.

### TransactionType

<table><thead><tr><th width="255">Type</th><th>Meaning</th></tr></thead><tbody><tr><td>DEPOSIT</td><td>Credit added (Vault deposit, plugin reward, …)</td></tr><tr><td>WITHDRAW</td><td>Debit removed (Vault withdraw, shop purchase, …)</td></tr><tr><td>TRANSFER_OUT</td><td>Outgoing side of a player-to-player transfer</td></tr><tr><td>TRANSFER_IN</td><td>Incoming side of a player-to-player transfer</td></tr><tr><td>SET</td><td>Balance overwritten to an absolute value</td></tr><tr><td>RESET</td><td>Balance reset to the currency's starting amount</td></tr></tbody></table>

## Events

Both events are **asynchronous** — they fire off the main thread. Handlers must be thread-safe and must not touch the Bukkit API directly.

### PreTransactionEvent

Fired **before** a change is committed, and **cancellable**. Cancelling aborts the operation with `EconomyResult.Status.CANCELLED`.

```java
@EventHandler
public void onPre(PreTransactionEvent event) {
    if (event.getType() == TransactionType.WITHDRAW && isFrozen(event.getPlayer())) {
        event.setCancelled(true);
    }
}
```

Exposes `getPlayer()`, `getCurrency()`, `getType()`, `getAmount()` and `getReason()`.

### BalanceUpdateEvent

Fired **after** a change has been committed and cached — including changes that originated on **another** network node and arrived over Redis. Informational; it cannot be cancelled.

```java
@EventHandler
public void onChange(BalanceUpdateEvent event) {
    if (event.isRemote()) {
        // the change happened on a different server
    }
    BigDecimal delta = event.getNewBalance().subtract(event.getOldBalance());
}
```

Exposes `getPlayer()`, `getCurrency()`, `getOldBalance()`, `getNewBalance()`, `getType()` and `isRemote()`.

{% hint style="info" %}
`isRemote()` is the hook you want for keeping network-wide UI in sync: it lets you refresh a scoreboard when a player earns money on a server they are not currently on.
{% endhint %}

## Using Vault instead

If you already target Vault, nothing changes — NetworkEconomy registers as the Vault `Economy` provider at the highest priority and your plugin becomes network-wide for free. Two caveats:

* Vault exposes **only the default currency**.
* Vault's API is synchronous, so the bridge blocks briefly (5-second timeout) on the underlying async operation to return an accurate response. Banks are not supported and return `NOT_IMPLEMENTED`.

Prefer the native async API for anything latency-sensitive.

## Database schema

Read-only integrations can query the tables directly. All three use the configured `table-prefix` (default `ne_`) in place of `{prefix}`.

### `{prefix}accounts`

| Column       | Type          | Notes                                 |
| ------------ | ------------- | ------------------------------------- |
| `uuid`       | CHAR(36) (PK) | Player unique id                      |
| `name`       | VARCHAR(16)   | Last known name, indexed (`idx_name`) |
| `first_seen` | BIGINT        | Epoch millis                          |
| `last_seen`  | BIGINT        | Epoch millis                          |

### `{prefix}balances`

| Column     | Type          | Notes                             |
| ---------- | ------------- | --------------------------------- |
| `uuid`     | CHAR(36)      | Part of PK `(uuid, currency)`     |
| `currency` | VARCHAR(32)   | Currency id                       |
| `balance`  | DECIMAL(20,4) | Exact fixed-point balance         |
| `version`  | BIGINT        | Monotonic, bumped on every change |

Indexed by `idx_currency_balance (currency, balance)`, which is what makes `/baltop` cheap.

### `{prefix}transactions`

Append-only audit log — rows are never updated or deleted.

| Column          | Type          | Notes                                  |
| --------------- | ------------- | -------------------------------------- |
| `id`            | BIGINT (PK)   | Auto-increment                         |
| `uuid`          | CHAR(36)      | Affected account                       |
| `currency`      | VARCHAR(32)   | Currency id                            |
| `type`          | VARCHAR(16)   | `TransactionType` name                 |
| `amount`        | DECIMAL(20,4) | Always positive                        |
| `balance_after` | DECIMAL(20,4) | Balance immediately after the change   |
| `counterparty`  | CHAR(36)      | Other party for transfers, else `NULL` |
| `reason`        | VARCHAR(128)  | Caller-supplied reason                 |
| `server_id`     | VARCHAR(64)   | Origin node's `server-id`              |
| `created_at`    | BIGINT        | Epoch millis                           |

Indexed by `idx_uuid_time (uuid, created_at)`, `idx_currency` and `idx_created`.

{% hint style="danger" %}
Never write to `{prefix}balances` from outside the plugin. Balance changes must go through the API so they stay atomic, get a `version` bump, produce an audit row, and broadcast to the other nodes. Direct writes bypass all four.
{% endhint %}

## Redis message format

Broadcasts are compact pipe-delimited strings on the configured channel — no JSON dependency:

```
serverId|uuid|currency|balance|version|type
```

`reason` is intentionally omitted to keep broadcasts small. Receivers apply an update only if its `version` is newer than the cached one, which makes delivery idempotent and out-of-order-safe, and skip messages stamped with their own `serverId`.

## Testing notes

The plugin's own test suite runs the real storage layer against a **real MariaDB** and the real sync layer against a **real Redis** — no mocks, because the properties under test (atomicity, lock ordering, pub/sub delivery) only exist in a real engine.

```bash
./gradlew test
```

Both integration classes skip themselves cleanly when no server is reachable, so the build still succeeds without a database. Override the endpoints with `-Dne.test.host=…`, `-Dne.test.port=…`, `-Dne.test.redis.host=…`.

The concurrency tests hammer a single row from many threads as a stand-in for many network nodes, and are written so that replacing the atomic statements with naive read-modify-write logic makes them **fail** — verified by temporarily doing exactly that, which let 20 concurrent withdrawals of 10 all succeed against a balance of 100.
