class_name FacilityUpkeepSettlement extends RefCounted ## Settles exact accrued upkeep against the existing balanced ledger. ## Whole gold is recognized as expense even when cash is insufficient. Sub-gold ## fractions carry forward; arrears survive the removal of the owning facility. ## The caller publishes the returned transaction notification after committing ## its wider Runtime transaction (as with payroll). const Accrual = preload("res://scripts/economy/facility_upkeep_accrual.gd") const VERSION := 1 const CASH: StringName = &"PLAYER_CASH" const EXPENSE: StringName = &"MAINTENANCE_EXPENSE" const PAYABLE: StringName = &"MAINTENANCE_PAYABLE" var _receipts: Array[Dictionary] = [] var _arrears: Dictionary = {} var _fractions: Dictionary = {} static func prepare_accounts(economy: EconomyService) -> bool: if economy == null or not economy.has_account(CASH): return false # Reject incompatible accounts before registering anything. if economy.has_account(EXPENSE) and economy.account_allows_negative(EXPENSE): return false if economy.has_account(PAYABLE) and not economy.account_allows_negative(PAYABLE): return false if not economy.has_account(EXPENSE): economy.register_account(EXPENSE) if not economy.has_account(PAYABLE): economy.register_account(PAYABLE, true) return true func settle(period: Dictionary, economy: EconomyService) -> Dictionary: var canonical := _parse_period(period) if canonical.is_empty(): return _failure(&"INVALID_PERIOD") for saved: Dictionary in _receipts: if saved.period.period_end == canonical.period_end: if saved.period != canonical or not validate_ledger(economy): return _failure(&"REPLAY_MISMATCH") return {&"ok": true, &"replayed": true, &"receipt": saved.duplicate(true), &"transaction_id": &""} if not _receipts.is_empty() and canonical.period_start != _receipts[-1].period.period_end: return _failure(&"PERIOD_GAP_OR_REWIND") if _receipts.size() >= Accrual.MAX_PERIODS: return _failure(&"TOO_MANY_PERIODS") if not validate_ledger(economy): return _failure(&"LEDGER_MISMATCH") var plan := _plan(canonical, economy.get_balance(CASH)) if not plan[&"ok"]: return plan var entries: Array = _entries(canonical, plan.rows) var transaction_id: StringName = &"" if not entries.is_empty(): var result := economy.post(entries, StringName("upkeep.period.%d" % int(canonical.period_end)), &"", false) if not result.is_success(): return _failure(&"LEDGER_POST_FAILED") transaction_id = StringName(result.details[&"transaction_id"]) # No fallible mutation follows the single, atomic ledger post. _arrears = plan.arrears _fractions = plan.fractions var receipt := {&"period": canonical, &"rows": plan.rows, &"transaction_id": String(transaction_id)} _receipts.append(receipt) return {&"ok": true, &"replayed": false, &"receipt": receipt.duplicate(true), &"transaction_id": transaction_id} func get_arrears_through(minute: int) -> int: var low := 0 var high := _receipts.size() while low < high: var middle := (low + high) / 2 if int(_receipts[middle][&"period"][&"period_end"]) <= minute: low = middle + 1 else: high = middle if low == 0: return 0 var amount := 0 for row: Dictionary in _receipts[low - 1][&"rows"]: amount += int(row[&"arrears"]) return amount func get_facility_balance(facility_id: StringName) -> Dictionary: var id := String(facility_id) return {&"arrears": int(_arrears.get(id, 0)), &"fraction_units": int(_fractions.get(id, 0))} func get_settled_period_count() -> int: return _receipts.size() func get_arrears_total() -> int: var debt := 0 for amount: int in _arrears.values(): debt += amount return debt func get_report() -> Dictionary: return {&"arrears_total": get_arrears_total(), &"arrears_by_facility": _arrears.duplicate(), &"fraction_units": _fractions.duplicate(), &"gold_denominator": Accrual.MINUTES_PER_DAY, &"receipts": _receipts.duplicate(true)} func create_snapshot() -> Dictionary: # Derived balances are reconstructed, not trusted from the save file. return {&"version": VERSION, &"receipts": _receipts.duplicate(true)} func restore_snapshot(source: Dictionary, economy: EconomyService) -> bool: if not Accrual._exact_keys(source, ["version", "receipts"]) \ or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \ or not source["receipts"] is Array or source["receipts"].size() > Accrual.MAX_PERIODS: return false var candidate := FacilityUpkeepSettlement.new() for raw: Variant in source["receipts"]: if not raw is Dictionary or not Accrual._exact_keys(raw, ["period", "rows", "transaction_id"]) \ or not raw["period"] is Dictionary or not raw["rows"] is Array \ or typeof(raw["transaction_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: return false var period := _parse_period(raw["period"]) if period.is_empty() or (not candidate._receipts.is_empty() \ and period.period_start != candidate._receipts[-1].period.period_end): return false var budget := 0 for row: Variant in raw["rows"]: if not row is Dictionary or not Accrual._exact_keys(row, ["facility_id", "new_expense", "paid", "arrears", "fraction_units"]) \ or typeof(row["facility_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: return false for field: String in ["new_expense", "paid", "arrears", "fraction_units"]: if typeof(row[field]) != TYPE_INT or row[field] < 0 or row[field] > 9000000000000000: return false if budget > 9000000000000000 - int(row["paid"]): return false budget += int(row["paid"]) var plan := candidate._plan(period, budget) if not plan[&"ok"] or plan.rows != raw["rows"]: return false candidate._arrears = plan.arrears candidate._fractions = plan.fractions candidate._receipts.append({&"period": period, &"rows": plan.rows, &"transaction_id": String(raw["transaction_id"])}) if not candidate.validate_ledger(economy): return false _receipts = candidate._receipts _arrears = candidate._arrears _fractions = candidate._fractions return true func validate_ledger(economy: EconomyService) -> bool: if economy == null or not economy.has_account(CASH) or not economy.has_account(EXPENSE) \ or not economy.has_account(PAYABLE) or economy.account_allows_negative(EXPENSE) \ or not economy.account_allows_negative(PAYABLE): return false var expected_expense := 0 var expected_debt := 0 var owned_transactions: Dictionary = {} for receipt: Dictionary in _receipts: var expected: Array = _entries(receipt.period, receipt.rows) var transaction := StringName(receipt.transaction_id) if economy.get_transaction_id_for_idempotency_key(StringName("upkeep.period.%d" % int(receipt.period.period_end))) != transaction: return false if expected.is_empty(): if not transaction.is_empty(): return false continue if transaction.is_empty() or owned_transactions.has(transaction): return false owned_transactions[transaction] = true var actual := economy.get_transaction_entries(transaction) if actual.size() != expected.size(): return false for i in range(expected.size()): var a: LedgerEntry = actual[i] var e: LedgerEntry = expected[i] if a.amount != e.amount or a.absolute_minute != e.absolute_minute or a.category != e.category \ or a.debit_account != e.debit_account or a.credit_account != e.credit_account \ or a.source_entity_id != e.source_entity_id or a.memo_key != e.memo_key \ or not a.related_transaction_id.is_empty(): return false for row: Dictionary in receipt.rows: expected_expense += int(row.new_expense) for value: int in _arrears.values(): expected_debt += value for entry: LedgerEntry in economy.get_entries(): if entry.debit_account in [EXPENSE, PAYABLE] or entry.credit_account in [EXPENSE, PAYABLE]: if not owned_transactions.has(entry.transaction_id): return false return economy.get_balance(EXPENSE) == expected_expense and economy.get_balance(PAYABLE) == -expected_debt func _plan(period: Dictionary, cash: int) -> Dictionary: if cash < 0: return _failure(&"INVALID_CASH") var ids: Dictionary = {} for source: Dictionary in [_arrears, _fractions, period.facility_units]: for id: String in source: ids[id] = true if ids.size() > Accrual.MAX_FACILITIES: return _failure(&"TOO_MANY_FACILITIES") var ordered := ids.keys() ordered.sort() var rows: Array[Dictionary] = [] var arrears: Dictionary = {} var fractions: Dictionary = {} for id: String in ordered: var units := int(period.facility_units.get(id, 0)) + int(_fractions.get(id, 0)) var expense := units / Accrual.MINUTES_PER_DAY var fraction := units % Accrual.MINUTES_PER_DAY var debt := int(_arrears.get(id, 0)) + expense var paid := mini(debt, cash) cash -= paid if debt > paid: arrears[id] = debt - paid if fraction > 0: fractions[id] = fraction rows.append({&"facility_id": id, &"new_expense": expense, &"paid": paid, &"arrears": debt - paid, &"fraction_units": fraction}) return {&"ok": true, &"rows": rows, &"arrears": arrears, &"fractions": fractions} static func _parse_period(source: Dictionary) -> Dictionary: if not Accrual._exact_keys(source, ["period_start", "period_end", "facility_units"]) \ or typeof(source["period_start"]) != TYPE_INT or typeof(source["period_end"]) != TYPE_INT: return {} var start := int(source["period_start"]) var end := int(source["period_end"]) if start < 0 or end > Accrual.MAX_MINUTE or end != (start / 1440 + 1) * 1440: return {} var units := Accrual._parse_units(source["facility_units"], Accrual.MAX_DAILY_RATE * (end - start), false) if not units[&"ok"]: return {} return {&"period_start": start, &"period_end": end, &"facility_units": units[&"value"]} static func _entries(period: Dictionary, rows: Array) -> Array: var entries: Array = [] for row: Dictionary in rows: var id := StringName(row.facility_id) if int(row.new_expense) > 0: entries.append(LedgerEntry.new().configure(&"", int(period.period_end) - 1, EXPENSE, PAYABLE, int(row.new_expense), &"MAINTENANCE_ACCRUAL", id, &"", &"facility.upkeep.accrued")) if int(row.paid) > 0: entries.append(LedgerEntry.new().configure(&"", int(period.period_end), PAYABLE, CASH, int(row.paid), &"MAINTENANCE_PAYMENT", id, &"", &"facility.upkeep.paid")) return entries static func _failure(reason: StringName) -> Dictionary: return {&"ok": false, &"reason": reason}