django-pledge 0.0.1a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_pledge-0.0.1a1/.gitignore +119 -0
- django_pledge-0.0.1a1/LICENSE +21 -0
- django_pledge-0.0.1a1/PKG-INFO +387 -0
- django_pledge-0.0.1a1/README.md +370 -0
- django_pledge-0.0.1a1/pyproject.toml +59 -0
- django_pledge-0.0.1a1/src/django_pledge/__init__.py +48 -0
- django_pledge-0.0.1a1/src/django_pledge/admin.py +17 -0
- django_pledge-0.0.1a1/src/django_pledge/apps.py +8 -0
- django_pledge-0.0.1a1/src/django_pledge/errors.py +192 -0
- django_pledge-0.0.1a1/src/django_pledge/fields.py +148 -0
- django_pledge-0.0.1a1/src/django_pledge/ids.py +172 -0
- django_pledge-0.0.1a1/src/django_pledge/json.py +15 -0
- django_pledge-0.0.1a1/src/django_pledge/migrations/0001_initial.py +70 -0
- django_pledge-0.0.1a1/src/django_pledge/migrations/__init__.py +0 -0
- django_pledge-0.0.1a1/src/django_pledge/models.py +449 -0
- django_pledge-0.0.1a1/src/django_pledge/operations.py +23 -0
- django_pledge-0.0.1a1/src/django_pledge/sql.py +72 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
debug
|
|
3
|
+
target
|
|
4
|
+
**/*.rs.bk
|
|
5
|
+
*.pdb
|
|
6
|
+
**/mutants.out*/
|
|
7
|
+
rustc-ice-*.txt
|
|
8
|
+
|
|
9
|
+
# Python
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.py[codz]
|
|
12
|
+
*$py.class
|
|
13
|
+
*.so
|
|
14
|
+
.Python
|
|
15
|
+
build/
|
|
16
|
+
develop-eggs/
|
|
17
|
+
dist/
|
|
18
|
+
downloads/
|
|
19
|
+
eggs/
|
|
20
|
+
.eggs/
|
|
21
|
+
lib/
|
|
22
|
+
lib64/
|
|
23
|
+
parts/
|
|
24
|
+
sdist/
|
|
25
|
+
var/
|
|
26
|
+
wheels/
|
|
27
|
+
share/python-wheels/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
.installed.cfg
|
|
30
|
+
*.egg
|
|
31
|
+
MANIFEST
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
pip-log.txt
|
|
35
|
+
pip-delete-this-directory.txt
|
|
36
|
+
htmlcov/
|
|
37
|
+
.tox/
|
|
38
|
+
.nox/
|
|
39
|
+
.coverage
|
|
40
|
+
.coverage.*
|
|
41
|
+
.cache
|
|
42
|
+
nosetests.xml
|
|
43
|
+
coverage.xml
|
|
44
|
+
*.cover
|
|
45
|
+
*.py.cover
|
|
46
|
+
*.lcov
|
|
47
|
+
.hypothesis/
|
|
48
|
+
.pytest_cache/
|
|
49
|
+
cover/
|
|
50
|
+
*.mo
|
|
51
|
+
*.pot
|
|
52
|
+
*.log
|
|
53
|
+
local_settings.py
|
|
54
|
+
db.sqlite3
|
|
55
|
+
db.sqlite3-journal
|
|
56
|
+
instance/
|
|
57
|
+
.webassets-cache
|
|
58
|
+
.scrapy
|
|
59
|
+
docs/_build/
|
|
60
|
+
.pybuilder/
|
|
61
|
+
target/
|
|
62
|
+
.ipynb_checkpoints
|
|
63
|
+
profile_default/
|
|
64
|
+
ipython_config.py
|
|
65
|
+
.pdm-python
|
|
66
|
+
.pdm-build/
|
|
67
|
+
.pixi/*
|
|
68
|
+
!.pixi/config.toml
|
|
69
|
+
__pypackages__/
|
|
70
|
+
celerybeat-schedule*
|
|
71
|
+
celerybeat.pid
|
|
72
|
+
*.rdb
|
|
73
|
+
*.aof
|
|
74
|
+
*.pid
|
|
75
|
+
mnesia/
|
|
76
|
+
rabbitmq/
|
|
77
|
+
rabbitmq-data/
|
|
78
|
+
activemq-data/
|
|
79
|
+
*.sage.py
|
|
80
|
+
.env
|
|
81
|
+
.envrc
|
|
82
|
+
.venv
|
|
83
|
+
env/
|
|
84
|
+
venv/
|
|
85
|
+
ENV/
|
|
86
|
+
env.bak/
|
|
87
|
+
venv.bak/
|
|
88
|
+
conda-meta/
|
|
89
|
+
.spyderproject
|
|
90
|
+
.spyproject
|
|
91
|
+
.ropeproject
|
|
92
|
+
/site
|
|
93
|
+
.mypy_cache/
|
|
94
|
+
.dmypy.json
|
|
95
|
+
dmypy.json
|
|
96
|
+
.pyre/
|
|
97
|
+
.pytype/
|
|
98
|
+
cython_debug/
|
|
99
|
+
.abstra/
|
|
100
|
+
tempCodeRunnerFile.py
|
|
101
|
+
.ruff_cache/
|
|
102
|
+
.pypirc
|
|
103
|
+
marimo/_static/
|
|
104
|
+
marimo/_lsp/
|
|
105
|
+
__marimo__/
|
|
106
|
+
.streamlit/secrets.toml
|
|
107
|
+
|
|
108
|
+
# pledge
|
|
109
|
+
.DS_Store
|
|
110
|
+
.idea/
|
|
111
|
+
*.iml
|
|
112
|
+
|
|
113
|
+
# Harness
|
|
114
|
+
.claude/
|
|
115
|
+
.skills/
|
|
116
|
+
.agent/
|
|
117
|
+
plans/
|
|
118
|
+
CLAUDE.md
|
|
119
|
+
.tackline/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 terminalkitten
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-pledge
|
|
3
|
+
Version: 0.0.1a1
|
|
4
|
+
Summary: Django support for the pledge PostgreSQL ledger extension
|
|
5
|
+
Project-URL: Documentation, https://pgpledge.github.io/pledge/
|
|
6
|
+
Project-URL: Source, https://github.com/pgpledge/pledge
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Database
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: django>=4.2
|
|
15
|
+
Requires-Dist: psycopg>=3.1
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="https://pgpledge.github.io/pledge/">
|
|
20
|
+
<picture>
|
|
21
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pgpledge/pledge/main/docs/public/logo-dark.svg">
|
|
22
|
+
<img src="https://raw.githubusercontent.com/pgpledge/pledge/main/docs/public/logo.svg" alt="pledge logo" width="72" height="72">
|
|
23
|
+
</picture>
|
|
24
|
+
</a>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<h1 align="center">django-pledge</h1>
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<strong>Django support for pledge, a double-entry ledger inside PostgreSQL.</strong>
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
> [!WARNING]
|
|
34
|
+
> **Alpha.** The SQL API, the stored schema and the Python API can still change between
|
|
35
|
+
> versions without upgrade scripts. Don't use it for real money yet. Feedback is welcome.
|
|
36
|
+
|
|
37
|
+
`django_pledge` gives you `Account`/`Transfer`/`Entry` models over pledge's views, written through
|
|
38
|
+
the extension's functions behind the usual Django API (`objects.create()`, `save()`,
|
|
39
|
+
`bulk_create()`), plus typed ids (`AccountId`, `TransferId`, `EntryId`), ledger errors, model
|
|
40
|
+
fields and a migration operation.
|
|
41
|
+
|
|
42
|
+
Requires Python 3.11+ and a database with the pledge extension available.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
pip install django-pledge
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
django-pledge pulls in Django and psycopg 3, Django's PostgreSQL driver, as usual for a
|
|
51
|
+
Postgres project (psycopg2 is not supported: error translation reads psycopg 3's diagnostics).
|
|
52
|
+
No psycopg adapters need to be registered; ids are sent as text and cast in SQL.
|
|
53
|
+
|
|
54
|
+
## Database setup
|
|
55
|
+
|
|
56
|
+
The extension is installed by a superuser, either by hand (`CREATE EXTENSION pledge;`) or by
|
|
57
|
+
the app's first migration (`pledge.0001_initial` runs `CREATE EXTENSION IF NOT EXISTS pledge`).
|
|
58
|
+
|
|
59
|
+
The ledger functions run with the caller's rights (they are not `SECURITY DEFINER`), so the
|
|
60
|
+
application role needs these grants. Run them as the extension owner, in each database:
|
|
61
|
+
|
|
62
|
+
```sql
|
|
63
|
+
GRANT USAGE ON SCHEMA pledge TO app;
|
|
64
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pledge TO app;
|
|
65
|
+
-- reading: the public views
|
|
66
|
+
GRANT SELECT ON pledge.accounts_view, pledge.transfers_view, pledge.entries_view TO app;
|
|
67
|
+
-- writing through pledge.create_account/create_transfer(s)/close_account
|
|
68
|
+
GRANT SELECT, INSERT, UPDATE ON pledge.accounts, pledge.transfers, pledge.entries TO app;
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Do not grant `DELETE` or `TRUNCATE`: ledger rows are never removed (triggers reject it anyway).
|
|
72
|
+
No `search_path` setup is needed: the id comparison operators live in `pg_catalog`, so
|
|
73
|
+
`=`, `IN`, joins and `ORDER BY` on id columns work for any role.
|
|
74
|
+
|
|
75
|
+
## Django
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
INSTALLED_APPS = [
|
|
79
|
+
# ...
|
|
80
|
+
"django_pledge", # app label "pledge"
|
|
81
|
+
]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
python manage.py migrate pledge
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The models are unmanaged and read the public views. Writes call the extension's functions
|
|
89
|
+
through the familiar Django entry points:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from decimal import Decimal
|
|
93
|
+
from django_pledge.models import Account, Entry, Transfer
|
|
94
|
+
|
|
95
|
+
cash = Account.objects.create(name="cash", currency="EUR", allow_negative_balance=False)
|
|
96
|
+
alice = Account.objects.create(name="alice", currency="EUR", metadata={"user": 42})
|
|
97
|
+
|
|
98
|
+
t = Transfer.objects.create(
|
|
99
|
+
from_account=cash, to_account=alice, amount=Decimal("10.00"), metadata={"order": 7}
|
|
100
|
+
)
|
|
101
|
+
t.id # TransferId('plt_01K9...'), t.created_at, t.event_at are filled in
|
|
102
|
+
cash.transfer(alice, 5) # shortcut for the same thing
|
|
103
|
+
Transfer.objects.create(from_account=cash.id, to_account="pla_01K9...", amount=1) # ids work too
|
|
104
|
+
|
|
105
|
+
t = Transfer(from_account=alice, to_account=cash, amount=1)
|
|
106
|
+
t.save() # a new instance is created the same way
|
|
107
|
+
|
|
108
|
+
# batch: atomic, one statement, returns the same instances populated, in order
|
|
109
|
+
Transfer.objects.bulk_create(
|
|
110
|
+
[
|
|
111
|
+
Transfer(from_account=alice, to_account=cash, amount=Decimal("2.50")),
|
|
112
|
+
Transfer(from_account=cash, to_account=alice, amount=1, metadata={"line": 2}),
|
|
113
|
+
],
|
|
114
|
+
metadata={"batch": "2025-01"}, # defaults for instances that don't set their own
|
|
115
|
+
event_at=None, # likewise for event_at
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
alice.refresh_from_db()
|
|
119
|
+
alice.balance # Decimal('...')
|
|
120
|
+
alice.history() # entries, oldest first (running balances)
|
|
121
|
+
alice.transfers() # transfers in/out, in the order they hit the account
|
|
122
|
+
alice.balance_at(some_datetime) # balance as of a point in time
|
|
123
|
+
alice.outgoing.all(), alice.incoming.all(), alice.entries.all()
|
|
124
|
+
Transfer.objects.filter(from_account__name="cash", amount__gte=5)
|
|
125
|
+
Entry.objects.select_related("account", "transfer").filter(account=alice)
|
|
126
|
+
|
|
127
|
+
alice.close() # keeps history, rejects new transfers; sets closed_at
|
|
128
|
+
Account.objects.verify() # [Check(check_name, ok, detail), ...]
|
|
129
|
+
Account.objects.assert_verified() # raises django_pledge.VerificationFailed on a failed check
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Django call | runs |
|
|
133
|
+
|--------------------------------------------------|------------------------------|
|
|
134
|
+
| `Account.objects.create(...)`, new `Account.save()` | `pledge.create_account` |
|
|
135
|
+
| `Transfer.objects.create(...)`, new `Transfer.save()`, `account.transfer(...)` | `pledge.create_transfer` |
|
|
136
|
+
| `Transfer.objects.bulk_create([...])` | `pledge.create_transfers` |
|
|
137
|
+
| `account.close()` | `pledge.close_account` |
|
|
138
|
+
| `Account.objects.verify()` / `assert_verified()` | `pledge.verify()` |
|
|
139
|
+
|
|
140
|
+
Amounts are `Decimal`, `int` or a numeric string; `float` and `bool` raise `ValidationError`. Every write runs in
|
|
141
|
+
`transaction.atomic(using=...)`, so a failure inside your own transaction rolls back only its
|
|
142
|
+
savepoint. Database routing works as usual: `Account.objects.db_manager("ledger").create(...)`,
|
|
143
|
+
`Transfer.objects.using("ledger").bulk_create(...)`, `obj.save(using="ledger")`, or your
|
|
144
|
+
router's `db_for_write`; `account.transfer()`/`close()` use the account's own database. The async
|
|
145
|
+
variants (`acreate`, `abulk_create`, `asave`) work too.
|
|
146
|
+
|
|
147
|
+
### Append-only
|
|
148
|
+
|
|
149
|
+
Ledger rows are never updated or deleted through the ORM. Saving an instance that is already
|
|
150
|
+
stored (or `save(update_fields=...)`), `delete()`, `QuerySet.update()`/`delete()`,
|
|
151
|
+
`bulk_update()`, `get_or_create()`, `update_or_create()` and `loaddata` raise
|
|
152
|
+
`django_pledge.models.ReadOnlyError`. `Entry` rows are written by the transfer functions only.
|
|
153
|
+
|
|
154
|
+
An account's balance and version change only through transfers, and closing is
|
|
155
|
+
`account.close()`. Account ids are assigned by the database (leave `id` unset). The extension
|
|
156
|
+
does allow renaming an account; that is deliberately not exposed through `save()`, so do it in
|
|
157
|
+
SQL if you need it (`UPDATE pledge.accounts SET name = %s, updated_at = now() WHERE id = %s`).
|
|
158
|
+
|
|
159
|
+
### Idempotent retries
|
|
160
|
+
|
|
161
|
+
Transfer ids are generated client-side. Generate one before the call, store it with your own
|
|
162
|
+
record, and send the same id when you retry; a retry with the same values returns the stored
|
|
163
|
+
transfer and moves no money, different values raise `TransferConflict`:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from django_pledge import TransferId
|
|
167
|
+
|
|
168
|
+
payment.transfer_id = TransferId.new()
|
|
169
|
+
payment.save()
|
|
170
|
+
cash.transfer(alice, payment.amount, id=payment.transfer_id) # safe to retry
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
When `id` is omitted a fresh `TransferId.new()` is set on the instance before the call, so the
|
|
174
|
+
transfer always has an id you can record, and calling `save()` (or `bulk_create()` with the same
|
|
175
|
+
instances) again after a failure is itself an idempotent retry.
|
|
176
|
+
|
|
177
|
+
### Linking your models to accounts
|
|
178
|
+
|
|
179
|
+
Use a normal `ForeignKey` to `pledge.Account` with `db_constraint=False` (the target is a view,
|
|
180
|
+
which cannot carry a foreign key constraint):
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from django.db import models
|
|
184
|
+
from django_pledge import AccountIdField
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Wallet(models.Model):
|
|
188
|
+
user = models.OneToOneField("auth.User", models.CASCADE)
|
|
189
|
+
account = models.ForeignKey(
|
|
190
|
+
"pledge.Account", models.PROTECT, db_constraint=False, related_name="wallets"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
Wallet.objects.filter(account__currency="EUR", account__balance__gt=0)
|
|
195
|
+
Account.objects.filter(wallets__user=request.user)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The column type is `pledge.account_id`. `AccountIdField`, `TransferIdField` and `EntryIdField`
|
|
199
|
+
can also be used directly, and `NumericField` is an unbounded `numeric` (no precision/scale),
|
|
200
|
+
like pledge's amounts.
|
|
201
|
+
|
|
202
|
+
`django_pledge.admin.ReadOnlyAdmin` is a `ModelAdmin` mixin for browsing the ledger in the admin.
|
|
203
|
+
|
|
204
|
+
## Errors
|
|
205
|
+
|
|
206
|
+
Ledger rule violations raise `django_pledge.LedgerError` subclasses (from the model write methods, or
|
|
207
|
+
via `django_pledge.translate(exc)` for any Django/psycopg database error, e.g. from raw SQL). They are
|
|
208
|
+
matched on the `code` in the JSON object the extension puts in the error DETAIL, not on the
|
|
209
|
+
message. The other keys of that object become typed attributes:
|
|
210
|
+
|
|
211
|
+
| class | code | attributes |
|
|
212
|
+
|-----------------------------|---------------------------------------|----------------------------------|
|
|
213
|
+
| `InvalidRequest` | `pledge.invalid_request` | |
|
|
214
|
+
| `AccountNotFound` | `pledge.account_not_found` | `account_id` |
|
|
215
|
+
| `AccountClosed` | `pledge.account_closed` | `account_id` |
|
|
216
|
+
| `AccountAlreadyClosed` | `pledge.account_already_closed` | `account_id` |
|
|
217
|
+
| `AccountNotEmpty` | `pledge.account_not_empty` | `account_id`, `balance` |
|
|
218
|
+
| `NegativeBalanceNotAllowed` | `pledge.negative_balance_not_allowed` | `account_id` |
|
|
219
|
+
| `PositiveBalanceNotAllowed` | `pledge.positive_balance_not_allowed` | `account_id` |
|
|
220
|
+
| `CurrencyMismatch` | `pledge.currency_mismatch` | `from_currency`, `to_currency` |
|
|
221
|
+
| `TransferConflict` | `pledge.transfer_conflict` | `transfer_id` |
|
|
222
|
+
| `DuplicateTransferId` | `pledge.duplicate_transfer_id` | `transfer_id` |
|
|
223
|
+
| `Immutable` | `pledge.immutable` | `table` |
|
|
224
|
+
|
|
225
|
+
`account_id` is an `AccountId`, `transfer_id` a `TransferId`, `balance` a `Decimal` and the
|
|
226
|
+
others are `str`. A class only has the attributes in its row. `TransferConflict.transfer_id` is
|
|
227
|
+
`None` when two transactions raced to create the same new id: Postgres reports that as a unique
|
|
228
|
+
violation on `transfers_pkey`, which has no JSON DETAIL. `e.detail` is the parsed DETAIL as a
|
|
229
|
+
dict.
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
import django_pledge
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
cash.transfer(alice, amount, id=tid)
|
|
236
|
+
except django_pledge.NegativeBalanceNotAllowed as e:
|
|
237
|
+
e.account_id # the AccountId that would go negative
|
|
238
|
+
except django_pledge.LedgerError as e:
|
|
239
|
+
e.code, e.message, e.detail
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## JSON
|
|
243
|
+
|
|
244
|
+
Balances keep the scale of their transfers, so a zero BTC balance is `Decimal('0E-8')` and
|
|
245
|
+
`DjangoJSONEncoder` writes `"0E-8"`. `django_pledge.json.LedgerJSONEncoder` writes Decimals in
|
|
246
|
+
plain notation (`"0.00000000"`). The models' `metadata` fields use it; pass it to `JsonResponse`
|
|
247
|
+
or `json.dumps(..., cls=LedgerJSONEncoder)` for your own output.
|
|
248
|
+
|
|
249
|
+
## Ids
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from django_pledge import AccountId
|
|
253
|
+
|
|
254
|
+
i = AccountId.new() # UUIDv7: millisecond timestamp + random
|
|
255
|
+
str(i) # 'pla_01K9FKX2S1FGYSWKX1PB1X9SFP'
|
|
256
|
+
AccountId("pla_01k9fkx2s1fgyswkx1pb1x9sfp") == i # lowercase, O->0, I/L->1 accepted
|
|
257
|
+
i.uuid, i.bytes, i.timestamp
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Ids of different kinds never compare equal and cannot be ordered against each other.
|
|
261
|
+
|
|
262
|
+
## Development
|
|
263
|
+
|
|
264
|
+
```sh
|
|
265
|
+
docker compose up -d --wait db # from the repo root: Postgres with pledge on :5432
|
|
266
|
+
uv sync --all-groups
|
|
267
|
+
uv run pytest
|
|
268
|
+
uv run ruff check src tests example && uv run --group lint ty check
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Tests create a fresh database (`PLEDGE_TEST_DB`, default `pledge_python_test`) and a role
|
|
272
|
+
`pledge_app` with only the grants above, using the superuser DSN in `PLEDGE_TEST_DSN`
|
|
273
|
+
(default `host=localhost port=5432 user=pledge password=pledge dbname=pledge`).
|
|
274
|
+
|
|
275
|
+
## Example project
|
|
276
|
+
|
|
277
|
+
`example/` is a small currency exchange (`core` settings, `exchange` app) built on the
|
|
278
|
+
public API. Start with `example/exchange/services.py`.
|
|
279
|
+
|
|
280
|
+
Run it with Docker from the repository root, no local Python needed:
|
|
281
|
+
|
|
282
|
+
```sh
|
|
283
|
+
just example # http://localhost:8000/ and /admin/ (admin / admin)
|
|
284
|
+
just example-simulate # 20 traders, 500 random trades, then checks every currency sums to 0
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The first start creates the `exchange` database, funds the exchange's liquidity and creates the
|
|
288
|
+
admin user. Open http://localhost:8000/ for a small web interface: create a trader, then deposit
|
|
289
|
+
by bank transfer or card, preview and place trades, withdraw, close wallets and read each wallet's
|
|
290
|
+
statement. The home page shows the broker's accounts per currency, a button for the card provider
|
|
291
|
+
to pay out, and whether `Account.objects.verify()` passes. Every form carries a request id
|
|
292
|
+
rendered with the page, so a double submit or a refresh books nothing twice.
|
|
293
|
+
|
|
294
|
+
The same exchange is also a JSON API under `/exchange/`:
|
|
295
|
+
|
|
296
|
+
```sh
|
|
297
|
+
curl -X POST localhost:8000/exchange/traders/ -d '{"name": "alice"}'
|
|
298
|
+
curl -X POST localhost:8000/exchange/traders/1/deposits/ -d '{"currency": "EUR", "amount": "1000.00"}'
|
|
299
|
+
curl -X POST localhost:8000/exchange/traders/1/deposits/ \
|
|
300
|
+
-d '{"currency": "EUR", "amount": "250.00", "method": "card"}'
|
|
301
|
+
curl -X POST localhost:8000/exchange/house/payouts/ -d '{"currency": "EUR"}'
|
|
302
|
+
curl -X POST localhost:8000/exchange/traders/1/trades/ \
|
|
303
|
+
-d '{"request_id": "t1", "sell": "EUR", "buy": "BTC", "amount": "500.00"}'
|
|
304
|
+
curl localhost:8000/exchange/traders/1/
|
|
305
|
+
curl localhost:8000/exchange/traders/
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
- Customers are the app's own `Trader` model; Django's auth users only log in to the admin.
|
|
309
|
+
- Every trader gets one ledger account per currency, EUR, USD and BTC with 8 decimals, linked by
|
|
310
|
+
a `Wallet` model with `OneToOneField("pledge.Account", db_constraint=False)`. Trader accounts
|
|
311
|
+
can't go negative.
|
|
312
|
+
- Per currency the broker owns a liquidity account holding its inventory and a fee revenue
|
|
313
|
+
account, plus three accounts for money outside the exchange, which never go above zero: `card`
|
|
314
|
+
(money held at the card provider, not yet paid out), `bank` (the broker's own bank account) and
|
|
315
|
+
`card_costs` (the card provider's fees, an expense). Ledger names look like `house:card:EUR`.
|
|
316
|
+
- A bank deposit moves money from `bank` to the trader. A card deposit is one atomic batch of
|
|
317
|
+
`card` → trader for the full amount and `card_costs` → `card` for the 1.5% card fee
|
|
318
|
+
(`EXCHANGE_CARD_FEE_RATE`, rounded up). The fee leg's id is derived from the deposit's id, so a
|
|
319
|
+
retried card deposit books nothing twice. When the card provider pays out, one `bank` → `card`
|
|
320
|
+
transfer brings `card` back to zero. Withdrawals pay out from `bank`, and `fund_liquidity` moves
|
|
321
|
+
the owner's capital from `bank` to liquidity.
|
|
322
|
+
- A trade converts at a quoted rate: pledge never crosses currencies, so it is one atomic
|
|
323
|
+
`bulk_create` batch of trader → house in the sold currency, house → trader in the bought one
|
|
324
|
+
and a fee leg, each with `{"kind": "trade", "trade": id, "leg": ...}` metadata. If any leg
|
|
325
|
+
fails because the trader or the exchange runs short or an account is closed, nothing is booked.
|
|
326
|
+
- Trades and withdrawals carry a client `request_id` and store their transfer ids before pledge
|
|
327
|
+
is called, so a retried request replays the original outcome instead of booking twice.
|
|
328
|
+
- Closing a wallet pays out its balance and closes the account in one transaction. There's a
|
|
329
|
+
portfolio, a statement from `account.history()`, a proxy model, the web pages, JSON views and
|
|
330
|
+
a read-only admin for the ledger.
|
|
331
|
+
|
|
332
|
+
`uv run pytest` runs its tests too, in a child process with their own database
|
|
333
|
+
(`PLEDGE_EXAMPLE_TEST_DB`, default `pledge_example_test`, as role `pledge_app`); run them
|
|
334
|
+
directly with `uv run pytest example`.
|
|
335
|
+
|
|
336
|
+
To run it by hand, point `PLEDGE_DSN` at a database with the extension and, from `example/`:
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
python manage.py migrate
|
|
340
|
+
python manage.py fund_liquidity BTC 10
|
|
341
|
+
python manage.py simulate --traders 20 --trades 500
|
|
342
|
+
python manage.py runserver
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`simulate` signs up traders, runs random card and bank deposits, trades (some retried),
|
|
346
|
+
withdrawals and card payouts, then runs `Account.objects.assert_verified()` and prints per
|
|
347
|
+
currency what the customers hold and each house account, which always sum to zero.
|
|
348
|
+
|
|
349
|
+
## Extending accounts with your own data
|
|
350
|
+
|
|
351
|
+
Ledger rows are immutable and only come from pledge's functions, so keep your own data next to
|
|
352
|
+
them instead of inside them:
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
class Wallet(models.Model): # your mutable data, your table
|
|
356
|
+
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
|
|
357
|
+
kind = models.CharField(max_length=20)
|
|
358
|
+
account = models.OneToOneField(
|
|
359
|
+
"pledge.Account", on_delete=models.PROTECT, db_constraint=False, related_name="wallet"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
wallet.account.balance, account.wallet.customer
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
For extra *behaviour* (methods, managers, a different admin), a proxy model is fine:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
class Pot(Account):
|
|
370
|
+
class Meta:
|
|
371
|
+
proxy = True
|
|
372
|
+
|
|
373
|
+
def is_empty(self) -> bool:
|
|
374
|
+
return self.balance == 0
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Multi-table inheritance (`class CustomerAccount(Account)` without `proxy = True`) is rejected
|
|
378
|
+
with `ImproperlyConfigured`: Django would write a parent row through the ORM.
|
|
379
|
+
|
|
380
|
+
## Names
|
|
381
|
+
|
|
382
|
+
Everything pledge stores lives in its own PostgreSQL schema `pledge` (`pledge.accounts`,
|
|
383
|
+
`pledge.transfers`, `pledge.entries`, read through `pledge.*_view`), so it can't collide with
|
|
384
|
+
your tables in `public`, including your own `Account` model. In Django the models are
|
|
385
|
+
`pledge.Account` / `pledge.Transfer` / `pledge.Entry` (app label `pledge`); import with an alias
|
|
386
|
+
if you also have an `Account` (`from django_pledge.models import Account as LedgerAccount`). The
|
|
387
|
+
only names pledge claims are the schema `pledge` and the Django app label `pledge`.
|