Coverage for app/services/co_owner_billing.py: 100%
184 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""Phase 39b: shared-ownership charge computation.
3A single idempotent pass converts source records (co-owner buy-ins, fixed
4Expense rows, and Flight PIC hours) into LedgerEntry rows on each
5co-owner's aircraft-scoped BillingAccount, via the shared billing core
6(services/billing.py, see docs/billing_service_design.md). Safe to run
7any number of times — the drift-correction mechanism compares the
8expected amount against what's currently posted and reverses/reposts
9only when something changed.
11See docs/implementation_plan.md, Phase 39 ("Shared Ownership"), for the
12full design.
13"""
15from __future__ import annotations
17from datetime import date
18from decimal import ROUND_HALF_UP, Decimal
19from typing import TYPE_CHECKING, Any, cast
21from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
23if TYPE_CHECKING:
24 from models import Aircraft, AircraftOwner, BillingAccount, LedgerEntry
26TWO_PLACES = Decimal("0.01")
29def _quantize(amount: Any) -> Decimal:
30 return Decimal(str(amount)).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
33def _current_owners(aircraft_id: int) -> list[AircraftOwner]:
34 from models import AircraftOwner
36 return cast(
37 "list[AircraftOwner]",
38 AircraftOwner.query.filter_by(aircraft_id=aircraft_id).all(),
39 )
42def _account_for(aircraft: Aircraft, user_id: int) -> BillingAccount:
43 from models import BillingAccountKind
45 from services.billing import BillingService
47 return BillingService.get_or_create_account(
48 aircraft.tenant_id,
49 user_id,
50 BillingAccountKind.CO_OWNER,
51 aircraft_id=aircraft.id,
52 )
55def _live_entry(
56 account_id: int, source_type: str, source_id: int
57) -> LedgerEntry | None:
58 """The one entry for this source that is neither a reversal itself
59 (reverses_id is NULL) nor already reversed (no other entry points at
60 it via reverses_id). None if the source was never posted or its last
61 posting was reversed."""
62 from models import LedgerEntry
64 candidates = (
65 LedgerEntry.query.filter_by(
66 account_id=account_id, source_type=source_type, source_id=source_id
67 )
68 .filter(LedgerEntry.reverses_id.is_(None))
69 .order_by(LedgerEntry.id.desc())
70 .all()
71 )
72 for entry in candidates:
73 if LedgerEntry.query.filter_by(reverses_id=entry.id).first() is None:
74 return cast("LedgerEntry", entry)
75 return None
78def _sync_entry(
79 account: BillingAccount,
80 source_type: str,
81 source_id: int,
82 expected_amount: Decimal,
83 entry_type: str,
84 entry_date: date,
85 description: str,
86) -> None:
87 """Post or refresh the one entry for this (account, source) so it
88 matches expected_amount. Called only while the source is currently in
89 scope and billable — reversing a source that's *left* scope is
90 `_reverse_orphaned`'s job (the shared mechanism across all three
91 sub-steps), not this function's."""
92 from services.billing import BillingService
94 live = _live_entry(account.id, source_type, source_id)
96 if live is None:
97 BillingService.post(
98 account,
99 entry_type,
100 expected_amount,
101 description,
102 entry_date,
103 source_type=source_type,
104 source_id=source_id,
105 )
106 return
108 if _quantize(live.amount) == _quantize(expected_amount):
109 return
111 BillingService.reverse(live, None, str(_("Source record changed")))
112 BillingService.post(
113 account,
114 entry_type,
115 expected_amount,
116 description,
117 entry_date,
118 source_type=source_type,
119 source_id=source_id,
120 )
123def _reverse_orphaned(
124 aircraft: Aircraft,
125 owners: list[AircraftOwner],
126 source_type: str,
127 expected_keys: set[tuple[int, int]],
128) -> None:
129 """Reverse any live entry of this source_type, on a current owner's
130 account, whose (account_id, source_id) is no longer in expected_keys —
131 the shared "left scope" mechanism for every sub-step (source deleted,
132 re-categorised, PIC edited away, date edited out of range, rate/split
133 change, ...). Accounts of departed owners are never touched, since
134 `owners` only ever holds current AircraftOwner rows."""
135 from models import LedgerEntry
137 from services.billing import BillingService
139 for owner in owners:
140 account = _account_for(aircraft, owner.user_id)
141 live_entries = (
142 LedgerEntry.query.filter_by(account_id=account.id, source_type=source_type)
143 .filter(LedgerEntry.reverses_id.is_(None))
144 .all()
145 )
146 for entry in live_entries:
147 if LedgerEntry.query.filter_by(reverses_id=entry.id).first() is not None:
148 continue # already reversed
149 if (account.id, entry.source_id) not in expected_keys:
150 BillingService.reverse(
151 entry, None, str(_("Source record removed or no longer billable"))
152 )
155def _expense_desc(expense: Any) -> str:
156 from models import ExpenseType
158 label = str(_(ExpenseType.LABELS.get(expense.expense_type, expense.expense_type)))
159 if expense.description:
160 return f"{label} — {expense.description}"
161 return label
164def _route_str(flight: Any) -> str:
165 if flight.departure_icao and flight.arrival_icao:
166 return f"{flight.departure_icao} → {flight.arrival_icao}"
167 return ""
170def _post_buy_ins(aircraft: Aircraft, owners: list[AircraftOwner]) -> None:
171 from models import LedgerEntryType
173 expected_keys: set[tuple[int, int]] = set()
174 for owner in owners:
175 account = _account_for(aircraft, owner.user_id)
176 if owner.buy_in_amount and owner.buy_in_amount > 0:
177 expected_keys.add((account.id, owner.id))
178 description = str(_("Buy-in — %(pct)s%% share", pct=owner.share_pct))
179 _sync_entry(
180 account,
181 "owner_buy_in",
182 owner.id,
183 -_quantize(owner.buy_in_amount),
184 LedgerEntryType.OPENING,
185 aircraft.co_owner_billing_start,
186 description,
187 )
188 _reverse_orphaned(aircraft, owners, "owner_buy_in", expected_keys)
191def _post_fixed_expense_shares(aircraft: Aircraft, owners: list[AircraftOwner]) -> None:
192 from models import Expense, ExpenseCategory, LedgerEntryType
194 expected_keys: set[tuple[int, int]] = set()
196 if owners:
197 expenses = Expense.query.filter(
198 Expense.aircraft_id == aircraft.id,
199 Expense.expense_category == ExpenseCategory.FIXED,
200 Expense.recurrence.is_(None),
201 Expense.date >= aircraft.co_owner_billing_start,
202 ).all()
204 # Largest-share-residue rule: sort ascending by share_pct (ties
205 # broken by user_id descending), every owner but the last gets
206 # their exact proportional share quantized; the last (largest
207 # share) absorbs the rounding residue so the split always sums
208 # to the expense total exactly.
209 ordered = sorted(owners, key=lambda o: (o.share_pct, -o.user_id))
210 accounts = {o.id: _account_for(aircraft, o.user_id) for o in ordered}
212 for expense in expenses:
213 running_total = Decimal(0)
214 for i, owner in enumerate(ordered):
215 account = accounts[owner.id]
216 if i < len(ordered) - 1:
217 share_amount = _quantize(
218 Decimal(expense.amount)
219 * Decimal(owner.share_pct)
220 / Decimal(100)
221 )
222 running_total += share_amount
223 else:
224 share_amount = _quantize(Decimal(expense.amount) - running_total)
225 expected_keys.add((account.id, expense.id))
226 description = str(
227 _(
228 "Fixed cost share (%(pct)s%%) — %(desc)s",
229 pct=owner.share_pct,
230 desc=_expense_desc(expense),
231 )
232 )
233 _sync_entry(
234 account,
235 "expense_share",
236 expense.id,
237 share_amount,
238 LedgerEntryType.CHARGE,
239 expense.date,
240 description,
241 )
243 _reverse_orphaned(aircraft, owners, "expense_share", expected_keys)
246def _post_flight_usage(aircraft: Aircraft, owners: list[AircraftOwner]) -> None:
247 from models import Flight, LedgerEntryType, LogbookEntryType
249 expected_keys: set[tuple[int, int]] = set()
251 if aircraft.co_owner_hourly_rate is not None and owners:
252 owner_by_user_id = {o.user_id: o for o in owners}
253 flights = Flight.query.filter(
254 Flight.aircraft_id == aircraft.id,
255 Flight.entry_type == LogbookEntryType.FLIGHT,
256 Flight.date >= aircraft.co_owner_billing_start,
257 Flight.flight_time.isnot(None),
258 Flight.flight_time > 0,
259 Flight.pic_user_id.isnot(None),
260 ).all()
261 for flight in flights:
262 owner = owner_by_user_id.get(flight.pic_user_id)
263 if owner is None:
264 continue # unattributed hours — surfaced on the 39c dashboard, not billed
265 account = _account_for(aircraft, owner.user_id)
266 amount = _quantize(
267 Decimal(flight.flight_time) * Decimal(aircraft.co_owner_hourly_rate)
268 )
269 expected_keys.add((account.id, flight.id))
270 description = str(
271 _(
272 "Flight %(date)s %(route)s — %(hours)s h",
273 date=flight.date.isoformat(),
274 route=_route_str(flight),
275 hours=flight.flight_time,
276 )
277 )
278 _sync_entry(
279 account,
280 "flight_usage",
281 flight.id,
282 amount,
283 LedgerEntryType.CHARGE,
284 flight.date,
285 description,
286 )
288 _reverse_orphaned(aircraft, owners, "flight_usage", expected_keys)
291def _post_reserve_contributions(
292 aircraft: Aircraft, owners: list[AircraftOwner]
293) -> None:
294 """Phase 39g (stretch): reserve/overhaul fund contributions. Exactly
295 one of the two rate fields is ever set (validated on the owners form)
296 — hourly mode piggybacks on the flight-usage source set (one charge
297 per flight, to the PIC); monthly mode posts one charge per owner per
298 calendar month from billing-start to the current month, split by
299 share % with the same largest-share-residue rule as fixed expenses."""
300 from models import Flight, LedgerEntryType, LogbookEntryType
302 expected_keys: set[tuple[int, int]] = set()
304 if aircraft.reserve_contribution_hourly is not None and owners:
305 owner_by_user_id = {o.user_id: o for o in owners}
306 flights = Flight.query.filter(
307 Flight.aircraft_id == aircraft.id,
308 Flight.entry_type == LogbookEntryType.FLIGHT,
309 Flight.date >= aircraft.co_owner_billing_start,
310 Flight.flight_time.isnot(None),
311 Flight.flight_time > 0,
312 Flight.pic_user_id.isnot(None),
313 ).all()
314 for flight in flights:
315 owner = owner_by_user_id.get(flight.pic_user_id)
316 if owner is None:
317 continue
318 account = _account_for(aircraft, owner.user_id)
319 amount = _quantize(
320 Decimal(flight.flight_time)
321 * Decimal(aircraft.reserve_contribution_hourly)
322 )
323 expected_keys.add((account.id, flight.id))
324 description = str(
325 _(
326 "Reserve fund contribution — flight %(date)s (%(hours)s h)",
327 date=flight.date.isoformat(),
328 hours=flight.flight_time,
329 )
330 )
331 _sync_entry(
332 account,
333 "reserve_contribution",
334 flight.id,
335 amount,
336 LedgerEntryType.CHARGE,
337 flight.date,
338 description,
339 )
341 elif aircraft.reserve_contribution_monthly is not None and owners:
342 ordered = sorted(owners, key=lambda o: (o.share_pct, -o.user_id))
343 accounts = {o.id: _account_for(aircraft, o.user_id) for o in ordered}
345 today = date.today()
346 year, month = (
347 aircraft.co_owner_billing_start.year,
348 aircraft.co_owner_billing_start.month,
349 )
350 while (year, month) <= (today.year, today.month):
351 source_id = year * 100 + month
352 entry_date = date(year, month, 1)
353 running_total = Decimal(0)
354 for i, owner in enumerate(ordered):
355 account = accounts[owner.id]
356 if i < len(ordered) - 1:
357 share_amount = _quantize(
358 Decimal(aircraft.reserve_contribution_monthly)
359 * Decimal(owner.share_pct)
360 / Decimal(100)
361 )
362 running_total += share_amount
363 else:
364 share_amount = _quantize(
365 Decimal(aircraft.reserve_contribution_monthly) - running_total
366 )
367 expected_keys.add((account.id, source_id))
368 description = str(
369 _(
370 "Reserve fund contribution (%(pct)s%%) — %(month)s",
371 pct=owner.share_pct,
372 month=f"{year:04d}-{month:02d}",
373 )
374 )
375 _sync_entry(
376 account,
377 "reserve_contribution",
378 source_id,
379 share_amount,
380 LedgerEntryType.CHARGE,
381 entry_date,
382 description,
383 )
384 month += 1
385 if month > 12:
386 month = 1
387 year += 1
389 _reverse_orphaned(aircraft, owners, "reserve_contribution", expected_keys)
392def reserve_fund_balance(aircraft: Aircraft) -> Decimal:
393 """Sum of all *live* reserve_contribution charges across this
394 aircraft's co-owner accounts — a contribution is money owed *into*
395 the fund, so it accumulates here regardless of which owner it was
396 charged to. Spending the fund is out of scope for this phase."""
397 from models import BillingAccount, BillingAccountKind, LedgerEntry
399 accounts = BillingAccount.query.filter_by(
400 tenant_id=aircraft.tenant_id,
401 kind=BillingAccountKind.CO_OWNER,
402 aircraft_id=aircraft.id,
403 ).all()
404 total = Decimal(0)
405 for account in accounts:
406 entries = (
407 LedgerEntry.query.filter_by(
408 account_id=account.id, source_type="reserve_contribution"
409 )
410 .filter(LedgerEntry.reverses_id.is_(None))
411 .all()
412 )
413 for entry in entries:
414 if LedgerEntry.query.filter_by(reverses_id=entry.id).first() is None:
415 total += Decimal(entry.amount)
416 return _quantize(total)
419def run_co_owner_billing_pass(aircraft: Aircraft) -> None:
420 """Post/refresh all co-owner ledger entries for one aircraft. Caller
421 owns the transaction (commit after calling)."""
422 owners = _current_owners(aircraft.id)
423 if aircraft.co_owner_billing_start is None:
424 # Owners exist but no billing-start date somehow — treat as "no
425 # billing" (post nothing), but still let any previously-posted
426 # entries be reversed since nothing is in scope any more.
427 _reverse_orphaned(aircraft, owners, "owner_buy_in", set())
428 _reverse_orphaned(aircraft, owners, "expense_share", set())
429 _reverse_orphaned(aircraft, owners, "flight_usage", set())
430 _reverse_orphaned(aircraft, owners, "reserve_contribution", set())
431 return
432 _post_buy_ins(aircraft, owners)
433 _post_fixed_expense_shares(aircraft, owners)
434 _post_flight_usage(aircraft, owners)
435 _post_reserve_contributions(aircraft, owners)
438def run_co_owner_billing_pass_all(today: date | None = None) -> int:
439 """Run the pass for every aircraft that has at least one AircraftOwner
440 row. Returns the number of aircraft processed.
442 Short-circuit rule: starts with a single cheap query for the distinct
443 set of aircraft ids with owner rows, and returns 0 immediately when
444 empty — on an instance that never uses shared ownership, this is the
445 only work this function ever does (no AircraftOwner rows can exist
446 there, since the manage-owners form — the sole write path — 404s for
447 other operating models)."""
448 from models import Aircraft, AircraftOwner, db
450 del today # accepted for interface symmetry with other daily-pass helpers
452 aircraft_ids = [
453 row[0] for row in db.session.query(AircraftOwner.aircraft_id).distinct().all()
454 ]
455 if not aircraft_ids:
456 return 0
458 count = 0
459 for aircraft_id in aircraft_ids:
460 aircraft = db.session.get(Aircraft, aircraft_id)
461 if aircraft is None: # pragma: no cover — defensive, FK guarantees it exists
462 continue
463 run_co_owner_billing_pass(aircraft)
464 count += 1
465 db.session.commit()
466 return count
469def overdue_since(account: BillingAccount) -> date | None:
470 """Date the capital balance last went negative (ledger balance went
471 positive), or None if it is currently >= 0.
473 Walks every entry (including reversals — they're ordinary entries that
474 already net out correctly) in (entry_date, id) order, tracking the
475 start of the current unbroken positive streak. On a
476 negative-then-recover-then-negative history this naturally lands on
477 the *latest* streak, since the candidate start date is cleared every
478 time the running balance dips back to zero or below."""
479 from models import LedgerEntry
481 entries = (
482 LedgerEntry.query.filter_by(account_id=account.id)
483 .order_by(LedgerEntry.entry_date, LedgerEntry.id)
484 .all()
485 )
486 running = Decimal(0)
487 streak_start: date | None = None
488 for entry in entries:
489 prev = running
490 running = _quantize(running + Decimal(entry.amount))
491 if running > 0 and prev <= 0:
492 streak_start = entry.entry_date
493 elif running <= 0:
494 streak_start = None
495 return streak_start if running > 0 else None