mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 15:01:35 +00:00
432 lines
19 KiB
Lua
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)
|