Files
sky_phone-sky-systems/sky_phone/source/server/billing.lua
T
2026-08-10 09:35:52 +02:00

432 lines
19 KiB
Lua

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)