allowances 0.2.0__py3-none-any.whl
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.
- allowances/__init__.py +86 -0
- allowances/backends/__init__.py +5 -0
- allowances/backends/memory.py +291 -0
- allowances/definitions.py +199 -0
- allowances/engine.py +493 -0
- allowances/errors.py +51 -0
- allowances/rounding.py +25 -0
- allowances/store.py +190 -0
- allowances/types.py +186 -0
- allowances/windows.py +86 -0
- allowances-0.2.0.dist-info/METADATA +137 -0
- allowances-0.2.0.dist-info/RECORD +25 -0
- allowances-0.2.0.dist-info/WHEEL +5 -0
- allowances-0.2.0.dist-info/top_level.txt +2 -0
- allowances_django/__init__.py +14 -0
- allowances_django/admin.py +90 -0
- allowances_django/apps.py +15 -0
- allowances_django/checks.py +48 -0
- allowances_django/migrations/0001_initial.py +125 -0
- allowances_django/migrations/__init__.py +0 -0
- allowances_django/models.py +279 -0
- allowances_django/providers.py +25 -0
- allowances_django/shortcuts.py +107 -0
- allowances_django/store.py +367 -0
- allowances_django/subjects.py +59 -0
allowances/engine.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The fulfillment engine — ``peek``, ``fulfill``, ``attach``, ``unfulfill``, ``plan_switch``.
|
|
3
|
+
|
|
4
|
+
Nothing in this module knows what a database is. It receives a :class:`~allowances.store.Store`
|
|
5
|
+
and a :class:`~allowances.definitions.DefinitionProvider` and does arithmetic between them, which
|
|
6
|
+
is why the same code runs against Postgres, an in-memory dict, or anything else somebody
|
|
7
|
+
implements the port for.
|
|
8
|
+
|
|
9
|
+
Every function takes ``store`` first. The Django adapter wraps these in module-level shortcuts
|
|
10
|
+
that supply a default store, so application code rarely passes one explicitly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, replace
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
from typing import Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from allowances.definitions import DefinitionProvider, FeatureDefinition
|
|
23
|
+
from allowances.errors import InsufficientBalanceError, RecordNotFoundError
|
|
24
|
+
from allowances.rounding import apply_round
|
|
25
|
+
from allowances.store import Store
|
|
26
|
+
from allowances.types import (
|
|
27
|
+
FulfillmentResult,
|
|
28
|
+
ObjectRef,
|
|
29
|
+
Transaction,
|
|
30
|
+
TransactionKind,
|
|
31
|
+
Wallet,
|
|
32
|
+
WalletSpec,
|
|
33
|
+
WalletType,
|
|
34
|
+
)
|
|
35
|
+
from allowances.windows import utcnow, window_start
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Draw:
|
|
40
|
+
"""One planned withdrawal: take ``cost`` units of currency out of ``wallet`` to cover ``qty``
|
|
41
|
+
units of the feature being requested."""
|
|
42
|
+
|
|
43
|
+
wallet: Wallet
|
|
44
|
+
cost: Decimal
|
|
45
|
+
qty: Decimal
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -------------------------------------------------------------------------------------------------
|
|
49
|
+
# Balances
|
|
50
|
+
# -------------------------------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def effective_balance(store: Store, wallet: Wallet, at: datetime | None = None) -> Decimal:
|
|
54
|
+
"""
|
|
55
|
+
The balance that governs a decision right now.
|
|
56
|
+
|
|
57
|
+
Without a ``reset_config`` the stored balance is authoritative. With one, the stored balance is
|
|
58
|
+
the per-window allotment and the transactions inside the current window are added to it — they
|
|
59
|
+
are negative for consumption, so the balance nets down as the window fills and climbs back as
|
|
60
|
+
old entries age out.
|
|
61
|
+
"""
|
|
62
|
+
at = at or utcnow()
|
|
63
|
+
if not wallet.reset_config:
|
|
64
|
+
return wallet.balance
|
|
65
|
+
since = window_start(wallet.reset_config, at)
|
|
66
|
+
return wallet.balance + store.sum_transactions(wallet_id=wallet.id, since=since, until=at)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# -------------------------------------------------------------------------------------------------
|
|
70
|
+
# Planning
|
|
71
|
+
# -------------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ordered_wallets(
|
|
75
|
+
store: Store, feature_def: FeatureDefinition, subjects: dict, at: datetime, lock: bool
|
|
76
|
+
) -> list[Wallet]:
|
|
77
|
+
"""
|
|
78
|
+
Expand ``fulfillment_order`` into a concrete, deduplicated list of candidate wallets in the
|
|
79
|
+
order they should be drawn from.
|
|
80
|
+
|
|
81
|
+
A callable ``fulfillment_order`` gets one flat list of every plausible candidate and decides
|
|
82
|
+
the order itself. A list of steps is walked in declaration order; within a step, wallets come
|
|
83
|
+
back oldest-first, which is what makes soon-to-expire grants get spent before fresh ones.
|
|
84
|
+
"""
|
|
85
|
+
subject_refs = list(subjects.values())
|
|
86
|
+
if not subject_refs:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
if callable(feature_def.fulfillment_order):
|
|
90
|
+
wallet_types = [wt for wt in (WalletType.GRANT, WalletType.CONSUMABLE) if feature_def.section_for(wt)]
|
|
91
|
+
if not wallet_types:
|
|
92
|
+
return []
|
|
93
|
+
currencies: set[str] = set()
|
|
94
|
+
for wallet_type in wallet_types:
|
|
95
|
+
currencies.update(feature_def.section_for(wallet_type).currency_names)
|
|
96
|
+
candidates = store.find_wallets(
|
|
97
|
+
subjects=subject_refs,
|
|
98
|
+
wallet_types=wallet_types,
|
|
99
|
+
currencies=sorted(currencies),
|
|
100
|
+
at=at,
|
|
101
|
+
lock=lock,
|
|
102
|
+
)
|
|
103
|
+
return list(feature_def.fulfillment_order(candidates))
|
|
104
|
+
|
|
105
|
+
seen: set = set()
|
|
106
|
+
ordered: list[Wallet] = []
|
|
107
|
+
for step_type, step_subject in feature_def.fulfillment_order:
|
|
108
|
+
wallet_types = [step_type] if step_type is not None else [WalletType.GRANT, WalletType.CONSUMABLE]
|
|
109
|
+
# A step naming a subject the caller didn't supply is skipped, not an error — features are
|
|
110
|
+
# declared once and called from contexts that legitimately know about different subjects.
|
|
111
|
+
if step_subject is None:
|
|
112
|
+
step_subjects = subject_refs
|
|
113
|
+
elif step_subject in subjects:
|
|
114
|
+
step_subjects = [subjects[step_subject]]
|
|
115
|
+
else:
|
|
116
|
+
step_subjects = []
|
|
117
|
+
for wallet_type in wallet_types:
|
|
118
|
+
section = feature_def.section_for(wallet_type)
|
|
119
|
+
if section is None or not step_subjects:
|
|
120
|
+
continue
|
|
121
|
+
for subject in step_subjects:
|
|
122
|
+
for wallet in store.find_wallets(
|
|
123
|
+
subjects=[subject],
|
|
124
|
+
wallet_types=[wallet_type],
|
|
125
|
+
currencies=section.currency_names,
|
|
126
|
+
at=at,
|
|
127
|
+
lock=lock,
|
|
128
|
+
):
|
|
129
|
+
if wallet.id not in seen:
|
|
130
|
+
seen.add(wallet.id)
|
|
131
|
+
ordered.append(wallet)
|
|
132
|
+
return ordered
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _consume(
|
|
136
|
+
store: Store,
|
|
137
|
+
wallets: Iterable[Wallet],
|
|
138
|
+
feature_def: FeatureDefinition,
|
|
139
|
+
remaining: Decimal,
|
|
140
|
+
modifier_factor: Decimal,
|
|
141
|
+
provider: DefinitionProvider,
|
|
142
|
+
at: datetime,
|
|
143
|
+
use_overdraft: bool,
|
|
144
|
+
pending: dict,
|
|
145
|
+
) -> tuple[list[_Draw], Decimal]:
|
|
146
|
+
"""
|
|
147
|
+
Walk the candidates once, planning draws until the request is covered. Nothing is persisted
|
|
148
|
+
here.
|
|
149
|
+
|
|
150
|
+
``pending`` carries in-flight draws per wallet id across calls, so the overdraft pass sees what
|
|
151
|
+
the ordinary pass already took and doesn't spend the same balance twice.
|
|
152
|
+
"""
|
|
153
|
+
drawn: list[_Draw] = []
|
|
154
|
+
for wallet in wallets:
|
|
155
|
+
if remaining <= 0:
|
|
156
|
+
break
|
|
157
|
+
section = feature_def.section_for(wallet.wallet_type)
|
|
158
|
+
rate = section.rate_for(wallet.currency) if section else None
|
|
159
|
+
if rate is None:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
already = pending.get(wallet.id, Decimal("0"))
|
|
163
|
+
balance = effective_balance(store, wallet, at) - already
|
|
164
|
+
|
|
165
|
+
if use_overdraft:
|
|
166
|
+
# Only wallets that were given an explicit overdraft limit take part in the second pass.
|
|
167
|
+
if wallet.overdraft_limit is None:
|
|
168
|
+
continue
|
|
169
|
+
capacity = max(balance, Decimal("0")) + wallet.overdraft_limit
|
|
170
|
+
else:
|
|
171
|
+
capacity = max(balance, Decimal("0"))
|
|
172
|
+
if capacity <= 0:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
effective_rate = rate * modifier_factor
|
|
176
|
+
if effective_rate == 0:
|
|
177
|
+
# A zero rate means the feature is free out of this wallet — it covers the whole
|
|
178
|
+
# remainder without costing anything.
|
|
179
|
+
qty_from_wallet = remaining
|
|
180
|
+
else:
|
|
181
|
+
qty_from_wallet = min(remaining, capacity / effective_rate)
|
|
182
|
+
if qty_from_wallet <= 0:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
cost = apply_round(qty_from_wallet * effective_rate, provider.get_currency(wallet.currency).round_fn)
|
|
186
|
+
if cost <= 0:
|
|
187
|
+
continue
|
|
188
|
+
if cost > capacity:
|
|
189
|
+
# Rounding pushed the cost past what is actually there. Clamp rather than overdraw:
|
|
190
|
+
# never spend more than the wallet holds, even at the price of covering slightly less
|
|
191
|
+
# than the rounding implied.
|
|
192
|
+
cost = capacity
|
|
193
|
+
|
|
194
|
+
qty_covered = remaining if effective_rate == 0 else min(cost / effective_rate, remaining)
|
|
195
|
+
|
|
196
|
+
drawn.append(_Draw(wallet=wallet, cost=cost, qty=qty_covered))
|
|
197
|
+
pending[wallet.id] = already + cost
|
|
198
|
+
remaining -= qty_covered
|
|
199
|
+
|
|
200
|
+
return drawn, remaining
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _modifier_factor(store: Store, feature: str, subjects: dict, at: datetime) -> Decimal:
|
|
204
|
+
"""Where several modifiers apply, the subject gets the most favourable one."""
|
|
205
|
+
if not subjects:
|
|
206
|
+
return Decimal("1")
|
|
207
|
+
factors = store.find_modifier_factors(feature=feature, subjects=list(subjects.values()), at=at)
|
|
208
|
+
return max(factors) if factors else Decimal("1")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _plan(
|
|
212
|
+
store: Store,
|
|
213
|
+
feature_def: FeatureDefinition,
|
|
214
|
+
feature: str,
|
|
215
|
+
quantity: Decimal,
|
|
216
|
+
subjects: dict,
|
|
217
|
+
provider: DefinitionProvider,
|
|
218
|
+
at: datetime,
|
|
219
|
+
lock: bool,
|
|
220
|
+
) -> tuple[list[_Draw], Decimal, Decimal]:
|
|
221
|
+
"""
|
|
222
|
+
Plan the whole request: ordinary balances first, and only if something is still uncovered and
|
|
223
|
+
the feature permits it, a second pass over wallets with overdraft headroom.
|
|
224
|
+
"""
|
|
225
|
+
pending: dict = {}
|
|
226
|
+
candidates = _ordered_wallets(store, feature_def, subjects, at, lock)
|
|
227
|
+
factor = _modifier_factor(store, feature, subjects, at)
|
|
228
|
+
|
|
229
|
+
drawn, remaining = _consume(store, candidates, feature_def, quantity, factor, provider, at, False, pending)
|
|
230
|
+
if remaining > 0 and feature_def.overdraft:
|
|
231
|
+
more, remaining = _consume(store, candidates, feature_def, remaining, factor, provider, at, True, pending)
|
|
232
|
+
drawn = drawn + more
|
|
233
|
+
return drawn, remaining, factor
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# -------------------------------------------------------------------------------------------------
|
|
237
|
+
# Archival
|
|
238
|
+
# -------------------------------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _archive_if_drained(store: Store, wallet: Wallet) -> bool:
|
|
242
|
+
"""
|
|
243
|
+
Archive a wallet the moment it is spent out, so the live tables only ever hold wallets that can
|
|
244
|
+
still pay for something.
|
|
245
|
+
|
|
246
|
+
A windowed wallet is never archived by this rule — it replenishes every window by design. A
|
|
247
|
+
wallet with an overdraft limit isn't either, since a zero balance still leaves it able to fund
|
|
248
|
+
the next request.
|
|
249
|
+
"""
|
|
250
|
+
if wallet.reset_config:
|
|
251
|
+
return False
|
|
252
|
+
if wallet.balance <= 0 and wallet.overdraft_limit is None:
|
|
253
|
+
store.archive_wallet(wallet)
|
|
254
|
+
return True
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def archive_expired_wallets(store: Store, at: datetime | None = None) -> int:
|
|
259
|
+
"""
|
|
260
|
+
Archive wallets whose ``valid_until`` has passed. Returns how many were archived.
|
|
261
|
+
|
|
262
|
+
Expired wallets are already invisible to fulfillment, so nothing will ever draw from them again
|
|
263
|
+
and the drain-triggered archival above will never see them. A host application is expected to
|
|
264
|
+
call this periodically — a Celery beat task, a cron job, a management command.
|
|
265
|
+
"""
|
|
266
|
+
at = at or utcnow()
|
|
267
|
+
archived = 0
|
|
268
|
+
with store.unit_of_work():
|
|
269
|
+
for wallet in store.find_expired_wallets(at=at, lock=True):
|
|
270
|
+
store.archive_wallet(wallet)
|
|
271
|
+
archived += 1
|
|
272
|
+
return archived
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# -------------------------------------------------------------------------------------------------
|
|
276
|
+
# Core API
|
|
277
|
+
# -------------------------------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def peek(
|
|
281
|
+
store: Store,
|
|
282
|
+
feature: str,
|
|
283
|
+
quantity,
|
|
284
|
+
subjects: dict,
|
|
285
|
+
provider: DefinitionProvider,
|
|
286
|
+
at: datetime | None = None,
|
|
287
|
+
) -> FulfillmentResult:
|
|
288
|
+
"""
|
|
289
|
+
Ask whether a request *could* be met, without touching anything.
|
|
290
|
+
|
|
291
|
+
No transactions, no fulfillment record, no balance changes. The answer is a snapshot and
|
|
292
|
+
carries no reservation — a concurrent caller can spend what this said was available.
|
|
293
|
+
"""
|
|
294
|
+
feature_def = provider.get_feature(feature)
|
|
295
|
+
at = at or utcnow()
|
|
296
|
+
quantity = Decimal(str(quantity))
|
|
297
|
+
subjects = subjects or {}
|
|
298
|
+
|
|
299
|
+
_, remaining, factor = _plan(store, feature_def, feature, quantity, subjects, provider, at, lock=False)
|
|
300
|
+
return FulfillmentResult(
|
|
301
|
+
requested=quantity,
|
|
302
|
+
granted=quantity - remaining,
|
|
303
|
+
satisfied=remaining <= 0,
|
|
304
|
+
transactions=[],
|
|
305
|
+
guid=None,
|
|
306
|
+
modifier_applied=factor if factor != Decimal("1") else None,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def fulfill(
|
|
311
|
+
store: Store,
|
|
312
|
+
feature: str,
|
|
313
|
+
quantity,
|
|
314
|
+
subjects: dict,
|
|
315
|
+
provider: DefinitionProvider,
|
|
316
|
+
guid: uuid.UUID | None = None,
|
|
317
|
+
allow_partial: bool = False,
|
|
318
|
+
at: datetime | None = None,
|
|
319
|
+
) -> FulfillmentResult:
|
|
320
|
+
"""
|
|
321
|
+
Actually consume the licence, writing one transaction per wallet drawn from.
|
|
322
|
+
|
|
323
|
+
With ``allow_partial=False`` (the default) a request that cannot be met in full raises
|
|
324
|
+
:class:`~allowances.errors.InsufficientBalanceError` and the whole call is rolled back — the
|
|
325
|
+
caller gets all of it or none of it. With ``allow_partial=True`` whatever was available is
|
|
326
|
+
consumed and ``result.satisfied`` reports whether that was enough.
|
|
327
|
+
|
|
328
|
+
Passing a ``guid`` groups this call with earlier ones under a single fulfillment record, so one
|
|
329
|
+
business operation spanning several features can be reversed as a unit.
|
|
330
|
+
"""
|
|
331
|
+
feature_def = provider.get_feature(feature)
|
|
332
|
+
at = at or utcnow()
|
|
333
|
+
quantity = Decimal(str(quantity))
|
|
334
|
+
subjects = subjects or {}
|
|
335
|
+
|
|
336
|
+
with store.unit_of_work():
|
|
337
|
+
record = store.get_or_create_record(guid)
|
|
338
|
+
drawn, remaining, factor = _plan(store, feature_def, feature, quantity, subjects, provider, at, lock=True)
|
|
339
|
+
satisfied = remaining <= 0
|
|
340
|
+
granted = quantity - remaining
|
|
341
|
+
|
|
342
|
+
if not satisfied and not allow_partial:
|
|
343
|
+
raise InsufficientBalanceError(
|
|
344
|
+
FulfillmentResult(
|
|
345
|
+
requested=quantity,
|
|
346
|
+
granted=granted,
|
|
347
|
+
satisfied=False,
|
|
348
|
+
transactions=[],
|
|
349
|
+
guid=record.guid,
|
|
350
|
+
modifier_applied=None,
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
created: list[Transaction] = []
|
|
355
|
+
# A wallet can be drawn from twice — once on the ordinary pass, once on the overdraft pass.
|
|
356
|
+
# Track its running state here so the second draw starts from the first draw's result.
|
|
357
|
+
current: dict = {}
|
|
358
|
+
for draw in drawn:
|
|
359
|
+
wallet = current.get(draw.wallet.id, draw.wallet)
|
|
360
|
+
created.append(
|
|
361
|
+
store.create_transaction(
|
|
362
|
+
wallet_id=wallet.id,
|
|
363
|
+
kind=TransactionKind.CONSUMPTION,
|
|
364
|
+
amount=-draw.cost,
|
|
365
|
+
record_guid=record.guid,
|
|
366
|
+
feature=feature,
|
|
367
|
+
at=at,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if not wallet.reset_config:
|
|
372
|
+
balance = wallet.balance - draw.cost
|
|
373
|
+
first_overdraft = wallet.first_overdraft_at
|
|
374
|
+
if balance < 0 and first_overdraft is None:
|
|
375
|
+
first_overdraft = at
|
|
376
|
+
wallet = replace(wallet, balance=balance, first_overdraft_at=first_overdraft)
|
|
377
|
+
store.save_wallet(wallet)
|
|
378
|
+
elif wallet.first_overdraft_at is None and effective_balance(store, wallet, at) < 0:
|
|
379
|
+
# A windowed wallet's stored balance never moves, but it can still go negative
|
|
380
|
+
# within a window, and that first moment is worth recording.
|
|
381
|
+
wallet = replace(wallet, first_overdraft_at=at)
|
|
382
|
+
store.save_wallet(wallet)
|
|
383
|
+
|
|
384
|
+
current[wallet.id] = wallet
|
|
385
|
+
_archive_if_drained(store, wallet)
|
|
386
|
+
|
|
387
|
+
return FulfillmentResult(
|
|
388
|
+
requested=quantity,
|
|
389
|
+
granted=granted,
|
|
390
|
+
satisfied=satisfied,
|
|
391
|
+
transactions=created,
|
|
392
|
+
guid=record.guid,
|
|
393
|
+
modifier_applied=factor if factor != Decimal("1") else None,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def attach(store: Store, guid: uuid.UUID, obj: ObjectRef) -> None:
|
|
398
|
+
"""
|
|
399
|
+
Record which domain object a fulfillment paid for.
|
|
400
|
+
|
|
401
|
+
Fulfillment happens before the business operation completes — you consume the quota, then send
|
|
402
|
+
the email. This closes the loop afterwards, and it is what makes a record with unattached
|
|
403
|
+
transactions a usable signal that something died in between.
|
|
404
|
+
"""
|
|
405
|
+
with store.unit_of_work():
|
|
406
|
+
if store.get_record(guid) is None:
|
|
407
|
+
raise RecordNotFoundError(f"No fulfillment record with guid {guid}")
|
|
408
|
+
pending = [t.id for t in store.transactions_for_record(record_guid=guid) if t.obj is None]
|
|
409
|
+
if pending:
|
|
410
|
+
store.attach_object(transaction_ids=pending, obj=obj)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def unfulfill(store: Store, guid: uuid.UUID, at: datetime | None = None) -> None:
|
|
414
|
+
"""
|
|
415
|
+
Give back everything consumed under one fulfillment record.
|
|
416
|
+
|
|
417
|
+
Nothing is deleted or edited: each original gets a matching storno entry, so the ledger still
|
|
418
|
+
shows what happened and what undid it. Calling this twice is a no-op the second time — a
|
|
419
|
+
transaction that already has its storno is left alone.
|
|
420
|
+
|
|
421
|
+
Transactions whose wallet has since been archived are skipped: that wallet is gone from live
|
|
422
|
+
storage and there is nothing left to credit.
|
|
423
|
+
"""
|
|
424
|
+
at = at or utcnow()
|
|
425
|
+
with store.unit_of_work():
|
|
426
|
+
# Locking the record — not just the rows — is what serialises two concurrent reversals. Both
|
|
427
|
+
# would otherwise read the same "not yet stornoed" set and each write a refund.
|
|
428
|
+
if store.get_record(guid, lock=True) is None:
|
|
429
|
+
raise RecordNotFoundError(f"No fulfillment record with guid {guid}")
|
|
430
|
+
|
|
431
|
+
transactions = store.transactions_for_record(record_guid=guid, lock=True)
|
|
432
|
+
reversed_ids = {t.storno_of_id for t in transactions if t.storno_of_id is not None}
|
|
433
|
+
originals = [t for t in transactions if t.kind != TransactionKind.STORNO and t.id not in reversed_ids]
|
|
434
|
+
|
|
435
|
+
for original in originals:
|
|
436
|
+
wallet = store.get_wallet(original.wallet_id, lock=True)
|
|
437
|
+
if wallet is None:
|
|
438
|
+
continue
|
|
439
|
+
amount = -original.amount
|
|
440
|
+
store.create_transaction(
|
|
441
|
+
wallet_id=wallet.id,
|
|
442
|
+
kind=TransactionKind.STORNO,
|
|
443
|
+
amount=amount,
|
|
444
|
+
record_guid=guid,
|
|
445
|
+
feature=original.feature,
|
|
446
|
+
storno_of_id=original.id,
|
|
447
|
+
at=at,
|
|
448
|
+
)
|
|
449
|
+
if not wallet.reset_config:
|
|
450
|
+
store.save_wallet(replace(wallet, balance=wallet.balance + amount))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def plan_switch(
|
|
454
|
+
store: Store,
|
|
455
|
+
subjects: dict,
|
|
456
|
+
open_wallets: Sequence[WalletSpec],
|
|
457
|
+
effective_date: datetime | None = None,
|
|
458
|
+
) -> list[Wallet]:
|
|
459
|
+
"""
|
|
460
|
+
Move subjects onto a different plan: close every wallet they currently hold and open the new
|
|
461
|
+
set, atomically.
|
|
462
|
+
|
|
463
|
+
Each spec is opened once per subject, so switching a user *and* their organisation onto the
|
|
464
|
+
same plan in one call gives each their own wallets. Returns the wallets opened.
|
|
465
|
+
"""
|
|
466
|
+
effective_date = effective_date or utcnow()
|
|
467
|
+
opened: list[Wallet] = []
|
|
468
|
+
with store.unit_of_work():
|
|
469
|
+
for subject in subjects.values():
|
|
470
|
+
store.close_wallets(subject=subject, at=effective_date)
|
|
471
|
+
for spec in open_wallets:
|
|
472
|
+
opened.append(
|
|
473
|
+
store.create_wallet(
|
|
474
|
+
subject=subject,
|
|
475
|
+
wallet_type=spec.wallet_type,
|
|
476
|
+
currency=spec.currency,
|
|
477
|
+
balance=spec.balance,
|
|
478
|
+
overdraft_limit=spec.overdraft_limit,
|
|
479
|
+
reset_config=spec.reset_config,
|
|
480
|
+
valid_from=effective_date,
|
|
481
|
+
valid_until=spec.valid_until,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
return opened
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def find_unattached_fulfillment_records(store: Store, older_than: datetime | None = None):
|
|
488
|
+
"""
|
|
489
|
+
Fulfillment records still holding a transaction with no domain object attached — quota that was
|
|
490
|
+
spent on something that may never have happened. A recovery job walks these and decides whether
|
|
491
|
+
to :func:`unfulfill` them.
|
|
492
|
+
"""
|
|
493
|
+
return store.records_with_unattached_transactions(older_than=older_than)
|
allowances/errors.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Every exception the core raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from allowances.types import FulfillmentResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AllowancesError(Exception):
|
|
12
|
+
"""Base class for everything this library raises, so callers can catch one thing."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DefinitionNotFoundError(AllowancesError, KeyError):
|
|
16
|
+
"""A provider was asked for a feature or currency it doesn't know about."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RecordNotFoundError(AllowancesError, LookupError):
|
|
20
|
+
"""
|
|
21
|
+
No fulfillment record exists for the given guid.
|
|
22
|
+
|
|
23
|
+
Distinct from the no-op that ``unfulfill`` performs on an *already reversed* record: that is a
|
|
24
|
+
legitimate repeat call, whereas a guid nobody ever fulfilled under means the caller has lost
|
|
25
|
+
track of which operation it was undoing.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InsufficientBalanceError(AllowancesError):
|
|
30
|
+
"""
|
|
31
|
+
``fulfill(..., allow_partial=False)`` could not cover the whole request.
|
|
32
|
+
|
|
33
|
+
Carries the :class:`~allowances.types.FulfillmentResult` that *would* have been returned had
|
|
34
|
+
``allow_partial=True`` been passed, so a caller can see how far it got. ``transactions`` is
|
|
35
|
+
empty — nothing was persisted, the whole call is rolled back.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, result: FulfillmentResult):
|
|
39
|
+
self.result = result
|
|
40
|
+
super().__init__(f"Requested {result.requested}, only {result.granted} available (guid={result.guid})")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UnsupportedOperationError(AllowancesError, NotImplementedError):
|
|
44
|
+
"""
|
|
45
|
+
A backend was asked for something it deliberately does not implement.
|
|
46
|
+
|
|
47
|
+
Not every store can do everything — an append-only or cache-shaped backend may have no archive
|
|
48
|
+
to move wallets into, and no way to answer a scan like
|
|
49
|
+
``records_with_unattached_transactions``. Raising this (rather than silently doing nothing)
|
|
50
|
+
keeps the gap visible. See ``docs/backends/writing-a-backend.md``.
|
|
51
|
+
"""
|
allowances/rounding.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Turning a fractional cost into a whole unit, per the consuming currency's ``round_fn``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP
|
|
6
|
+
|
|
7
|
+
ROUND_MODES = {
|
|
8
|
+
"floor": ROUND_FLOOR,
|
|
9
|
+
"ceil": ROUND_CEILING,
|
|
10
|
+
"round": ROUND_HALF_UP,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def apply_round(value: Decimal, round_fn: str | None) -> Decimal:
|
|
15
|
+
"""
|
|
16
|
+
Round ``value`` to a whole unit. ``round_fn=None`` means the currency is happy with fractions
|
|
17
|
+
and the value is returned untouched.
|
|
18
|
+
"""
|
|
19
|
+
if round_fn is None:
|
|
20
|
+
return value
|
|
21
|
+
try:
|
|
22
|
+
mode = ROUND_MODES[round_fn]
|
|
23
|
+
except KeyError:
|
|
24
|
+
raise ValueError(f"Unknown round_fn: {round_fn!r} (expected one of {sorted(ROUND_MODES)} or None)") from None
|
|
25
|
+
return value.to_integral_value(rounding=mode)
|