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/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)