Files
TheTree_mock/scripts/economy/economy_service.gd
T
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
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.
2026-10-11 15:58:00 +09:00

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)