allowances 0.2.0__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.
Files changed (38) hide show
  1. allowances-0.2.0/PKG-INFO +137 -0
  2. allowances-0.2.0/README.md +120 -0
  3. allowances-0.2.0/allowances/__init__.py +86 -0
  4. allowances-0.2.0/allowances/backends/__init__.py +5 -0
  5. allowances-0.2.0/allowances/backends/memory.py +291 -0
  6. allowances-0.2.0/allowances/definitions.py +199 -0
  7. allowances-0.2.0/allowances/engine.py +493 -0
  8. allowances-0.2.0/allowances/errors.py +51 -0
  9. allowances-0.2.0/allowances/rounding.py +25 -0
  10. allowances-0.2.0/allowances/store.py +190 -0
  11. allowances-0.2.0/allowances/types.py +186 -0
  12. allowances-0.2.0/allowances/windows.py +86 -0
  13. allowances-0.2.0/allowances.egg-info/PKG-INFO +137 -0
  14. allowances-0.2.0/allowances.egg-info/SOURCES.txt +36 -0
  15. allowances-0.2.0/allowances.egg-info/dependency_links.txt +1 -0
  16. allowances-0.2.0/allowances.egg-info/requires.txt +10 -0
  17. allowances-0.2.0/allowances.egg-info/top_level.txt +2 -0
  18. allowances-0.2.0/allowances_django/__init__.py +14 -0
  19. allowances-0.2.0/allowances_django/admin.py +90 -0
  20. allowances-0.2.0/allowances_django/apps.py +15 -0
  21. allowances-0.2.0/allowances_django/checks.py +48 -0
  22. allowances-0.2.0/allowances_django/migrations/0001_initial.py +125 -0
  23. allowances-0.2.0/allowances_django/migrations/__init__.py +0 -0
  24. allowances-0.2.0/allowances_django/models.py +279 -0
  25. allowances-0.2.0/allowances_django/providers.py +25 -0
  26. allowances-0.2.0/allowances_django/shortcuts.py +107 -0
  27. allowances-0.2.0/allowances_django/store.py +367 -0
  28. allowances-0.2.0/allowances_django/subjects.py +59 -0
  29. allowances-0.2.0/pyproject.toml +62 -0
  30. allowances-0.2.0/setup.cfg +4 -0
  31. allowances-0.2.0/tests/test_definitions.py +134 -0
  32. allowances-0.2.0/tests/test_engine_archive.py +95 -0
  33. allowances-0.2.0/tests/test_engine_attach_unfulfill.py +128 -0
  34. allowances-0.2.0/tests/test_engine_fulfill.py +404 -0
  35. allowances-0.2.0/tests/test_engine_peek.py +61 -0
  36. allowances-0.2.0/tests/test_engine_plan_switch.py +132 -0
  37. allowances-0.2.0/tests/test_engine_sliding_window.py +94 -0
  38. allowances-0.2.0/tests/test_windows.py +100 -0
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: allowances
3
+ Version: 0.2.0
4
+ Summary: Grants and consumable budgets — enforced before the fact, auditable after it. Storage-agnostic, with a Django backend.
5
+ License-Expression: MIT
6
+ Keywords: licensing,entitlements,quota,metering,billing,ledger,django
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: django
10
+ Requires-Dist: Django<7,>=5.2; extra == "django"
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest; extra == "test"
13
+ Requires-Dist: pytest-django; extra == "test"
14
+ Requires-Dist: psycopg2-binary; extra == "test"
15
+ Requires-Dist: coverage; extra == "test"
16
+ Requires-Dist: Django<7,>=5.2; extra == "test"
17
+
18
+ # allowances
19
+
20
+ Grants and consumable budgets — enforced before the fact, auditable after it. Storage-agnostic, with a Django backend.
21
+
22
+ 📖 **Documentation:** [docs.velis.si/allowances](https://docs.velis.si/allowances). The source lives in `docs/` — `npm install && npm run docs:dev` serves it locally.
23
+
24
+ ---
25
+
26
+ ## What it does
27
+
28
+ Applications keep needing to answer the same shape of question:
29
+
30
+ > Can this user send an email right now? How many API calls does this organisation have left this month? Does this device still have a service contract, and does the customer have enough coolant credit to fill it?
31
+
32
+ `allowances` answers it, records what the answer cost, and lets you take it back if the operation it paid for never happened.
33
+
34
+ It is **not a payment system** — no invoices, no card charges, no money anywhere. The units it counts are whatever you say they are. Use it for rights that are bought, expire, or get spent.
35
+
36
+ ## Install
37
+
38
+ Django is optional. The core is `import allowances` and depends on nothing at all — not Django, not a database driver, not anything outside the standard library. Everything that matters (the ordering walk, conversion, rounding, overdraft, sliding windows) lives there, and talks to storage through a sixteen-method port.
39
+
40
+ ```bash
41
+ pip install allowances # the engine, zero dependencies
42
+ pip install "allowances[django]" # ...and the Django models, store and admin
43
+ ```
44
+
45
+ The Django binding is one extra line, and the rest of this README assumes you took it:
46
+
47
+ ```python
48
+ INSTALLED_APPS = ["allowances_django"]
49
+ ```
50
+
51
+ Without it you supply your own `Store` — a dict-backed one ships in the box for tests, and [writing another](https://docs.velis.si/allowances/backends/writing-a-backend) is an afternoon.
52
+
53
+ ## Use
54
+
55
+ ```python
56
+ # settings.py
57
+ ALLOWANCES = {
58
+ "currencies": {
59
+ "email_send": {},
60
+ "credits": {"general": True, "round_fn": "ceil"},
61
+ },
62
+ "features": {
63
+ "send_email": {
64
+ "consumable": {"currencies": ["email_send", ("credits", 0.5)]},
65
+ "subjects": {"user", "team"},
66
+ "fulfillment_order": [("consumable", "user"), ("consumable", "team")],
67
+ },
68
+ },
69
+ }
70
+ ```
71
+
72
+ ```python
73
+ from allowances import InsufficientBalanceError
74
+ from allowances_django.shortcuts import fulfill, attach, unfulfill
75
+
76
+ try:
77
+ result = fulfill("send_email", 1, {"user": request.user, "team": team})
78
+ except InsufficientBalanceError as exc:
79
+ return http_402(f"Only {exc.result.granted} left")
80
+
81
+ message = send_the_email()
82
+ attach(result.guid, message) # record what the quota bought
83
+ # ...or unfulfill(result.guid) if it failed
84
+ ```
85
+
86
+ ## The shape of it
87
+
88
+ Two layers, kept apart. **Definition** — what rights exist at all: features, currencies, conversion rates, priority order. Configuration, not rows. **State** — what a subject holds and has spent: wallets and a transaction ledger.
89
+
90
+ The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a sixteen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
91
+
92
+ ```
93
+ allowances.engine peek · fulfill · attach · unfulfill · plan_switch
94
+ │
95
+ ┌───────┴────────┐
96
+ ▼ ▼
97
+ Store port DefinitionProvider
98
+ │ │
99
+ ▼ ▼
100
+ DjangoStore SettingsProvider
101
+ MemoryStore DictProvider
102
+ ```
103
+
104
+ Highlights:
105
+
106
+ - **Grants and consumables in one flow.** A boolean entitlement and a metered budget resolve together, in an order you declare.
107
+ - **Multi-source payment.** One operation can draw from a dedicated budget, then general credits, then a promotional grant, at conversion rates you set.
108
+ - **Nothing edited in place.** Consumption writes a ledger entry; reversal writes a matching storno; spent-out wallets move to an archive.
109
+ - **Sliding windows without a reset job.** "200 a day" works by ageing consumption out of a window, not by a nightly job that can fail.
110
+ - **Multi-subject.** A licence can belong to a user, an organisation, a device, a location — resolved across all of them in one call.
111
+
112
+ ## What it could also be
113
+
114
+ A permissions layer, if you push it. Not the point of the library, but the pieces line up: a grant with no expiry *is* a permission bit, the fulfillment order *is* a resolution order across user, team and organisation, and the ledger is the audit trail permission systems usually bolt on afterwards. You get things they rarely offer natively — rights that expire on a date, that may be exercised *n* times, or that are inherited from an organisation and revoked with it.
115
+
116
+ Only the plumbing is missing, and `NEXT_STEPS.md` already scopes it: a grant-only permissions backend, after which the decorators, mixins and checks you have already written keep working unchanged. See [the guide](https://docs.velis.si/allowances/guide/).
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ pip install -e ".[test]"
122
+ pytest # 188 tests, both stores, no setup
123
+ ruff check . && ruff format --check .
124
+
125
+ npm install
126
+ npm run docs:dev
127
+ ```
128
+
129
+ No database server required: the suite defaults to a file-backed SQLite. Copy `tests/env.example.py` to `tests/env.py` to point it at a local Postgres instead, and `ALLOWANCES_TEST_DB=sqlite pytest` to go back. Both engines are supported and both are covered — including `tests/django/test_concurrency.py`, which races eight threads for the same wallet and insists it is granted exactly once.
130
+
131
+ Locally that runs against whichever Django you have installed. CI runs the suite five times — Django 5.2 on Python 3.11, 6.0 on 3.12 and 6.1 on 3.13 against Postgres, plus each end of that range against SQLite — which is what the `Django>=5.2,<7` pin is based on.
132
+
133
+ `DECISIONS.md` records every judgment call and what it would cost to revisit — read it before changing behaviour.
134
+
135
+ ## Licence
136
+
137
+ MIT.
@@ -0,0 +1,120 @@
1
+ # allowances
2
+
3
+ Grants and consumable budgets — enforced before the fact, auditable after it. Storage-agnostic, with a Django backend.
4
+
5
+ 📖 **Documentation:** [docs.velis.si/allowances](https://docs.velis.si/allowances). The source lives in `docs/` — `npm install && npm run docs:dev` serves it locally.
6
+
7
+ ---
8
+
9
+ ## What it does
10
+
11
+ Applications keep needing to answer the same shape of question:
12
+
13
+ > Can this user send an email right now? How many API calls does this organisation have left this month? Does this device still have a service contract, and does the customer have enough coolant credit to fill it?
14
+
15
+ `allowances` answers it, records what the answer cost, and lets you take it back if the operation it paid for never happened.
16
+
17
+ It is **not a payment system** — no invoices, no card charges, no money anywhere. The units it counts are whatever you say they are. Use it for rights that are bought, expire, or get spent.
18
+
19
+ ## Install
20
+
21
+ Django is optional. The core is `import allowances` and depends on nothing at all — not Django, not a database driver, not anything outside the standard library. Everything that matters (the ordering walk, conversion, rounding, overdraft, sliding windows) lives there, and talks to storage through a sixteen-method port.
22
+
23
+ ```bash
24
+ pip install allowances # the engine, zero dependencies
25
+ pip install "allowances[django]" # ...and the Django models, store and admin
26
+ ```
27
+
28
+ The Django binding is one extra line, and the rest of this README assumes you took it:
29
+
30
+ ```python
31
+ INSTALLED_APPS = ["allowances_django"]
32
+ ```
33
+
34
+ Without it you supply your own `Store` — a dict-backed one ships in the box for tests, and [writing another](https://docs.velis.si/allowances/backends/writing-a-backend) is an afternoon.
35
+
36
+ ## Use
37
+
38
+ ```python
39
+ # settings.py
40
+ ALLOWANCES = {
41
+ "currencies": {
42
+ "email_send": {},
43
+ "credits": {"general": True, "round_fn": "ceil"},
44
+ },
45
+ "features": {
46
+ "send_email": {
47
+ "consumable": {"currencies": ["email_send", ("credits", 0.5)]},
48
+ "subjects": {"user", "team"},
49
+ "fulfillment_order": [("consumable", "user"), ("consumable", "team")],
50
+ },
51
+ },
52
+ }
53
+ ```
54
+
55
+ ```python
56
+ from allowances import InsufficientBalanceError
57
+ from allowances_django.shortcuts import fulfill, attach, unfulfill
58
+
59
+ try:
60
+ result = fulfill("send_email", 1, {"user": request.user, "team": team})
61
+ except InsufficientBalanceError as exc:
62
+ return http_402(f"Only {exc.result.granted} left")
63
+
64
+ message = send_the_email()
65
+ attach(result.guid, message) # record what the quota bought
66
+ # ...or unfulfill(result.guid) if it failed
67
+ ```
68
+
69
+ ## The shape of it
70
+
71
+ Two layers, kept apart. **Definition** — what rights exist at all: features, currencies, conversion rates, priority order. Configuration, not rows. **State** — what a subject holds and has spent: wallets and a transaction ledger.
72
+
73
+ The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a sixteen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
74
+
75
+ ```
76
+ allowances.engine peek · fulfill · attach · unfulfill · plan_switch
77
+ │
78
+ ┌───────┴────────┐
79
+ ▼ ▼
80
+ Store port DefinitionProvider
81
+ │ │
82
+ ▼ ▼
83
+ DjangoStore SettingsProvider
84
+ MemoryStore DictProvider
85
+ ```
86
+
87
+ Highlights:
88
+
89
+ - **Grants and consumables in one flow.** A boolean entitlement and a metered budget resolve together, in an order you declare.
90
+ - **Multi-source payment.** One operation can draw from a dedicated budget, then general credits, then a promotional grant, at conversion rates you set.
91
+ - **Nothing edited in place.** Consumption writes a ledger entry; reversal writes a matching storno; spent-out wallets move to an archive.
92
+ - **Sliding windows without a reset job.** "200 a day" works by ageing consumption out of a window, not by a nightly job that can fail.
93
+ - **Multi-subject.** A licence can belong to a user, an organisation, a device, a location — resolved across all of them in one call.
94
+
95
+ ## What it could also be
96
+
97
+ A permissions layer, if you push it. Not the point of the library, but the pieces line up: a grant with no expiry *is* a permission bit, the fulfillment order *is* a resolution order across user, team and organisation, and the ledger is the audit trail permission systems usually bolt on afterwards. You get things they rarely offer natively — rights that expire on a date, that may be exercised *n* times, or that are inherited from an organisation and revoked with it.
98
+
99
+ Only the plumbing is missing, and `NEXT_STEPS.md` already scopes it: a grant-only permissions backend, after which the decorators, mixins and checks you have already written keep working unchanged. See [the guide](https://docs.velis.si/allowances/guide/).
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ pip install -e ".[test]"
105
+ pytest # 188 tests, both stores, no setup
106
+ ruff check . && ruff format --check .
107
+
108
+ npm install
109
+ npm run docs:dev
110
+ ```
111
+
112
+ No database server required: the suite defaults to a file-backed SQLite. Copy `tests/env.example.py` to `tests/env.py` to point it at a local Postgres instead, and `ALLOWANCES_TEST_DB=sqlite pytest` to go back. Both engines are supported and both are covered — including `tests/django/test_concurrency.py`, which races eight threads for the same wallet and insists it is granted exactly once.
113
+
114
+ Locally that runs against whichever Django you have installed. CI runs the suite five times — Django 5.2 on Python 3.11, 6.0 on 3.12 and 6.1 on 3.13 against Postgres, plus each end of that range against SQLite — which is what the `Django>=5.2,<7` pin is based on.
115
+
116
+ `DECISIONS.md` records every judgment call and what it would cost to revisit — read it before changing behaviour.
117
+
118
+ ## Licence
119
+
120
+ MIT.
@@ -0,0 +1,86 @@
1
+ """
2
+ allowances — grants and consumable budgets, enforced before the fact and auditable after it.
3
+
4
+ The core is dependency-free and storage-agnostic: it does arithmetic against a
5
+ :class:`~allowances.store.Store` you provide. ``import allowances`` never imports a web framework
6
+ or a database driver.
7
+
8
+ Most applications don't use this module directly — they install a backend
9
+ (``pip install allowances[django]``) and call its shortcuts, which supply the store for you.
10
+ """
11
+
12
+ from allowances.definitions import (
13
+ CurrencyDefinition,
14
+ CurrencySpec,
15
+ DefinitionProvider,
16
+ DictProvider,
17
+ FeatureDefinition,
18
+ FeatureSection,
19
+ )
20
+ from allowances.engine import (
21
+ archive_expired_wallets,
22
+ attach,
23
+ effective_balance,
24
+ find_unattached_fulfillment_records,
25
+ fulfill,
26
+ peek,
27
+ plan_switch,
28
+ unfulfill,
29
+ )
30
+ from allowances.errors import (
31
+ AllowancesError,
32
+ DefinitionNotFoundError,
33
+ InsufficientBalanceError,
34
+ RecordNotFoundError,
35
+ UnsupportedOperationError,
36
+ )
37
+ from allowances.store import Store
38
+ from allowances.types import (
39
+ FulfillmentRecord,
40
+ FulfillmentResult,
41
+ Modifier,
42
+ ObjectRef,
43
+ SubjectRef,
44
+ Transaction,
45
+ TransactionKind,
46
+ Wallet,
47
+ WalletSpec,
48
+ WalletType,
49
+ )
50
+ from allowances.windows import window_start
51
+
52
+ __version__ = "0.1.0"
53
+
54
+ __all__ = [
55
+ "AllowancesError",
56
+ "CurrencyDefinition",
57
+ "CurrencySpec",
58
+ "DefinitionNotFoundError",
59
+ "DefinitionProvider",
60
+ "DictProvider",
61
+ "FeatureDefinition",
62
+ "FeatureSection",
63
+ "FulfillmentRecord",
64
+ "FulfillmentResult",
65
+ "InsufficientBalanceError",
66
+ "Modifier",
67
+ "ObjectRef",
68
+ "RecordNotFoundError",
69
+ "Store",
70
+ "SubjectRef",
71
+ "Transaction",
72
+ "TransactionKind",
73
+ "UnsupportedOperationError",
74
+ "Wallet",
75
+ "WalletSpec",
76
+ "WalletType",
77
+ "archive_expired_wallets",
78
+ "attach",
79
+ "effective_balance",
80
+ "find_unattached_fulfillment_records",
81
+ "fulfill",
82
+ "peek",
83
+ "plan_switch",
84
+ "unfulfill",
85
+ "window_start",
86
+ ]
@@ -0,0 +1,5 @@
1
+ """Storage backends shipped with the core. See ``docs/backends/`` for writing your own."""
2
+
3
+ from allowances.backends.memory import MemoryStore
4
+
5
+ __all__ = ["MemoryStore"]
@@ -0,0 +1,291 @@
1
+ """
2
+ An in-memory :class:`~allowances.store.Store`.
3
+
4
+ Two jobs. It makes the test suite fast — the engine's behaviour is arithmetic, and proving it
5
+ doesn't need a database. And it keeps the port honest: every time the engine reaches for something
6
+ only an ORM could do, this backend stops compiling, which is the signal that the abstraction leaked.
7
+
8
+ Not for production. State lives in a dict, so it dies with the process and is invisible to every
9
+ other one. Locking is a process-local ``RLock``, which is exactly as much concurrency control as a
10
+ single-process test needs and no more.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import itertools
16
+ import threading
17
+ import uuid
18
+
19
+ from contextlib import contextmanager
20
+ from dataclasses import replace
21
+ from datetime import datetime
22
+ from decimal import Decimal
23
+ from typing import Sequence
24
+
25
+ from allowances.types import (
26
+ FulfillmentRecord,
27
+ Modifier,
28
+ ObjectRef,
29
+ SubjectRef,
30
+ Transaction,
31
+ TransactionKind,
32
+ Wallet,
33
+ WalletType,
34
+ )
35
+ from allowances.windows import utcnow
36
+
37
+
38
+ class MemoryStore:
39
+ """A complete Store implementation over plain dicts."""
40
+
41
+ def __init__(self):
42
+ self._wallets: dict[int, Wallet] = {}
43
+ self._transactions: dict[int, Transaction] = {}
44
+ self._records: dict[uuid.UUID, FulfillmentRecord] = {}
45
+ self._modifiers: list[Modifier] = []
46
+ self.archived_wallets: list[Wallet] = []
47
+ self.archived_transactions: list[Transaction] = []
48
+
49
+ self._wallet_ids = itertools.count(1)
50
+ self._transaction_ids = itertools.count(1)
51
+ self._modifier_ids = itertools.count(1)
52
+ self._lock = threading.RLock()
53
+ self._depth = 0
54
+ # Distinguishes rows created within the same microsecond, so ordering stays deterministic
55
+ # on clocks too coarse to separate them.
56
+ self._sequence = itertools.count(1)
57
+ self._created_seq: dict[tuple[str, int], int] = {}
58
+
59
+ # -- transactions / locking ----------------------------------------------------------------
60
+
61
+ @contextmanager
62
+ def unit_of_work(self):
63
+ """
64
+ Atomic and re-entrant. The outermost block snapshots every collection and restores it if
65
+ anything raises; nested blocks join the outer one, exactly as a nested ``atomic()`` does.
66
+ """
67
+ self._lock.acquire()
68
+ self._depth += 1
69
+ snapshot = self._snapshot() if self._depth == 1 else None
70
+ try:
71
+ yield
72
+ except BaseException:
73
+ if snapshot is not None:
74
+ self._restore(snapshot)
75
+ raise
76
+ finally:
77
+ self._depth -= 1
78
+ self._lock.release()
79
+
80
+ def _snapshot(self):
81
+ # Every stored value is a frozen dataclass, so copying the containers is enough.
82
+ return (
83
+ dict(self._wallets),
84
+ dict(self._transactions),
85
+ dict(self._records),
86
+ list(self._modifiers),
87
+ list(self.archived_wallets),
88
+ list(self.archived_transactions),
89
+ dict(self._created_seq),
90
+ )
91
+
92
+ def _restore(self, snapshot):
93
+ (
94
+ self._wallets,
95
+ self._transactions,
96
+ self._records,
97
+ self._modifiers,
98
+ self.archived_wallets,
99
+ self.archived_transactions,
100
+ self._created_seq,
101
+ ) = snapshot
102
+
103
+ def _sort_key(self, kind: str, obj):
104
+ return (obj.created_at, self._created_seq.get((kind, obj.id), 0), obj.id)
105
+
106
+ # -- wallets -------------------------------------------------------------------------------
107
+
108
+ def find_wallets(
109
+ self,
110
+ *,
111
+ subjects: Sequence[SubjectRef],
112
+ wallet_types: Sequence[WalletType],
113
+ currencies: Sequence[str],
114
+ at: datetime,
115
+ lock: bool = False,
116
+ ) -> list[Wallet]:
117
+ subject_set = set(subjects)
118
+ type_set = {str(wallet_type) for wallet_type in wallet_types}
119
+ currency_set = set(currencies)
120
+ found = [
121
+ wallet
122
+ for wallet in self._wallets.values()
123
+ if wallet.subject in subject_set
124
+ and str(wallet.wallet_type) in type_set
125
+ and wallet.currency in currency_set
126
+ and wallet.is_valid_at(at)
127
+ ]
128
+ return sorted(found, key=lambda w: self._sort_key("wallet", w))
129
+
130
+ def get_wallet(self, wallet_id: object, *, lock: bool = False) -> Wallet | None:
131
+ return self._wallets.get(wallet_id)
132
+
133
+ def save_wallet(self, wallet: Wallet) -> None:
134
+ if wallet.id in self._wallets:
135
+ self._wallets[wallet.id] = wallet
136
+
137
+ def create_wallet(
138
+ self,
139
+ *,
140
+ subject: SubjectRef,
141
+ wallet_type: WalletType,
142
+ currency: str,
143
+ balance: Decimal,
144
+ overdraft_limit: Decimal | None = None,
145
+ reset_config: dict | None = None,
146
+ valid_from: datetime | None = None,
147
+ valid_until: datetime | None = None,
148
+ ) -> Wallet:
149
+ wallet = Wallet(
150
+ id=next(self._wallet_ids),
151
+ subject=subject,
152
+ wallet_type=WalletType(wallet_type),
153
+ currency=currency,
154
+ balance=Decimal(str(balance)),
155
+ created_at=utcnow(),
156
+ valid_from=valid_from,
157
+ valid_until=valid_until,
158
+ overdraft_limit=None if overdraft_limit is None else Decimal(str(overdraft_limit)),
159
+ reset_config=reset_config,
160
+ )
161
+ self._wallets[wallet.id] = wallet
162
+ self._created_seq[("wallet", wallet.id)] = next(self._sequence)
163
+ return wallet
164
+
165
+ def close_wallets(self, *, subject: SubjectRef, at: datetime) -> int:
166
+ closed = 0
167
+ for wallet_id, wallet in list(self._wallets.items()):
168
+ if wallet.subject == subject and wallet.is_valid_at(at):
169
+ self._wallets[wallet_id] = replace(wallet, valid_until=at)
170
+ closed += 1
171
+ return closed
172
+
173
+ def find_expired_wallets(self, *, at: datetime, lock: bool = False) -> list[Wallet]:
174
+ expired = [w for w in self._wallets.values() if w.valid_until is not None and w.valid_until <= at]
175
+ return sorted(expired, key=lambda w: self._sort_key("wallet", w))
176
+
177
+ def archive_wallet(self, wallet: Wallet) -> None:
178
+ stored = self._wallets.pop(wallet.id, None)
179
+ if stored is None:
180
+ return
181
+ self.archived_wallets.append(wallet)
182
+ for txn_id, txn in list(self._transactions.items()):
183
+ if txn.wallet_id == wallet.id:
184
+ self.archived_transactions.append(txn)
185
+ del self._transactions[txn_id]
186
+
187
+ # -- ledger --------------------------------------------------------------------------------
188
+
189
+ def sum_transactions(self, *, wallet_id: object, since: datetime, until: datetime) -> Decimal:
190
+ return sum(
191
+ (
192
+ t.amount
193
+ for t in self._transactions.values()
194
+ if t.wallet_id == wallet_id and since <= t.created_at <= until
195
+ ),
196
+ Decimal("0"),
197
+ )
198
+
199
+ def create_transaction(
200
+ self,
201
+ *,
202
+ wallet_id: object,
203
+ kind: TransactionKind,
204
+ amount: Decimal,
205
+ record_guid: uuid.UUID | None = None,
206
+ feature: str = "",
207
+ storno_of_id: object = None,
208
+ at: datetime | None = None,
209
+ ) -> Transaction:
210
+ if storno_of_id is not None and any(t.storno_of_id == storno_of_id for t in self._transactions.values()):
211
+ # The relational backends enforce this with a unique constraint; a lost race here would
212
+ # silently refund twice, so the in-memory one refuses just as loudly.
213
+ raise ValueError(f"Transaction {storno_of_id!r} has already been stornoed")
214
+ txn = Transaction(
215
+ id=next(self._transaction_ids),
216
+ wallet_id=wallet_id,
217
+ kind=TransactionKind(kind),
218
+ amount=Decimal(str(amount)),
219
+ created_at=at or utcnow(),
220
+ record_guid=record_guid,
221
+ feature=feature,
222
+ storno_of_id=storno_of_id,
223
+ )
224
+ self._transactions[txn.id] = txn
225
+ self._created_seq[("transaction", txn.id)] = next(self._sequence)
226
+ return txn
227
+
228
+ def transactions_for_record(self, *, record_guid: uuid.UUID, lock: bool = False) -> list[Transaction]:
229
+ found = [t for t in self._transactions.values() if t.record_guid == record_guid]
230
+ return sorted(found, key=lambda t: self._sort_key("transaction", t))
231
+
232
+ def attach_object(self, *, transaction_ids: Sequence[object], obj: ObjectRef) -> None:
233
+ for txn_id in transaction_ids:
234
+ txn = self._transactions.get(txn_id)
235
+ if txn is not None:
236
+ self._transactions[txn_id] = replace(txn, obj=obj)
237
+
238
+ # -- fulfillment records -------------------------------------------------------------------
239
+
240
+ def get_or_create_record(self, guid: uuid.UUID | None = None) -> FulfillmentRecord:
241
+ if guid is None:
242
+ guid = uuid.uuid4()
243
+ record = self._records.get(guid)
244
+ if record is None:
245
+ record = FulfillmentRecord(guid=guid, created_at=utcnow())
246
+ self._records[guid] = record
247
+ return record
248
+
249
+ def get_record(self, guid: uuid.UUID, *, lock: bool = False) -> FulfillmentRecord | None:
250
+ return self._records.get(guid)
251
+
252
+ def records_with_unattached_transactions(self, *, older_than: datetime | None = None) -> list[FulfillmentRecord]:
253
+ guids = {t.record_guid for t in self._transactions.values() if t.obj is None and t.record_guid is not None}
254
+ records = [self._records[g] for g in guids if g in self._records]
255
+ if older_than is not None:
256
+ records = [r for r in records if r.created_at <= older_than]
257
+ return sorted(records, key=lambda r: (r.created_at, str(r.guid)))
258
+
259
+ # -- modifiers -----------------------------------------------------------------------------
260
+
261
+ def add_modifier(
262
+ self,
263
+ *,
264
+ subject: SubjectRef,
265
+ feature: str,
266
+ factor: Decimal,
267
+ valid_from: datetime | None = None,
268
+ valid_until: datetime | None = None,
269
+ ) -> Modifier:
270
+ """Not part of the port — modifiers are seeded by an application, not by the engine."""
271
+ modifier = Modifier(
272
+ id=next(self._modifier_ids),
273
+ subject=subject,
274
+ feature=feature,
275
+ factor=Decimal(str(factor)),
276
+ valid_from=valid_from,
277
+ valid_until=valid_until,
278
+ )
279
+ self._modifiers.append(modifier)
280
+ return modifier
281
+
282
+ def find_modifier_factors(self, *, feature: str, subjects: Sequence[SubjectRef], at: datetime) -> list[Decimal]:
283
+ subject_set = set(subjects)
284
+ return [
285
+ m.factor
286
+ for m in self._modifiers
287
+ if m.feature == feature
288
+ and m.subject in subject_set
289
+ and (m.valid_from is None or m.valid_from <= at)
290
+ and (m.valid_until is None or m.valid_until >= at)
291
+ ]