Metadata-Version: 2.5
Name: hf-nostro
Version: 0.1.0
Summary: An embeddable append-only credit ledger, account, and promo-code library backed by Postgres.
Author: Hyperface Technologies Pvt. Ltd.
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: opentelemetry-api>=1.20
Requires-Dist: psycopg[binary,pool]>=3.2
Provides-Extra: testkit
Requires-Dist: pytest>=8.0; extra == 'testkit'
Requires-Dist: testcontainers[postgres]>=4.8; extra == 'testkit'
Description-Content-Type: text/markdown

# nostro

An embeddable append-only credit ledger for Python, backed by Postgres. Accounts, an
immutable entry log, expiring credit lots, overdraft as debt, and promo codes.

A consuming application imports it, hands it a connection pool, and calls it in-process. It is
a library, not a service. It moves numbers on instruction and owns no pricing, no rate limits,
and no eligibility rules: those are yours, and they stay in your code.

## Why it exists

Metering an agentic product means charging for work after the work is done. The LLM call has
already happened by the time you know what it cost, so a ledger that refuses a debit for want
of funds is refusing after the money is spent. nostro takes the debit, drives the balance
negative, and records the shortfall as debt that the next top-up repays.

The rest follows from wanting that to be auditable. Every movement is an immutable entry, and
the balance on the account row is checked against the entry that produced it by a database
trigger on every insert, so drift between the two is impossible rather than merely detectable.

## Requirements

- Python 3.11, 3.12, or 3.13
- PostgreSQL 14 through 17
- Docker, if you want to run the test suite

## Install

```
pip install hf-nostro
```

or with `uv`:

```
uv add hf-nostro
```

The distribution is `hf-nostro`. The import is `import nostro`.

Then create the schema. It is one command, it takes an advisory lock, and running it twice is
a no-op:

```
python -m nostro migrate --dsn postgresql://... --scale 6
```

`--dsn` defaults to `$DATABASE_URL`. The same runner is importable as `nostro.migrate.migrate`
if you would rather call it from an Alembic revision than from a shell. Nothing else is
required: no roles to grant, no extensions to install.

`--scale` is how many decimal places this database stores amounts at, and it is yours to
choose. It is written the first time and checked every time after, so a later run naming a
different one refuses rather than reinterpreting what is already stored. It can be raised
later with `python -m nostro raise-scale --to 7`, which rewrites every stored amount to match,
and it never goes down.

## Quickstart

```python
from nostro import AccountKey, Ledger

ledger = Ledger(dsn="postgresql://localhost/app", scale=6)

# Name the account with identifiers you already own. No id of ours to store.
customer = AccountKey("acme-corp", "org-1")
ledger.accounts.get_or_create(customer.tenant_id, customer.scope_ref)

ledger.credit(customer, ledger.amount("100.00"), category="topup", idempotency_key="topup-1")

charge = ledger.debit(
    customer,
    ledger.amount("12.50"),
    category="llm_usage",
    idempotency_key="activity-42",  # a retry with this key charges nothing twice
    external_ref="activity-42",  # your id for whatever produced the charge
    metadata={"model": "sonnet"},
)
assert charge.balance_after == ledger.amount("87.50")

for entry in ledger.entries.list(customer):
    print(entry.kind, entry.category, entry.amount)
```

`scale` is the number of decimal places every amount is stored at, and you have to pick it.
It has to match what the database was migrated at, which is checked once per process on first
use. Amounts are integers underneath, never floats, and an `Amount` refuses to be multiplied,
divided, or mixed with one at a different scale.

## What it does

**Accounts** are identified by `(tenant_id, scope, scope_ref)`, all three of them yours.
`status` is `active`, `frozen`, or `closed`; freezing stops money leaving and nothing else, and
closing is final.

**Entries** are append-only. A trigger refuses any update or delete, and a second one asserts
`balance_after = prior_balance + amount` against the locked account row before each insert.
`category` is your own taxonomy and the library never interprets it.

