Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,895 @@
|
||||
class_name EconomyService
|
||||
extends RefCounted
|
||||
|
||||
## Authoritative Gold ledger. There is intentionally no public balance setter:
|
||||
## every change is derived from an append-only, balanced LedgerEntry.
|
||||
|
||||
signal transaction_posted(transaction_id: StringName, entries: Array)
|
||||
|
||||
const LedgerEntryType = preload("res://scripts/economy/ledger_entry.gd")
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const SYSTEM_SOURCE: StringName = &"SYSTEM_SOURCE"
|
||||
const SYSTEM_SINK: StringName = &"SYSTEM_SINK"
|
||||
const DEFAULT_CATEGORY: StringName = &"TRANSFER"
|
||||
|
||||
var current_minute := 0
|
||||
|
||||
var _accounts: Dictionary = {}
|
||||
var _balance_cache: Dictionary = {}
|
||||
var _ledger: Array[LedgerEntry] = []
|
||||
var _transaction_ids: Dictionary = {}
|
||||
var _idempotency_keys: Dictionary = {}
|
||||
var _next_transaction_sequence := 1
|
||||
var _pending_transaction_notifications: Dictionary = {}
|
||||
|
||||
|
||||
func _init(account_definitions: Dictionary = {}) -> void:
|
||||
# SOURCE may go negative because it is the explicit counter-account for
|
||||
# initialization/generation. SINK only receives destruction entries.
|
||||
_register_account_internal(SYSTEM_SOURCE, true, true)
|
||||
_register_account_internal(SYSTEM_SINK, false, true)
|
||||
var account_ids: Array = account_definitions.keys()
|
||||
account_ids.sort_custom(_variant_string_less)
|
||||
for account_value: Variant in account_ids:
|
||||
var account_id := StringName(account_value)
|
||||
if account_id in [SYSTEM_SOURCE, SYSTEM_SINK]:
|
||||
continue
|
||||
var definition: Variant = account_definitions[account_value]
|
||||
var allow_negative := false
|
||||
if definition is bool:
|
||||
allow_negative = definition
|
||||
elif definition is Dictionary:
|
||||
allow_negative = bool(definition.get(
|
||||
&"allow_negative",
|
||||
definition.get("allow_negative", false)
|
||||
))
|
||||
_register_account_internal(account_id, allow_negative, false)
|
||||
|
||||
|
||||
func set_current_minute(absolute_minute: int) -> bool:
|
||||
if absolute_minute < 0:
|
||||
return false
|
||||
current_minute = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func register_account(account_id: StringName, allow_negative := false) -> bool:
|
||||
if account_id.is_empty() or _accounts.has(account_id):
|
||||
return false
|
||||
return _register_account_internal(account_id, allow_negative, false)
|
||||
|
||||
|
||||
func has_account(account_id: StringName) -> bool:
|
||||
return _accounts.has(account_id)
|
||||
|
||||
|
||||
func account_allows_negative(account_id: StringName) -> bool:
|
||||
if not _accounts.has(account_id):
|
||||
return false
|
||||
return bool((_accounts[account_id] as Dictionary).get(&"allow_negative", false))
|
||||
|
||||
|
||||
func get_account(account_id: StringName) -> Dictionary:
|
||||
if not _accounts.has(account_id):
|
||||
return {}
|
||||
var result: Dictionary = (_accounts[account_id] as Dictionary).duplicate(true)
|
||||
result[&"account_id"] = account_id
|
||||
result[&"balance"] = get_balance(account_id)
|
||||
return result
|
||||
|
||||
|
||||
func get_accounts() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for account_id: StringName in _sorted_string_name_keys(_accounts):
|
||||
result.append(get_account(account_id))
|
||||
return result
|
||||
|
||||
|
||||
func get_balance(account_id: StringName) -> int:
|
||||
return int(_balance_cache.get(account_id, 0))
|
||||
|
||||
|
||||
func initialize_account(
|
||||
account_id: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = 0,
|
||||
idempotency_key: StringName = &"",
|
||||
emit_notification := true
|
||||
) -> CommandResult:
|
||||
if amount == 0:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"An initial balance must be non-zero."
|
||||
)
|
||||
if amount > 0:
|
||||
return transfer(
|
||||
SYSTEM_SOURCE,
|
||||
account_id,
|
||||
amount,
|
||||
absolute_minute,
|
||||
&"INITIALIZATION",
|
||||
account_id,
|
||||
idempotency_key,
|
||||
&"account.initial_balance",
|
||||
&"",
|
||||
emit_notification
|
||||
)
|
||||
return transfer(
|
||||
account_id,
|
||||
SYSTEM_SINK,
|
||||
-amount,
|
||||
absolute_minute,
|
||||
&"INITIALIZATION",
|
||||
account_id,
|
||||
idempotency_key,
|
||||
&"account.initial_balance",
|
||||
&"",
|
||||
emit_notification
|
||||
)
|
||||
|
||||
|
||||
## Moves Gold from from_account to to_account. The receiver is debited and the
|
||||
## sender is credited, so signed balance is always debit minus credit.
|
||||
func transfer(
|
||||
from_account: StringName,
|
||||
to_account: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = -1,
|
||||
category: StringName = DEFAULT_CATEGORY,
|
||||
source_entity_id: StringName = &"",
|
||||
idempotency_key: StringName = &"",
|
||||
memo_key: StringName = &"",
|
||||
related_transaction_id: StringName = &"",
|
||||
emit_notification := true
|
||||
) -> CommandResult:
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
var entry := LedgerEntryType.new().configure(
|
||||
&"",
|
||||
minute,
|
||||
to_account,
|
||||
from_account,
|
||||
amount,
|
||||
category,
|
||||
source_entity_id,
|
||||
related_transaction_id,
|
||||
memo_key
|
||||
)
|
||||
return post([entry], idempotency_key, &"", emit_notification)
|
||||
|
||||
|
||||
## Convenience API for callers which naturally specify debit and credit.
|
||||
func post_entry(
|
||||
debit_account: StringName,
|
||||
credit_account: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int,
|
||||
category: StringName,
|
||||
source_entity_id: StringName = &"",
|
||||
idempotency_key: StringName = &"",
|
||||
memo_key: StringName = &"",
|
||||
related_transaction_id: StringName = &""
|
||||
) -> CommandResult:
|
||||
var entry := LedgerEntryType.new().configure(
|
||||
&"",
|
||||
absolute_minute,
|
||||
debit_account,
|
||||
credit_account,
|
||||
amount,
|
||||
category,
|
||||
source_entity_id,
|
||||
related_transaction_id,
|
||||
memo_key
|
||||
)
|
||||
return post([entry], idempotency_key)
|
||||
|
||||
|
||||
## Appends one transaction. All entries are validated and all resulting account
|
||||
## balances are checked before any live state is changed.
|
||||
func post(
|
||||
entries: Array,
|
||||
idempotency_key: StringName = &"",
|
||||
transaction_id: StringName = &"",
|
||||
emit_notification := true
|
||||
) -> CommandResult:
|
||||
if entries.is_empty():
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, "A transaction needs at least one entry.")
|
||||
if not idempotency_key.is_empty() and _idempotency_keys.has(idempotency_key):
|
||||
return _failure(
|
||||
CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY,
|
||||
"The idempotency key has already been posted.",
|
||||
{&"transaction_id": _idempotency_keys[idempotency_key]}
|
||||
)
|
||||
|
||||
var normalized: Array[LedgerEntry] = []
|
||||
var draft_ids: Dictionary = {}
|
||||
for value: Variant in entries:
|
||||
var entry := _entry_from_value(value)
|
||||
if entry == null:
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, "A ledger line is malformed.")
|
||||
if not entry.transaction_id.is_empty():
|
||||
draft_ids[entry.transaction_id] = true
|
||||
normalized.append(entry)
|
||||
if draft_ids.size() > 1:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"All entries in one post must share a transaction ID."
|
||||
)
|
||||
|
||||
var generated := false
|
||||
var generated_sequence := _next_transaction_sequence
|
||||
var effective_id := transaction_id
|
||||
if effective_id.is_empty() and draft_ids.size() == 1:
|
||||
effective_id = draft_ids.keys()[0]
|
||||
if effective_id.is_empty():
|
||||
var generated_data := _peek_generated_transaction_id()
|
||||
effective_id = generated_data[&"transaction_id"]
|
||||
generated_sequence = int(generated_data[&"sequence"])
|
||||
generated = true
|
||||
if effective_id.is_empty() or _transaction_ids.has(effective_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.DUPLICATE_COMMAND_ID,
|
||||
"The transaction ID is empty or already exists.",
|
||||
{&"transaction_id": effective_id}
|
||||
)
|
||||
|
||||
var candidate_balances := _balance_cache.duplicate(true)
|
||||
var debit_total := 0
|
||||
var credit_total := 0
|
||||
for entry: LedgerEntry in normalized:
|
||||
if not entry.transaction_id.is_empty() and entry.transaction_id != effective_id:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"A ledger line conflicts with the requested transaction ID."
|
||||
)
|
||||
entry.transaction_id = effective_id
|
||||
var errors := entry.validation_errors()
|
||||
if not errors.is_empty():
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"A ledger line violates the ledger schema.",
|
||||
{&"errors": errors}
|
||||
)
|
||||
if not _accounts.has(entry.debit_account) or not _accounts.has(entry.credit_account):
|
||||
return _failure(
|
||||
CommandResultType.Code.NOT_FOUND,
|
||||
"A ledger account is not registered.",
|
||||
{
|
||||
&"debit_account": entry.debit_account,
|
||||
&"credit_account": entry.credit_account,
|
||||
}
|
||||
)
|
||||
if not entry.related_transaction_id.is_empty() \
|
||||
and not _transaction_ids.has(entry.related_transaction_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.NOT_FOUND,
|
||||
"The related transaction does not exist.",
|
||||
{&"related_transaction_id": entry.related_transaction_id}
|
||||
)
|
||||
if not _system_account_use_is_valid(entry):
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"SYSTEM_SOURCE and SYSTEM_SINK are restricted to explicit system flows.",
|
||||
{&"category": entry.category}
|
||||
)
|
||||
candidate_balances[entry.debit_account] = int(candidate_balances[entry.debit_account]) + entry.amount
|
||||
candidate_balances[entry.credit_account] = int(candidate_balances[entry.credit_account]) - entry.amount
|
||||
debit_total += entry.amount
|
||||
credit_total += entry.amount
|
||||
|
||||
if debit_total != credit_total:
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Debit and credit totals differ.")
|
||||
for account_id: StringName in _sorted_string_name_keys(candidate_balances):
|
||||
if int(candidate_balances[account_id]) < 0 and not account_allows_negative(account_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"An account does not permit a negative balance.",
|
||||
{
|
||||
&"account_id": account_id,
|
||||
&"available": get_balance(account_id),
|
||||
&"resulting_balance": int(candidate_balances[account_id]),
|
||||
}
|
||||
)
|
||||
|
||||
# Commit starts here. Every possibly failing check is complete.
|
||||
var start_index := _ledger.size()
|
||||
for entry: LedgerEntry in normalized:
|
||||
_ledger.append(entry.copy())
|
||||
_balance_cache = candidate_balances
|
||||
_transaction_ids[effective_id] = {
|
||||
&"start_index": start_index,
|
||||
&"entry_count": normalized.size(),
|
||||
&"idempotency_key": idempotency_key,
|
||||
}
|
||||
if not idempotency_key.is_empty():
|
||||
_idempotency_keys[idempotency_key] = effective_id
|
||||
if generated:
|
||||
_next_transaction_sequence = generated_sequence + 1
|
||||
var posted_entries := get_transaction_entries(effective_id)
|
||||
if emit_notification:
|
||||
transaction_posted.emit(effective_id, posted_entries)
|
||||
else:
|
||||
_pending_transaction_notifications[effective_id] = true
|
||||
return CommandResultType.succeeded(0, {
|
||||
&"transaction_id": effective_id,
|
||||
&"entry_count": normalized.size(),
|
||||
&"entries": _entries_to_dictionaries(posted_entries),
|
||||
})
|
||||
|
||||
|
||||
func has_pending_transaction_notification(transaction_id: StringName) -> bool:
|
||||
return _pending_transaction_notifications.has(transaction_id)
|
||||
|
||||
|
||||
func commit_transaction_notification(transaction_id: StringName) -> bool:
|
||||
if not _pending_transaction_notifications.has(transaction_id) \
|
||||
or not _transaction_ids.has(transaction_id):
|
||||
return false
|
||||
_pending_transaction_notifications.erase(transaction_id)
|
||||
transaction_posted.emit(transaction_id, get_transaction_entries(transaction_id))
|
||||
return true
|
||||
|
||||
|
||||
func discard_transaction_notification(transaction_id: StringName) -> bool:
|
||||
if not _pending_transaction_notifications.has(transaction_id):
|
||||
return false
|
||||
_pending_transaction_notifications.erase(transaction_id)
|
||||
return true
|
||||
|
||||
|
||||
func reverse(
|
||||
transaction_id: StringName,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &"",
|
||||
category: StringName = &"REVERSAL",
|
||||
source_entity_id: StringName = &"",
|
||||
memo_key: StringName = &"transaction.reversal"
|
||||
) -> CommandResult:
|
||||
var originals := get_transaction_entries(transaction_id)
|
||||
if originals.is_empty():
|
||||
return _failure(
|
||||
CommandResultType.Code.NOT_FOUND,
|
||||
"The transaction to reverse was not found.",
|
||||
{&"transaction_id": transaction_id}
|
||||
)
|
||||
if _has_related_financial_entry(transaction_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"The transaction has already been reversed or refunded.",
|
||||
{&"transaction_id": transaction_id}
|
||||
)
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
var entries: Array[LedgerEntry] = []
|
||||
for original: LedgerEntry in originals:
|
||||
entries.append(original.reversed(
|
||||
&"",
|
||||
minute,
|
||||
category,
|
||||
source_entity_id,
|
||||
memo_key
|
||||
))
|
||||
return post(entries, idempotency_key)
|
||||
|
||||
|
||||
func reverse_transaction(
|
||||
transaction_id: StringName,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> CommandResult:
|
||||
return reverse(transaction_id, absolute_minute, idempotency_key)
|
||||
|
||||
|
||||
## Partial refunds are deliberately limited to a one-line source transaction;
|
||||
## this avoids silently choosing an allocation across compound entries.
|
||||
func refund(
|
||||
transaction_id: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &"",
|
||||
source_entity_id: StringName = &"",
|
||||
memo_key: StringName = &"transaction.refund"
|
||||
) -> CommandResult:
|
||||
var originals := get_transaction_entries(transaction_id)
|
||||
if originals.is_empty():
|
||||
return _failure(CommandResultType.Code.NOT_FOUND, "The transaction to refund was not found.")
|
||||
if originals.size() != 1:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"A partial refund needs a one-line source transaction."
|
||||
)
|
||||
var original: LedgerEntry = originals[0]
|
||||
var refundable := original.amount - get_refunded_amount(transaction_id)
|
||||
if amount <= 0 or amount > refundable:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"The refund amount is outside the remaining refundable amount.",
|
||||
{&"refundable": refundable}
|
||||
)
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
var entry := original.reversed(
|
||||
&"",
|
||||
minute,
|
||||
&"REFUND",
|
||||
source_entity_id,
|
||||
memo_key
|
||||
)
|
||||
entry.amount = amount
|
||||
return post([entry], idempotency_key)
|
||||
|
||||
|
||||
func refund_transaction(
|
||||
transaction_id: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> CommandResult:
|
||||
return refund(transaction_id, amount, absolute_minute, idempotency_key)
|
||||
|
||||
|
||||
func get_refunded_amount(transaction_id: StringName) -> int:
|
||||
var originals := get_transaction_entries(transaction_id)
|
||||
if originals.size() != 1:
|
||||
return 0
|
||||
var original: LedgerEntry = originals[0]
|
||||
var total := 0
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.related_transaction_id == transaction_id \
|
||||
and entry.debit_account == original.credit_account \
|
||||
and entry.credit_account == original.debit_account:
|
||||
total += entry.amount
|
||||
return total
|
||||
|
||||
|
||||
func has_transaction(transaction_id: StringName) -> bool:
|
||||
return _transaction_ids.has(transaction_id)
|
||||
|
||||
|
||||
func get_transaction_id_for_idempotency_key(idempotency_key: StringName) -> StringName:
|
||||
return _idempotency_keys.get(idempotency_key, &"")
|
||||
|
||||
|
||||
func get_entries() -> Array[LedgerEntry]:
|
||||
var result: Array[LedgerEntry] = []
|
||||
for entry: LedgerEntry in _ledger:
|
||||
result.append(entry.copy())
|
||||
return result
|
||||
|
||||
|
||||
func get_transaction_entries(transaction_id: StringName) -> Array[LedgerEntry]:
|
||||
var result: Array[LedgerEntry] = []
|
||||
if not _transaction_ids.has(transaction_id):
|
||||
return result
|
||||
var metadata: Dictionary = _transaction_ids[transaction_id]
|
||||
var start_index := int(metadata[&"start_index"])
|
||||
var entry_count := int(metadata[&"entry_count"])
|
||||
for index in range(start_index, start_index + entry_count):
|
||||
result.append(_ledger[index].copy())
|
||||
return result
|
||||
|
||||
|
||||
func aggregate_categories(from_minute: int, to_minute_exclusive: int) -> Dictionary:
|
||||
var totals: Dictionary = {}
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.absolute_minute < from_minute or entry.absolute_minute >= to_minute_exclusive:
|
||||
continue
|
||||
totals[entry.category] = int(totals.get(entry.category, 0)) + entry.amount
|
||||
return _sorted_dictionary(totals)
|
||||
|
||||
|
||||
func aggregate_accounts(from_minute: int, to_minute_exclusive: int) -> Dictionary:
|
||||
var totals: Dictionary = {}
|
||||
for account_id: StringName in _sorted_string_name_keys(_accounts):
|
||||
totals[account_id] = {&"debit": 0, &"credit": 0, &"net": 0}
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.absolute_minute < from_minute or entry.absolute_minute >= to_minute_exclusive:
|
||||
continue
|
||||
var debit: Dictionary = totals[entry.debit_account]
|
||||
debit[&"debit"] = int(debit[&"debit"]) + entry.amount
|
||||
debit[&"net"] = int(debit[&"net"]) + entry.amount
|
||||
var credit: Dictionary = totals[entry.credit_account]
|
||||
credit[&"credit"] = int(credit[&"credit"]) + entry.amount
|
||||
credit[&"net"] = int(credit[&"net"]) - entry.amount
|
||||
return totals
|
||||
|
||||
|
||||
func get_daily_summary(day: int) -> Dictionary:
|
||||
if day < 1:
|
||||
return {}
|
||||
var start_minute := (day - 1) * 1_440
|
||||
var end_minute := start_minute + 1_440
|
||||
var transaction_set: Dictionary = {}
|
||||
var entry_count := 0
|
||||
var total_debit := 0
|
||||
var total_credit := 0
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.absolute_minute < start_minute or entry.absolute_minute >= end_minute:
|
||||
continue
|
||||
entry_count += 1
|
||||
transaction_set[entry.transaction_id] = true
|
||||
total_debit += entry.amount
|
||||
total_credit += entry.amount
|
||||
return {
|
||||
&"day": day,
|
||||
&"start_minute": start_minute,
|
||||
&"end_minute_exclusive": end_minute,
|
||||
&"category_totals": aggregate_categories(start_minute, end_minute),
|
||||
&"account_totals": aggregate_accounts(start_minute, end_minute),
|
||||
&"transaction_count": transaction_set.size(),
|
||||
&"entry_count": entry_count,
|
||||
&"total_debit": total_debit,
|
||||
&"total_credit": total_credit,
|
||||
&"balanced": total_debit == total_credit,
|
||||
}
|
||||
|
||||
|
||||
func daily_summary(day: int) -> Dictionary:
|
||||
return get_daily_summary(day)
|
||||
|
||||
|
||||
func validate_ledger() -> Dictionary:
|
||||
var calculated := _calculate_and_validate(_accounts, _ledger, _idempotency_keys)
|
||||
var errors: Array = calculated[&"errors"]
|
||||
var expected_balances: Dictionary = calculated[&"balances"]
|
||||
if expected_balances != _balance_cache:
|
||||
errors.append({
|
||||
&"code": &"BALANCE_CACHE_MISMATCH",
|
||||
&"expected": expected_balances,
|
||||
&"actual": _balance_cache.duplicate(true),
|
||||
})
|
||||
var expected_transactions: Dictionary = calculated[&"transactions"]
|
||||
if expected_transactions != _transaction_ids:
|
||||
errors.append({
|
||||
&"code": &"TRANSACTION_INDEX_MISMATCH",
|
||||
&"expected": expected_transactions,
|
||||
&"actual": _transaction_ids.duplicate(true),
|
||||
})
|
||||
return {
|
||||
&"ok": errors.is_empty(),
|
||||
&"errors": errors,
|
||||
&"total_debit": calculated[&"total_debit"],
|
||||
&"total_credit": calculated[&"total_credit"],
|
||||
&"account_count": _accounts.size(),
|
||||
&"transaction_count": _transaction_ids.size(),
|
||||
&"entry_count": _ledger.size(),
|
||||
}
|
||||
|
||||
|
||||
func validate() -> Dictionary:
|
||||
return validate_ledger()
|
||||
|
||||
|
||||
func rebuild_balance_cache() -> bool:
|
||||
var calculated := _calculate_and_validate(_accounts, _ledger, _idempotency_keys)
|
||||
if not (calculated[&"errors"] as Array).is_empty():
|
||||
return false
|
||||
_balance_cache = (calculated[&"balances"] as Dictionary).duplicate(true)
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var saved_accounts: Array[Dictionary] = []
|
||||
for account_id: StringName in _sorted_string_name_keys(_accounts):
|
||||
var definition: Dictionary = _accounts[account_id]
|
||||
saved_accounts.append({
|
||||
&"account_id": String(account_id),
|
||||
&"allow_negative": bool(definition[&"allow_negative"]),
|
||||
&"system": bool(definition[&"system"]),
|
||||
})
|
||||
var saved_entries: Array[Dictionary] = []
|
||||
for entry: LedgerEntry in _ledger:
|
||||
saved_entries.append(entry.to_dictionary())
|
||||
var saved_idempotency: Array[Dictionary] = []
|
||||
for key: StringName in _sorted_string_name_keys(_idempotency_keys):
|
||||
saved_idempotency.append({
|
||||
&"key": String(key),
|
||||
&"transaction_id": String(_idempotency_keys[key]),
|
||||
})
|
||||
return {
|
||||
&"schema_version": SNAPSHOT_VERSION,
|
||||
&"current_minute": current_minute,
|
||||
&"next_transaction_sequence": _next_transaction_sequence,
|
||||
&"accounts": saved_accounts,
|
||||
&"entries": saved_entries,
|
||||
&"idempotency": saved_idempotency,
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if int(snapshot.get(&"schema_version", snapshot.get("schema_version", 0))) != SNAPSHOT_VERSION:
|
||||
return false
|
||||
var saved_accounts: Variant = snapshot.get(&"accounts", snapshot.get("accounts", null))
|
||||
var saved_entries: Variant = snapshot.get(&"entries", snapshot.get("entries", null))
|
||||
var saved_idempotency: Variant = snapshot.get(&"idempotency", snapshot.get("idempotency", null))
|
||||
if not saved_accounts is Array or not saved_entries is Array or not saved_idempotency is Array:
|
||||
return false
|
||||
|
||||
var restored_accounts: Dictionary = {}
|
||||
for value: Variant in saved_accounts:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
var account_id := StringName(value.get(&"account_id", value.get("account_id", "")))
|
||||
if account_id.is_empty() or restored_accounts.has(account_id):
|
||||
return false
|
||||
restored_accounts[account_id] = {
|
||||
&"allow_negative": bool(value.get(&"allow_negative", value.get("allow_negative", false))),
|
||||
&"system": bool(value.get(&"system", value.get("system", false))),
|
||||
}
|
||||
if not restored_accounts.has(SYSTEM_SOURCE) or not restored_accounts.has(SYSTEM_SINK):
|
||||
return false
|
||||
if not bool((restored_accounts[SYSTEM_SOURCE] as Dictionary)[&"allow_negative"]) \
|
||||
or bool((restored_accounts[SYSTEM_SINK] as Dictionary)[&"allow_negative"]):
|
||||
return false
|
||||
if not bool((restored_accounts[SYSTEM_SOURCE] as Dictionary)[&"system"]) \
|
||||
or not bool((restored_accounts[SYSTEM_SINK] as Dictionary)[&"system"]):
|
||||
return false
|
||||
for account_id: Variant in restored_accounts:
|
||||
if account_id not in [SYSTEM_SOURCE, SYSTEM_SINK] \
|
||||
and bool((restored_accounts[account_id] as Dictionary)[&"system"]):
|
||||
return false
|
||||
|
||||
var restored_ledger: Array[LedgerEntry] = []
|
||||
for value: Variant in saved_entries:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
restored_ledger.append(LedgerEntryType.from_dictionary(value))
|
||||
|
||||
var restored_idempotency: Dictionary = {}
|
||||
var idempotent_transactions: Dictionary = {}
|
||||
for value: Variant in saved_idempotency:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
var key := StringName(value.get(&"key", value.get("key", "")))
|
||||
var transaction_id := StringName(value.get(
|
||||
&"transaction_id",
|
||||
value.get("transaction_id", "")
|
||||
))
|
||||
if key.is_empty() or transaction_id.is_empty() \
|
||||
or restored_idempotency.has(key) or idempotent_transactions.has(transaction_id):
|
||||
return false
|
||||
restored_idempotency[key] = transaction_id
|
||||
idempotent_transactions[transaction_id] = true
|
||||
|
||||
var calculated := _calculate_and_validate(
|
||||
restored_accounts,
|
||||
restored_ledger,
|
||||
restored_idempotency
|
||||
)
|
||||
if not (calculated[&"errors"] as Array).is_empty():
|
||||
return false
|
||||
var restored_transactions: Dictionary = calculated[&"transactions"]
|
||||
var next_sequence := int(snapshot.get(
|
||||
&"next_transaction_sequence",
|
||||
snapshot.get("next_transaction_sequence", 0)
|
||||
))
|
||||
var restored_minute := int(snapshot.get(&"current_minute", snapshot.get("current_minute", -1)))
|
||||
if next_sequence < 1 or restored_minute < 0:
|
||||
return false
|
||||
|
||||
# Atomic swap: live fields are untouched until every saved value is valid.
|
||||
_accounts = restored_accounts
|
||||
_ledger = restored_ledger
|
||||
_idempotency_keys = restored_idempotency
|
||||
_transaction_ids = restored_transactions
|
||||
_balance_cache = (calculated[&"balances"] as Dictionary).duplicate(true)
|
||||
_next_transaction_sequence = next_sequence
|
||||
_pending_transaction_notifications.clear()
|
||||
current_minute = restored_minute
|
||||
return true
|
||||
|
||||
|
||||
func _register_account_internal(
|
||||
account_id: StringName,
|
||||
allow_negative: bool,
|
||||
is_system: bool
|
||||
) -> bool:
|
||||
if account_id.is_empty() or _accounts.has(account_id):
|
||||
return false
|
||||
_accounts[account_id] = {
|
||||
&"allow_negative": allow_negative,
|
||||
&"system": is_system,
|
||||
}
|
||||
_balance_cache[account_id] = 0
|
||||
return true
|
||||
|
||||
|
||||
func _entry_from_value(value: Variant) -> LedgerEntry:
|
||||
if value is LedgerEntry:
|
||||
return (value as LedgerEntry).copy()
|
||||
if value is Dictionary:
|
||||
return LedgerEntryType.from_dictionary(value)
|
||||
return null
|
||||
|
||||
|
||||
func _peek_generated_transaction_id() -> Dictionary:
|
||||
var sequence := maxi(_next_transaction_sequence, 1)
|
||||
var transaction_id := StringName("ledger_%08d" % sequence)
|
||||
while _transaction_ids.has(transaction_id):
|
||||
sequence += 1
|
||||
transaction_id = StringName("ledger_%08d" % sequence)
|
||||
return {&"transaction_id": transaction_id, &"sequence": sequence}
|
||||
|
||||
|
||||
func _has_related_financial_entry(transaction_id: StringName) -> bool:
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.related_transaction_id == transaction_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _system_account_use_is_valid(entry: LedgerEntry) -> bool:
|
||||
if entry.debit_account not in [SYSTEM_SOURCE, SYSTEM_SINK] \
|
||||
and entry.credit_account not in [SYSTEM_SOURCE, SYSTEM_SINK]:
|
||||
return true
|
||||
var normalized := String(entry.category).to_upper()
|
||||
return normalized.begins_with("INIT") \
|
||||
or normalized in ["GENERATION", "DESTRUCTION", "REVERSAL", "REFUND", "MIGRATION"]
|
||||
|
||||
|
||||
func _calculate_and_validate(
|
||||
accounts: Dictionary,
|
||||
ledger: Array[LedgerEntry],
|
||||
idempotency: Dictionary
|
||||
) -> Dictionary:
|
||||
var errors: Array[Dictionary] = []
|
||||
var balances: Dictionary = {}
|
||||
for account_id: Variant in accounts:
|
||||
balances[StringName(account_id)] = 0
|
||||
var transactions: Dictionary = {}
|
||||
var closed_transactions: Dictionary = {}
|
||||
var active_transaction: StringName = &""
|
||||
var transaction_start := 0
|
||||
var transaction_count := 0
|
||||
var transaction_delta: Dictionary = {}
|
||||
var total_debit := 0
|
||||
var total_credit := 0
|
||||
|
||||
for index in ledger.size():
|
||||
var entry: LedgerEntry = ledger[index]
|
||||
var entry_errors := entry.validation_errors()
|
||||
if not entry_errors.is_empty():
|
||||
errors.append({&"code": &"INVALID_ENTRY", &"index": index, &"errors": entry_errors})
|
||||
continue
|
||||
if not accounts.has(entry.debit_account) or not accounts.has(entry.credit_account):
|
||||
errors.append({&"code": &"UNKNOWN_ACCOUNT", &"index": index})
|
||||
continue
|
||||
if not _system_account_use_is_valid(entry):
|
||||
errors.append({&"code": &"INVALID_SYSTEM_ACCOUNT_USE", &"index": index})
|
||||
if not entry.related_transaction_id.is_empty() \
|
||||
and entry.related_transaction_id == entry.transaction_id:
|
||||
errors.append({&"code": &"SELF_RELATED_TRANSACTION", &"index": index})
|
||||
|
||||
if active_transaction != entry.transaction_id:
|
||||
if not active_transaction.is_empty():
|
||||
_finalize_calculated_transaction(
|
||||
active_transaction,
|
||||
transaction_start,
|
||||
transaction_count,
|
||||
transaction_delta,
|
||||
accounts,
|
||||
balances,
|
||||
transactions,
|
||||
idempotency,
|
||||
errors
|
||||
)
|
||||
closed_transactions[active_transaction] = true
|
||||
if closed_transactions.has(entry.transaction_id):
|
||||
errors.append({&"code": &"NON_CONTIGUOUS_TRANSACTION", &"index": index})
|
||||
active_transaction = entry.transaction_id
|
||||
transaction_start = index
|
||||
transaction_count = 0
|
||||
transaction_delta = {}
|
||||
transaction_count += 1
|
||||
transaction_delta[entry.debit_account] = int(transaction_delta.get(entry.debit_account, 0)) + entry.amount
|
||||
transaction_delta[entry.credit_account] = int(transaction_delta.get(entry.credit_account, 0)) - entry.amount
|
||||
total_debit += entry.amount
|
||||
total_credit += entry.amount
|
||||
|
||||
if not active_transaction.is_empty():
|
||||
_finalize_calculated_transaction(
|
||||
active_transaction,
|
||||
transaction_start,
|
||||
transaction_count,
|
||||
transaction_delta,
|
||||
accounts,
|
||||
balances,
|
||||
transactions,
|
||||
idempotency,
|
||||
errors
|
||||
)
|
||||
if total_debit != total_credit:
|
||||
errors.append({&"code": &"LEDGER_IMBALANCE"})
|
||||
for entry: LedgerEntry in ledger:
|
||||
if not entry.related_transaction_id.is_empty() \
|
||||
and not transactions.has(entry.related_transaction_id):
|
||||
errors.append({
|
||||
&"code": &"MISSING_RELATED_TRANSACTION",
|
||||
&"transaction_id": entry.transaction_id,
|
||||
})
|
||||
for key: Variant in idempotency:
|
||||
var related_id := StringName(idempotency[key])
|
||||
if StringName(key).is_empty() or not transactions.has(related_id):
|
||||
errors.append({&"code": &"INVALID_IDEMPOTENCY_REFERENCE", &"key": key})
|
||||
return {
|
||||
&"errors": errors,
|
||||
&"balances": balances,
|
||||
&"transactions": transactions,
|
||||
&"total_debit": total_debit,
|
||||
&"total_credit": total_credit,
|
||||
}
|
||||
|
||||
|
||||
func _finalize_calculated_transaction(
|
||||
transaction_id: StringName,
|
||||
start_index: int,
|
||||
entry_count: int,
|
||||
delta: Dictionary,
|
||||
accounts: Dictionary,
|
||||
balances: Dictionary,
|
||||
transactions: Dictionary,
|
||||
idempotency: Dictionary,
|
||||
errors: Array[Dictionary]
|
||||
) -> void:
|
||||
if transactions.has(transaction_id):
|
||||
errors.append({&"code": &"DUPLICATE_TRANSACTION_ID", &"transaction_id": transaction_id})
|
||||
return
|
||||
var candidate := balances.duplicate(true)
|
||||
for account_id: Variant in delta:
|
||||
candidate[account_id] = int(candidate.get(account_id, 0)) + int(delta[account_id])
|
||||
for account_id: Variant in candidate:
|
||||
var definition: Dictionary = accounts.get(account_id, {})
|
||||
if int(candidate[account_id]) < 0 and not bool(definition.get(&"allow_negative", false)):
|
||||
errors.append({
|
||||
&"code": &"NEGATIVE_BALANCE_NOT_ALLOWED",
|
||||
&"transaction_id": transaction_id,
|
||||
&"account_id": account_id,
|
||||
})
|
||||
return
|
||||
balances.clear()
|
||||
balances.merge(candidate, true)
|
||||
var idempotency_key: StringName = &""
|
||||
for key: Variant in idempotency:
|
||||
if StringName(idempotency[key]) == transaction_id:
|
||||
idempotency_key = StringName(key)
|
||||
break
|
||||
transactions[transaction_id] = {
|
||||
&"start_index": start_index,
|
||||
&"entry_count": entry_count,
|
||||
&"idempotency_key": idempotency_key,
|
||||
}
|
||||
|
||||
|
||||
static func _entries_to_dictionaries(entries: Array[LedgerEntry]) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for entry: LedgerEntry in entries:
|
||||
result.append(entry.to_dictionary())
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_string_name_keys(source: Dictionary) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for key: Variant in source:
|
||||
result.append(StringName(key))
|
||||
result.sort_custom(func(left: StringName, right: StringName) -> bool:
|
||||
return String(left) < String(right)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_dictionary(source: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for key: StringName in _sorted_string_name_keys(source):
|
||||
result[key] = source[key]
|
||||
return result
|
||||
|
||||
|
||||
static func _variant_string_less(left: Variant, right: Variant) -> bool:
|
||||
return String(left) < String(right)
|
||||
|
||||
|
||||
static func _failure(
|
||||
code: CommandResult.Code,
|
||||
message: String,
|
||||
details: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
return CommandResultType.failed(code, 0, message, details)
|
||||
Reference in New Issue
Block a user