diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 08be8d2..a8677ee 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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): 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') && diff --git a/frontend/src/assets/img/app-icons/billing.svg b/frontend/src/assets/img/app-icons/billing.svg new file mode 100644 index 0000000..5cc30ba --- /dev/null +++ b/frontend/src/assets/img/app-icons/billing.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 0d1c071..c56d222 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -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 = { diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index b47e77d..6d5638d 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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( diff --git a/frontend/src/stores/billing.test.ts b/frontend/src/stores/billing.test.ts new file mode 100644 index 0000000..beae76b --- /dev/null +++ b/frontend/src/stores/billing.test.ts @@ -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, + }) + }) +}) diff --git a/frontend/src/stores/billing.ts b/frontend/src/stores/billing.ts new file mode 100644 index 0000000..04eb10b --- /dev/null +++ b/frontend/src/stores/billing.ts @@ -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 { + this.isLoading = true + const response = await nuiCall('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 { + if (append && (!this.hasMore || this.isLoadingMore)) return false + if (append) this.isLoadingMore = true + else this.isLoading = true + const response = await nuiCall('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 { + this.isLoading = true + const response = await nuiCall('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 { + 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> { + this.isPaying = true + const response = await nuiCall('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> { + const response = await nuiCall('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 + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 44505b4..6f8027e 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 662c92f..af95753 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -10,6 +10,7 @@ export type PhoneAppId = | 'calendar' | 'weather' | 'banking' + | 'billing' | 'garage' | 'house' | 'mail' diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts new file mode 100644 index 0000000..e774b05 --- /dev/null +++ b/frontend/src/types/billing.ts @@ -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 +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 4621aa9..0f63fdc 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/BillingApp.vue b/frontend/src/views/apps/BillingApp.vue new file mode 100644 index 0000000..4efd9b8 --- /dev/null +++ b/frontend/src/views/apps/BillingApp.vue @@ -0,0 +1,1403 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 2700ec2..9d30391 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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: 'Benny’s 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, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 47bcc9e..2e0d2d7 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 1f3d1ed..134c7a1 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 8cd547c..ff3f1f9 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index ceb1e1d..a68e4ea 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -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) diff --git a/sky_phone/source/server/billing.lua b/sky_phone/source/server/billing.lua new file mode 100644 index 0000000..77eb12d --- /dev/null +++ b/sky_phone/source/server/billing.lua @@ -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) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index a7843ec..0b55ed7 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -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 = { diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 51c3f7d..7d7e29b 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -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,