Bridge.Database.AfterMigration("sky_phone", function() local function affected_rows(result) if type(result) == "number" then return result end return type(result) == "table" and tonumber(result.affectedRows) or 0 end local function uuid() local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) if not rows[1] or type(rows[1].id) ~= "string" then error("[sky_phone] Database did not generate a Billing UUID.") end return rows[1].id end local function trimmed(value, maximum_length) if type(value) ~= "string" then return nil end local result = value:match("^%s*(.-)%s*$") if result == "" or #result > maximum_length then return nil end return result end local function valid_amount(value) local amount = tonumber(value) if not amount or amount ~= math.floor(amount) then return nil end if amount < Config.Billing.MinimumAmount or amount > Config.Billing.MaximumAmount then return nil end return amount end local function valid_invoice_id(value) return type(value) == "string" and #value == 36 and value:match("^[0-9a-fA-F%-]+$") ~= nil end local function require_billing_session(source) if not Config.Billing.Enabled then return nil, { success = false, error = "billing_unavailable" } end local session, error_response = SkyPhone.RequireSession(source) if not session then return nil, error_response end local identifier = Bridge.Framework.GetIdentifier(source) if type(identifier) ~= "string" or identifier == "" then return nil, { success = false, error = "billing_unavailable" } end return identifier end local function invoice_dto(row, identifier) local due_at = tonumber(row.due_at_unix) local paid_at = tonumber(row.paid_at_unix) local issued_at = tonumber(row.issued_at_unix) or 0 local direction = row.recipient_identifier == identifier and "inbox" or "sent" local status = row.status or "open" return { id = row.id, amount = tonumber(row.amount) or 0, currency = row.currency or Config.Billing.Currency, description = row.description or "", direction = direction, dueAt = due_at and due_at * 1000 or nil, issuedAt = issued_at * 1000, issuerAccount = row.issuer_account or "", issuerLabel = row.issuer_label or "", isOverdue = status == "open" and due_at ~= nil and due_at < os.time(), isUnread = direction == "inbox" and row.read_at == nil, paidAt = paid_at and paid_at * 1000 or nil, paymentReference = row.payment_reference or "", status = status, title = row.title or "", canPay = direction == "inbox" and status == "open", canDispute = direction == "inbox" and status == "open" and Config.Billing.AllowDisputes, } end local function invoice_select(where_sql, parameters) local rows = Bridge.Database.Query(([=[ SELECT `id`, `recipient_identifier`, `issuer_identifier`, `issuer_account`, `issuer_label`, `title`, `description`, `amount`, `currency`, `status`, `read_at`, `payment_reference`, UNIX_TIMESTAMP(`issued_at`) AS `issued_at_unix`, UNIX_TIMESTAMP(`due_at`) AS `due_at_unix`, UNIX_TIMESTAMP(`paid_at`) AS `paid_at_unix` FROM `sky_phone_billing_invoices` WHERE %s ]=]):format(where_sql), parameters) return rows end local function find_owned_invoice(id, identifier) if not valid_invoice_id(id) then return nil end return invoice_select("`id` = ? AND (`recipient_identifier` = ? OR `issuer_identifier` = ?) LIMIT 1", { id, identifier, identifier, })[1] end local function unread_count(identifier) local rows = Bridge.Database.Query([[ SELECT COUNT(*) AS `count` FROM `sky_phone_billing_invoices` WHERE `recipient_identifier` = ? AND `read_at` IS NULL ]], { identifier }) return tonumber(rows[1] and rows[1].count) or 0 end local function notify_identifier(identifier, event_name, data) for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do local target = tonumber(player_source) or player_source if Bridge.Framework.GetIdentifier(target) == identifier then TriggerClientEvent(event_name, target, data or {}) end end end local function create_invoice(data) if not Config.Billing.Enabled or type(data) ~= "table" then return nil, "billing_unavailable" end local recipient_identifier = trimmed(data.recipientIdentifier, 80) if not recipient_identifier and tonumber(data.recipientSource) then recipient_identifier = Bridge.Framework.GetIdentifier(tonumber(data.recipientSource)) end local issuer_identifier = trimmed(data.issuerIdentifier, 80) or "" if issuer_identifier == "" and tonumber(data.issuerSource) then issuer_identifier = Bridge.Framework.GetIdentifier(tonumber(data.issuerSource)) or "" end local issuer_account = trimmed(data.issuerAccount, 80) local issuer_label = trimmed(data.issuerLabel, 80) local title = trimmed(data.title, Config.Billing.MaximumTitleLength) local description = type(data.description) == "string" and data.description:match("^%s*(.-)%s*$") or "" local amount = valid_amount(data.amount) if not recipient_identifier or not issuer_account or not issuer_label or not title or not amount or #description > Config.Billing.MaximumDescriptionLength then return nil, "invalid_request" end local due_days = math.max(0, math.min(365, math.floor(tonumber(data.dueDays) or Config.Billing.DefaultDueDays))) local id = uuid() local result = Bridge.Database.Query([[ INSERT INTO `sky_phone_billing_invoices` (`id`, `recipient_identifier`, `issuer_identifier`, `issuer_account`, `issuer_label`, `title`, `description`, `amount`, `currency`, `due_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? DAY)) ]], { id, recipient_identifier, issuer_identifier, issuer_account, issuer_label, title, description, amount, Config.Billing.Currency, due_days, }) if affected_rows(result) ~= 1 then return nil, "request_failed" end Bridge.Database.Query([[ INSERT INTO `sky_phone_billing_events` (`invoice_id`, `event`, `actor_identifier`) VALUES (?, 'created', ?) ]], { id, issuer_identifier }) notify_identifier(recipient_identifier, "sky_phone:billing:new", { amount = amount, issuer = issuer_label, }) return id end Bridge.Callbacks.Register("sky_phone:billing:overview", function(source, data) local identifier, error_response = require_billing_session(source) if not identifier then return error_response end local direction = data and data.direction == "sent" and "sent" or "inbox" local owner_column = direction == "sent" and "issuer_identifier" or "recipient_identifier" local rows = Bridge.Database.Query(([=[ SELECT COUNT(CASE WHEN `status` = 'open' THEN 1 END) AS `open_count`, COALESCE(SUM(CASE WHEN `status` = 'open' THEN `amount` ELSE 0 END), 0) AS `open_total`, COUNT(CASE WHEN `status` = 'open' AND `due_at` < NOW() THEN 1 END) AS `overdue_count` FROM `sky_phone_billing_invoices` WHERE `%s` = ? ]=]):format(owner_column), { identifier }) local summary = rows[1] or {} local urgent_rows = invoice_select(("`%s` = ? AND `status` = 'open' ORDER BY (`due_at` IS NULL), `due_at`, `id` DESC LIMIT ?"):format(owner_column), { identifier, Config.Billing.UrgentLimit, }) local urgent = {} for _, row in ipairs(urgent_rows) do urgent[#urgent + 1] = invoice_dto(row, identifier) end return { success = true, data = { currency = Config.Billing.Currency, openCount = tonumber(summary.open_count) or 0, openTotal = tonumber(summary.open_total) or 0, overdueCount = tonumber(summary.overdue_count) or 0, supportsDisputes = Config.Billing.AllowDisputes, supportsSent = true, unreadCount = unread_count(identifier), urgentInvoices = urgent, }, } end) Bridge.Callbacks.Register("sky_phone:billing:list", function(source, data) local identifier, error_response = require_billing_session(source) if not identifier then return error_response end local direction = data and data.direction == "sent" and "sent" or "inbox" local filter = type(data and data.filter) == "string" and data.filter or "all" if filter ~= "all" and filter ~= "open" and filter ~= "overdue" and filter ~= "paid" then return { success = false, error = "invalid_request" } end local offset = math.max(0, math.floor(tonumber(data and data.offset) or 0)) local search = type(data and data.search) == "string" and data.search:sub(1, 80) or "" local owner_column = direction == "sent" and "issuer_identifier" or "recipient_identifier" local where = { ("`%s` = ?"):format(owner_column) } local parameters = { identifier } if filter == "open" then where[#where + 1] = "`status` = 'open'" elseif filter == "overdue" then where[#where + 1] = "`status` = 'open' AND `due_at` < NOW()" elseif filter == "paid" then where[#where + 1] = "`status` IN ('paid', 'disputed', 'cancelled', 'refunded')" end if search ~= "" then where[#where + 1] = "(`title` LIKE ? OR `issuer_label` LIKE ? OR `description` LIKE ?)" local pattern = "%" .. search .. "%" parameters[#parameters + 1] = pattern parameters[#parameters + 1] = pattern parameters[#parameters + 1] = pattern end parameters[#parameters + 1] = Config.Billing.PageSize + 1 parameters[#parameters + 1] = offset local rows = invoice_select(table.concat(where, " AND ") .. " ORDER BY `issued_at` DESC, `id` DESC LIMIT ? OFFSET ?", parameters) local has_more = #rows > Config.Billing.PageSize if has_more then rows[#rows] = nil end local invoices = {} for _, row in ipairs(rows) do invoices[#invoices + 1] = invoice_dto(row, identifier) end return { success = true, data = { invoices = invoices, hasMore = has_more, nextOffset = offset + #invoices }, } end) Bridge.Callbacks.Register("sky_phone:billing:detail", function(source, data) local identifier, error_response = require_billing_session(source) if not identifier then return error_response end local row = find_owned_invoice(data and data.id, identifier) if not row then return { success = false, error = "invoice_not_found" } end return { success = true, data = invoice_dto(row, identifier) } end) Bridge.Callbacks.Register("sky_phone:billing:markRead", function(source, data) local identifier, error_response = require_billing_session(source) if not identifier then return error_response end if not valid_invoice_id(data and data.id) then return { success = false, error = "invoice_not_found" } end Bridge.Database.Query([[ UPDATE `sky_phone_billing_invoices` SET `read_at` = COALESCE(`read_at`, NOW()) WHERE `id` = ? AND `recipient_identifier` = ? ]], { data and data.id, identifier }) return { success = true, data = { unreadCount = unread_count(identifier) } } end) Bridge.Callbacks.Register("sky_phone:billing:dispute", function(source, data) if not Config.Billing.AllowDisputes then return { success = false, error = "dispute_unavailable" } end local identifier, error_response = require_billing_session(source) if not identifier then return error_response end if not valid_invoice_id(data and data.id) then return { success = false, error = "invoice_not_found" } end local result = Bridge.Database.Query([[ UPDATE `sky_phone_billing_invoices` SET `status` = 'disputed', `read_at` = COALESCE(`read_at`, NOW()) WHERE `id` = ? AND `recipient_identifier` = ? AND `status` = 'open' ]], { data and data.id, identifier }) if affected_rows(result) ~= 1 then return { success = false, error = "dispute_unavailable" } end Bridge.Database.Query([[ INSERT INTO `sky_phone_billing_events` (`invoice_id`, `event`, `actor_identifier`) VALUES (?, 'disputed', ?) ]], { data.id, identifier }) notify_identifier(identifier, "sky_phone:billing:changed") local row = find_owned_invoice(data.id, identifier) return { success = true, data = invoice_dto(row, identifier) } end) Bridge.Callbacks.Register("sky_phone:billing:pay", function(source, data) if not SkyPhone.AllowOperation(source, "billing_payment", Config.Billing.ActionsPerMinute, 60) then return { success = false, error = "rate_limited" } end local identifier, error_response = require_billing_session(source) if not identifier then return error_response end local invoice_id = data and data.id if not valid_invoice_id(invoice_id) then return { success = false, error = "invoice_not_found" } end local rows = invoice_select("`id` = ? AND `recipient_identifier` = ? LIMIT 1", { invoice_id, identifier }) local row = rows[1] if not row then return { success = false, error = "invoice_not_found" } end if row.status == "paid" then return { success = false, error = "invoice_already_paid" } end if row.status == "processing" then return { success = false, error = "payment_in_progress" } end if row.status ~= "open" then return { success = false, error = "invoice_not_payable" } end local claim = Bridge.Database.Query([[ UPDATE `sky_phone_billing_invoices` SET `status` = 'processing' WHERE `id` = ? AND `recipient_identifier` = ? AND `status` = 'open' ]], { invoice_id, identifier }) if affected_rows(claim) ~= 1 then return { success = false, error = "payment_in_progress" } end local payment_id = uuid() local amount = tonumber(row.amount) or 0 Bridge.Database.Query([[ INSERT INTO `sky_phone_billing_payments` (`id`, `invoice_id`, `recipient_identifier`, `amount`) VALUES (?, ?, ?, ?) ]], { payment_id, invoice_id, identifier, amount }) if not Bridge.Framework.RemoveMoney(source, Config.Billing.PaymentAccount, amount) then Bridge.Database.Transaction({ { query = "UPDATE `sky_phone_billing_invoices` SET `status` = 'open' WHERE `id` = ? AND `status` = 'processing'", params = { invoice_id } }, { query = "UPDATE `sky_phone_billing_payments` SET `status` = 'failed', `error_code` = 'insufficient_funds' WHERE `id` = ?", params = { payment_id } }, }) return { success = false, error = "insufficient_funds" } end local completed = Bridge.Database.Transaction({ { query = "UPDATE `sky_phone_billing_invoices` SET `status` = 'paid', `paid_at` = NOW(), `read_at` = COALESCE(`read_at`, NOW()), `payment_reference` = ? WHERE `id` = ? AND `status` = 'processing'", params = { payment_id, invoice_id } }, { query = "UPDATE `sky_phone_billing_payments` SET `status` = 'paid' WHERE `id` = ?", params = { payment_id } }, { query = "INSERT INTO `sky_phone_billing_events` (`invoice_id`, `event`, `actor_identifier`) VALUES (?, 'paid', ?)", params = { invoice_id, identifier } }, { query = "INSERT INTO `sky_phone_billing_accounts` (`account_key`, `balance`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `balance` = `balance` + VALUES(`balance`)", params = { row.issuer_account, amount } }, { query = "INSERT INTO `sky_phone_bank_transactions` (`owner_identifier`, `kind`, `amount`, `label`, `reference`) VALUES (?, 'withdrawal', ?, ?, ?)", params = { identifier, amount, row.issuer_label, payment_id } }, }) if not completed then Bridge.Framework.AddMoney(source, Config.Billing.PaymentAccount, amount) Bridge.Database.Transaction({ { query = "UPDATE `sky_phone_billing_invoices` SET `status` = 'open' WHERE `id` = ? AND `status` = 'processing'", params = { invoice_id } }, { query = "UPDATE `sky_phone_billing_payments` SET `status` = 'failed', `error_code` = 'payment_failed' WHERE `id` = ?", params = { payment_id } }, }) Bridge.Debug("error", "[sky_phone] Billing payment transaction failed for invoice %s; payer was refunded.", tostring(invoice_id)) return { success = false, error = "payment_failed" } end TriggerClientEvent("sky_phone:banking:changed", source) notify_identifier(identifier, "sky_phone:billing:changed") if row.issuer_identifier ~= "" then notify_identifier(row.issuer_identifier, "sky_phone:billing:changed") end local paid_row = find_owned_invoice(invoice_id, identifier) return { success = true, data = invoice_dto(paid_row, identifier) } end) exports("CreateInvoice", function(data) return create_invoice(data) end) exports("CancelInvoice", function(invoice_id, actor_identifier) if not valid_invoice_id(invoice_id) then return false end local result = Bridge.Database.Query([[ UPDATE `sky_phone_billing_invoices` SET `status` = 'cancelled' WHERE `id` = ? AND `status` = 'open' ]], { invoice_id }) if affected_rows(result) ~= 1 then return false end Bridge.Database.Query([[ INSERT INTO `sky_phone_billing_events` (`invoice_id`, `event`, `actor_identifier`) VALUES (?, 'cancelled', ?) ]], { invoice_id, type(actor_identifier) == "string" and actor_identifier or "" }) local rows = Bridge.Database.Query("SELECT `recipient_identifier`, `issuer_identifier` FROM `sky_phone_billing_invoices` WHERE `id` = ? LIMIT 1", { invoice_id }) if rows[1] then notify_identifier(rows[1].recipient_identifier, "sky_phone:billing:changed") if rows[1].issuer_identifier ~= "" then notify_identifier(rows[1].issuer_identifier, "sky_phone:billing:changed") end end return true end) exports("GetBillingAccountBalance", function(account_key) local key = trimmed(account_key, 80) if not key then return nil end local rows = Bridge.Database.Query("SELECT `balance` FROM `sky_phone_billing_accounts` WHERE `account_key` = ? LIMIT 1", { key }) return tonumber(rows[1] and rows[1].balance) or 0 end) exports("RemoveBillingAccountBalance", function(account_key, value) local key = trimmed(account_key, 80) local amount = valid_amount(value) if not key or not amount then return false end local result = Bridge.Database.Query([[ UPDATE `sky_phone_billing_accounts` SET `balance` = `balance` - ? WHERE `account_key` = ? AND `balance` >= ? ]], { amount, key, amount }) return affected_rows(result) == 1 end) end)