Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
931 lines
30 KiB
GDScript
931 lines
30 KiB
GDScript
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 = {}
|
|
# Transient nested observer boundary; never serialized as gameplay state.
|
|
var _notification_scopes: Array[Array] = []
|
|
|
|
|
|
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:
|
|
if _notification_scopes.is_empty():
|
|
transaction_posted.emit(effective_id, posted_entries)
|
|
else:
|
|
_notification_scopes.back().append(effective_id)
|
|
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),
|
|
})
|
|
|
|
|
|
## Retain ordinary post semantics while postponing observer delivery through a
|
|
## complete event effect/cost boundary. Explicit emit_notification=false remains
|
|
## owned by its existing caller, including Operations and supply transactions.
|
|
func notification_scope_depth() -> int:
|
|
return _notification_scopes.size()
|
|
|
|
|
|
func begin_notification_scope() -> void:
|
|
_notification_scopes.append([])
|
|
|
|
|
|
func end_notification_scope() -> void:
|
|
assert(not _notification_scopes.is_empty(), "Balanced notification scope required.")
|
|
if _notification_scopes.is_empty():
|
|
return
|
|
var queued: Array = _notification_scopes.pop_back()
|
|
if not _notification_scopes.is_empty():
|
|
_notification_scopes.back().append_array(queued)
|
|
return
|
|
var seen: Dictionary = {}
|
|
for raw_id: Variant in queued:
|
|
var transaction_id := StringName(raw_id)
|
|
# Restoring a ledger removes rolled-back IDs. A failure branch can reuse
|
|
# an ID; deliver the surviving final receipt once in insertion order.
|
|
if seen.has(transaction_id) or not _transaction_ids.has(transaction_id):
|
|
continue
|
|
seen[transaction_id] = true
|
|
transaction_posted.emit(transaction_id, get_transaction_entries(transaction_id))
|
|
|
|
|
|
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)
|