Compare commits

...

10 commits

Author SHA1 Message Date
bfa5118fbe feat(libra/balance): clarify income/expenses cards with info captions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:14:17 +02:00
30ad4cf512 feat(libra/balance): show lifetime income vs expenses breakdown
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:14:17 +02:00
26a89c58dd feat(libra/balance): show fiat balances alongside sats
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:14:17 +02:00
84456a849e fix(libra): drop payment-method field from income submission
Income now lands on the submitting user as a receivable rather than
on an entity asset account — see the matching libra backend change.
Removes the "Received into" select and its supporting state from the
form, and drops payment_method_account from IncomeEntryRequest /
IncomeEntry. The form is now description + amount + currency + revenue
account + optional reference.
2026-05-17 16:14:17 +02:00
0e03a424cb feat(libra): shrink filter controls on mobile
Drop the date-range/type-filter buttons and custom-date inputs from
h-8 to h-7 below md to claw back a bit of vertical space on phones,
keeping h-8 on tablet/desktop where it's comfortable.
2026-05-17 16:14:17 +02:00
5902aed431 feat(libra): drop User and Source rows from transaction cards
User row is noise on a personal history view, and Source is always
libra-api right now — both just clutter the card. Drop them; can
bring back if/when there's a second source to disambiguate.
2026-05-17 16:14:17 +02:00
4fee9c015d feat(libra): add income/expense type filter to transaction history
All / Income / Expenses toggle row with a Filter icon, aligned to the
Calendar-iconed date range row above. Filters transactionsToDisplay
client-side using the existing isIncome/isExpense helpers; works on
top of the search and date filters.
2026-05-17 16:14:17 +02:00
0390ecd4a0 feat(libra): color-code income/expense entries in transaction history
Left green/red stripe on each card plus a matching tint on the
income-entry / expense-entry tag badge — mirrors the Record page's
red/green palette so the two screens read consistently.
2026-05-17 16:14:17 +02:00
31cefac183 feat(libra): wire up income submission flow
Adds the frontend pair to libra's new POST /entries/income endpoint:
SUBMIT_INCOME in PermissionType, IncomeEntry/IncomeEntryRequest types,
ExpensesAPI.submitIncome wrapping the new endpoint, and the AddIncome
view collecting description / amount / revenue account / payment-method
account / currency / reference. Mirrors the existing expense flow so
non-admin users can log income on behalf of the organization for
super-user review.
2026-05-17 16:14:17 +02:00
d33359a901 feat(restaurant/checkout): tag outgoing payment with restaurant + order id
Adds extra={tag:'restaurant', restaurant_id, order_id} to the
POST /api/v1/payments body when paying a placed-order's bolt11.
Mirrors the same extras the extension stamps on its incoming
invoice, so a customer's wallet history can later filter or
surface restaurant orders rather than showing them as generic
Lightning sends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:14:17 +02:00
6 changed files with 560 additions and 55 deletions

View file

