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

1"""Phase 39b: shared-ownership charge computation. 

2 

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. 

10 

11See docs/implementation_plan.md, Phase 39 ("Shared Ownership"), for the 

12full design. 

13""" 

14 

15from __future__ import annotations 

16 

17from datetime import date 

18from decimal import ROUND_HALF_UP, Decimal 

19from typing import TYPE_CHECKING, Any, cast 

20 

21from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

22 

23if TYPE_CHECKING: 

24 from models import Aircraft, AircraftOwner, BillingAccount, LedgerEntry 

25 

26TWO_PLACES = Decimal("0.01") 

27 

28 

29def _quantize(amount: Any) -> Decimal: 

30 return Decimal(str(amount)).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) 

31 

32 

33def _current_owners(aircraft_id: int) -> list[AircraftOwner]: 

34 from models import AircraftOwner 

35 

36 return cast( 

37 "list[AircraftOwner]", 

38 AircraftOwner.query.filter_by(aircraft_id=aircraft_id).all(), 

39 ) 

40 

41 

42def _account_for(aircraft: Aircraft, user_id: int) -> BillingAccount: 

43 from models import BillingAccountKind 

44 

45 from services.billing import BillingService 

46 

47 return BillingService.get_or_create_account( 

48 aircraft.tenant_id, 

49 user_id, 

50 BillingAccountKind.CO_OWNER, 

51 aircraft_id=aircraft.id, 

52 ) 

53 

54 

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 

63 

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 

76 

77 

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 

93 

94 live = _live_entry(account.id, source_type, source_id) 

95 

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 

107 

108 if _quantize(live.amount) == _quantize(expected_amount): 

109 return 

110 

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 ) 

121 

122 

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 

136 

137 from services.billing import BillingService 

138 

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 ) 

153 

154 

155def _expense_desc(expense: Any) -> str: 

156 from models import ExpenseType 

157 

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 

162 

163 

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 "" 

168 

169 

170def _post_buy_ins(aircraft: Aircraft, owners: list[AircraftOwner]) -> None: 

171 from models import LedgerEntryType 

172 

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) 

189 

190 

191def _post_fixed_expense_shares(aircraft: Aircraft, owners: list[AircraftOwner]) -> None: 

192 from models import Expense, ExpenseCategory, LedgerEntryType 

193 

194 expected_keys: set[tuple[int, int]] = set() 

195 

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

203 

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} 

211 

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 ) 

242 

243 _reverse_orphaned(aircraft, owners, "expense_share", expected_keys) 

244 

245 

246def _post_flight_usage(aircraft: Aircraft, owners: list[AircraftOwner]) -> None: 

247 from models import Flight, LedgerEntryType, LogbookEntryType 

248 

249 expected_keys: set[tuple[int, int]] = set() 

250 

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 ) 

287 

288 _reverse_orphaned(aircraft, owners, "flight_usage", expected_keys) 

289 

290 

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 

301 

302 expected_keys: set[tuple[int, int]] = set() 

303 

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 ) 

340 

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} 

344 

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 

388 

389 _reverse_orphaned(aircraft, owners, "reserve_contribution", expected_keys) 

390 

391 

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 

398 

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) 

417 

418 

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) 

436 

437 

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. 

441 

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 

449 

450 del today # accepted for interface symmetry with other daily-pass helpers 

451 

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 

457 

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 

467 

468 

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. 

472 

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 

480 

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