ADD - implement billing app

This commit is contained in:
smx.pusha
2026-08-10 09:35:52 +02:00
parent 384b4cb84e
commit 6fbb358932
19 changed files with 2672 additions and 0 deletions
+39
View File
@@ -27,6 +27,7 @@ import { useClockStore } from '@/stores/clock'
import { useGamesStore } from '@/features/games/store'
import { useCallsStore } from '@/stores/calls'
import { useBankingStore } from '@/stores/banking'
import { useBillingStore } from '@/stores/billing'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
@@ -70,6 +71,7 @@ type AppMessage = {
| PicstagramVerificationData
| PicstagramNotificationData
| FeatherNotificationData
| BillingNotificationData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
@@ -171,6 +173,14 @@ type FeatherNotificationData = {
text?: string
title?: string
}
type BillingNotificationData = {
amount?: number
device?: PhoneNotificationDevicePayload
issuer?: string
text?: string
title?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
@@ -183,6 +193,7 @@ const clock = useClockStore()
const games = useGamesStore()
const calls = useCallsStore()
const banking = useBankingStore()
const billing = useBillingStore()
const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
@@ -281,6 +292,7 @@ function loadUnlockedPhoneData(): void {
else marketplace.setCounts({ active: 0, unread: 0 })
void calls.bootstrap()
void messages.loadConversations()
void billing.loadOverview()
if (account.email) void darkchat.bootstrap()
})
}
@@ -593,6 +605,33 @@ function onMessage(event: MessageEvent<AppMessage>): void {
void calls.loadRecents()
} else if (event.data?.type === 'banking:changed') {
void banking.load()
} else if (event.data?.type === 'billing:changed') {
void billing.loadOverview()
} else if (event.data?.type === 'billing:new' && event.data.data) {
const data = event.data.data as BillingNotificationData
void billing.loadOverview()
const notification: PhoneNotificationInput = {
appId: 'billing',
subtitle: data.issuer,
text:
data.text ??
phone.t('Apps.billing.notifications.newInvoice', {
amount: String(data.amount ?? 0),
issuer: data.issuer ?? '',
}),
title: data.title ?? phone.t('Apps.billing.name'),
}
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
notifications.show(notification)
} else if (
(event.data?.type === 'call:incoming' ||
event.data?.type === 'call:state') &&
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Billing">
<defs>
<linearGradient id="bg" x1="18" y1="14" x2="108" y2="116" gradientUnits="userSpaceOnUse">
<stop stop-color="#ff856f"/>
<stop offset="1" stop-color="#ff4f64"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#bg)"/>
<path d="M38 26h52v76l-7-5-7 5-7-5-7 5-7-5-7 5-10-7V26Z" fill="#fff"/>
<path d="M49 45h30M49 58h30M49 71h18" fill="none" stroke="#ff6370" stroke-linecap="round" stroke-width="7"/>
<circle cx="80" cy="75" r="8" fill="#ff6370"/>
</svg>

After

Width:  |  Height:  |  Size: 610 B

+3
View File
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
import { useMailStore } from '@/stores/mail'
import { useBillingStore } from '@/stores/billing'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useDarkChatStore } from '@/stores/darkchat'
import { usePhoneStore } from '@/stores/phone'
@@ -35,6 +36,7 @@ const emit = defineEmits<{
const phone = usePhoneStore()
const mail = useMailStore()
const billing = useBillingStore()
const marketplace = useMarketplaceStore()
const darkchat = useDarkChatStore()
const router = useRouter()
@@ -60,6 +62,7 @@ const unreadCount = computed(() => {
if (props.app.id === 'mail') return mail.counts.unread
if (props.app.id === 'citymarkt') return marketplace.counts.unread
if (props.app.id === 'darkchat') return darkchat.unreadCount
if (props.app.id === 'billing') return billing.overview?.unreadCount ?? 0
return 0
})
const notificationBadgeColors = {
+16
View File
@@ -30,6 +30,7 @@ import {
House,
Music2,
Feather,
ReceiptText,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
@@ -56,6 +57,7 @@ import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import bankingIcon from '@/assets/img/app-icons/banking.webp'
import billingIcon from '@/assets/img/app-icons/billing.svg'
import garageIcon from '@/assets/img/app-icons/garage.webp'
import houseIcon from '@/assets/img/app-icons/house.svg'
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
@@ -297,6 +299,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.banking.name',
route: '/apps/banking',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/BillingApp.vue')),
),
dockOrder: null,
gridOrder: 6,
icon: markRaw(ReceiptText),
iconClass: 'app-icon--billing',
iconImage: billingIcon,
id: 'billing',
labelKey: 'Apps.billing.name',
route: '/apps/billing',
},
{
category: 'social',
component: markRaw(
+99
View File
@@ -0,0 +1,99 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingStore } from '@/stores/billing'
import type { BillingOverview, InvoiceDetail } from '@/types/billing'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const overview: BillingOverview = {
currency: '$',
openCount: 2,
openTotal: 2299,
overdueCount: 1,
supportsDisputes: true,
supportsSent: true,
unreadCount: 2,
urgentInvoices: [],
}
const detail: InvoiceDetail = {
amount: 1300,
canDispute: true,
canPay: true,
currency: '$',
description: 'Treatment',
direction: 'inbox',
dueAt: 1_800_000,
id: 'invoice-id',
isOverdue: false,
isUnread: false,
issuedAt: 1_000_000,
issuerAccount: 'ambulance',
issuerLabel: 'Los Santos Medical',
paidAt: null,
paymentReference: '',
status: 'open',
title: 'Treatment',
}
describe('billing store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads the billing overview', async () => {
mockNuiCall.mockResolvedValueOnce({ data: overview, success: true })
const billing = useBillingStore()
expect(await billing.loadOverview()).toBe(true)
expect(billing.overview).toEqual(overview)
expect(mockNuiCall).toHaveBeenCalledWith('billing:overview', {
direction: 'inbox',
})
})
it('loads and appends paged invoices', async () => {
mockNuiCall
.mockResolvedValueOnce({
data: { hasMore: true, invoices: [detail], nextOffset: 1 },
success: true,
})
.mockResolvedValueOnce({
data: {
hasMore: false,
invoices: [{ ...detail, id: 'second' }],
nextOffset: 2,
},
success: true,
})
const billing = useBillingStore()
await billing.loadInvoices('inbox', 'open')
await billing.loadInvoices('inbox', 'open', '', true)
expect(billing.invoices.map((invoice) => invoice.id)).toEqual([
'invoice-id',
'second',
])
})
it('never sends an amount when paying', async () => {
mockNuiCall
.mockResolvedValueOnce({
data: { ...detail, status: 'paid' },
success: true,
})
.mockResolvedValueOnce({ data: overview, success: true })
const billing = useBillingStore()
await billing.pay(detail.id)
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'billing:pay', {
id: detail.id,
})
})
})
+129
View File
@@ -0,0 +1,129 @@
import { defineStore } from 'pinia'
import type {
BillingDirection,
BillingFilter,
BillingListResult,
BillingOverview,
InvoiceDetail,
InvoiceSummary,
} from '@/types/billing'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useBillingStore = defineStore('billing', {
state: () => ({
detail: null as InvoiceDetail | null,
error: '',
hasMore: false,
invoices: [] as InvoiceSummary[],
isLoading: false,
isLoadingMore: false,
isPaying: false,
nextOffset: 0,
overview: null as BillingOverview | null,
}),
actions: {
async loadOverview(
direction: BillingDirection = 'inbox',
): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<BillingOverview>('billing:overview', {
direction,
})
this.isLoading = false
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async loadInvoices(
direction: BillingDirection,
filter: BillingFilter,
search = '',
append = false,
): Promise<boolean> {
if (append && (!this.hasMore || this.isLoadingMore)) return false
if (append) this.isLoadingMore = true
else this.isLoading = true
const response = await nuiCall<BillingListResult>('billing:list', {
direction,
filter,
offset: append ? this.nextOffset : 0,
search,
})
this.isLoading = false
this.isLoadingMore = false
if (response.success && response.data) {
this.invoices = append
? [...this.invoices, ...response.data.invoices]
: response.data.invoices
this.hasMore = response.data.hasMore
this.nextOffset = response.data.nextOffset
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async loadDetail(id: string): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<InvoiceDetail>('billing:detail', { id })
this.isLoading = false
if (response.success && response.data) {
this.detail = response.data
this.error = ''
if (response.data.isUnread) {
await this.markRead(id)
this.detail.isUnread = false
}
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async markRead(id: string): Promise<void> {
const response = await nuiCall<{ unreadCount: number }>(
'billing:markRead',
{
id,
},
)
if (response.success && response.data && this.overview) {
this.overview.unreadCount = response.data.unreadCount
}
},
async pay(id: string): Promise<NuiResponse<InvoiceDetail>> {
this.isPaying = true
const response = await nuiCall<InvoiceDetail>('billing:pay', { id })
this.isPaying = false
if (response.success && response.data) {
this.detail = response.data
await this.loadOverview()
} else {
this.error = response.error ?? 'payment_failed'
}
return response
},
async dispute(id: string): Promise<NuiResponse<InvoiceDetail>> {
const response = await nuiCall<InvoiceDetail>('billing:dispute', { id })
if (response.success && response.data) {
this.detail = response.data
await this.loadOverview()
} else {
this.error = response.error ?? 'dispute_unavailable'
}
return response
},
reset(): void {
this.detail = null
this.error = ''
this.hasMore = false
this.invoices = []
this.nextOffset = 0
this.overview = null
},
},
})
+81
View File
@@ -1128,6 +1128,87 @@ const defaultLocales: LocaleTree = {
default: 'The banking request failed.',
},
},
billing: {
name: 'Billing',
back: 'Back',
navigation: 'Billing navigation',
search: 'Search invoices',
loadMore: 'Load more',
tryAgain: 'Try again',
tabs: { overview: 'Overview', inbox: 'Inbox', history: 'History' },
direction: { inbox: 'Received', sent: 'Sent' },
summary: { open: 'Open invoices', due: 'Amount due', overdue: 'Overdue' },
overview: {
eyebrow: 'Payment center',
urgent: 'Needs attention',
viewAll: 'View all invoices',
},
filters: {
scope: 'Invoice source',
status: 'Status',
all: 'All',
open: 'Open',
overdue: 'Overdue',
paid: 'Paid',
},
status: {
open: 'Open',
overdue: 'Overdue',
processing: 'Processing',
paid: 'Paid',
disputed: 'Disputed',
cancelled: 'Cancelled',
refunded: 'Refunded',
},
detail: {
title: 'Invoice details',
noDueDate: 'No due date',
total: 'Total',
reason: 'Reason',
issued: 'Issued',
due: 'Due',
paidAt: 'Paid',
paidOn: 'Payment on {date}',
issuer: 'Issuer',
invoiceNumber: 'Invoice number',
invoiceInformation: 'Invoice information',
paymentInformation: 'Payment information',
note: 'Invoice note',
paymentReference: 'Payment reference',
dispute: 'Dispute invoice',
disputedSuccess: 'The invoice was disputed.',
},
payment: {
payNow: 'Pay this invoice',
title: 'Confirm payment',
body: 'Pay this invoice from {issuer} with your bank account.',
confirm: 'Pay invoice',
cancel: 'Cancel',
success: 'Invoice paid successfully.',
},
empty: {
openTitle: "You're all caught up",
openBody: 'Invoices requiring your attention will appear here.',
inboxTitle: 'No invoices found',
inboxBody: 'Received invoices matching your filters will appear here.',
historyTitle: 'No payment history',
historyBody: 'Completed and closed invoices will appear here.',
},
notifications: { newInvoice: 'New invoice from {issuer}: {amount}' },
errors: {
billing_unavailable: 'Billing is currently unavailable.',
invoice_not_found: 'This invoice could not be found.',
invoice_not_payable: 'This invoice can no longer be paid.',
invoice_already_paid: 'This invoice has already been paid.',
insufficient_funds: 'There is not enough money in your bank account.',
payment_in_progress: 'This invoice is already being processed.',
payment_failed: 'The payment could not be completed.',
dispute_unavailable: 'This invoice cannot be disputed.',
rate_limited: 'Please wait before trying again.',
request_failed: 'The billing request failed.',
default: 'The billing request failed.',
},
},
garage: {
name: 'Garage',
subtitle: 'Your vehicle collection',
+1
View File
@@ -10,6 +10,7 @@ export type PhoneAppId =
| 'calendar'
| 'weather'
| 'banking'
| 'billing'
| 'garage'
| 'house'
| 'mail'
+51
View File
@@ -0,0 +1,51 @@
export type BillingDirection = 'inbox' | 'sent'
export type BillingStatus =
| 'open'
| 'processing'
| 'paid'
| 'disputed'
| 'cancelled'
| 'refunded'
export type BillingFilter = 'all' | 'open' | 'overdue' | 'paid'
export type InvoiceSummary = {
amount: number
currency: string
description: string
direction: BillingDirection
dueAt: number | null
id: string
isOverdue: boolean
isUnread: boolean
issuedAt: number
issuerLabel: string
paymentReference: string
status: BillingStatus
title: string
}
export type InvoiceDetail = InvoiceSummary & {
canDispute: boolean
canPay: boolean
issuerAccount: string
paidAt: number | null
}
export type BillingOverview = {
currency: string
openCount: number
openTotal: number
overdueCount: number
supportsDisputes: boolean
supportsSent: boolean
unreadCount: number
urgentInvoices: InvoiceSummary[]
}
export type BillingListResult = {
hasMore: boolean
invoices: InvoiceSummary[]
nextOffset: number
}
+1
View File
@@ -89,6 +89,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
calendar: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
banking: { enabled: true, sounds: true },
billing: { enabled: true, sounds: true },
garage: { enabled: true, sounds: true },
skyride: { enabled: true, sounds: true },
house: { enabled: true, sounds: true },
File diff suppressed because it is too large Load Diff
+205
View File
@@ -490,6 +490,72 @@ const mockBankTransactions = [
createdAt: Date.now() - 120 * 60 * 60 * 1000,
},
]
let mockBillingInvoices = [
{
id: '31cc4342-1abd-4c34-a283-fc653632e54f',
amount: 1300,
currency: '$',
description: 'Emergency treatment and medication.',
direction: 'inbox',
dueAt: Date.now() + 2 * 86400000,
issuedAt: Date.now() - 2 * 3600000,
issuerAccount: 'ambulance',
issuerLabel: 'Los Santos Medical',
isUnread: true,
paidAt: null,
paymentReference: '',
status: 'open',
title: 'Medical treatment',
},
{
id: '1098d704-a8e7-4050-99c9-a496399669ae',
amount: 999,
currency: '$',
description: 'Vehicle repair and replacement parts.',
direction: 'inbox',
dueAt: Date.now() - 86400000,
issuedAt: Date.now() - 4 * 86400000,
issuerAccount: 'mechanic',
issuerLabel: 'Bennys Motorworks',
isUnread: true,
paidAt: null,
paymentReference: '',
status: 'open',
title: 'Vehicle repair',
},
{
id: '666f23e1-747e-4df9-b3bb-603340e0af98',
amount: 480,
currency: '$',
description: 'Tow service from Vespucci Boulevard.',
direction: 'inbox',
dueAt: Date.now() - 10 * 86400000,
issuedAt: Date.now() - 14 * 86400000,
issuerAccount: 'mechanic',
issuerLabel: 'Los Santos Customs',
isUnread: false,
paidAt: Date.now() - 9 * 86400000,
paymentReference: 'mock-payment-1',
status: 'paid',
title: 'Tow service',
},
{
id: '6c6817ae-c859-459c-9934-7ae0ff3b55fb',
amount: 750,
currency: '$',
description: 'Consulting services.',
direction: 'sent',
dueAt: Date.now() + 5 * 86400000,
issuedAt: Date.now() - 86400000,
issuerAccount: 'consulting',
issuerLabel: 'Alex Morgan',
isUnread: false,
paidAt: null,
paymentReference: '',
status: 'open',
title: 'Consulting',
},
]
const mockGarageVehicles = [
{
id: 'vehicle-1',
@@ -2847,6 +2913,38 @@ app.post('/api/:endpoint', (request, response) => {
playerName: 'Alex Morgan',
transactions: mockBankTransactions,
})
const billingInvoice = (invoice) => ({
...invoice,
canDispute: invoice.direction === 'inbox' && invoice.status === 'open',
canPay: invoice.direction === 'inbox' && invoice.status === 'open',
isOverdue:
invoice.status === 'open' &&
invoice.dueAt !== null &&
invoice.dueAt < Date.now(),
})
const billingOverview = (direction) => {
const visible = mockBillingInvoices.filter(
(invoice) => invoice.direction === direction,
)
const open = visible.filter((invoice) => invoice.status === 'open')
return {
currency: '$',
openCount: open.length,
openTotal: open.reduce((total, invoice) => total + invoice.amount, 0),
overdueCount: open.filter(
(invoice) => invoice.dueAt && invoice.dueAt < Date.now(),
).length,
supportsDisputes: true,
supportsSent: true,
unreadCount: mockBillingInvoices.filter(
(invoice) => invoice.direction === 'inbox' && invoice.isUnread,
).length,
urgentInvoices: open
.sort((left, right) => left.dueAt - right.dueAt)
.slice(0, 5)
.map(billingInvoice),
}
}
if (endpoint === 'flare:bootstrap') {
response.json({ success: true, data: flareBootstrap() })
return
@@ -3964,6 +4062,113 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: bankingOverview() })
return
}
if (endpoint === 'billing:overview') {
response.json({
success: true,
data: billingOverview(
request.body.direction === 'sent' ? 'sent' : 'inbox',
),
})
return
}
if (endpoint === 'billing:list') {
const direction = request.body.direction === 'sent' ? 'sent' : 'inbox'
const filter = String(request.body.filter ?? 'all')
const search = String(request.body.search ?? '').toLowerCase()
const offset = Math.max(0, Number(request.body.offset) || 0)
let invoices = mockBillingInvoices.filter(
(invoice) => invoice.direction === direction,
)
if (filter === 'open')
invoices = invoices.filter((invoice) => invoice.status === 'open')
if (filter === 'overdue') {
invoices = invoices.filter(
(invoice) => invoice.status === 'open' && invoice.dueAt < Date.now(),
)
}
if (filter === 'paid')
invoices = invoices.filter((invoice) => invoice.status !== 'open')
if (search) {
invoices = invoices.filter((invoice) =>
`${invoice.title} ${invoice.issuerLabel} ${invoice.description}`
.toLowerCase()
.includes(search),
)
}
const page = invoices.slice(offset, offset + 30).map(billingInvoice)
response.json({
success: true,
data: {
hasMore: offset + page.length < invoices.length,
invoices: page,
nextOffset: offset + page.length,
},
})
return
}
if (endpoint === 'billing:detail') {
const invoice = mockBillingInvoices.find(
(item) => item.id === request.body.id,
)
response.json(
invoice
? { success: true, data: billingInvoice(invoice) }
: { success: false, error: 'invoice_not_found' },
)
return
}
if (endpoint === 'billing:markRead') {
const invoice = mockBillingInvoices.find(
(item) => item.id === request.body.id,
)
if (invoice) invoice.isUnread = false
response.json({
success: true,
data: { unreadCount: billingOverview('inbox').unreadCount },
})
return
}
if (endpoint === 'billing:pay') {
const invoice = mockBillingInvoices.find(
(item) => item.id === request.body.id,
)
if (
!invoice ||
invoice.direction !== 'inbox' ||
invoice.status !== 'open'
) {
response.json({ success: false, error: 'invoice_not_payable' })
return
}
if (mockBankBalance < invoice.amount) {
response.json({ success: false, error: 'insufficient_funds' })
return
}
mockBankBalance -= invoice.amount
invoice.status = 'paid'
invoice.paidAt = Date.now()
invoice.isUnread = false
invoice.paymentReference = `mock-billing-${Date.now()}`
response.json({ success: true, data: billingInvoice(invoice) })
return
}
if (endpoint === 'billing:dispute') {
const invoice = mockBillingInvoices.find(
(item) => item.id === request.body.id,
)
if (
!invoice ||
invoice.direction !== 'inbox' ||
invoice.status !== 'open'
) {
response.json({ success: false, error: 'dispute_unavailable' })
return
}
invoice.status = 'disputed'
invoice.isUnread = false
response.json({ success: true, data: billingInvoice(invoice) })
return
}
if (endpoint === 'garage:vehicles') {
response.json({
success: true,
+15
View File
@@ -181,6 +181,21 @@ Config.Banking = {
HistoryLimit = 50,
}
Config.Billing = {
Enabled = true,
Currency = "$",
PaymentAccount = "bank", -- bank or cash
MinimumAmount = 1,
MaximumAmount = 1000000,
PageSize = 30,
UrgentLimit = 5,
ActionsPerMinute = 8,
AllowDisputes = true,
DefaultDueDays = 7,
MaximumTitleLength = 160,
MaximumDescriptionLength = 1000,
}
Config.Garage = {
System = "auto", -- auto, custom, esx, qb, qbox, ak47, bp, cd, codem, ds-servercreator, hex, jg, my, okok, op, quasar, rx, vms, ws, zyke_garages
MaximumVehicles = 250,
+38
View File
@@ -380,6 +380,44 @@ Locales["en"] = {
default = "The banking request failed.",
},
},
billing = {
name = "Billing", back = "Back", navigation = "Billing navigation",
search = "Search invoices", loadMore = "Load more", tryAgain = "Try again",
tabs = { overview = "Overview", inbox = "Inbox", history = "History" },
direction = { inbox = "Received", sent = "Sent" },
summary = { open = "Open invoices", due = "Amount due", overdue = "Overdue" },
overview = { eyebrow = "Payment center", urgent = "Needs attention", viewAll = "View all invoices" },
filters = { scope = "Invoice source", status = "Status", all = "All", open = "Open", overdue = "Overdue", paid = "Paid" },
status = { open = "Open", overdue = "Overdue", processing = "Processing", paid = "Paid", disputed = "Disputed", cancelled = "Cancelled", refunded = "Refunded" },
detail = {
title = "Invoice details", noDueDate = "No due date", total = "Total", reason = "Reason",
issued = "Issued", due = "Due", paidAt = "Paid", paidOn = "Payment on {date}",
issuer = "Issuer", invoiceNumber = "Invoice number",
invoiceInformation = "Invoice information", paymentInformation = "Payment information",
note = "Invoice note",
paymentReference = "Payment reference", dispute = "Dispute invoice",
disputedSuccess = "The invoice was disputed.",
},
payment = {
payNow = "Pay this invoice", title = "Confirm payment",
body = "Pay this invoice from {issuer} with your bank account.",
confirm = "Pay invoice", cancel = "Cancel", success = "Invoice paid successfully.",
},
empty = {
openTitle = "You're all caught up", openBody = "Invoices requiring your attention will appear here.",
inboxTitle = "No invoices found", inboxBody = "Received invoices matching your filters will appear here.",
historyTitle = "No payment history", historyBody = "Completed and closed invoices will appear here.",
},
notifications = { newInvoice = "New invoice from {issuer}: {amount}" },
errors = {
billing_unavailable = "Billing is currently unavailable.", invoice_not_found = "This invoice could not be found.",
invoice_not_payable = "This invoice can no longer be paid.", invoice_already_paid = "This invoice has already been paid.",
insufficient_funds = "There is not enough money in your bank account.", payment_in_progress = "This invoice is already being processed.",
payment_failed = "The payment could not be completed.", dispute_unavailable = "This invoice cannot be disputed.",
rate_limited = "Please wait before trying again.", request_failed = "The billing request failed.",
default = "The billing request failed.",
},
},
garage = {
name = "Garage", subtitle = "Your vehicle collection", myVehicles = "My Vehicles",
searchPlaceholder = "Search name, plate or garage",
+1
View File
@@ -62,6 +62,7 @@ server_scripts {
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
'source/server/billing.lua',
'source/server/garage.lua',
'source/server/housing.lua',
'source/server/marketplace.lua',
+19
View File
@@ -158,6 +158,12 @@ local server_callbacks = {
"calls:hangup",
"banking:overview",
"banking:transfer",
"billing:overview",
"billing:list",
"billing:detail",
"billing:markRead",
"billing:pay",
"billing:dispute",
"messages:conversations",
"messages:thread",
"messages:send",
@@ -597,6 +603,19 @@ RegisterNetEvent("sky_phone:banking:changed", function()
SendNUIMessage({ type = "banking:changed" })
end)
RegisterNetEvent("sky_phone:billing:changed", function()
SendNUIMessage({ type = "billing:changed" })
end)
RegisterNetEvent("sky_phone:billing:new", function(data)
local billing_locale = get_locale().Nui.Apps.billing
data.title = billing_locale.name
data.text = billing_locale.notifications.newInvoice
:gsub("{issuer}", tostring(data.issuer))
:gsub("{amount}", ("%s %s"):format(tostring(data.amount), Config.Billing.Currency))
SendNUIMessage({ type = "billing:new", data = data })
end)
RegisterNetEvent("sky_phone:messages:changed", function(data)
SendNUIMessage({ type = "messages:changed", data = data })
end)
+431
View File
@@ -0,0 +1,431 @@
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)
+71
View File
@@ -402,6 +402,77 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_billing_invoices",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "recipient_identifier", type = "VARCHAR(80) NOT NULL" },
{ name = "issuer_identifier", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "issuer_account", type = "VARCHAR(80) NOT NULL" },
{ name = "issuer_label", type = "VARCHAR(80) NOT NULL" },
{ name = "title", type = "VARCHAR(160) NOT NULL" },
{ name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" },
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "status", type = "ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open'" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "issued_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "due_at", type = "DATETIME NULL" },
{ name = "paid_at", type = "DATETIME NULL" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
{ name = "payment_reference", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_billing_recipient", columns = "(`recipient_identifier`, `status`, `due_at`, `id`)" },
{ name = "idx_sky_phone_billing_issuer", columns = "(`issuer_identifier`, `status`, `id`)" },
{ name = "idx_sky_phone_billing_unread", columns = "(`recipient_identifier`, `read_at`)" },
{ name = "idx_sky_phone_billing_account", columns = "(`issuer_account`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_billing_payments",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "invoice_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "recipient_identifier", type = "VARCHAR(80) NOT NULL" },
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "status", type = "ENUM('processing', 'paid', 'failed') NOT NULL DEFAULT 'processing'" },
{ name = "error_code", type = "VARCHAR(48) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = { { name = "idx_sky_phone_billing_payment_invoice", columns = "(`invoice_id`, `id`)" } },
foreignKeys = { { column = "invoice_id", references = "`sky_phone_billing_invoices` (`id`) ON DELETE CASCADE" } },
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_billing_events",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "invoice_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "event", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "actor_identifier", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "note", type = "VARCHAR(255) NOT NULL DEFAULT ''" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = { { name = "idx_sky_phone_billing_event_invoice", columns = "(`invoice_id`, `id`)" } },
foreignKeys = { { column = "invoice_id", references = "`sky_phone_billing_invoices` (`id`) ON DELETE CASCADE" } },
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_billing_accounts",
columns = {
{ name = "account_key", type = "VARCHAR(80) NOT NULL" },
{ name = "balance", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "account_key",
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_sms_messages",
columns = {
+57
View File
@@ -202,6 +202,63 @@ CREATE TABLE IF NOT EXISTS `sky_phone_bank_transactions` (
KEY `idx_sky_phone_bank_reference` (`reference`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_billing_invoices` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`recipient_identifier` VARCHAR(80) NOT NULL,
`issuer_identifier` VARCHAR(80) NOT NULL DEFAULT '',
`issuer_account` VARCHAR(80) NOT NULL,
`issuer_label` VARCHAR(80) NOT NULL,
`title` VARCHAR(160) NOT NULL,
`description` VARCHAR(1000) NOT NULL DEFAULT '',
`amount` BIGINT UNSIGNED NOT NULL,
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`status` ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open',
`read_at` DATETIME NULL,
`issued_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`due_at` DATETIME NULL,
`paid_at` DATETIME NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`payment_reference` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_billing_recipient` (`recipient_identifier`, `status`, `due_at`, `id`),
KEY `idx_sky_phone_billing_issuer` (`issuer_identifier`, `status`, `id`),
KEY `idx_sky_phone_billing_unread` (`recipient_identifier`, `read_at`),
KEY `idx_sky_phone_billing_account` (`issuer_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_billing_payments` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`invoice_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`recipient_identifier` VARCHAR(80) NOT NULL,
`amount` BIGINT UNSIGNED NOT NULL,
`status` ENUM('processing', 'paid', 'failed') NOT NULL DEFAULT 'processing',
`error_code` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_billing_payment_invoice` (`invoice_id`, `id`),
FOREIGN KEY (`invoice_id`) REFERENCES `sky_phone_billing_invoices` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_billing_events` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`invoice_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`event` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`actor_identifier` VARCHAR(80) NOT NULL DEFAULT '',
`note` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_billing_event_invoice` (`invoice_id`, `id`),
FOREIGN KEY (`invoice_id`) REFERENCES `sky_phone_billing_invoices` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_billing_accounts` (
`account_key` VARCHAR(80) NOT NULL,
`balance` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`account_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sender_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,