@ -1,31 +1,44 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { TrendingUp, Info } from 'lucide-vue-next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface Emits {
(e: 'close'): void
}
const emit = defineEmits<Emits>()
const { t } = useI18n()
function handleClose() {
emit('close')
}
</script>
<template> <template>
<Dialog :open="true" @update:open="(open) => !open && handleClose()"> <Dialog :open="true" @update:open="(open) => !open && handleDialogClose()">
<DialogContent class="max-w-md"> <DialogContent class="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col p-0 gap-0 top-[5%] translate-y-0 sm:top-[5%] sm:translate-y-0">
<DialogHeader> <!-- Success State -->
<div v-if="showSuccessDialog" class="flex flex-col items-center justify-center p-8 space-y-6">
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
<div class="text-center space-y-3">
<h2 class="text-2xl font-bold">Income Submitted Successfully!</h2>
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-orange-100 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800">
<Clock class="h-4 w-4 text-orange-600 dark:text-orange-400" />
<span class="text-sm font-medium text-orange-700 dark:text-orange-300">
Pending Admin Approval
</span>
</div>
<p class="text-muted-foreground">
Your income entry has been submitted successfully. An administrator will review and approve it shortly.
</p>
<p class="text-sm text-muted-foreground">
You can track the approval status in your transactions page.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-3 w-full max-w-sm">
<Button variant="outline" @click="closeSuccessDialog" class="flex-1">
Close
</Button>
<Button @click="goToTransactions" class="flex-1">
<Receipt class="h-4 w-4 mr-2" />
View My Transactions
</Button>
</div>
</div>
<!-- Form State -->
<template v-else>
<DialogHeader class="px-6 pt-6 pb-4 border-b shrink-0">
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<TrendingUp class="h-5 w-5 text-green-600 dark:text-green-400" /> <TrendingUp class="h-5 w-5 text-green-600 dark:text-green-400" />
<span>{{ t('libra.income.title') }}</span> <span>{{ t('libra.income.title') }}</span>
@ -35,18 +48,304 @@ function handleClose() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<!-- Placeholder content --> <div class="flex-1 overflow-y-auto px-6 py-4 space-y-4 min-h-0">
<div class="flex flex-col items-center py-8 space-y-4"> <!-- Step indicator -->
<div class="rounded-full bg-muted p-4"> <div class="flex items-center justify-center gap-2 mb-4">
<Info class="h-8 w-8 text-muted-foreground" /> <div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 1
? 'bg-primary text-primary-foreground'
: selectedRevenueAccount
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
]"
>
1
</div> </div>
<p class="text-sm text-muted-foreground text-center max-w-xs"> <div class="w-12 h-px bg-border" />
{{ t('libra.income.notAvailable') }} <div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 2
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
]"
>
2
</div>
</div>
<!-- Step 1: Revenue Account Selection -->
<div v-if="currentStep === 1">
<p class="text-sm text-muted-foreground mb-4">
{{ t('libra.income.selectAccount') }}
</p> </p>
<Button variant="outline" @click="handleClose"> <AccountSelector
Close v-model="selectedRevenueAccount"
root-account="Income"
@account-selected="handleRevenueAccountSelected"
/>
</div>
<!-- Step 2: Income Details -->
<div v-if="currentStep === 2">
<form @submit="onSubmit" class="space-y-4">
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="e.g., Workshop fee, Donation, Service revenue"
v-bind="componentField"
rows="3"
/>
</FormControl>
<FormDescription>Describe the source of this income</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0.00"
v-bind="componentField"
min="0.01"
step="0.01"
/>
</FormControl>
<FormDescription>Amount in selected currency</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Currency for this income</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="reference">
<FormItem>
<FormLabel>Reference</FormLabel>
<FormControl>
<Input placeholder="e.g., Invoice #123, Receipt #456" v-bind="componentField" />
</FormControl>
<FormDescription>Optional reference number or note</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="p-3 rounded-lg bg-muted/50">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm text-muted-foreground">Revenue account:</span>
<Badge variant="secondary" class="font-mono">{{ selectedRevenueAccount?.name }}</Badge>
</div>
<p v-if="selectedRevenueAccount?.description" class="text-xs text-muted-foreground">
{{ selectedRevenueAccount.description }}
</p>
</div>
<div class="flex items-center gap-2 pt-2 pb-6">
<Button
type="button"
variant="outline"
@click="currentStep = 1"
:disabled="isSubmitting"
class="flex-1"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Back
</Button>
<Button type="submit" :disabled="isSubmitting || !isFormValid" class="flex-1">
<Loader2 v-if="isSubmitting" class="h-4 w-4 mr-2 animate-spin" />
<span>{{ isSubmitting ? 'Submitting...' : t('libra.income.submitIncome') }}</span>
</Button> </Button>
</div> </div>
</form>
</div>
</div>
</template>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI'
import type { Account } from '@/modules/expenses/types'
import AccountSelector from '@/modules/expenses/components/AccountSelector.vue'
interface Emits {
(e: 'close'): void
}
const emit = defineEmits<Emits>()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const { user } = useAuth()
const toast = useToast()
const router = useRouter()
const { t } = useI18n()
const currentStep = ref(1)
const selectedRevenueAccount = ref<Account | null>(null)
const isSubmitting = ref(false)
const availableCurrencies = ref<string[]>([])
const showSuccessDialog = ref(false)
const formSchema = toTypedSchema(
z.object({
description: z.string().min(1, 'Description is required').max(500, 'Description too long'),
amount: z.coerce.number().min(0.01, 'Amount must be at least 0.01'),
currency: z.string().min(1, 'Currency is required'),
reference: z.string().max(100, 'Reference too long').optional(),
})
)
const form = useForm({
validationSchema: formSchema,
initialValues: {
description: '',
amount: 0,
currency: '',
reference: '',
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
onMounted(async () => {
try {
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
console.warn('[AddIncome] No wallet available for loading data')
return
}
const currencies = await expensesAPI.getCurrencies()
availableCurrencies.value = currencies
const defaultCurrency = await expensesAPI.getDefaultCurrency()
const initialCurrency = defaultCurrency || currencies[0] || 'EUR'
form.setFieldValue('currency', initialCurrency)
} catch (error) {
console.error('[AddIncome] Failed to load data:', error)
toast.error('Failed to load form data', { description: 'Please check your connection and try again' })
}
})
function handleRevenueAccountSelected(account: Account) {
selectedRevenueAccount.value = account
currentStep.value = 2
}
const onSubmit = form.handleSubmit(async (values) => {
if (!selectedRevenueAccount.value) {
toast.error('No account selected', { description: 'Please select a revenue account first' })
return
}
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
toast.error('No wallet available', { description: 'Please log in to submit income' })
return
}
isSubmitting.value = true
try {
await expensesAPI.submitIncome(wallet.inkey, {
description: values.description,
amount: values.amount,
revenue_account: selectedRevenueAccount.value.name,
currency: values.currency,
reference: values.reference,
})
showSuccessDialog.value = true
resetForm()
selectedRevenueAccount.value = null
currentStep.value = 1
} catch (error) {
console.error('[AddIncome] Error submitting income:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
toast.error('Submission failed', { description: errorMessage })
} finally {
isSubmitting.value = false
}
})
function goToTransactions() {
showSuccessDialog.value = false
emit('close')
router.push('/expenses/transactions')
}
function closeSuccessDialog() {
showSuccessDialog.value = false
emit('close')
}
function handleDialogClose() {
if (showSuccessDialog.value) {
closeSuccessDialog()
} else {
emit('close')
}
}
</script>

View file

@ -6,7 +6,7 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI' import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI'
import type { Transaction } from '@/modules/expenses/types' import type { Transaction } from '@/modules/expenses/types'
import { ArrowDown, ArrowUp, Clock, Loader2, PieChart } from 'lucide-vue-next' import { ArrowDown, ArrowUp, Clock, Info, Loader2, PieChart, TrendingUp, TrendingDown } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
const { t } = useI18n() const { t } = useI18n()
@ -17,9 +17,30 @@ const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const balance = ref<number | null>(null) const balance = ref<number | null>(null)
const balanceCurrency = ref<string>('sats') const balanceCurrency = ref<string>('sats')
const fiatBalances = ref<Record<string, number>>({})
const totalExpensesSats = ref<number>(0)
const totalExpensesFiat = ref<Record<string, number>>({})
const totalIncomeSats = ref<number>(0)
const totalIncomeFiat = ref<Record<string, number>>({})
const pendingTransactions = ref<Transaction[]>([]) const pendingTransactions = ref<Transaction[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const fiatBalanceEntries = computed(() =>
Object.entries(fiatBalances.value).filter(([, amount]) => Math.abs(amount) > 0.005)
)
const expensesFiatEntries = computed(() =>
Object.entries(totalExpensesFiat.value).filter(([, amount]) => Math.abs(amount) > 0.005)
)
const incomeFiatEntries = computed(() =>
Object.entries(totalIncomeFiat.value).filter(([, amount]) => Math.abs(amount) > 0.005)
)
const hasBreakdown = computed(() =>
totalExpensesSats.value > 0 || totalIncomeSats.value > 0
)
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey) const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
const budgetsEnabled = computed(() => import.meta.env.VITE_LIBRA_BUDGETS_ENABLED === 'true') const budgetsEnabled = computed(() => import.meta.env.VITE_LIBRA_BUDGETS_ENABLED === 'true')
@ -55,6 +76,11 @@ async function loadData() {
balance.value = balanceData.balance balance.value = balanceData.balance
balanceCurrency.value = balanceData.currency || 'sats' balanceCurrency.value = balanceData.currency || 'sats'
fiatBalances.value = balanceData.fiat_balances || {}
totalExpensesSats.value = balanceData.total_expenses_sats || 0
totalExpensesFiat.value = balanceData.total_expenses_fiat || {}
totalIncomeSats.value = balanceData.total_income_sats || 0
totalIncomeFiat.value = balanceData.total_income_fiat || {}
// Filter for pending transactions (flag = '!') // Filter for pending transactions (flag = '!')
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!') pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
@ -112,6 +138,15 @@ function formatFiat(amount: number, currency: string): string {
</span> </span>
<span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span> <span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span>
</div> </div>
<div v-if="fiatBalanceEntries.length > 0" class="flex flex-wrap items-center gap-x-3 gap-y-1 pl-7">
<span
v-for="[currency, amount] in fiatBalanceEntries"
:key="currency"
class="text-base text-muted-foreground"
>
{{ formatFiat(Math.abs(amount), currency) }}
</span>
</div>
<p class="text-sm" :class="libraOwesUser ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"> <p class="text-sm" :class="libraOwesUser ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ libraOwesUser ? t('libra.balance.owedToYou') : t('libra.balance.youOwe') }} {{ libraOwesUser ? t('libra.balance.owedToYou') : t('libra.balance.youOwe') }}
</p> </p>
@ -122,6 +157,63 @@ function formatFiat(amount: number, currency: string): string {
</div> </div>
</div> </div>
<!-- Lifetime Breakdown -->
<div v-if="hasBreakdown" class="grid grid-cols-2 gap-3 mb-6">
<!-- Income -->
<div class="rounded-xl border bg-card p-4">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-muted-foreground" />
<h3 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Income</h3>
</div>
<p class="text-lg font-semibold text-foreground">
{{ formatAmount(totalIncomeSats) }}
<span class="text-sm font-normal text-muted-foreground">sats</span>
</p>
<div v-if="incomeFiatEntries.length > 0" class="mt-1 space-y-0.5">
<p
v-for="[currency, amount] in incomeFiatEntries"
:key="currency"
class="text-xs text-muted-foreground"
>
{{ formatFiat(amount, currency) }}
</p>
</div>
<div class="mt-3 flex items-start gap-1.5">
<Info class="w-3 h-3 mt-0.5 shrink-0 text-primary/70" />
<p class="text-[11px] leading-snug text-primary/80">
Collected on behalf of the organization you owe this back.
</p>
</div>
</div>
<!-- Expenses -->
<div class="rounded-xl border bg-card p-4">
<div class="flex items-center gap-2 mb-2">
<TrendingDown class="w-4 h-4 text-muted-foreground" />
<h3 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Expenses</h3>
</div>
<p class="text-lg font-semibold text-foreground">
{{ formatAmount(totalExpensesSats) }}
<span class="text-sm font-normal text-muted-foreground">sats</span>
</p>
<div v-if="expensesFiatEntries.length > 0" class="mt-1 space-y-0.5">
<p
v-for="[currency, amount] in expensesFiatEntries"
:key="currency"
class="text-xs text-muted-foreground"
>
{{ formatFiat(amount, currency) }}
</p>
</div>
<div class="mt-3 flex items-start gap-1.5">
<Info class="w-3 h-3 mt-0.5 shrink-0 text-primary/70" />
<p class="text-[11px] leading-snug text-primary/80">
Paid on the organization's behalf owed back to you.
</p>
</div>
</div>
</div>
<!-- Pending Section --> <!-- Pending Section -->
<div v-if="pendingCount > 0" class="rounded-xl border bg-card p-5 mb-6"> <div v-if="pendingCount > 0" class="rounded-xl border bg-card p-5 mb-6">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">

View file

@ -7,6 +7,8 @@ import type {
Account, Account,
ExpenseEntryRequest, ExpenseEntryRequest,
ExpenseEntry, ExpenseEntry,
IncomeEntryRequest,
IncomeEntry,
AccountNode, AccountNode,
UserInfo, UserInfo,
AccountPermission, AccountPermission,
@ -188,6 +190,33 @@ export class ExpensesAPI extends BaseService {
} }
} }
/**
* Submit income entry to libra
*/
async submitIncome(walletKey: string, request: IncomeEntryRequest): Promise<IncomeEntry> {
try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/entries/income`, {
method: 'POST',
headers: this.getHeaders(walletKey),
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to submit income: ${response.statusText}`
throw new Error(errorMessage)
}
const entry = await response.json()
return entry as IncomeEntry
} catch (error) {
console.error('[ExpensesAPI] Error submitting income:', error)
throw error
}
}
/** /**
* Get user's expense entries * Get user's expense entries
*/ */
@ -216,7 +245,15 @@ export class ExpensesAPI extends BaseService {
/** /**
* Get user's balance with libra * Get user's balance with libra
*/ */
async getUserBalance(walletKey: string): Promise<{ balance: number; currency: string }> { async getUserBalance(walletKey: string): Promise<{
balance: number
currency: string
fiat_balances?: Record<string, number>
total_expenses_sats?: number
total_expenses_fiat?: Record<string, number>
total_income_sats?: number
total_income_fiat?: Record<string, number>
}> {
try { try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/balance`, { const response = await fetch(`${this.baseUrl}/libra/api/v1/balance`, {
method: 'GET', method: 'GET',

View file

@ -43,6 +43,7 @@ export interface AccountWithPermissions extends Account {
export enum PermissionType { export enum PermissionType {
READ = 'read', READ = 'read',
SUBMIT_EXPENSE = 'submit_expense', SUBMIT_EXPENSE = 'submit_expense',
SUBMIT_INCOME = 'submit_income',
MANAGE = 'manage' MANAGE = 'manage'
} }
@ -78,6 +79,35 @@ export interface ExpenseEntry {
status: 'pending' | 'approved' | 'rejected' | 'void' status: 'pending' | 'approved' | 'rejected' | 'void'
} }
/**
* Income entry request payload. Income lands on the submitting user as
* a receivable (Assets:Receivable:User-{id}) no payment-method account.
*/
export interface IncomeEntryRequest {
description: string
amount: number
revenue_account: string
currency: string
reference?: string
entry_date?: string
}
/**
* Income entry response from libra API
*/
export interface IncomeEntry {
id: string
journal_id: string
description: string
amount: number
revenue_account: string
currency: string
reference?: string
entry_date: string
created_at: string
status: 'pending' | 'approved' | 'rejected' | 'void'
}
/** /**
* Hierarchical account tree node for UI rendering * Hierarchical account tree node for UI rendering
*/ */

View file

@ -17,7 +17,8 @@ import {
Flag, Flag,
XCircle, XCircle,
RefreshCw, RefreshCw,
Calendar Calendar,
Filter
} from 'lucide-vue-next' } from 'lucide-vue-next'
const { user } = useAuth() const { user } = useAuth()
@ -29,6 +30,21 @@ const isLoading = ref(false)
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom' const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
const customStartDate = ref<string>('') const customStartDate = ref<string>('')
const customEndDate = ref<string>('') const customEndDate = ref<string>('')
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
const typeFilterOptions = [
{ label: 'All', value: 'all' as const },
{ label: 'Income', value: 'income' as const },
{ label: 'Expenses', value: 'expense' as const }
]
function isIncome(t: Transaction): boolean {
return t.tags?.includes('income-entry') ?? false
}
function isExpense(t: Transaction): boolean {
return t.tags?.includes('expense-entry') ?? false
}
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey) const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
@ -55,9 +71,12 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
matchAllWhenSearchEmpty: true matchAllWhenSearchEmpty: true
} }
// Transactions to display (search results or all transactions) // Transactions to display (search results or all transactions), filtered by type
const transactionsToDisplay = computed(() => { const transactionsToDisplay = computed(() => {
return searchResults.value.length > 0 ? searchResults.value : transactions.value const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
if (typeFilter.value === 'income') return base.filter(isIncome)
if (typeFilter.value === 'expense') return base.filter(isExpense)
return base
}) })
// Handle search results // Handle search results
@ -197,7 +216,7 @@ onMounted(() => {
:key="option.value" :key="option.value"
:variant="dateRangeType === option.value ? 'default' : 'outline'" :variant="dateRangeType === option.value ? 'default' : 'outline'"
size="sm" size="sm"
class="h-8 px-3 text-xs" class="h-7 md:h-8 px-3 text-xs"
@click="onDateRangeTypeChange(option.value)" @click="onDateRangeTypeChange(option.value)"
:disabled="isLoading" :disabled="isLoading"
> >
@ -205,6 +224,22 @@ onMounted(() => {
</Button> </Button>
</div> </div>
<!-- Type Filter (All / Income / Expenses) -->
<div class="flex items-center gap-2 flex-wrap">
<Filter class="h-4 w-4 text-muted-foreground" />
<Button
v-for="option in typeFilterOptions"
:key="option.value"
:variant="typeFilter === option.value ? 'default' : 'outline'"
size="sm"
class="h-7 md:h-8 px-3 text-xs"
@click="typeFilter = option.value"
:disabled="isLoading"
>
{{ option.label }}
</Button>
</div>
<!-- Custom Date Range Inputs --> <!-- Custom Date Range Inputs -->
<div v-if="dateRangeType === 'custom'" class="flex items-end gap-2 flex-wrap"> <div v-if="dateRangeType === 'custom'" class="flex items-end gap-2 flex-wrap">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@ -212,7 +247,7 @@ onMounted(() => {
<Input <Input
type="date" type="date"
v-model="customStartDate" v-model="customStartDate"
class="h-8 text-xs" class="h-7 md:h-8 text-xs"
:disabled="isLoading" :disabled="isLoading"
/> />
</div> </div>
@ -221,13 +256,13 @@ onMounted(() => {
<Input <Input
type="date" type="date"
v-model="customEndDate" v-model="customEndDate"
class="h-8 text-xs" class="h-7 md:h-8 text-xs"
:disabled="isLoading" :disabled="isLoading"
/> />
</div> </div>
<Button <Button
size="sm" size="sm"
class="h-8 px-3 text-xs" class="h-7 md:h-8 px-3 text-xs"
@click="applyCustomDateRange" @click="applyCustomDateRange"
:disabled="isLoading || !customStartDate || !customEndDate" :disabled="isLoading || !customStartDate || !customEndDate"
> >
@ -281,7 +316,11 @@ onMounted(() => {
<div <div
v-for="transaction in transactionsToDisplay" v-for="transaction in transactionsToDisplay"
:key="transaction.id" :key="transaction.id"
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors" :class="[
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
isIncome(transaction) && 'border-l-4 border-l-green-600',
isExpense(transaction) && 'border-l-4 border-l-red-600'
]"
> >
<!-- Transaction Header --> <!-- Transaction Header -->
<div class="flex items-start justify-between gap-3 mb-2"> <div class="flex items-start justify-between gap-3 mb-2">
@ -328,22 +367,21 @@ onMounted(() => {
<span class="font-medium">Ref:</span> {{ transaction.reference }} <span class="font-medium">Ref:</span> {{ transaction.reference }}
</div> </div>
<!-- Username (if available) -->
<div v-if="transaction.username" class="text-muted-foreground">
<span class="font-medium">User:</span> {{ transaction.username }}
</div>
<!-- Tags --> <!-- Tags -->
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2"> <div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
<Badge v-for="tag in transaction.tags" :key="tag" variant="secondary" class="text-xs"> <Badge
v-for="tag in transaction.tags"
:key="tag"
variant="secondary"
:class="[
'text-xs',
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
]"
>
{{ tag }} {{ tag }}
</Badge> </Badge>
</div> </div>
<!-- Metadata Source -->
<div v-if="transaction.meta?.source" class="text-muted-foreground mt-1">
<span class="text-xs">Source: {{ transaction.meta.source }}</span>
</div>
</div> </div>
</div> </div>

View file

@ -236,7 +236,8 @@ export function useCheckout(): UseCheckoutReturn {
async function payBolt11Raw( async function payBolt11Raw(
bolt11: string, bolt11: string,
adminkey: string adminkey: string,
extra?: Record<string, unknown>
): Promise<void> { ): Promise<void> {
const response = await fetch(`${apiBaseUrl}/api/v1/payments`, { const response = await fetch(`${apiBaseUrl}/api/v1/payments`, {
method: 'POST', method: 'POST',
@ -244,7 +245,7 @@ export function useCheckout(): UseCheckoutReturn {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Api-Key': adminkey, 'X-Api-Key': adminkey,
}, },
body: JSON.stringify({ out: true, bolt11 }), body: JSON.stringify({ out: true, bolt11, ...(extra ? { extra } : {}) }),
}) })
if (!response.ok) { if (!response.ok) {
let detail = response.statusText let detail = response.statusText
@ -272,7 +273,15 @@ export function useCheckout(): UseCheckoutReturn {
state.value.step = 'paying' state.value.step = 'paying'
state.value.currentRestaurantSlug = placed.restaurantSlug state.value.currentRestaurantSlug = placed.restaurantSlug
try { try {
await payBolt11Raw(placed.invoice.bolt11, adminkey) // Tag the outgoing payment so the customer's wallet history
// can later surface it as a restaurant order. Mirrors the
// `extra={"tag": "restaurant", ...}` the extension stamps on
// its incoming invoice.
await payBolt11Raw(placed.invoice.bolt11, adminkey, {
tag: 'restaurant',
restaurant_id: placed.restaurantId,
order_id: placed.order.id,
})
// Set semantics keeps `paidOrderIds` from re-renders; rebuild // Set semantics keeps `paidOrderIds` from re-renders; rebuild
// it on update so Vue picks up the change. // it on update so Vue picks up the change.
state.value.paidOrderIds = new Set([ state.value.paidOrderIds = new Set([