mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
ADD - implement billing app
This commit is contained in:
@@ -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 |
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PhoneAppId =
|
||||
| 'calendar'
|
||||
| 'weather'
|
||||
| 'banking'
|
||||
| 'billing'
|
||||
| 'garage'
|
||||
| 'house'
|
||||
| 'mail'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user