**Credit lots** carry their own expiry and are consumed soonest-expiry-first. Every debit
records which lots it drew from and how much, so a reversal restores the exact lots the
original touched rather than guessing.

**Expiry is an accounting event**, not a read filter. `sweep_expired` posts a real entry for
what lapsed, one chunk at a time, so a customer sees a row saying what happened instead of
watching a number shrink overnight. You own the schedule; the library owns no cron.

**Overdraft is debt.** A debit with no funds behind it writes an overdraft slice, and the next
credit repays the oldest slices before creating a lot. An account can owe money or hold credit,
never both.

**Promo codes** have immutable terms. Amending a code publishes a new version, and grants
already made keep the terms they were made under. Redemption checks the cap, the per-account
limit, and the audience under the same lock as the grant.

**Idempotency** is required on every mutating call. A replayed key returns the committed entry
with `replayed = True`; the same key with different arguments raises `IdempotencyConflict`,
because the key identifies the operation and reusing it for another one is a bug rather than a
retry.

## Naming accounts and entries

Every account argument takes one of three things:

| form | cost | when |
|---|---|---|
| `Account` | nothing | you already read the account |
| `UUID` | nothing | you stored our id, or you hold an `Entry` |
| `AccountKey(tenant_id, scope_ref, scope="org")` | one indexed lookup | you have your own identifiers and nothing of ours |

`AccountKey` exists so the library never forces its uuids into your schema. `reverse` takes the
same trade through `EntryKey(account, external_ref)`, which undoes a charge by the reference
you filed it under:

```python
from nostro import AccountKey, EntryKey

ledger.reverse(
    EntryKey(AccountKey("acme-corp", "org-1"), "invoice-4711"),
    category="refund",
    idempotency_key="refund-1",
)
```

## Async

`AsyncLedger` is the same class with `await` in front of it. The two are signature-identical,
enforced by a test that fails if a method is added to one and not the other.

```python
from nostro import AsyncLedger

ledger = AsyncLedger(dsn="postgresql://localhost/app", scale=6)


async def charge(customer, key):
    await ledger.debit(customer, ledger.amount("0.01"), category="run", idempotency_key=key)
```

## Joining your own transaction

Every method takes `cur=`, a cursor you already opened. The library then writes inside your
transaction, commits nothing, sets no timeouts, and does not retry, because all three of those
belong to whoever owns the transaction.

```python
with pool.connection() as conn, conn.cursor() as cur:
    ledger.debit(customer, cost, category="run", idempotency_key=key, cur=cur)
    record_activity(cur, activity)  # both land, or neither does
```

The account row lock is held until you commit, so avoid doing I/O inside that window.

## Testing your application

The `testkit` extra ships pytest fixtures. The suite starts its own Postgres through
testcontainers, which is why Docker is a hard requirement and there is deliberately no DSN
escape hatch: a suite you can point at a developer's local database is a suite that eventually
truncates one.

```
pip install "hf-nostro[testkit]"
```

or with `uv`:

```
uv add --dev "hf-nostro[testkit]"
```

Load the fixtures from your root `conftest.py`:

```python
pytest_plugins = ["nostro.testkit.fixtures"]
```

```python
from nostro.testkit import assert_invariants


def test_a_run_is_charged_once(ledger):
    account = ledger.accounts.create("acme-corp", "org-1")
    ledger.credit(account, ledger.amount(10), category="topup", idempotency_key="c1")
    charge_for(ledger, account, run_id="r1")
    charge_for(ledger, account, run_id="r1")  # a retry, not a second charge

    assert ledger.balance(account).balance == ledger.amount(9)
    assert_invariants(ledger, account)
```

`assert_invariants` checks all three of the ledger's invariants on one account: the balance
matches the lots and slices behind it, every entry is continuous with the one before it, and no
account owes money while holding open credit. It is four indexed reads, cheap enough to run at
the end of any test that moved money.

The testkit also ships `Clock` for controlling expiry, and two multi-process concurrency
harnesses if you want to prove your own call pattern holds under contention.

## License

MIT.
