اسناد حسابداری فانزون
- برای دریافت پرداختهای رویداد، فاکتور فروش و سند حسابداری از فانزون، از صفحه{' '}
+ برای همگامسازی فروش بلیت (فاکتور با مالیات و ۱۴٪)، لغو بلیت (فاکتور لغو شده)، و اسناد حسابداری از فانزون، از صفحه{' '}
اسناد حسابداری
{' '}
diff --git a/src/pages/sales/PendingWithdrawalsPanel.tsx b/src/pages/sales/PendingWithdrawalsPanel.tsx
index f884cf2..06d726e 100644
--- a/src/pages/sales/PendingWithdrawalsPanel.tsx
+++ b/src/pages/sales/PendingWithdrawalsPanel.tsx
@@ -1,18 +1,20 @@
import { useMemo } from 'react'
import type { ApiWithdrawal } from '../../api/types'
-import type { Invoice, Party } from '../../types'
+import type { Party } from '../../types'
import { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
import { formatDate, formatIban, formatMoney, formatTime, toFa } from '../../utils/format'
-import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
+import type { WalletTxnStatus } from '../../api/types'
interface PendingWithdrawalsPanelProps {
withdrawals: ApiWithdrawal[]
loading: boolean
+ updatingId: string | null
error: string | null
- returnInvoices: Invoice[]
customerList: Party[]
+ bookedWithdrawalIds: Set
onReload: () => void
- onCreateReturn: (withdrawal: ApiWithdrawal) => void
+ onToggleStatus: (withdrawal: ApiWithdrawal) => void
+ onBookPayment: (withdrawal: ApiWithdrawal) => void
}
function CustomerCell({ withdrawal }: { withdrawal: ApiWithdrawal }) {
@@ -58,24 +60,74 @@ function RequestDateCell({ iso }: { iso: string }) {
)
}
-export function PendingWithdrawalsPanel({
- withdrawals,
- loading,
- error,
- returnInvoices,
- customerList,
- onReload,
- onCreateReturn,
-}: PendingWithdrawalsPanelProps) {
- const openWithdrawals = useMemo(
- () => withdrawals.filter((w) => !invoiceForWithdrawal(returnInvoices, w.id)),
- [withdrawals, returnInvoices],
- )
+function statusLabel(status: WalletTxnStatus): string {
+ if (status === 'completed') return 'تکمیل شده'
+ if (status === 'pending') return 'در انتظار'
+ return 'ناموفق'
+}
+function StatusToggleButton({
+ withdrawal,
+ busy,
+ onToggle,
+}: {
+ withdrawal: ApiWithdrawal
+ busy: boolean
+ onToggle: (withdrawal: ApiWithdrawal) => void
+}) {
+ const tone =
+ withdrawal.status === 'completed'
+ ? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
+ : withdrawal.status === 'pending'
+ ? 'bg-amber-100 text-amber-800 hover:bg-amber-200'
+ : 'bg-rose-100 text-rose-800 hover:bg-rose-200'
+
+ return (
+ onToggle(withdrawal)}
+ title={
+ withdrawal.status === 'failed'
+ ? undefined
+ : withdrawal.status === 'pending'
+ ? 'تغییر به تکمیل شده و ثبت پرداخت خزانه'
+ : 'بازگشت به در انتظار'
+ }
+ className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${tone}`}
+ >
+ {busy ? '…' : statusLabel(withdrawal.status)}
+
+ )
+}
+
+function WithdrawalsTable({
+ title,
+ subtitle,
+ rows,
+ loading,
+ updatingId,
+ customerList,
+ bookedWithdrawalIds,
+ showBookPayment,
+ onToggleStatus,
+ onBookPayment,
+}: {
+ title: string
+ subtitle: string
+ rows: ApiWithdrawal[]
+ loading: boolean
+ updatingId: string | null
+ customerList: Party[]
+ bookedWithdrawalIds: Set
+ showBookPayment: boolean
+ onToggleStatus: (withdrawal: ApiWithdrawal) => void
+ onBookPayment: (withdrawal: ApiWithdrawal) => void
+}) {
const columns: Array> = [
{
- key: 'customer',
- header: 'مشتری',
+ key: 'user',
+ header: 'نام',
headerClassName: 'w-[11rem]',
className: 'w-[11rem]',
render: (w) => ,
@@ -90,6 +142,19 @@ export function PendingWithdrawalsPanel({
{formatMoney(w.amount)}
),
},
+ {
+ key: 'status',
+ header: 'وضعیت',
+ headerClassName: 'w-[7rem]',
+ className: 'w-[7rem] whitespace-nowrap',
+ render: (w) => (
+
+ ),
+ },
{
key: 'iban',
header: 'شماره شبا',
@@ -97,6 +162,17 @@ export function PendingWithdrawalsPanel({
className: 'min-w-[14rem]',
render: (w) => ,
},
+ {
+ key: 'description',
+ header: 'توضیحات',
+ headerClassName: 'min-w-[10rem]',
+ className: 'min-w-[10rem]',
+ render: (w) => (
+
+ {w.description?.trim() || '—'}
+
+ ),
+ },
{
key: 'requestedAt',
header: 'تاریخ درخواست',
@@ -104,14 +180,10 @@ export function PendingWithdrawalsPanel({
className: 'w-[6.5rem]',
render: (w) => ,
},
- {
- key: 'status',
- header: 'وضعیت',
- headerClassName: 'w-[5.5rem]',
- className: 'w-[5.5rem] whitespace-nowrap',
- render: () => در انتظار ,
- },
- {
+ ]
+
+ if (showBookPayment) {
+ columns.push({
key: 'actions',
header: '',
align: 'end',
@@ -119,66 +191,70 @@ export function PendingWithdrawalsPanel({
headerClassName: 'w-[9.5rem]',
className: 'w-[9.5rem] whitespace-nowrap',
render: (w) => {
+ const booked = bookedWithdrawalIds.has(w.id)
+ if (booked) {
+ return پرداخت ثبت شد
+ }
+ if (w.status === 'completed') {
+ return (
+ p.externalId === w.user_id) || updatingId === w.id}
+ onClick={() => onBookPayment(w)}
+ title="وضعیت تکمیل است ولی سند خزانه هنوز ثبت نشده"
+ >
+ ثبت پرداخت
+
+ )
+ }
const party = customerList.find((p) => p.externalId === w.user_id)
return (
onCreateReturn(w)}
- title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
+ disabled={!party || updatingId === w.id}
+ onClick={() => onBookPayment(w)}
+ title={party ? 'ثبت پرداخت خزانه بدون فاکتور برگشتی' : 'ابتدا مشتری را همگام کنید'}
>
- ثبت فاکتور برگشتی
+ ثبت پرداخت
)
},
- },
- ]
+ })
+ }
+
+ const pendingCount = rows.filter((w) => w.status === 'pending').length
+ const pendingSum = rows.filter((w) => w.status === 'pending').reduce((s, w) => s + w.amount, 0)
return (
-
-
+
+
-
برداشت در انتظار (پنل مدیریت)
-
- درخواستهای برداشت کیف پول مشتریان — شبا، مبلغ و تاریخ هر درخواست جدا نمایش داده میشود.
-
+
{title}
+
{subtitle}
-
- بروزرسانی
-
-
+
0 ? 'amber' : 'green'}
- />
- s + w.amount, 0))}
- icon="💳"
- tone="brand"
+ tone={pendingCount > 0 ? 'amber' : 'green'}
/>
+
- {error && (
-
- {error}
-
- )}
-
{loading ? (
در حال بارگذاری برداشتها…
) : (
w.id}
- emptyTitle="برداشت در انتظاری ثبت نشده است"
+ emptyTitle="برداشتی یافت نشد"
minWidth="52rem"
size="comfortable"
className="rounded-xl"
@@ -188,3 +264,73 @@ export function PendingWithdrawalsPanel({
)
}
+
+export function PendingWithdrawalsPanel({
+ withdrawals,
+ loading,
+ updatingId,
+ error,
+ customerList,
+ bookedWithdrawalIds,
+ onReload,
+ onToggleStatus,
+ onBookPayment,
+}: PendingWithdrawalsPanelProps) {
+ const customerWithdrawals = useMemo(
+ () => withdrawals.filter((w) => w.user_type === 'customer'),
+ [withdrawals],
+ )
+ const ownerWithdrawals = useMemo(
+ () => withdrawals.filter((w) => w.user_type === 'owner'),
+ [withdrawals],
+ )
+
+ return (
+
+
+
+
برداشتها
+
+ وضعیت را بین «در انتظار» و «تکمیل شده» عوض کنید. تکمیلشده برای مشتری و مالک،
+ پرداخت خزانه ثبت میکند.
+
+
+
+ بروزرسانی
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/pages/sales/SalesDocumentSection.tsx b/src/pages/sales/SalesDocumentSection.tsx
index 4e4a65d..36e0e12 100644
--- a/src/pages/sales/SalesDocumentSection.tsx
+++ b/src/pages/sales/SalesDocumentSection.tsx
@@ -24,24 +24,18 @@ import {
customers,
documentTypeLabels,
invoiceRegarding,
- isWithdrawalReturn,
reconcileInvoicePaymentStatus,
statusLabels,
} from './salesUtils'
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
import { InvoiceLineGoodsCell } from '../../components/business/InvoiceLineGoodsCell'
import { formatEventName } from '../../utils/eventName'
-import {
- usePendingCustomerWithdrawals,
- withdrawalNoteTag,
-} from './usePendingCustomerWithdrawals'
-import { PendingWithdrawalsPanel } from './PendingWithdrawalsPanel'
-import type { ApiWithdrawal } from '../../api/types'
-const statusTones: Record = {
+const statusTones: Record = {
draft: 'amber',
confirmed: 'blue',
paid: 'green',
+ cancelled: 'rose',
}
const newLine = (): InvoiceLine => ({
@@ -59,8 +53,6 @@ interface SalesDocumentSectionProps {
subtitle: string
/** Allow converting from proforma when creating sales invoice */
allowProformaConversion?: boolean
- /** Allow linking to original invoice for returns */
- allowRelatedInvoice?: boolean
}
export function SalesDocumentSection({
@@ -68,7 +60,6 @@ export function SalesDocumentSection({
title,
subtitle,
allowProformaConversion = false,
- allowRelatedInvoice = false,
}: SalesDocumentSectionProps) {
const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore()
const location = useLocation()
@@ -81,22 +72,15 @@ export function SalesDocumentSection({
() => customers(data.parties).filter((p) => p.id),
[data.parties],
)
- const documents = useMemo(() => byDocumentType(data.invoices, documentType), [data.invoices, documentType])
- const proformas = useMemo(() => byDocumentType(data.invoices, 'proforma'), [data.invoices])
- const saleInvoices = useMemo(() => byDocumentType(data.invoices, 'invoice'), [data.invoices])
- const activeSalesTypes = useMemo(() => data.salesTypes.filter((t) => t.active), [data.salesTypes])
- const isReturnSection = documentType === 'return'
- const {
- withdrawals: pendingWithdrawals,
- loading: pendingLoading,
- error: pendingError,
- reload: reloadPending,
- } = usePendingCustomerWithdrawals(isReturnSection)
-
- const returnDocuments = useMemo(
- () => (isReturnSection ? documents : []),
- [documents, isReturnSection],
+ const documents = useMemo(
+ () =>
+ byDocumentType(data.invoices, documentType).filter((i) =>
+ documentType === 'invoice' ? i.status !== 'cancelled' : true,
+ ),
+ [data.invoices, documentType],
)
+ const proformas = useMemo(() => byDocumentType(data.invoices, 'proforma'), [data.invoices])
+ const activeSalesTypes = useMemo(() => data.salesTypes.filter((t) => t.active), [data.salesTypes])
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
const salesTypeName = (id?: string) =>
@@ -162,51 +146,11 @@ export function SalesDocumentSection({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
- const loadFromInvoice = (invoiceId: string) => {
- const source = saleInvoices.find((i) => i.id === invoiceId)
- if (!source) return
- setPreviousDraft(null)
- setDraft({
- ...createDraft(),
- partyId: source.partyId,
- salesTypeId: source.salesTypeId,
- relatedInvoiceId: source.id,
- note: `برگشت از فاکتور #${source.number}`,
- lines: source.lines.map((l) => ({ ...l, id: createId('il-'), eventName: l.eventName })),
- })
- }
-
- const loadFromWithdrawal = (withdrawal: ApiWithdrawal) => {
- const party = customerList.find((p) => p.externalId === withdrawal.user_id)
- if (!party) return
- setPreviousDraft(null)
- setDraft({
- ...createDraft(),
- partyId: party.id,
- date: withdrawal.created_at.slice(0, 10),
- status: 'draft',
- note: `${withdrawalNoteTag(withdrawal.id)}\nبرداشت کیف پول مشتری — ${withdrawal.user_name}${withdrawal.iban ? `\nشبا: ${withdrawal.iban}` : ''}`,
- lines: [
- {
- id: createId('il-'),
- productId: '',
- quantity: 1,
- unitPrice: withdrawal.amount,
- discount: 0,
- taxRate: 0,
- eventName: 'wallet',
- },
- ],
- })
- }
-
const handleSave = () => {
if (!draft || !draft.partyId) return
- const fromWithdrawal = isWithdrawalReturn(draft)
- const cleanLines = draft.lines.filter((line) => {
- if (fromWithdrawal && line.unitPrice > 0) return true
- return Boolean(line.productId || line.eventName) && line.quantity > 0
- })
+ const cleanLines = draft.lines.filter(
+ (line) => Boolean(line.productId || line.eventName) && line.quantity > 0,
+ )
if (cleanLines.length === 0) return
const saved: Invoice = {
@@ -228,7 +172,7 @@ export function SalesDocumentSection({
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
if (effects.voucher) upsertVoucher(effects.voucher)
- if (saved.partyId && saved.status !== 'draft') {
+ if (saved.partyId && saved.status !== 'draft' && saved.status !== 'cancelled') {
const dataAfterSave = {
...data,
invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved],
@@ -311,18 +255,6 @@ export function SalesDocumentSection({
return (
<>
- {isReturnSection && (
- void reloadPending()}
- onCreateReturn={loadFromWithdrawal}
- />
- )}
-
@@ -417,21 +349,6 @@ export function SalesDocumentSection({
)}
- {allowRelatedInvoice && !draft.id && saleInvoices.length > 0 && (
-
- e.target.value && loadFromInvoice(e.target.value)}>
- — انتخاب فاکتور فروش —
- {saleInvoices
- .filter((i) => i.status !== 'draft')
- .map((i) => (
-
- #{toFa(i.number)} · {partyName(i.partyId)} · {formatMoney(invoiceTotals(i).net)}
-
- ))}
-
-
- )}
-
diff --git a/src/pages/sales/SalesDocuments.tsx b/src/pages/sales/SalesDocuments.tsx
index afd3521..87a5850 100644
--- a/src/pages/sales/SalesDocuments.tsx
+++ b/src/pages/sales/SalesDocuments.tsx
@@ -1,4 +1,5 @@
import { SalesDocumentSection } from './SalesDocumentSection'
+import { SalesReturn as SalesReturnPage } from './SalesReturnPage'
export function SalesProforma() {
return (
@@ -21,13 +22,7 @@ export function SalesInvoice() {
)
}
+/** Withdrawals + cancelled sales invoices (no فاکتور برگشتی). */
export function SalesReturn() {
- return (
-
- )
+ return
}
diff --git a/src/pages/sales/SalesProcess.tsx b/src/pages/sales/SalesProcess.tsx
index 20f664c..d512a00 100644
--- a/src/pages/sales/SalesProcess.tsx
+++ b/src/pages/sales/SalesProcess.tsx
@@ -12,7 +12,7 @@ const processSteps = [
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
{ step: 2, label: 'فاکتور فروش', desc: 'ثبت فروش قطعی و کاهش موجودی', to: '/sales/invoice', icon: '🧾' },
{ step: 3, label: 'دریافت', desc: 'تسویه از مشتری در ماژول دریافت و پرداخت', to: '/treasury', icon: '💰' },
- { step: 4, label: 'فاکتور برگشتی', desc: 'برداشت در انتظار مشتریان و برگشت کالا', to: '/sales/return', icon: '↩️' },
+ { step: 4, label: 'برداشت و لغو', desc: 'برداشت کیف پول و فاکتورهای فروش لغو شده', to: '/sales/return', icon: '🚫' },
]
export function SalesProcess() {
@@ -31,7 +31,7 @@ export function SalesProcess() {
فرایند فروش
- جریان استاندارد فروش مشابه سپیدار: از پیشفاکتور تا فاکتور، دریافت و برگشت.
+ جریان استاندارد فروش: از پیشفاکتور تا فاکتور، دریافت، و لغو/برداشت.
@@ -61,7 +61,7 @@ export function SalesProcess() {
-
+
@@ -73,7 +73,7 @@ export function SalesProcess() {
📦
- انبارداری: فاکتور فروش قطعی موجودی را کاهش و فاکتور برگشتی آن را افزایش میدهد.
+ انبارداری: فاکتور فروش قطعی موجودی را کاهش میدهد؛ فاکتور لغو شده اثر موجودی را برنمیگرداند اگر قبلاً لغو نشده باشد.
diff --git a/src/pages/sales/SalesReturnPage.tsx b/src/pages/sales/SalesReturnPage.tsx
new file mode 100644
index 0000000..057ccaf
--- /dev/null
+++ b/src/pages/sales/SalesReturnPage.tsx
@@ -0,0 +1,306 @@
+import { useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useStore } from '../../store/AppStore'
+import type { ApiWithdrawal } from '../../api/types'
+import type { Invoice } from '../../types'
+import {
+ Badge,
+ Card,
+ DataTable,
+ StatCard,
+ type Column,
+} from '../../components/ui'
+import { formatDate, formatMoney, toFa } from '../../utils/format'
+import { invoiceTotals } from '../../utils/accounting'
+import { formatEventName } from '../../utils/eventName'
+import { createId } from '../../utils/id'
+import {
+ buildTreasuryVoucher,
+ customers,
+ invoiceRegarding,
+ statusLabels,
+ suppliers,
+ voucherSourceForTreasury,
+} from './salesUtils'
+import { usePendingCustomerWithdrawals } from './usePendingCustomerWithdrawals'
+import { PendingWithdrawalsPanel } from './PendingWithdrawalsPanel'
+import {
+ applyWithdrawalCompletedEffects,
+ applyWithdrawalReopenedEffects,
+ treasurySourceForWithdrawal,
+ withdrawalAlreadyBooked,
+} from './withdrawalTreasury'
+import { FunZoneSalesSyncBanner } from './FunZoneSalesSyncBanner'
+import { isCancelledSalesInvoice } from './funzoneSalesSync'
+
+/**
+ * Withdrawals + cancelled ticket sales.
+ * Replaces فاکتور برگشتی — cancellations void the original sales invoice.
+ */
+export function SalesReturn() {
+ const {
+ data,
+ upsertTreasuryAsync,
+ removeTreasuryAsync,
+ upsertVoucherAsync,
+ removeVoucherAsync,
+ removeInvoiceAsync,
+ reload,
+ } = useStore()
+
+ const customerList = useMemo(
+ () => customers(data.parties).filter((p) => p.id),
+ [data.parties],
+ )
+ const ownerList = useMemo(
+ () => suppliers(data.parties).filter((p) => p.id),
+ [data.parties],
+ )
+
+ const {
+ withdrawals,
+ loading,
+ updatingId,
+ error,
+ reload: reloadWithdrawals,
+ toggleStatus,
+ } = usePendingCustomerWithdrawals(true)
+
+ const [actionError, setActionError] = useState(null)
+ const [bookingId, setBookingId] = useState(null)
+
+ const cancelledInvoices = useMemo(
+ () =>
+ data.invoices
+ .filter(isCancelledSalesInvoice)
+ .sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
+ [data.invoices],
+ )
+
+ const bookedWithdrawalIds = useMemo(
+ () => new Set(withdrawals.filter((w) => withdrawalAlreadyBooked(data, w)).map((w) => w.id)),
+ [withdrawals, data],
+ )
+
+ const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
+
+ const partyForWithdrawal = (withdrawal: ApiWithdrawal) =>
+ withdrawal.user_type === 'owner'
+ ? ownerList.find((p) => p.externalId === withdrawal.user_id)
+ : customerList.find((p) => p.externalId === withdrawal.user_id)
+
+ /** Persist treasury پرداخت + voucher for customer or owner payout. */
+ const persistWithdrawalPayment = async (withdrawal: ApiWithdrawal, partyId: string) => {
+ if (withdrawalAlreadyBooked(data, withdrawal)) return
+
+ const effects = applyWithdrawalCompletedEffects(withdrawal, partyId, data)
+ for (const voucherId of effects.removeVoucherIds) {
+ await removeVoucherAsync(voucherId)
+ }
+ for (const invoiceId of effects.removeInvoiceIds) {
+ await removeInvoiceAsync(invoiceId)
+ }
+
+ const persisted = await upsertTreasuryAsync({
+ ...effects.treasury,
+ eventName: withdrawal.user_type === 'customer' ? 'wallet' : effects.treasury.eventName || '',
+ })
+
+ const voucher = buildTreasuryVoucher(
+ {
+ ...persisted,
+ eventName:
+ persisted.eventName || (withdrawal.user_type === 'customer' ? 'wallet' : ''),
+ },
+ data,
+ )
+ if (voucher) {
+ const existing = data.vouchers.find(
+ (v) =>
+ v.source === voucherSourceForTreasury(persisted) ||
+ v.source === treasurySourceForWithdrawal(withdrawal),
+ )
+ await upsertVoucherAsync({
+ ...voucher,
+ id: existing?.id || createId('v-'),
+ source: voucherSourceForTreasury(persisted),
+ })
+ }
+ }
+
+ const bookWithdrawalPayment = async (withdrawal: ApiWithdrawal) => {
+ const party = partyForWithdrawal(withdrawal)
+ if (!party) {
+ setActionError(
+ withdrawal.user_type === 'owner'
+ ? 'ابتدا مالک را از بخش طرفحسابها همگام کنید'
+ : 'ابتدا مشتری را از بخش مشتریان همگام کنید',
+ )
+ return
+ }
+
+ setBookingId(withdrawal.id)
+ setActionError(null)
+ try {
+ await persistWithdrawalPayment(withdrawal, party.id)
+ await reload()
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : 'خطا در ثبت پرداخت برداشت')
+ } finally {
+ setBookingId(null)
+ }
+ }
+
+ const handleToggleStatus = async (withdrawal: ApiWithdrawal) => {
+ setActionError(null)
+ const nextIsCompleted = withdrawal.status === 'pending'
+ const party = partyForWithdrawal(withdrawal)
+
+ if (nextIsCompleted && !party) {
+ setActionError(
+ withdrawal.user_type === 'owner'
+ ? 'ابتدا مالک را همگام کنید؛ بدون طرفحساب نمیتوان برداشت را تکمیل کرد'
+ : 'ابتدا مشتری را همگام کنید؛ بدون طرفحساب نمیتوان برداشت را تکمیل کرد',
+ )
+ return
+ }
+
+ setBookingId(withdrawal.id)
+ try {
+ if (nextIsCompleted && party) {
+ await persistWithdrawalPayment(withdrawal, party.id)
+ } else if (!nextIsCompleted) {
+ const effects = applyWithdrawalReopenedEffects(withdrawal, data)
+ for (const voucherId of effects.removeVoucherIds) {
+ await removeVoucherAsync(voucherId)
+ }
+ if (effects.removeTreasuryId) await removeTreasuryAsync(effects.removeTreasuryId)
+ }
+
+ await toggleStatus(withdrawal)
+ await reloadWithdrawals()
+ await reload()
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : 'خطا در بروزرسانی وضعیت برداشت')
+ await reloadWithdrawals()
+ } finally {
+ setBookingId(null)
+ }
+ }
+
+ const cancelledColumns: Array> = [
+ {
+ key: 'number',
+ header: 'شماره',
+ render: (i) => {toFa(i.number)} ,
+ },
+ { key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
+ { key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
+ {
+ key: 'regarding',
+ header: 'بابت',
+ render: (i) => {
+ const regarding = invoiceRegarding(i)
+ return regarding ? formatEventName(regarding) : '—'
+ },
+ },
+ {
+ key: 'net',
+ header: 'مبلغ',
+ align: 'end',
+ render: (i) => {formatMoney(invoiceTotals(i).net)} ,
+ },
+ {
+ key: 'status',
+ header: 'وضعیت',
+ render: () => {statusLabels.cancelled} ,
+ },
+ {
+ key: 'cancelledAt',
+ header: 'جزئیات',
+ render: (i) => {
+ const cancelLine = i.note.split('\n').find((l) => l.startsWith('تاریخ لغو:'))
+ return (
+
+ {cancelLine?.replace('تاریخ لغو: ', '') ?? '—'}
+
+ )
+ },
+ },
+ ]
+
+ return (
+
+
+
+
+
+
برداشتها و فاکتورهای لغو شده
+
+ با لغو بلیت، همان فاکتور فروش «لغو شده» میشود (بدون مالیات/۱۴٪). برداشت مشتری فقط بهصورت
+ پرداخت خزانه ثبت میشود.
+
+
+
+ 0 ? 'rose' : 'green'}
+ />
+ s + invoiceTotals(i).net, 0))}
+ icon="↩️"
+ tone="amber"
+ />
+
+
+
+
+ {(actionError || error) && (
+
+ {actionError || error}
+
+ )}
+
+
void reloadWithdrawals()}
+ onToggleStatus={(w) => void handleToggleStatus(w)}
+ onBookPayment={(w) => void bookWithdrawalPayment(w)}
+ />
+
+
+
+
+
فاکتورهای فروش لغو شده
+
+ بلیتهایی که کاربر یا رویداد لغو کرده — همان فاکتور فروش با وضعیت لغو شده.
+
+
+
+ مشاهده فاکتورهای فروش
+
+
+
+
+ i.id}
+ emptyTitle="فاکتور لغوشدهای ثبت نشده است"
+ minWidth="48rem"
+ size="comfortable"
+ className="rounded-xl"
+ />
+
+
+
+ )
+}
diff --git a/src/pages/sales/SalesReview.tsx b/src/pages/sales/SalesReview.tsx
index d7eef43..4a1a8e1 100644
--- a/src/pages/sales/SalesReview.tsx
+++ b/src/pages/sales/SalesReview.tsx
@@ -38,7 +38,7 @@ export function SalesReview() {
const summary = useMemo(() => salesSummary(data), [data])
const filteredDocs = useMemo(() => {
- let docs = salesDocuments(data.invoices)
+ let docs = salesDocuments(data.invoices).filter((i) => (i.documentType ?? 'invoice') !== 'return')
if (filterType !== 'all') {
docs = docs.filter((i) => (i.documentType ?? 'invoice') === filterType)
}
@@ -81,7 +81,7 @@ export function SalesReview() {
header: 'وضعیت',
render: (i) => (
-
+
{statusLabels[i.status]}
{isFunZoneSalesInvoice(i) ? فانزون : null}
@@ -96,7 +96,7 @@ export function SalesReview() {
-
+
@@ -106,7 +106,9 @@ export function SalesReview() {
setFilterType(e.target.value as SalesDocumentType | 'all')}>
همه
- {Object.entries(documentTypeLabels).map(([value, label]) => (
+ {Object.entries(documentTypeLabels)
+ .filter(([value]) => value !== 'return')
+ .map(([value, label]) => (
{label}
diff --git a/src/pages/sales/funzoneSalesSync.cancel.test.ts b/src/pages/sales/funzoneSalesSync.cancel.test.ts
new file mode 100644
index 0000000..4dc12c6
--- /dev/null
+++ b/src/pages/sales/funzoneSalesSync.cancel.test.ts
@@ -0,0 +1,123 @@
+/**
+ * Run: npx tsx src/pages/sales/funzoneSalesSync.cancel.test.ts
+ */
+import assert from 'node:assert/strict'
+import type { AppData, Invoice, Party } from '../../types'
+import type { ApiPaymentOrRefund } from '../../api/types'
+import {
+ buildFunZoneSalesSync,
+ findOriginalTicketInvoiceForCancellation,
+} from './funzoneSalesSync'
+
+const customer: Party = {
+ id: 'c1',
+ kind: 'customer',
+ name: 'Ali',
+ phone: '',
+ economicCode: '',
+ address: '',
+ openingBalance: 0,
+ externalId: 'cust-1',
+ externalSource: 'funzone_customer',
+}
+
+const baseData = (invoices: Invoice[]): Pick => ({
+ parties: [customer],
+ invoices,
+ vouchers: [],
+})
+
+const paidInvoice: Invoice = {
+ id: 'inv-1',
+ kind: 'sale',
+ documentType: 'invoice',
+ number: 1,
+ partyId: 'c1',
+ date: '2026-01-01',
+ status: 'paid',
+ note: 'funzone:pay:pay-1\nفاکتور فروش بلیت',
+ lines: [
+ {
+ id: 'l1',
+ productId: '',
+ quantity: 1,
+ unitPrice: 100_000,
+ discount: 0,
+ taxRate: 0,
+ eventName: 'Jazz Night',
+ },
+ ],
+}
+
+const payment: ApiPaymentOrRefund = {
+ id: 'pay-1',
+ type: 'payment',
+ customer_id: 'cust-1',
+ customer_name: 'Ali',
+ trace_no: '',
+ ref_num: '',
+ amount: 100_000,
+ event_name: 'Jazz Night',
+ category_name: null,
+ payment_method: 'gateway',
+ event_date: null,
+ event_time: null,
+ created_at: '2026-01-01T10:00:00Z',
+ reservation_id: 'res-1',
+ refund_status: null,
+}
+
+const cancellation: ApiPaymentOrRefund = {
+ id: 'ref-1',
+ type: 'cancellation',
+ customer_id: 'cust-1',
+ customer_name: 'Ali',
+ trace_no: '',
+ ref_num: '',
+ amount: 100_000,
+ event_name: 'Jazz Night',
+ category_name: null,
+ event_date: null,
+ event_time: null,
+ created_at: '2026-01-02T12:00:00Z',
+ reservation_id: 'res-1',
+ refund_status: 'completed',
+}
+
+{
+ const found = findOriginalTicketInvoiceForCancellation(
+ cancellation,
+ [payment],
+ [paidInvoice],
+ 'c1',
+ )
+ assert.equal(found?.id, 'inv-1')
+}
+
+{
+ const result = buildFunZoneSalesSync(baseData([paidInvoice]), [payment, cancellation])
+ assert.equal(result.toUpsert.length, 1)
+ assert.equal(result.toUpsert[0].status, 'cancelled')
+ assert.equal(result.toUpsert[0].documentType, 'invoice')
+ assert.ok(result.toUpsert[0].note.includes('funzone:cancel:ref-1'))
+ assert.equal(result.cancelledInvoices, 1)
+ assert.ok(!result.toUpsert.some((i) => i.documentType === 'return'))
+}
+
+{
+ const legacyReturn: Invoice = {
+ id: 'inv-ret',
+ kind: 'sale',
+ documentType: 'return',
+ number: 9,
+ partyId: 'c1',
+ date: '2026-01-02',
+ status: 'confirmed',
+ note: 'funzone:cancel:old-1\nبرگشت خودکار',
+ lines: [{ id: 'lr', productId: '', quantity: 1, unitPrice: 50_000, discount: 0, taxRate: 0 }],
+ }
+ const result = buildFunZoneSalesSync(baseData([paidInvoice, legacyReturn]), [payment, cancellation])
+ assert.ok(result.toRemoveIds.includes('inv-ret'))
+}
+
+console.log('funzoneSalesSync.cancel.test.ts: ok')
diff --git a/src/pages/sales/funzoneSalesSync.ts b/src/pages/sales/funzoneSalesSync.ts
index 8a90bfc..4cdc3a6 100644
--- a/src/pages/sales/funzoneSalesSync.ts
+++ b/src/pages/sales/funzoneSalesSync.ts
@@ -2,10 +2,10 @@ import type { ApiPaymentOrRefund } from '../../api/types'
import type { AppData, Invoice } from '../../types'
import { findExistingParty } from '../../parties/funzonePartySync'
import { createId, nextNumber } from '../../utils/id'
-import { isEventCustomerPayment } from '../../utils/funzoneFees'
+import { isEventCustomerPayment, splitEventPayment } from '../../utils/funzoneFees'
import { invoiceTotals } from '../../utils/accounting'
import { eventNameWithCategory } from '../../utils/eventName'
-import { byDocumentType, isWithdrawalReturn } from './salesUtils'
+import { byDocumentType, findVoucherForInvoice, isWithdrawalReturn } from './salesUtils'
export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:'
export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:'
@@ -32,6 +32,10 @@ export function isFunZoneSalesInvoice(invoice: Invoice): boolean {
return isFunZoneTicketSalesInvoice(invoice) || invoice.note.includes(FUNZONE_CANCEL_NOTE_PREFIX)
}
+export function isCancelledSalesInvoice(invoice: Invoice): boolean {
+ return invoice.kind === 'sale' && invoice.status === 'cancelled'
+}
+
function isoDateFromApi(iso: string): string {
return iso.slice(0, 10)
}
@@ -40,37 +44,102 @@ function findInvoiceByNoteTag(invoices: Invoice[], tag: string): Invoice | undef
return invoices.find((inv) => inv.note.includes(tag))
}
-function cancellationAlreadyBooked(
+function eventNamesMatch(invoiceEvent: string | undefined, cancelEvent: string): boolean {
+ const a = (invoiceEvent || '').toLowerCase().trim()
+ const b = (cancelEvent || '').toLowerCase().trim()
+ if (!a || !b) return false
+ const aBase = a.split('(')[0].trim()
+ const bBase = b.split('(')[0].trim()
+ return a.includes(bBase) || b.includes(aBase) || a === b
+}
+
+/** Resolve the original ticket sales invoice for a FunZone cancellation. */
+export function findOriginalTicketInvoiceForCancellation(
+ cancellation: ApiPaymentOrRefund,
+ payments: ApiPaymentOrRefund[],
invoices: Invoice[],
- customerName: string,
- amount: number,
-): boolean {
- for (const inv of invoices) {
- if (!isWithdrawalReturn(inv) || inv.status === 'draft') continue
- const net = invoiceTotals(inv).net
- if (Math.abs(net - amount) > 0.01) continue
- if (inv.note.includes(customerName)) return true
+ partyId: string,
+): Invoice | undefined {
+ if (cancellation.reservation_id) {
+ const payment = payments.find(
+ (p) =>
+ p.type === 'payment' &&
+ p.reservation_id &&
+ p.reservation_id === cancellation.reservation_id,
+ )
+ if (payment) {
+ const byTag =
+ findInvoiceByNoteTag(invoices, funzonePaymentNoteTag(payment.id, true)) ??
+ findInvoiceByNoteTag(invoices, funzonePaymentNoteTag(payment.id, false))
+ if (byTag) return byTag
+ }
+ }
+
+ const amount = Math.abs(cancellation.amount)
+ const candidates = invoices
+ .filter((inv) => {
+ if (inv.partyId !== partyId) return false
+ if (inv.status === 'draft') return false
+ if (!isFunZoneTicketSalesInvoice(inv)) return false
+ // Already cancelled by a different refund — do not reassign.
+ if (inv.status === 'cancelled') {
+ const existingCancel = inv.note
+ .split('\n')
+ .find((l) => l.startsWith(FUNZONE_CANCEL_NOTE_PREFIX))
+ if (existingCancel && !existingCancel.includes(cancellation.id)) return false
+ }
+ if (Math.abs(invoiceTotals(inv).net - amount) > 0.01) return false
+ return eventNamesMatch(inv.lines[0]?.eventName, cancellation.event_name)
+ })
+ .sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
+
+ // Heuristic only when unique (avoids cancelling the wrong same-price ticket).
+ const open = candidates.filter((c) => c.status !== 'cancelled')
+ if (open.length === 1) return open[0]
+ if (open.length === 0 && candidates.length === 1) return candidates[0]
+ return undefined
+}
+
+function markInvoiceCancelled(
+ invoice: Invoice,
+ cancellation: ApiPaymentOrRefund,
+): Invoice {
+ const tag = funzoneCancellationNoteTag(cancellation.id)
+ const noteLines = invoice.note
+ .split('\n')
+ .filter((line) => !line.startsWith(FUNZONE_CANCEL_NOTE_PREFIX) && !line.startsWith('لغو شده'))
+ noteLines.push(tag)
+ noteLines.push(`لغو شده — ${cancellation.customer_name}`)
+ noteLines.push(`تاریخ لغو: ${isoDateFromApi(cancellation.created_at)}`)
+
+ return {
+ ...invoice,
+ status: 'cancelled',
+ documentType: 'invoice',
+ note: noteLines.join('\n'),
+ lines: invoice.lines.map((l) => ({ ...l })),
}
- return false
}
export function invoiceUnchanged(existing: Invoice, built: Invoice): boolean {
if (existing.partyId !== built.partyId || existing.date !== built.date) return false
if (existing.documentType !== built.documentType) return false
if (existing.status !== built.status) return false
+ if (existing.note !== built.note) return false
if (Math.abs(invoiceTotals(existing).net - invoiceTotals(built).net) > 0.01) return false
const existingEvent = existing.lines[0]?.eventName ?? ''
const builtEvent = built.lines[0]?.eventName ?? ''
return existingEvent === builtEvent
}
-/** Event ticket payment → فاکتور فروش; booking cancellation → فاکتور برگشتی. */
+/** Event ticket payment → فاکتور فروش; booking cancellation → mark original as لغو شده. */
export function buildSalesInvoiceFromCustomerPayment(
item: ApiPaymentOrRefund,
partyId: string,
existingInvoices: Invoice[],
number: number,
existing?: Invoice,
+ allPayments: ApiPaymentOrRefund[] = [],
): Invoice | null {
const amount = Math.abs(item.amount)
if (amount <= 0) return null
@@ -80,6 +149,12 @@ export function buildSalesInvoiceFromCustomerPayment(
const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
+ // Keep cancelled sales invoices cancelled; do not recreate as paid.
+ if (prior?.status === 'cancelled') return null
+
+ const split = splitEventPayment(amount)
+ const payLabel =
+ item.payment_method === 'wallet' ? 'پرداخت از کیف پول' : 'پرداخت درگاه'
return {
id: prior?.id ?? '',
@@ -89,7 +164,15 @@ export function buildSalesInvoiceFromCustomerPayment(
partyId,
date: isoDateFromApi(item.created_at),
status: prior?.status === 'draft' ? 'draft' : 'paid',
- note: `${tag}\nفاکتور خودکار از فانزون — ${item.customer_name}`,
+ note: [
+ tag,
+ `فاکتور فروش بلیت از فانزون — ${item.customer_name}`,
+ `روش پرداخت: ${payLabel}`,
+ `مبلغ کل: ${split.gross}`,
+ `مالیات (۱۰٪): ${split.tax}`,
+ `سود پلتفرم (۱۴٪): ${split.platformProfit}`,
+ `سهم تامینکننده (مالک): ${split.ownerNet}`,
+ ].join('\n'),
lines: [
{
id: prior?.lines[0]?.id ?? createId('il-'),
@@ -104,81 +187,74 @@ export function buildSalesInvoiceFromCustomerPayment(
}
}
+ // Wallet withdrawals / wallet refunds are handled via برداشتها → treasury payment.
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
- if (isWallet) {
- if (cancellationAlreadyBooked(existingInvoices, item.customer_name, amount)) return null
- return null
- }
+ if (isWallet) return null
- const tag = funzoneCancellationNoteTag(item.id)
- const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
+ if (!isEventCustomerPayment(item.event_name)) return null
- return {
- id: prior?.id ?? '',
- kind: 'sale',
- documentType: 'return',
- number: prior?.number ?? number,
+ const original = findOriginalTicketInvoiceForCancellation(
+ item,
+ allPayments,
+ existingInvoices,
partyId,
- date: isoDateFromApi(item.created_at),
- status: prior?.status ?? 'confirmed',
- note: `${tag}\nبرگشت خودکار از فانزون — ${item.customer_name}`,
- lines: [
- {
- id: prior?.lines[0]?.id ?? createId('il-'),
- productId: '',
- quantity: 1,
- unitPrice: amount,
- discount: 0,
- taxRate: 0,
- eventName: eventNameWithCategory(item.event_name, item.category_name),
- },
- ],
- relatedInvoiceId: prior?.relatedInvoiceId,
- }
+ )
+ if (!original) return null
+
+ const cancelled = markInvoiceCancelled(original, item)
+ if (existing && invoiceUnchanged(existing, cancelled)) return null
+ return cancelled
}
export interface FunZoneSalesSyncResult {
toUpsert: Invoice[]
+ toRemoveIds: string[]
+ toRemoveVoucherIds: string[]
skipped: number
unmatchedCustomers: number
paymentCount: number
cancellationCount: number
+ cancelledInvoices: number
}
export function buildFunZoneSalesSync(
- data: Pick,
+ data: Pick,
customerPayments: ApiPaymentOrRefund[],
): FunZoneSalesSyncResult {
const toUpsert: Invoice[] = []
+ const toRemoveIds: string[] = []
+ const toRemoveVoucherIds: string[] = []
let skipped = 0
let unmatchedCustomers = 0
let paymentCount = 0
let cancellationCount = 0
+ let cancelledInvoices = 0
let invoiceNumber = nextNumber(byDocumentType(data.invoices, 'invoice'))
- let returnNumber = nextNumber(byDocumentType(data.invoices, 'return'))
+ const working = [...data.invoices]
- for (const item of customerPayments) {
+ const payments = customerPayments.filter((i) => i.type === 'payment')
+ const cancellations = customerPayments.filter((i) => i.type === 'cancellation')
+
+ // Pass 1: ticket sales → فاکتور فروش
+ for (const item of payments) {
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
if (!party?.id) {
unmatchedCustomers += 1
continue
}
- if (item.type === 'payment' && isEventCustomerPayment(item.event_name)) paymentCount += 1
- if (item.type === 'cancellation' && isEventCustomerPayment(item.event_name)) cancellationCount += 1
+ if (isEventCustomerPayment(item.event_name)) paymentCount += 1
- const tag =
- item.type === 'payment'
- ? funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
- : funzoneCancellationNoteTag(item.id)
- const existing = findInvoiceByNoteTag(data.invoices, tag)
+ const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
+ const existing = findInvoiceByNoteTag(working, tag)
const built = buildSalesInvoiceFromCustomerPayment(
item,
party.id,
- data.invoices,
- item.type === 'payment' ? invoiceNumber : returnNumber,
+ working,
+ invoiceNumber,
existing,
+ customerPayments,
)
if (!built) continue
@@ -187,14 +263,77 @@ export function buildFunZoneSalesSync(
continue
}
- toUpsert.push(built)
- if (!existing) {
- if (built.documentType === 'invoice') invoiceNumber += 1
- else returnNumber += 1
- }
+ const withId = { ...built, id: built.id || createId('inv-') }
+ const upsertIdx = toUpsert.findIndex((i) => i.id === withId.id)
+ if (upsertIdx >= 0) toUpsert[upsertIdx] = withId
+ else toUpsert.push(withId)
+
+ const idx = working.findIndex((i) => i.id === withId.id)
+ if (idx >= 0) working[idx] = withId
+ else working.push(withId)
+
+ if (!existing) invoiceNumber += 1
}
- return { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount }
+ // Pass 2: cancellations → mark original فاکتور فروش as لغو شده (no فاکتور برگشتی)
+ for (const item of cancellations) {
+ const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
+ if (!party?.id) {
+ unmatchedCustomers += 1
+ continue
+ }
+
+ if (isEventCustomerPayment(item.event_name)) cancellationCount += 1
+
+ const built = buildSalesInvoiceFromCustomerPayment(
+ item,
+ party.id,
+ working,
+ 0,
+ undefined,
+ customerPayments,
+ )
+ if (!built) continue
+
+ const existing = working.find((i) => i.id === built.id)
+ if (existing && invoiceUnchanged(existing, built)) {
+ skipped += 1
+ continue
+ }
+
+ const upsertIdx = toUpsert.findIndex((i) => i.id === built.id)
+ if (upsertIdx >= 0) toUpsert[upsertIdx] = built
+ else toUpsert.push(built)
+
+ cancelledInvoices += 1
+ const idx = working.findIndex((i) => i.id === built.id)
+ if (idx >= 0) working[idx] = built
+ }
+
+ // Remove legacy FunZone cancel return invoices (and their vouchers).
+ for (const inv of data.invoices) {
+ const isLegacyCancelReturn =
+ inv.kind === 'sale' &&
+ (inv.documentType ?? 'invoice') === 'return' &&
+ inv.note.includes(FUNZONE_CANCEL_NOTE_PREFIX) &&
+ !isWithdrawalReturn(inv)
+
+ if (!isLegacyCancelReturn) continue
+ toRemoveIds.push(inv.id)
+ const voucher = findVoucherForInvoice(data.vouchers, inv)
+ if (voucher) toRemoveVoucherIds.push(voucher.id)
+ }
+
+ return {
+ toUpsert,
+ toRemoveIds,
+ toRemoveVoucherIds,
+ skipped,
+ unmatchedCustomers,
+ paymentCount,
+ cancellationCount,
+ cancelledInvoices,
+ }
}
export function assignInvoiceIds(items: Invoice[]): Invoice[] {
diff --git a/src/pages/sales/funzoneSalesSync.wallet.test.ts b/src/pages/sales/funzoneSalesSync.wallet.test.ts
new file mode 100644
index 0000000..33ce757
--- /dev/null
+++ b/src/pages/sales/funzoneSalesSync.wallet.test.ts
@@ -0,0 +1,94 @@
+/**
+ * Run: npx tsx src/pages/sales/funzoneSalesSync.wallet.test.ts
+ */
+import assert from 'node:assert/strict'
+import type { ApiPaymentOrRefund } from '../../api/types'
+import type { Party } from '../../types'
+import { buildSalesInvoiceFromCustomerPayment } from './funzoneSalesSync'
+import { buildTreasuryFromCustomerPayment } from '../treasury/funzoneTreasurySync'
+import { buildSalesVoucher } from './salesUtils'
+
+const party: Party = {
+ id: 'c1',
+ kind: 'customer',
+ name: 'Ali',
+ phone: '',
+ economicCode: '',
+ address: '',
+ openingBalance: 0,
+ externalId: 'cust-1',
+ externalSource: 'funzone_customer',
+}
+
+const walletTicket: ApiPaymentOrRefund = {
+ id: 'bp-1',
+ type: 'payment',
+ customer_id: 'cust-1',
+ customer_name: 'Ali',
+ trace_no: '',
+ ref_num: '',
+ amount: 100_000,
+ event_name: 'Football Night',
+ category_name: 'Sport',
+ payment_method: 'wallet',
+ event_date: '2026-01-01',
+ event_time: '18:00',
+ created_at: '2026-01-01T10:00:00Z',
+ reservation_id: 'res-1',
+ refund_status: null,
+}
+
+// Must create فاکتور فروش
+const invoice = buildSalesInvoiceFromCustomerPayment(walletTicket, party.id, [], 1)
+assert.ok(invoice)
+assert.equal(invoice!.documentType, 'invoice')
+assert.equal(invoice!.kind, 'sale')
+assert.ok(invoice!.note.includes('funzone:pay:wallet:bp-1'))
+assert.ok(invoice!.note.includes('پرداخت از کیف پول'))
+assert.ok(invoice!.note.includes('سود پلتفرم (۱۴٪)'))
+assert.equal(invoice!.lines[0].unitPrice, 100_000)
+
+// Must NOT create دریافت in treasury
+assert.equal(buildTreasuryFromCustomerPayment(walletTicket, party.id, 1), null)
+
+// Voucher from invoice debits customer wallet liability
+const accounts = [
+ { id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a1004', code: '1004', name: 'AR', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2002', code: '2002', name: 'Tax', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2005', code: '2005', name: 'Owner', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2006', code: '2006', name: 'Wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a4001', code: '4001', name: 'Income', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
+]
+const voucher = buildSalesVoucher(
+ { ...invoice!, id: 'inv-1', status: 'paid' },
+ {
+ accounts,
+ vouchers: [],
+ parties: [party],
+ products: [],
+ settings: {
+ name: 'T',
+ economicCode: '',
+ fiscalYear: '1404',
+ baseCurrency: 'تومان',
+ taxRate: 0,
+ address: '',
+ phone: '',
+ voucherAccounts: {
+ bankAccountId: 'a1002',
+ ownerPayableAccountId: 'a2005',
+ customerPayableAccountId: 'a2006',
+ ownerShareAccountId: 'a4001',
+ salesIncomeAccountId: 'a4001',
+ },
+ },
+ },
+)
+assert.ok(voucher)
+assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 100_000))
+assert.ok(voucher!.lines.some((l) => l.accountId === 'a4001' && l.credit === 14_000))
+assert.ok(voucher!.lines.some((l) => l.accountId === 'a2002' && l.credit === 10_000))
+assert.ok(voucher!.lines.some((l) => l.accountId === 'a2005' && l.credit === 76_000))
+
+console.log('wallet ticket → فاکتور فروش checks passed')
diff --git a/src/pages/sales/funzoneVoucherLink.ts b/src/pages/sales/funzoneVoucherLink.ts
index bb44ed8..66c3396 100644
--- a/src/pages/sales/funzoneVoucherLink.ts
+++ b/src/pages/sales/funzoneVoucherLink.ts
@@ -28,31 +28,40 @@ export function treasurySourceForFunzoneInvoice(invoice: Invoice): string | null
: treasurySourceCustomerPayment(ref.id)
}
-/** Event ticket sales are booked once via treasury (fee split); skip duplicate invoice vouchers. */
-export function shouldSkipInvoiceVoucherForFunzoneTicket(
- invoice: Invoice,
- data: Pick,
-): boolean {
- const source = treasurySourceForFunzoneInvoice(invoice)
- if (!source) return false
- return data.treasury.some((txn) => txn.source === source)
+function treasurySourcesForFunzonePaymentId(paymentId: string): string[] {
+ return [treasurySourceCustomerPayment(paymentId), treasurySourceWalletPayment(paymentId)]
}
-/** Removes invoice vouchers when a treasury voucher already exists for the same FunZone payment. */
+/**
+ * Ticket fee split is booked on the فاکتور فروش voucher — never skip it.
+ * (Kept for call-site compatibility.)
+ */
+export function shouldSkipInvoiceVoucherForFunzoneTicket(
+ _invoice: Invoice,
+ _data: Pick,
+): boolean {
+ return false
+}
+
+/**
+ * Removes leftover treasury vouchers for ticket payments once the sales invoice
+ * voucher exists (invoice is the source of truth for tax / 14% / owner).
+ */
export function duplicateFunzoneInvoiceVoucherIds(data: AppData): string[] {
const toRemove: string[] = []
for (const invoice of data.invoices) {
if (!isFunZoneTicketSalesInvoice(invoice)) continue
- const source = treasurySourceForFunzoneInvoice(invoice)
- if (!source) continue
- const treasuryTxn = data.treasury.find((txn) => txn.source === source)
+ const ref = funzonePaymentRefFromInvoice(invoice)
+ if (!ref) continue
+ const sources = treasurySourcesForFunzonePaymentId(ref.id)
+ const treasuryTxn = data.treasury.find((txn) => sources.includes(txn.source ?? ''))
if (!treasuryTxn) continue
const treasuryVoucher = data.vouchers.find(
(v) => v.source === voucherSourceForTreasury(treasuryTxn.id),
)
const invoiceVoucher = findVoucherForInvoice(data.vouchers, invoice)
if (treasuryVoucher && invoiceVoucher && treasuryVoucher.id !== invoiceVoucher.id) {
- toRemove.push(invoiceVoucher.id)
+ toRemove.push(treasuryVoucher.id)
}
}
return toRemove
diff --git a/src/pages/sales/salesNavigation.ts b/src/pages/sales/salesNavigation.ts
index 645b1c9..9391107 100644
--- a/src/pages/sales/salesNavigation.ts
+++ b/src/pages/sales/salesNavigation.ts
@@ -10,7 +10,7 @@ export const salesNavItems: SalesNavItem[] = [
{ to: '/sales', label: 'فرایند فروش', icon: '🔀', end: true },
{ to: '/sales/proforma', label: 'پیشفاکتور جدید', icon: '📋' },
{ to: '/sales/invoice', label: 'فاکتور فروش جدید', icon: '🧾' },
- { to: '/sales/return', label: 'فاکتور برگشتی جدید', icon: '↩️' },
+ { to: '/sales/return', label: 'برداشتها و لغو شده', icon: '🚫' },
{ to: '/sales/adjustments', label: 'اعلامیه بدهکار/بستانکار', icon: '⚖️' },
{ to: '/sales/sales-types', label: 'نوع فروش جدید', icon: '🏷️' },
{ to: '/sales/customers', label: 'مشتریان', icon: '👤' },
diff --git a/src/pages/sales/salesUtils.test.ts b/src/pages/sales/salesUtils.test.ts
index 2b4ec5a..426ed97 100644
--- a/src/pages/sales/salesUtils.test.ts
+++ b/src/pages/sales/salesUtils.test.ts
@@ -9,6 +9,7 @@ import {
buildInvoiceVoucher,
buildPurchaseVoucher,
buildSalesVoucher,
+ buildTreasuryVoucher,
invoicesToSettle,
isWithdrawalReturn,
partyAccountBalance,
@@ -58,6 +59,9 @@ const accounts = [
{ id: 'a1004', code: '1004', name: 'Receivable', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
{ id: 'a1005', code: '1005', name: 'Inventory', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
{ id: 'a2001', code: '2001', name: 'Payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2002', code: '2002', name: 'Tax', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2005', code: '2005', name: 'Owner payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2006', code: '2006', name: 'Customer wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
{ id: 'a4001', code: '4001', name: 'Sales', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
]
@@ -82,8 +86,8 @@ const baseData = (): AppData => ({
phone: '',
voucherAccounts: {
bankAccountId: 'a1002',
- ownerPayableAccountId: 'a2001',
- customerPayableAccountId: 'a2001',
+ ownerPayableAccountId: 'a2005',
+ customerPayableAccountId: 'a2006',
ownerShareAccountId: 'a4001',
salesIncomeAccountId: 'a4001',
},
@@ -190,6 +194,50 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
assert.ok(buildInvoiceVoucher(purchaseInvoice('confirmed'), data))
}
+// FunZone ticket sale voucher: tax 10% + platform 14% + owner 76%
+{
+ const data = baseData()
+ const ticketInvoice: Invoice = {
+ id: 'inv-fz',
+ kind: 'sale',
+ documentType: 'invoice',
+ number: 9,
+ partyId: 'c1',
+ date: '2026-01-01',
+ status: 'paid',
+ note: 'funzone:pay:pay-1\nفاکتور فروش بلیت',
+ lines: [{ id: 'l-fz', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
+ }
+ const voucher = buildSalesVoucher(ticketInvoice, data)
+ assert.ok(voucher)
+ assert.equal(voucher!.lines.length, 4)
+ assert.equal(voucher!.lines.reduce((s, l) => s + l.debit, 0), 100_000)
+ assert.equal(voucher!.lines.reduce((s, l) => s + l.credit, 0), 100_000)
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a2002' && l.credit === 10_000))
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a4001' && l.credit === 14_000))
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a2005' && l.credit === 76_000))
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.debit === 100_000))
+}
+
+// FunZone ticket paid from wallet → debit customer wallet liability
+{
+ const data = baseData()
+ const ticketInvoice: Invoice = {
+ id: 'inv-fz-w',
+ kind: 'sale',
+ documentType: 'invoice',
+ number: 10,
+ partyId: 'c1',
+ date: '2026-01-01',
+ status: 'paid',
+ note: 'funzone:pay:wallet:pay-2\nفاکتور فروش بلیت',
+ lines: [{ id: 'l-fz-w', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
+ }
+ const voucher = buildSalesVoucher(ticketInvoice, data)
+ assert.ok(voucher)
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 100_000))
+}
+
// Withdrawal return: no stock impact
{
const withdrawalReturn: Invoice = {
@@ -237,7 +285,72 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
assert.equal(settled[0].id, 'inv-a')
}
-// Return + receipt: FIFO marks both invoices paid when fully covered
+// Cancelled FunZone gateway ticket: bank stays, liability to customer wallet (no tax/14%)
+{
+ const data = baseData()
+ const cancelled: Invoice = {
+ id: 'inv-fz-cancel',
+ kind: 'sale',
+ documentType: 'invoice',
+ number: 1,
+ partyId: 'c1',
+ date: '2026-01-01',
+ status: 'cancelled',
+ note: 'funzone:pay:pay-9\nfunzone:cancel:ref-9\nلغو شده\nتاریخ لغو: 2026-01-02',
+ lines: [{ id: 'l-fz-c', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
+ }
+ const voucher = buildSalesVoucher(cancelled, data)
+ assert.ok(voucher)
+ assert.equal(voucher!.date, '2026-01-02')
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.debit === 100_000))
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.credit === 100_000))
+ assert.equal(voucher!.lines.length, 2)
+ assert.equal(partyReceivable('c1', { ...data, invoices: [cancelled] }), 0)
+ assert.equal(stockDeltas(cancelled).size, 0)
+}
+
+// Cancelled FunZone wallet ticket: remove voucher (sale was wallet debit)
+{
+ const data = baseData()
+ const cancelled: Invoice = {
+ id: 'inv-fz-w-cancel',
+ kind: 'sale',
+ documentType: 'invoice',
+ number: 2,
+ partyId: 'c1',
+ date: '2026-01-01',
+ status: 'cancelled',
+ note: 'funzone:pay:wallet:pay-8\nfunzone:cancel:ref-8\nلغو شده',
+ lines: [{ id: 'l-fz-wc', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
+ }
+ assert.equal(buildSalesVoucher(cancelled, data), null)
+}
+
+// Withdrawal treasury payment without eventName still hits wallet payable (source-based)
+{
+ const data = baseData()
+ const voucher = buildTreasuryVoucher(
+ {
+ id: 'tr-wd',
+ number: 1,
+ kind: 'payment',
+ method: 'bank',
+ date: '2026-01-03',
+ partyId: 'c1',
+ amount: 50_000,
+ reference: '',
+ description: 'برداشت کیف پول مشتری',
+ source: 'withdrawal:wd-99',
+ },
+ data,
+ )
+ assert.ok(voucher)
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 50_000))
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.credit === 50_000))
+}
+
+
+// Cancelled invoice is excluded from sales receivable; legacy return still subtracts
{
const data = baseData()
data.invoices = [
@@ -271,6 +384,7 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
assert.equal(settled.length, 2)
}
+
// Treasury delete reverts paid status
{
const data = baseData()
diff --git a/src/pages/sales/salesUtils.ts b/src/pages/sales/salesUtils.ts
index e381754..de4aafa 100644
--- a/src/pages/sales/salesUtils.ts
+++ b/src/pages/sales/salesUtils.ts
@@ -14,6 +14,7 @@ import type {
import { invoiceTotals } from '../../utils/accounting'
import { eventNameFromTreasuryDescription } from '../../utils/eventName'
import { createId, nextNumber } from '../../utils/id'
+import { isWalletCustomerPayment, splitEventPayment } from '../../utils/funzoneFees'
/** Resolves a chart account by code, with optional settings fallback. */
export function accountByCode(
@@ -27,13 +28,24 @@ export function accountByCode(
export const documentTypeLabels: Record = {
proforma: 'پیشفاکتور',
invoice: 'فاکتور فروش',
- return: 'فاکتور برگشتی',
+ /** Legacy only — app no longer creates return documents. */
+ return: 'فاکتور برگشتی (قدیمی)',
}
export const statusLabels: Record = {
draft: 'پیشنویس',
confirmed: 'قطعی',
paid: 'تسویهشده',
+ cancelled: 'لغو شده',
+}
+
+/** Active sales invoice (not draft/cancelled/proforma/legacy return). */
+export function isActiveSalesInvoice(invoice: Invoice): boolean {
+ if (invoice.kind !== 'sale' || invoice.status === 'draft' || invoice.status === 'cancelled') {
+ return false
+ }
+ const docType = invoice.documentType ?? 'invoice'
+ return docType === 'invoice'
}
/** Sales documents only (kind=sale). */
@@ -63,10 +75,13 @@ export function partyReceivable(
let balance = party?.openingBalance ?? 0
for (const inv of salesDocuments(data.invoices)) {
- if (inv.partyId !== partyId || inv.status === 'draft') continue
+ if (inv.partyId !== partyId || inv.status === 'draft' || inv.status === 'cancelled') continue
const net = invoiceTotals(inv).net
const docType = inv.documentType ?? 'invoice'
if (docType === 'proforma') continue
+ // FunZone tickets are prepaid (bank/wallet voucher), not customer AR.
+ if (inv.note.includes('funzone:pay:')) continue
+ // Legacy return docs still reverse receivable until sync removes them.
balance += docType === 'return' ? -net : net
}
@@ -142,7 +157,7 @@ export function invoiceRegarding(invoice: Invoice): string | undefined {
/** Whether stock/inventory should be affected for this document. */
export function affectsStock(invoice: Invoice): boolean {
- if (invoice.status === 'draft') return false
+ if (invoice.status === 'draft' || invoice.status === 'cancelled') return false
if (isWithdrawalReturn(invoice)) return false
if (invoice.kind === 'purchase') return true
const docType = invoice.documentType ?? 'invoice'
@@ -221,6 +236,7 @@ export function buildSalesVoucher(
credit,
})
+ // Legacy withdrawal return invoices (no longer created by the app).
if (isWithdrawalReturn(invoice)) {
const { customerPayableAccountId, bankAccountId: bankSettingId } = data.settings.voucherAccounts
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
@@ -235,7 +251,7 @@ export function buildSalesVoucher(
description: `برداشت کیف پول - ${label}`,
regarding: regarding ?? 'wallet',
status: 'posted',
- source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
+ source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice),
lines: [
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`),
makeLine(bankId, 0, net, `پرداخت از بانک - ${label}`),
@@ -243,8 +259,89 @@ export function buildSalesVoucher(
}
}
+ const isFunZoneTicket =
+ docType === 'invoice' && invoice.note.includes('funzone:pay:')
+
+ // Cancelled FunZone ticket — void fee split; keep cash correct.
+ if (invoice.status === 'cancelled') {
+ if (!isFunZoneTicket) return null
+
+ const {
+ bankAccountId: bankSettingId,
+ customerPayableAccountId,
+ } = data.settings.voucherAccounts
+ const bankId = accountByCode(data.accounts, '1002', bankSettingId)
+ const customerPayableId = accountByCode(data.accounts, '2006', customerPayableAccountId)
+ if (!customerPayableId) return null
+
+ const fromWallet = /funzone:pay:wallet:/.test(invoice.note)
+ const cancelLabel = `لغو فاکتور فروش #${invoice.number} - ${party?.name ?? ''}`
+ const cancelDate =
+ invoice.note
+ .split('\n')
+ .find((l) => l.startsWith('تاریخ لغو:'))
+ ?.replace('تاریخ لغو:', '')
+ .trim() || invoice.date
+
+ // Wallet sale was Dr wallet / Cr fees — removing the voucher restores both (net zero).
+ if (fromWallet) return null
+
+ // Gateway sale was Dr bank / Cr fees — after cancel, cash stays and is owed to customer wallet.
+ if (!bankId) return null
+ return {
+ id: createId('v-'),
+ number: nextNumber(data.vouchers),
+ date: cancelDate,
+ description: invoice.note || cancelLabel,
+ regarding,
+ status: 'posted',
+ source: voucherSourceForInvoice(invoice),
+ lines: [
+ makeLine(bankId, net, 0, `مانده بانکی پس از لغو بلیت - ${cancelLabel}`),
+ makeLine(customerPayableId, 0, net, `بدهی کیف پول مشتری پس از لغو - ${cancelLabel}`),
+ ],
+ }
+ }
+
+ // FunZone ticket sale: cash/wallet sale with tax 10% + platform 14% + owner 76%.
+ if (isFunZoneTicket) {
+ const {
+ bankAccountId: bankSettingId,
+ salesIncomeAccountId: incomeSettingId,
+ ownerPayableAccountId,
+ customerPayableAccountId,
+ } = data.settings.voucherAccounts
+ const bankId = accountByCode(data.accounts, '1002', bankSettingId)
+ const taxAccountId = accountByCode(data.accounts, '2002')
+ const ownerPayableId = accountByCode(data.accounts, '2005', ownerPayableAccountId)
+ const platformIncomeId = accountByCode(data.accounts, '4001', incomeSettingId || salesIncomeAccountId)
+ const customerPayableId = accountByCode(data.accounts, '2006', customerPayableAccountId)
+ const fromWallet = /funzone:pay:wallet:/.test(invoice.note)
+ const debitAccountId = fromWallet ? customerPayableId : bankId
+ if (!debitAccountId || !taxAccountId || !ownerPayableId || !platformIncomeId) return null
+
+ const split = splitEventPayment(net)
+ const debitLabel = fromWallet ? 'کاهش کیف پول مشتری' : 'واریز بابت فروش بلیت'
+ return {
+ id: createId('v-'),
+ number: nextNumber(data.vouchers),
+ date: invoice.date,
+ description: invoice.note || label,
+ regarding,
+ status: 'posted',
+ source: voucherSourceForInvoice(invoice),
+ lines: [
+ makeLine(debitAccountId, split.gross, 0, `${debitLabel} - ${label}`),
+ makeLine(ownerPayableId, 0, split.ownerNet, `سهم تامینکننده (مالک) - ${label}`),
+ makeLine(platformIncomeId, 0, split.platformProfit, `سود پلتفرم (۱۴٪) - ${label}`),
+ makeLine(taxAccountId, 0, split.tax, `مالیات (۱۰٪) - ${label}`),
+ ],
+ }
+ }
+
if (!salesIncomeAccountId || !receivableAccountId) return null
+ // Legacy return path (app no longer creates these).
const isReturn = docType === 'return'
const lines: VoucherLine[] = isReturn
? [
@@ -263,7 +360,7 @@ export function buildSalesVoucher(
description: label,
regarding,
status: 'posted',
- source: voucherSourceForInvoice(invoice.id),
+ source: voucherSourceForInvoice(invoice),
lines,
}
}
@@ -299,7 +396,7 @@ export function buildPurchaseVoucher(
date: invoice.date,
description: label,
status: 'posted',
- source: voucherSourceForInvoice(invoice.id),
+ source: voucherSourceForInvoice(invoice),
lines: [
makeLine(inventoryAccountId, net, 0, `افزایش موجودی - ${label}`),
makeLine(payableAccountId, 0, net, `بدهی به تأمینکننده - ${label}`),
@@ -379,6 +476,7 @@ export function reconcileInvoicePaymentStatus(
const updates: Invoice[] = []
for (const inv of data.invoices) {
if (inv.kind !== kind || inv.partyId !== partyId || inv.status === 'draft') continue
+ if (inv.status === 'cancelled') continue
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
const shouldPaid = shouldBePaidIds.has(inv.id)
@@ -395,8 +493,12 @@ export function findVoucherForInvoice(
vouchers: Voucher[],
invoice: Invoice,
): Voucher | undefined {
- const byInvoice = vouchers.find((v) => v.source === voucherSourceForInvoice(invoice.id))
- if (byInvoice) return byInvoice
+ const preferred = voucherSourceForInvoice(invoice)
+ const byPreferred = vouchers.find((v) => v.source === preferred)
+ if (byPreferred) return byPreferred
+
+ const byInvoiceId = vouchers.find((v) => v.source === `invoice:${invoice.id}`)
+ if (byInvoiceId) return byInvoiceId
const withdrawalId = withdrawalIdFromNote(invoice.note)
if (withdrawalId) {
@@ -489,6 +591,51 @@ export function buildTreasuryVoucher(
const tax = txn.taxAmount ?? 0
const profit = txn.platformProfitAmount ?? 0
const ownerNet = txn.ownerNetAmount ?? 0
+
+ const source = txn.source ?? ''
+ const isWalletBySource =
+ source.startsWith('withdrawal:') ||
+ source.startsWith('funzone:wallet-pay:') ||
+ isWalletCustomerPayment(txn.eventName) ||
+ /کیف\s*پول|wallet/i.test(txn.description || '')
+
+ // Wallet top-up: bank in, liability to customer (no tax / 14%).
+ const isWalletTopUp =
+ txn.kind === 'receipt' && isWalletBySource && tax <= 0 && profit <= 0
+ if (isWalletTopUp && customerPayableId) {
+ return {
+ id: createId('v-'),
+ number: nextNumber(data.vouchers),
+ date: txn.date,
+ description: txn.description || label,
+ regarding: 'wallet',
+ status: 'posted',
+ source: voucherSourceForTreasury(txn),
+ lines: [
+ makeLine(bankId, amount, 0, `واریز شارژ کیف پول - ${label}`),
+ makeLine(customerPayableId, 0, amount, `بدهی به مشتری (کیف پول) - ${label}`),
+ ],
+ }
+ }
+
+ // Wallet withdraw: pay customer back (clear liability).
+ const isWalletWithdraw = txn.kind === 'payment' && isWalletBySource && customerPayableId
+ if (isWalletWithdraw) {
+ return {
+ id: createId('v-'),
+ number: nextNumber(data.vouchers),
+ date: txn.date,
+ description: txn.description || label,
+ regarding: 'wallet',
+ status: 'posted',
+ source: voucherSourceForTreasury(txn),
+ lines: [
+ makeLine(customerPayableId, amount, 0, `برداشت کیف پول مشتری - ${label}`),
+ makeLine(bankId, 0, amount, `پرداخت از بانک - ${label}`),
+ ],
+ }
+ }
+
const hasEventSplit =
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
@@ -505,7 +652,7 @@ export function buildTreasuryVoucher(
description: txn.description || label,
regarding,
status: 'posted',
- source: voucherSourceForTreasury(txn.id),
+ source: voucherSourceForTreasury(txn),
lines: [
makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`),
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
@@ -535,45 +682,61 @@ export function buildTreasuryVoucher(
description: txn.description || label,
regarding: txn.eventName,
status: 'posted',
- source: voucherSourceForTreasury(txn.id),
+ source: voucherSourceForTreasury(txn),
lines,
}
}
-export function voucherSourceForInvoice(invoiceId: string): string {
- return `invoice:${invoiceId}`
+export function voucherSourceForInvoice(invoiceOrId: Invoice | string): string {
+ if (typeof invoiceOrId !== 'string') {
+ const wallet = invoiceOrId.note.match(/funzone:pay:wallet:([^\s\n]+)/)
+ if (wallet?.[1]) return `funzone:invoice:wallet:${wallet[1]}`
+ const pay = invoiceOrId.note.match(/funzone:pay:([^\s\n]+)/)
+ if (pay?.[1] && !pay[1].startsWith('wallet:')) return `funzone:invoice:${pay[1]}`
+ return `invoice:${invoiceOrId.id}`
+ }
+ return `invoice:${invoiceOrId}`
}
export function voucherSourceForAdjustment(adjustmentId: string): string {
return `adjustment:${adjustmentId}`
}
-export function voucherSourceForTreasury(txnId: string): string {
- return `treasury:${txnId}`
+/** Prefer stable FunZone/withdrawal source when present so vouchers survive server UUID remap. */
+export function voucherSourceForTreasury(txnOrId: TreasuryTxn | string): string {
+ if (typeof txnOrId !== 'string') {
+ if (txnOrId.source?.trim()) return txnOrId.source.trim()
+ return `treasury:${txnOrId.id}`
+ }
+ return `treasury:${txnOrId}`
}
/** Summary stats for the sales process dashboard. */
export function salesSummary(data: AppData) {
const proformas = byDocumentType(data.invoices, 'proforma')
const invoices = byDocumentType(data.invoices, 'invoice')
- const returns = byDocumentType(data.invoices, 'return')
+ const activeInvoices = invoices.filter((i) => i.status !== 'cancelled' && i.status !== 'draft')
+ const cancelled = invoices.filter((i) => i.status === 'cancelled')
const sumNet = (list: Invoice[]) =>
- list.filter((i) => i.status !== 'draft').reduce((s, i) => s + invoiceTotals(i).net, 0)
+ list.filter((i) => i.status !== 'draft' && i.status !== 'cancelled').reduce((s, i) => s + invoiceTotals(i).net, 0)
const unpaid = invoices.filter((i) => i.status === 'confirmed')
const customersList = customers(data.parties)
return {
proformaCount: proformas.length,
- invoiceCount: invoices.length,
- returnCount: returns.length,
+ invoiceCount: activeInvoices.length,
+ cancelledCount: cancelled.length,
+ /** @deprecated kept for older UI — always 0 conceptually; use cancelledCount */
+ returnCount: cancelled.length,
adjustmentCount: data.partyAdjustments.length,
salesTypeCount: data.salesTypes.length,
customerCount: customersList.length,
totalSales: sumNet(invoices),
- totalReturns: sumNet(returns),
- netSales: sumNet(invoices) - sumNet(returns),
+ totalCancelled: cancelled.reduce((s, i) => s + invoiceTotals(i).net, 0),
+ totalReturns: cancelled.reduce((s, i) => s + invoiceTotals(i).net, 0),
+ netSales: sumNet(invoices),
unpaidCount: unpaid.length,
unpaidAmount: sumNet(unpaid),
openProformas: proformas.filter((p) => p.status === 'draft').length,
diff --git a/src/pages/sales/usePendingCustomerWithdrawals.ts b/src/pages/sales/usePendingCustomerWithdrawals.ts
index 79f3a4a..cf8f91f 100644
--- a/src/pages/sales/usePendingCustomerWithdrawals.ts
+++ b/src/pages/sales/usePendingCustomerWithdrawals.ts
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
-import { apiGet } from '../../api/client'
+import { apiGet, apiPatch } from '../../api/client'
import { ENDPOINTS } from '../../api/config'
-import type { ApiWithdrawal } from '../../api/types'
+import type { ApiWithdrawal, WalletTxnStatus } from '../../api/types'
-/** Pending customer wallet withdrawals from admin panel accounting API. */
+/** All owner/customer withdrawals from the FunZone admin withdrawals API. */
export function usePendingCustomerWithdrawals(enabled = true) {
const [withdrawals, setWithdrawals] = useState([])
const [loading, setLoading] = useState(false)
+ const [updatingId, setUpdatingId] = useState(null)
const [error, setError] = useState(null)
const load = useCallback(async () => {
@@ -14,10 +15,10 @@ export function usePendingCustomerWithdrawals(enabled = true) {
setError(null)
try {
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
- const pending = (res.withdrawals ?? []).filter(
- (w) => w.user_type === 'customer' && w.status === 'pending',
+ const list = [...(res.withdrawals ?? [])].sort(
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)
- setWithdrawals(pending)
+ setWithdrawals(list)
} catch (err) {
setError(err instanceof Error ? err.message : 'خطا در بارگذاری برداشتها')
setWithdrawals([])
@@ -30,7 +31,31 @@ export function usePendingCustomerWithdrawals(enabled = true) {
if (enabled) void load()
}, [enabled, load])
- return { withdrawals, loading, error, reload: load }
+ const toggleStatus = useCallback(async (withdrawal: ApiWithdrawal) => {
+ const nextStatus: WalletTxnStatus =
+ withdrawal.status === 'pending' ? 'completed' : 'pending'
+
+ setUpdatingId(withdrawal.id)
+ setError(null)
+ try {
+ await apiPatch(ENDPOINTS.UPDATE_WITHDRAWAL_STATUS(withdrawal.id), {
+ status: nextStatus,
+ user_type: withdrawal.user_type,
+ })
+ setWithdrawals((prev) =>
+ prev.map((w) => (w.id === withdrawal.id ? { ...w, status: nextStatus } : w)),
+ )
+ return nextStatus
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'خطا در بروزرسانی وضعیت برداشت'
+ setError(message)
+ throw err instanceof Error ? err : new Error(message)
+ } finally {
+ setUpdatingId(null)
+ }
+ }, [])
+
+ return { withdrawals, loading, updatingId, error, reload: load, toggleStatus }
}
export const withdrawalNoteTag = (withdrawalId: string): string => `withdrawal:${withdrawalId}`
diff --git a/src/pages/sales/withdrawalTreasury.test.ts b/src/pages/sales/withdrawalTreasury.test.ts
new file mode 100644
index 0000000..0598016
--- /dev/null
+++ b/src/pages/sales/withdrawalTreasury.test.ts
@@ -0,0 +1,122 @@
+/**
+ * Run: npx tsx src/pages/sales/withdrawalTreasury.test.ts
+ */
+import assert from 'node:assert/strict'
+import type { AppData, Party } from '../../types'
+import type { ApiWithdrawal } from '../../api/types'
+import {
+ applyWithdrawalCompletedEffects,
+ treasurySourceForWithdrawal,
+ withdrawalAlreadyBooked,
+} from './withdrawalTreasury'
+import { treasurySourceOwnerTxn } from '../treasury/funzoneTreasurySync'
+
+const customer: Party = {
+ id: 'c1',
+ kind: 'customer',
+ name: 'Customer',
+ phone: '',
+ economicCode: '',
+ address: '',
+ openingBalance: 0,
+ externalId: 'cust-1',
+ externalSource: 'funzone_customer',
+}
+
+const owner: Party = {
+ id: 'o1',
+ kind: 'supplier',
+ name: 'Owner',
+ phone: '',
+ economicCode: '',
+ address: '',
+ openingBalance: 0,
+ externalId: 'own-1',
+ externalSource: 'funzone_owner',
+}
+
+const accounts = [
+ { id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a1004', code: '1004', name: 'Receivable', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2005', code: '2005', name: 'Owner payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2006', code: '2006', name: 'Customer wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a4001', code: '4001', name: 'Sales', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
+]
+
+const baseData = (): AppData => ({
+ accounts,
+ vouchers: [],
+ parties: [customer, owner],
+ products: [],
+ invoices: [],
+ salesTypes: [],
+ partyAdjustments: [],
+ treasury: [],
+ employees: [],
+ payslips: [],
+ settings: {
+ name: 'Test',
+ economicCode: '',
+ fiscalYear: '1404',
+ baseCurrency: 'تومان',
+ taxRate: 0,
+ address: '',
+ phone: '',
+ voucherAccounts: {
+ bankAccountId: 'a1002',
+ ownerPayableAccountId: 'a2005',
+ customerPayableAccountId: 'a2006',
+ ownerShareAccountId: 'a4001',
+ salesIncomeAccountId: 'a4001',
+ },
+ },
+})
+
+{
+ const w: ApiWithdrawal = {
+ id: 'wd-c',
+ user_type: 'customer',
+ user_id: 'cust-1',
+ user_name: 'Ali',
+ username: 'ali',
+ amount: 50_000,
+ status: 'pending',
+ description: '',
+ iban: 'IR00',
+ created_at: '2026-01-03T10:00:00Z',
+ }
+ assert.equal(treasurySourceForWithdrawal(w), 'withdrawal:wd-c')
+ const effects = applyWithdrawalCompletedEffects(w, 'c1', baseData())
+ assert.equal(effects.treasury.kind, 'payment')
+ assert.equal(effects.treasury.source, 'withdrawal:wd-c')
+ assert.equal(effects.treasury.eventName, 'wallet')
+ assert.ok(effects.voucher)
+ assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 50_000))
+ assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a1002' && l.credit === 50_000))
+ assert.equal(withdrawalAlreadyBooked({ invoices: [], treasury: [effects.treasury] }, w), true)
+}
+
+{
+ const w: ApiWithdrawal = {
+ id: 'wd-o',
+ user_type: 'owner',
+ user_id: 'own-1',
+ user_name: 'Owner',
+ username: 'own',
+ amount: 80_000,
+ status: 'pending',
+ description: '',
+ iban: 'IR11',
+ created_at: '2026-01-04T10:00:00Z',
+ }
+ assert.equal(treasurySourceForWithdrawal(w), treasurySourceOwnerTxn('wd-o'))
+ const effects = applyWithdrawalCompletedEffects(w, 'o1', baseData())
+ assert.equal(effects.treasury.kind, 'payment')
+ assert.equal(effects.treasury.source, 'funzone:owner-txn:wd-o')
+ assert.ok(effects.voucher)
+ // Owner پرداخت: Dr owner payable / Cr bank
+ assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a2005' && l.debit === 80_000))
+ assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a1002' && l.credit === 80_000))
+}
+
+console.log('withdrawalTreasury.test.ts: ok')
diff --git a/src/pages/sales/withdrawalTreasury.ts b/src/pages/sales/withdrawalTreasury.ts
new file mode 100644
index 0000000..76515cc
--- /dev/null
+++ b/src/pages/sales/withdrawalTreasury.ts
@@ -0,0 +1,168 @@
+import type { ApiWithdrawal } from '../../api/types'
+import type { AppData, Invoice, TreasuryTxn, Voucher } from '../../types'
+import { createId, nextNumber } from '../../utils/id'
+import { treasurySourceOwnerTxn } from '../treasury/funzoneTreasurySync'
+import {
+ buildTreasuryVoucher,
+ findVoucherForInvoice,
+ isWithdrawalReturn,
+ voucherSourceForTreasury,
+ withdrawalIdFromNote,
+} from './salesUtils'
+import { withdrawalNoteTag } from './usePendingCustomerWithdrawals'
+
+/** Customer IBAN payouts use withdrawal:{id}; owner payouts share FunZone owner-txn source. */
+export function treasurySourceForWithdrawal(withdrawal: ApiWithdrawal | string): string {
+ if (typeof withdrawal === 'string') return `withdrawal:${withdrawal}`
+ if (withdrawal.user_type === 'owner') return treasurySourceOwnerTxn(withdrawal.id)
+ return `withdrawal:${withdrawal.id}`
+}
+
+export function findTreasuryForWithdrawal(
+ treasury: TreasuryTxn[],
+ withdrawal: ApiWithdrawal | string,
+): TreasuryTxn | undefined {
+ const source = treasurySourceForWithdrawal(withdrawal)
+ const byPrimary = treasury.find((t) => t.source === source)
+ if (byPrimary) return byPrimary
+ // Legacy customer rows / reopen fallback
+ if (typeof withdrawal !== 'string') {
+ return treasury.find((t) => t.source === `withdrawal:${withdrawal.id}`)
+ }
+ return undefined
+}
+
+export function withdrawalAlreadyBooked(
+ data: Pick,
+ withdrawal: ApiWithdrawal | string,
+): boolean {
+ if (findTreasuryForWithdrawal(data.treasury, withdrawal)) return true
+ const id = typeof withdrawal === 'string' ? withdrawal : withdrawal.id
+ const tag = withdrawalNoteTag(id)
+ return data.invoices.some(
+ (inv) =>
+ inv.note.includes(tag) &&
+ inv.status !== 'draft' &&
+ (isWithdrawalReturn(inv) || inv.documentType === 'return'),
+ )
+}
+
+/** Build a treasury پرداخت for a completed customer or owner withdrawal. */
+export function buildWithdrawalTreasuryPayment(
+ withdrawal: ApiWithdrawal,
+ partyId: string,
+ data: Pick,
+ existing?: TreasuryTxn,
+): TreasuryTxn {
+ const isOwner = withdrawal.user_type === 'owner'
+ return {
+ id: existing?.id ?? '',
+ number: existing?.number ?? nextNumber(data.treasury),
+ kind: 'payment',
+ method: 'bank',
+ date: withdrawal.created_at.slice(0, 10),
+ partyId,
+ amount: withdrawal.amount,
+ taxAmount: 0,
+ platformProfitAmount: 0,
+ ownerNetAmount: isOwner ? withdrawal.amount : 0,
+ reference: '',
+ description: [
+ isOwner
+ ? `برداشت مالک به بانک — ${withdrawal.user_name}`
+ : `برداشت کیف پول مشتری — ${withdrawal.user_name}`,
+ withdrawal.username ? `@${withdrawal.username}` : '',
+ withdrawal.iban ? `شبا: ${withdrawal.iban}` : '',
+ 'منبع: فانزون',
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ eventName: isOwner ? '' : 'wallet',
+ source: treasurySourceForWithdrawal(withdrawal),
+ }
+}
+
+export function legacyWithdrawalReturnInvoices(
+ invoices: Invoice[],
+ withdrawalId: string,
+): Invoice[] {
+ const tag = withdrawalNoteTag(withdrawalId)
+ return invoices.filter(
+ (inv) => isWithdrawalReturn(inv) && inv.note.includes(tag) && inv.status !== 'draft',
+ )
+}
+
+export function applyWithdrawalCompletedEffects(
+ withdrawal: ApiWithdrawal,
+ partyId: string,
+ data: AppData,
+): {
+ treasury: TreasuryTxn
+ voucher: Voucher | null
+ removeTreasuryId?: string
+ removeVoucherIds: string[]
+ removeInvoiceIds: string[]
+} {
+ const existingTreasury = findTreasuryForWithdrawal(data.treasury, withdrawal)
+ const treasury = {
+ ...buildWithdrawalTreasuryPayment(withdrawal, partyId, data, existingTreasury),
+ id: existingTreasury?.id || createId('tr-'),
+ }
+
+ const existingVoucher =
+ data.vouchers.find((v) => v.source === voucherSourceForTreasury(treasury)) ||
+ data.vouchers.find((v) => v.source === voucherSourceForTreasury(treasury.id))
+ const voucher = buildTreasuryVoucher(treasury, data)
+ const voucherToSave =
+ voucher && existingVoucher ? { ...voucher, id: existingVoucher.id } : voucher
+
+ const removeVoucherIds: string[] = []
+ const removeInvoiceIds: string[] = []
+
+ for (const inv of legacyWithdrawalReturnInvoices(data.invoices, withdrawal.id)) {
+ removeInvoiceIds.push(inv.id)
+ const legacyVoucher = findVoucherForInvoice(data.vouchers, inv)
+ if (legacyVoucher) removeVoucherIds.push(legacyVoucher.id)
+ const bySource = data.vouchers.find((v) => v.source === `withdrawal:${withdrawal.id}`)
+ if (bySource && !removeVoucherIds.includes(bySource.id)) removeVoucherIds.push(bySource.id)
+ }
+
+ return {
+ treasury,
+ voucher: voucherToSave,
+ removeTreasuryId: undefined,
+ removeVoucherIds,
+ removeInvoiceIds,
+ }
+}
+
+export function applyWithdrawalReopenedEffects(
+ withdrawal: ApiWithdrawal | string,
+ data: AppData,
+): {
+ removeTreasuryId?: string
+ removeVoucherIds: string[]
+} {
+ const treasury = findTreasuryForWithdrawal(data.treasury, withdrawal)
+ const removeVoucherIds: string[] = []
+ if (treasury) {
+ const v =
+ data.vouchers.find((x) => x.source === voucherSourceForTreasury(treasury)) ||
+ data.vouchers.find((x) => x.source === voucherSourceForTreasury(treasury.id))
+ if (v) removeVoucherIds.push(v.id)
+ }
+ const id = typeof withdrawal === 'string' ? withdrawal : withdrawal.id
+ const byWithdrawal = data.vouchers.find((v) => v.source === `withdrawal:${id}`)
+ if (byWithdrawal && !removeVoucherIds.includes(byWithdrawal.id)) {
+ removeVoucherIds.push(byWithdrawal.id)
+ }
+ const byOwner = data.vouchers.find((v) => v.source === treasurySourceOwnerTxn(id))
+ if (byOwner && !removeVoucherIds.includes(byOwner.id)) {
+ removeVoucherIds.push(byOwner.id)
+ }
+ return { removeTreasuryId: treasury?.id, removeVoucherIds }
+}
+
+export function withdrawalIdFromInvoiceNote(note: string): string | null {
+ return withdrawalIdFromNote(note)
+}
diff --git a/src/pages/treasury/funzoneTreasurySync.test.ts b/src/pages/treasury/funzoneTreasurySync.test.ts
index 04a7c49..2ff9c15 100644
--- a/src/pages/treasury/funzoneTreasurySync.test.ts
+++ b/src/pages/treasury/funzoneTreasurySync.test.ts
@@ -9,9 +9,10 @@ import {
buildFunZoneTreasurySync,
buildTreasuryFromCustomerPayment,
buildTreasuryFromOwnerTransaction,
+ isMisplacedTicketTreasuryTxn,
} from './funzoneTreasurySync'
+import { buildTreasuryVoucher } from '../sales/salesUtils'
-// Fee split matches admin panel (10% tax + 14% profit)
{
const split = splitEventPayment(100_000)
assert.equal(split.tax, 10_000)
@@ -47,7 +48,37 @@ const owner: Party = {
externalSource: 'funzone_owner',
}
-// Event payment → receipt with fee split
+const accounts = [
+ { id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a1004', code: '1004', name: 'AR', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2005', code: '2005', name: 'Owner', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a2006', code: '2006', name: 'Wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
+ { id: 'a4001', code: '4001', name: 'Income', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
+]
+
+const voucherData = {
+ accounts,
+ vouchers: [],
+ parties: [customer, owner],
+ settings: {
+ name: 'T',
+ economicCode: '',
+ fiscalYear: '1404',
+ baseCurrency: 'تومان',
+ taxRate: 0,
+ address: '',
+ phone: '',
+ voucherAccounts: {
+ bankAccountId: 'a1002',
+ ownerPayableAccountId: 'a2005',
+ customerPayableAccountId: 'a2006',
+ ownerShareAccountId: 'a4001',
+ salesIncomeAccountId: 'a4001',
+ },
+ },
+}
+
+// Ticket sale → NOT treasury
{
const item: ApiPaymentOrRefund = {
id: 'pay-1',
@@ -64,14 +95,119 @@ const owner: Party = {
reservation_id: 'res-1',
refund_status: null,
}
- const txn = buildTreasuryFromCustomerPayment(item, 'c1', 1)!
- assert.equal(txn.kind, 'receipt')
- assert.equal(txn.platformProfitAmount, 14_000)
- assert.equal(txn.taxAmount, 10_000)
- assert.equal(txn.ownerNetAmount, 76_000)
+ assert.equal(buildTreasuryFromCustomerPayment(item, 'c1', 1), null)
}
-// Owner deposit & booking_payment excluded from sync
+// Booking refund → NOT treasury (wallet credit, not bank out)
+{
+ const item: ApiPaymentOrRefund = {
+ id: 'ref-1',
+ type: 'cancellation',
+ customer_id: 'cust-1',
+ customer_name: 'Ali',
+ trace_no: '',
+ ref_num: '',
+ amount: 100_000,
+ event_name: 'Football Night',
+ event_date: null,
+ event_time: null,
+ created_at: '2026-01-02T10:00:00Z',
+ reservation_id: 'res-1',
+ refund_status: 'completed',
+ }
+ assert.equal(buildTreasuryFromCustomerPayment(item, 'c1', 1), null)
+}
+
+// Wallet top-up → دریافت without tax/14% + correct voucher
+{
+ const item: ApiPaymentOrRefund = {
+ id: 'pay-w',
+ type: 'payment',
+ customer_id: 'cust-1',
+ customer_name: 'Ali',
+ trace_no: 'TW',
+ ref_num: 'RW',
+ amount: 50_000,
+ event_name: 'wallet',
+ event_date: null,
+ event_time: null,
+ created_at: '2026-01-01T10:00:00Z',
+ reservation_id: null,
+ refund_status: null,
+ }
+ const txn = buildTreasuryFromCustomerPayment(item, 'c1', 1)!
+ assert.equal(txn.kind, 'receipt')
+ assert.equal(txn.taxAmount, 0)
+ assert.equal(txn.platformProfitAmount, 0)
+ assert.equal(txn.ownerNetAmount, 0)
+ assert.equal(txn.source, 'funzone:wallet-pay:pay-w')
+ assert.ok(txn.description.includes('شارژ کیف پول'))
+
+ const voucher = buildTreasuryVoucher({ ...txn, id: 'tr-w' }, voucherData)
+ assert.ok(voucher)
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.debit === 50_000))
+ assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.credit === 50_000))
+}
+
+// Misplaced detection
+{
+ assert.equal(
+ isMisplacedTicketTreasuryTxn({
+ id: 'tr1',
+ number: 1,
+ kind: 'receipt',
+ method: 'bank',
+ date: '2026-01-01',
+ partyId: 'c1',
+ amount: 100_000,
+ taxAmount: 10_000,
+ platformProfitAmount: 14_000,
+ ownerNetAmount: 76_000,
+ reference: '',
+ description: '',
+ eventName: 'Football Night',
+ source: 'funzone:cust-pay:pay-1',
+ }),
+ true,
+ )
+ assert.equal(
+ isMisplacedTicketTreasuryTxn({
+ id: 'tr2',
+ number: 2,
+ kind: 'receipt',
+ method: 'bank',
+ date: '2026-01-01',
+ partyId: 'c1',
+ amount: 50_000,
+ taxAmount: 0,
+ platformProfitAmount: 0,
+ ownerNetAmount: 0,
+ reference: '',
+ description: '',
+ eventName: 'wallet',
+ source: 'funzone:wallet-pay:pay-w',
+ }),
+ false,
+ )
+ assert.equal(
+ isMisplacedTicketTreasuryTxn({
+ id: 'tr3',
+ number: 3,
+ kind: 'payment',
+ method: 'bank',
+ date: '2026-01-01',
+ partyId: 'c1',
+ amount: 100_000,
+ reference: '',
+ description: 'old refund',
+ eventName: 'Football Night',
+ source: 'funzone:cust-pay:ref-1',
+ }),
+ true,
+ )
+}
+
+// Owner sync + cleanup
{
const ownerTxns: ApiOwnerTransaction[] = [
{
@@ -106,9 +242,26 @@ const owner: Party = {
},
]
- const stale: TreasuryTxn = {
- id: 'tr-stale',
+ const staleTicket: TreasuryTxn = {
+ id: 'tr-ticket',
number: 1,
+ kind: 'receipt',
+ method: 'bank',
+ date: '2026-01-02',
+ partyId: 'c1',
+ amount: 100_000,
+ taxAmount: 10_000,
+ platformProfitAmount: 14_000,
+ ownerNetAmount: 76_000,
+ reference: '',
+ description: 'old ticket',
+ eventName: 'Football Night',
+ source: 'funzone:cust-pay:old-ticket',
+ }
+
+ const staleBooking: TreasuryTxn = {
+ id: 'tr-stale',
+ number: 2,
kind: 'payment',
method: 'bank',
date: '2026-01-02',
@@ -120,32 +273,70 @@ const owner: Party = {
}
const result = buildFunZoneTreasurySync(
- { parties: [customer, owner], treasury: [stale] },
+ { parties: [customer, owner], treasury: [staleTicket, staleBooking] },
[],
ownerTxns,
)
assert.equal(result.excludedOwnerTxns, 2)
- assert.equal(result.toRemove.length, 1)
- assert.equal(result.toRemove[0].id, 'tr-stale')
+ assert.equal(result.toRemove.length, 2)
+ assert.ok(result.toRemove.some((t) => t.id === 'tr-ticket'))
+ assert.ok(result.toRemove.some((t) => t.id === 'tr-stale'))
assert.equal(result.toUpsert.length, 1)
assert.equal(result.toUpsert[0].source, 'funzone:owner-txn:ot-withdraw')
}
-assert.equal(buildTreasuryFromOwnerTransaction(
- {
- id: 'x',
+// Pending owner withdraw must NOT enter خزانه
+{
+ const pendingWithdraw: ApiOwnerTransaction = {
+ id: 'ot-pending',
owner_id: 'own-1',
- type: 'deposit',
- amount: 1,
- status: 'completed',
- description: '',
- created_at: '2026-01-01',
- updated_at: '2026-01-01',
- },
- 'o1',
- 'Owner',
- 1,
-), null)
+ type: 'withdraw',
+ amount: 10_000,
+ status: 'pending',
+ description: 'pending',
+ created_at: '2026-01-04T10:00:00Z',
+ updated_at: '2026-01-04T10:00:00Z',
+ }
+ const premature: TreasuryTxn = {
+ id: 'tr-pending',
+ number: 9,
+ kind: 'payment',
+ method: 'bank',
+ date: '2026-01-04',
+ partyId: 'o1',
+ amount: 10_000,
+ reference: '',
+ description: 'premature',
+ source: 'funzone:owner-txn:ot-pending',
+ }
+ const result = buildFunZoneTreasurySync(
+ { parties: [customer, owner], treasury: [premature] },
+ [],
+ [pendingWithdraw],
+ )
+ assert.equal(result.toUpsert.length, 0)
+ assert.equal(result.toRemove.length, 1)
+ assert.equal(result.toRemove[0].id, 'tr-pending')
+}
+
+assert.equal(
+ buildTreasuryFromOwnerTransaction(
+ {
+ id: 'x',
+ owner_id: 'own-1',
+ type: 'deposit',
+ amount: 1,
+ status: 'completed',
+ description: '',
+ created_at: '2026-01-01',
+ updated_at: '2026-01-01',
+ },
+ 'o1',
+ 'Owner',
+ 1,
+ ),
+ null,
+)
console.log('funzoneTreasurySync tests passed.')
diff --git a/src/pages/treasury/funzoneTreasurySync.ts b/src/pages/treasury/funzoneTreasurySync.ts
index f4e93cc..896d655 100644
--- a/src/pages/treasury/funzoneTreasurySync.ts
+++ b/src/pages/treasury/funzoneTreasurySync.ts
@@ -2,12 +2,11 @@ import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
import type { AppData, TreasuryTxn } from '../../types'
import { findExistingParty } from '../../parties/funzonePartySync'
import { createId, nextNumber } from '../../utils/id'
-import { formatEventName } from '../../utils/eventName'
import {
isEventCustomerPayment,
+ isWalletCustomerPayment,
ownerTxnExcludedFromTreasury,
ownerTxnIncludedInTreasury,
- splitEventPayment,
} from '../../utils/funzoneFees'
const ownerTypeLabels: Record = {
@@ -39,10 +38,6 @@ function isoDateFromApi(iso: string): string {
return iso.slice(0, 10)
}
-function eventLabel(eventName: string | null | undefined): string {
- return formatEventName(eventName)
-}
-
function statusLabel(status: string): string {
if (status === 'completed') return 'انجامشده'
if (status === 'pending') return 'در انتظار'
@@ -50,7 +45,11 @@ function statusLabel(status: string): string {
return status
}
-/** Customer platform payment → دریافت; cancellation/refund → پرداخت. */
+/**
+ * Wallet top-up → دریافت (no tax/14%).
+ * Ticket sales → NOT treasury (فاکتور فروش).
+ * Booking refunds → NOT treasury (credit customer wallet; IBAN withdraw is in فروش).
+ */
export function buildTreasuryFromCustomerPayment(
item: ApiPaymentOrRefund,
partyId: string,
@@ -61,56 +60,58 @@ export function buildTreasuryFromCustomerPayment(
if (amount <= 0) return null
const isPayment = item.type === 'payment'
- const fromWallet = item.payment_method === 'wallet'
- const kind = isPayment ? 'receipt' : 'payment'
- const isEvent = isPayment && isEventCustomerPayment(item.event_name)
- const split = isEvent ? splitEventPayment(amount) : null
+ // Event ticket payments belong on sales invoices (tax + 14% + owner), not دریافت.
+ if (isPayment && isEventCustomerPayment(item.event_name)) return null
+ // Cancellation/refunds credit the customer wallet — not a bank payout.
+ if (!isPayment) return null
+
+ const isWallet = isWalletCustomerPayment(item.event_name)
+ // Only wallet top-ups become دریافت. Unknown non-wallet payments are ignored.
+ if (!isWallet) return null
const lines = [
- isPayment
- ? fromWallet
- ? `پرداخت از کیف پول — ${item.customer_name}`
- : `دریافت از مشتری — ${item.customer_name}`
- : `بازپرداخت به مشتری — ${item.customer_name}`,
- `نوع: ${isPayment ? (fromWallet ? 'پرداخت کیف پول' : 'پرداخت مشتری') : 'لغو/بازپرداخت'}`,
- `رویداد: ${eventLabel(item.event_name)}`,
- split
- ? [
- `مبلغ کل: ${split.gross}`,
- `مالیات (۱۰٪): ${split.tax}`,
- `سود پلتفرم (۱۴٪): ${split.platformProfit}`,
- `سهم مالک (خالص): ${split.ownerNet}`,
- ].join('\n')
- : null,
- item.event_date ? `تاریخ رویداد: ${item.event_date}${item.event_time ? ` ${item.event_time}` : ''}` : null,
- item.reservation_id ? `شناسه رزرو: ${item.reservation_id}` : null,
+ `دریافت از مشتری (شارژ کیف پول) — ${item.customer_name}`,
+ 'نوع: شارژ کیف پول',
+ `مبلغ: ${amount}`,
item.trace_no ? `شماره پیگیری: ${item.trace_no}` : null,
item.ref_num ? `مرجع بانکی: ${item.ref_num}` : null,
- item.refund_status ? `وضعیت بازپرداخت: ${statusLabel(item.refund_status)}` : null,
'منبع: فانزون',
].filter(Boolean)
return {
id: existing?.id ?? '',
number: existing?.number ?? number,
- kind,
- method: fromWallet ? 'cash' : 'bank',
+ kind: 'receipt',
+ method: 'bank',
date: isoDateFromApi(item.created_at),
partyId,
amount,
- taxAmount: split?.tax ?? 0,
- platformProfitAmount: split?.platformProfit ?? 0,
- ownerNetAmount: split?.ownerNet ?? 0,
+ taxAmount: 0,
+ platformProfitAmount: 0,
+ ownerNetAmount: 0,
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
description: lines.join('\n'),
- eventName: item.event_name || undefined,
- source:
- isPayment && fromWallet
- ? treasurySourceWalletPayment(item.id)
- : treasurySourceCustomerPayment(item.id),
+ eventName: 'wallet',
+ source: treasurySourceWalletPayment(item.id),
}
}
+/** Legacy rows that no longer belong in خزانه after ticket/wallet accounting split. */
+export function isMisplacedTicketTreasuryTxn(txn: TreasuryTxn): boolean {
+ const source = txn.source ?? ''
+ if (!source.startsWith('funzone:cust-pay:') && !source.startsWith('funzone:wallet-pay:')) {
+ return false
+ }
+ // Wallet top-ups stay in treasury (eventName may be missing after reload — trust source).
+ if (source.startsWith('funzone:wallet-pay:') && txn.kind === 'receipt') return false
+ if (isWalletCustomerPayment(txn.eventName) && txn.kind === 'receipt') return false
+ // Old ticket receipts (with or without fee split)
+ if (txn.kind === 'receipt') return true
+ // Old booking-refund "پرداخت" rows (wallet credit, not bank out)
+ if (txn.kind === 'payment' && source.startsWith('funzone:cust-pay:')) return true
+ return false
+}
+
/** Owner bank payout or refund only (excludes deposit & booking_payment). */
export function buildTreasuryFromOwnerTransaction(
txn: ApiOwnerTransaction,
@@ -174,23 +175,29 @@ export function buildFunZoneTreasurySync(
.filter((t) => ownerTxnExcludedFromTreasury(t.type))
.map((t) => treasurySourceOwnerTxn(t.id)),
)
- const toRemove = data.treasury.filter((t) => t.source && staleSources.has(t.source))
+ const toRemoveMap = new Map()
+ for (const txn of data.treasury) {
+ if (txn.source && staleSources.has(txn.source)) toRemoveMap.set(txn.id, txn)
+ if (isMisplacedTicketTreasuryTxn(txn)) toRemoveMap.set(txn.id, txn)
+ }
let receiptNumber = nextNumber(data.treasury.filter((t) => t.kind === 'receipt'))
let paymentNumber = nextNumber(data.treasury.filter((t) => t.kind === 'payment'))
for (const item of customerPayments) {
+ // Only wallet top-ups — skip tickets/refunds before party lookup.
+ if (item.type !== 'payment' || !isWalletCustomerPayment(item.event_name)) continue
+
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
if (!party?.id) {
unmatchedCustomers += 1
continue
}
- const source =
- item.type === 'payment' && item.payment_method === 'wallet'
- ? treasurySourceWalletPayment(item.id)
- : treasurySourceCustomerPayment(item.id)
- const existing = findTreasuryBySource(data.treasury, source)
+ const source = treasurySourceWalletPayment(item.id)
+ const existing =
+ findTreasuryBySource(data.treasury, source) ??
+ findTreasuryBySource(data.treasury, treasurySourceCustomerPayment(item.id))
const built = buildTreasuryFromCustomerPayment(
item,
party.id,
@@ -205,12 +212,24 @@ export function buildFunZoneTreasurySync(
}
toUpsert.push(built)
- if (!existing && built.kind === 'receipt') receiptNumber += 1
- if (!existing && built.kind === 'payment') paymentNumber += 1
+ if (!existing) receiptNumber += 1
}
+ // Pending/failed owner payouts previously imported — remove until completed.
+ for (const txn of data.treasury) {
+ if (!txn.source?.startsWith('funzone:owner-txn:')) continue
+ const ownerTxnId = txn.source.slice('funzone:owner-txn:'.length)
+ const ownerTxn = ownerTransactions.find((t) => t.id === ownerTxnId)
+ if (ownerTxn && ownerTxn.status !== 'completed') {
+ toRemoveMap.set(txn.id, txn)
+ }
+ }
+
+ const toRemove = Array.from(toRemoveMap.values())
+
for (const txn of ownerTransactions) {
- if (txn.status === 'failed') continue
+ // Only completed bank payouts — pending must not hit خزانه yet.
+ if (txn.status !== 'completed') continue
if (ownerTxnExcludedFromTreasury(txn.type)) {
excludedOwnerTxns += 1
diff --git a/src/pages/treasury/useFunZoneTreasurySync.ts b/src/pages/treasury/useFunZoneTreasurySync.ts
index e03a4c8..c0afc28 100644
--- a/src/pages/treasury/useFunZoneTreasurySync.ts
+++ b/src/pages/treasury/useFunZoneTreasurySync.ts
@@ -22,8 +22,20 @@ export interface FunZoneTreasurySyncStats {
ownerTxnCount: number
}
+export interface FunZoneTreasurySyncInput {
+ customerPayments?: ApiPaymentOrRefund[]
+ ownerTransactions?: ApiOwnerTransaction[]
+}
+
export function useFunZoneTreasurySync() {
- const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
+ const {
+ data,
+ upsertTreasuryAsync,
+ removeTreasuryAsync,
+ upsertVoucherAsync,
+ removeVoucherAsync,
+ upsertInvoiceAsync,
+ } = useStore()
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [stats, setStats] = useState(null)
@@ -32,7 +44,7 @@ export function useFunZoneTreasurySync() {
dataRef.current = data
const persistTreasuryBatch = useCallback(
- (baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
+ async (baseData: AppData, items: TreasuryTxn[]): Promise => {
const saved = assignTreasuryIds(items)
let created = 0
let updated = 0
@@ -43,27 +55,38 @@ export function useFunZoneTreasurySync() {
if (isNew) created += 1
else updated += 1
- upsertTreasury(txn)
+ const persisted = await upsertTreasuryAsync({
+ ...txn,
+ eventName: txn.eventName || (txn.source?.includes('wallet') ? 'wallet' : ''),
+ })
- const existingVoucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(txn.id))
- const voucher = buildTreasuryVoucher(txn, working)
- if (existingVoucher) removeVoucher(existingVoucher.id)
- if (voucher) upsertVoucher(voucher)
+ const voucherKey = voucherSourceForTreasury(persisted)
+ const existingVoucher = working.vouchers.find((v) => v.source === voucherKey)
+ const builtVoucher = buildTreasuryVoucher(persisted, working)
+ const voucher =
+ builtVoucher && existingVoucher
+ ? { ...builtVoucher, id: existingVoucher.id }
+ : builtVoucher
+ if (!voucher && existingVoucher) await removeVoucherAsync(existingVoucher.id)
+ if (voucher) await upsertVoucherAsync(voucher)
working = {
...working,
- treasury: [...working.treasury.filter((t) => t.id !== txn.id && t.source !== txn.source), txn],
+ treasury: [
+ ...working.treasury.filter((t) => t.id !== persisted.id && t.source !== persisted.source),
+ persisted,
+ ],
vouchers: [
- ...working.vouchers.filter((v) => v.id !== existingVoucher?.id),
+ ...working.vouchers.filter((v) => v.id !== existingVoucher?.id && v.source !== voucherKey),
...(voucher ? [voucher] : []),
],
}
- if (txn.partyId) {
- for (const inv of reconcileInvoicePaymentStatus(txn.partyId, working)) {
+ if (persisted.partyId) {
+ for (const inv of reconcileInvoicePaymentStatus(persisted.partyId, working)) {
const current = working.invoices.find((i) => i.id === inv.id)
if (current?.status === inv.status) continue
- upsertInvoice(inv)
+ await upsertInvoiceAsync(inv)
working = {
...working,
invoices: working.invoices.map((i) => (i.id === inv.id ? inv : i)),
@@ -84,10 +107,10 @@ export function useFunZoneTreasurySync() {
ownerTxnCount: 0,
}
},
- [upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice],
+ [upsertTreasuryAsync, removeTreasuryAsync, upsertVoucherAsync, removeVoucherAsync, upsertInvoiceAsync],
)
- const sync = useCallback(async (): Promise => {
+ const sync = useCallback(async (input?: FunZoneTreasurySyncInput): Promise => {
if (syncingRef.current) return null
syncingRef.current = true
setLoading(true)
@@ -95,10 +118,14 @@ export function useFunZoneTreasurySync() {
try {
const current = dataRef.current
const [customerPayments, ownerTransactions] = await Promise.all([
- apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
- (res) => res.payments_and_refunds ?? [],
- ),
- apiGet(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList),
+ input?.customerPayments
+ ? Promise.resolve(input.customerPayments)
+ : apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
+ (res) => res.payments_and_refunds ?? [],
+ ),
+ input?.ownerTransactions
+ ? Promise.resolve(input.ownerTransactions)
+ : apiGet(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList),
])
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
@@ -106,9 +133,10 @@ export function useFunZoneTreasurySync() {
let working = { ...current }
for (const stale of toRemove) {
- const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
- if (voucher) removeVoucher(voucher.id)
- removeTreasury(stale.id)
+ const voucherKey = voucherSourceForTreasury(stale)
+ const voucher = working.vouchers.find((v) => v.source === voucherKey)
+ if (voucher) await removeVoucherAsync(voucher.id)
+ await removeTreasuryAsync(stale.id)
working = {
...working,
treasury: working.treasury.filter((t) => t.id !== stale.id),
@@ -116,7 +144,7 @@ export function useFunZoneTreasurySync() {
}
}
- const batchStats = persistTreasuryBatch(working, toUpsert)
+ const batchStats = await persistTreasuryBatch(working, toUpsert)
setStats({
...batchStats,
@@ -126,7 +154,7 @@ export function useFunZoneTreasurySync() {
unmatchedOwners,
excludedOwnerTxns,
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
- ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
+ ownerTxnCount: ownerTransactions.filter((t) => t.status === 'completed' && t.type === 'withdraw').length,
})
return {
...batchStats,
@@ -136,7 +164,7 @@ export function useFunZoneTreasurySync() {
unmatchedOwners,
excludedOwnerTxns,
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
- ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
+ ownerTxnCount: ownerTransactions.filter((t) => t.status === 'completed' && t.type === 'withdraw').length,
}
} catch (err) {
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
diff --git a/src/store/AppStore.tsx b/src/store/AppStore.tsx
index ef25ed0..39b7236 100644
--- a/src/store/AppStore.tsx
+++ b/src/store/AppStore.tsx
@@ -94,22 +94,28 @@ interface AppStoreValue {
upsertAccount: (item: Account) => void
removeAccount: (id: string) => void
upsertVoucher: (item: Voucher) => void
+ upsertVoucherAsync: (item: Voucher) => Promise
removeVoucher: (id: string) => void
+ removeVoucherAsync: (id: string) => Promise
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
createVouchers: (items: Voucher[]) => Promise
upsertParty: (item: Party) => void
- upsertPartyAsync: (item: Party) => Promise
+ upsertPartyAsync: (item: Party) => Promise
removeParty: (id: string) => void
upsertProduct: (item: Product) => void
removeProduct: (id: string) => void
upsertInvoice: (item: Invoice) => void
+ upsertInvoiceAsync: (item: Invoice) => Promise
removeInvoice: (id: string) => void
+ removeInvoiceAsync: (id: string) => Promise
upsertSalesType: (item: SalesType) => void
removeSalesType: (id: string) => void
upsertPartyAdjustment: (item: PartyAdjustment) => void
removePartyAdjustment: (id: string) => void
upsertTreasury: (item: TreasuryTxn) => void
+ upsertTreasuryAsync: (item: TreasuryTxn) => Promise
removeTreasury: (id: string) => void
+ removeTreasuryAsync: (id: string) => Promise
upsertEmployee: (item: Employee) => void
removeEmployee: (id: string) => void
upsertPayslip: (item: Payslip) => void
@@ -147,6 +153,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false)
const dataRef = useRef(data)
+ const inFlightLoadRef = useRef | null>(null)
useEffect(() => {
dataRef.current = data
}, [data])
@@ -164,33 +171,62 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
)
const load = useCallback(async (): Promise => {
- setLoading(true)
- setError(null)
- try {
- const [accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings] =
- await Promise.all([
+ if (inFlightLoadRef.current) return inFlightLoadRef.current
+
+ const request = (async (): Promise => {
+ setLoading(true)
+ setError(null)
+ try {
+ // Keep load stable under production traffic by avoiding one large request burst.
+ const [accounts, vouchers, parties] = await Promise.all([
acct.get(ENDPOINT.accounts).then(asList),
acct.get(ENDPOINT.vouchers).then(asList),
acct.get(ENDPOINT.parties).then(asList),
+ ])
+ const [products, invoices, salesTypes] = await Promise.all([
acct.get(ENDPOINT.products).then(asList),
acct.get(ENDPOINT.invoices).then(asList),
acct.get(ENDPOINT.salesTypes).then(asList),
+ ])
+ const [partyAdjustments, treasury, employees] = await Promise.all([
acct.get(ENDPOINT.partyAdjustments).then(asList),
acct.get(ENDPOINT.treasury).then(asList),
acct.get(ENDPOINT.employees).then(asList),
+ ])
+ const [payslips, settings] = await Promise.all([
acct.get(ENDPOINT.payslips).then(asList),
acct.get(ACCOUNTING_ENDPOINTS.SETTINGS),
])
- const loaded = { accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings }
- setData(loaded)
- setReady(true)
- return loaded
- } catch (err) {
- setReady(false)
- handleApiError(err)
- return null
+ const loaded = {
+ accounts,
+ vouchers,
+ parties,
+ products,
+ invoices,
+ salesTypes,
+ partyAdjustments,
+ treasury,
+ employees,
+ payslips,
+ settings,
+ }
+ setData(loaded)
+ setReady(true)
+ return loaded
+ } catch (err) {
+ setReady(false)
+ handleApiError(err)
+ return null
+ } finally {
+ setLoading(false)
+ }
+ })()
+
+ inFlightLoadRef.current = request
+ try {
+ return await request
} finally {
- setLoading(false)
+ inFlightLoadRef.current = null
}
}, [handleApiError])
@@ -205,7 +241,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
}, [connected, load])
const upsert = useCallback(
- async (key: K, item: AppData[K][number]) => {
+ async (key: K, item: AppData[K][number]): Promise => {
const entity = item as Identifiable
const exists = (dataRef.current[key] as Identifiable[]).some((x) => x.id === entity.id)
const payload = serializePayload(key, entity)
@@ -218,10 +254,11 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
x.id === entity.id ? (saved as Identifiable) : x,
),
}))
- } else {
- const saved = await acct.post(ENDPOINT[key], payload)
- setData((prev) => ({ ...prev, [key]: [...(prev[key] as Identifiable[]), saved as Identifiable] }))
+ return saved
}
+ const saved = await acct.post(ENDPOINT[key], payload)
+ setData((prev) => ({ ...prev, [key]: [...(prev[key] as Identifiable[]), saved as Identifiable] }))
+ return saved
} catch (err) {
handleApiError(err)
throw err
@@ -286,7 +323,9 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
upsertAccount: (item) => void upsert('accounts', item),
removeAccount: (id) => void remove('accounts', id),
upsertVoucher: (item) => void upsert('vouchers', item),
+ upsertVoucherAsync: (item) => upsert('vouchers', item),
removeVoucher: (id) => void remove('vouchers', id),
+ removeVoucherAsync: (id) => remove('vouchers', id),
createVouchers,
upsertParty: (item) => void upsert('parties', item),
upsertPartyAsync: (item) => upsert('parties', item),
@@ -294,13 +333,17 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
upsertProduct: (item) => void upsert('products', item),
removeProduct: (id) => void remove('products', id),
upsertInvoice: (item) => void upsert('invoices', item),
+ upsertInvoiceAsync: (item) => upsert('invoices', item),
removeInvoice: (id) => void remove('invoices', id),
+ removeInvoiceAsync: (id) => remove('invoices', id),
upsertSalesType: (item) => void upsert('salesTypes', item),
removeSalesType: (id) => void remove('salesTypes', id),
upsertPartyAdjustment: (item) => void upsert('partyAdjustments', item),
removePartyAdjustment: (id) => void remove('partyAdjustments', id),
upsertTreasury: (item) => void upsert('treasury', item),
+ upsertTreasuryAsync: (item) => upsert('treasury', item),
removeTreasury: (id) => void remove('treasury', id),
+ removeTreasuryAsync: (id) => remove('treasury', id),
upsertEmployee: (item) => void upsert('employees', item),
removeEmployee: (id) => void remove('employees', id),
upsertPayslip: (item) => void upsert('payslips', item),
diff --git a/src/types/index.ts b/src/types/index.ts
index 0d4a0ae..f658744 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -110,7 +110,8 @@ export interface InvoiceLine {
}
export type InvoiceKind = 'sale' | 'purchase'
-export type InvoiceStatus = 'draft' | 'confirmed' | 'paid'
+/** `cancelled` = voided sales invoice (ticket/event cancel). Legacy `return` docs may still exist in DB. */
+export type InvoiceStatus = 'draft' | 'confirmed' | 'paid' | 'cancelled'
export interface Invoice {
id: ID
diff --git a/src/utils/funzoneFees.ts b/src/utils/funzoneFees.ts
index fdcd287..1bcd25f 100644
--- a/src/utils/funzoneFees.ts
+++ b/src/utils/funzoneFees.ts
@@ -25,6 +25,11 @@ export function isEventCustomerPayment(eventName: string | null | undefined): bo
return Boolean(eventName && eventName.toLowerCase() !== 'wallet')
}
+/** Customer wallet top-up / wallet withdrawal (not a ticket sale). */
+export function isWalletCustomerPayment(eventName: string | null | undefined): boolean {
+ return (eventName || '').toLowerCase() === 'wallet'
+}
+
/** Owner wallet credits already reflected in customer receipts — exclude from treasury. */
export function ownerTxnExcludedFromTreasury(type: string): boolean {
return type === 'deposit' || type === 'booking_payment'
diff --git a/src/utils/voucherFilters.ts b/src/utils/voucherFilters.ts
index 6d8ed1e..42ec502 100644
--- a/src/utils/voucherFilters.ts
+++ b/src/utils/voucherFilters.ts
@@ -2,7 +2,7 @@ import type { AppData, Voucher } from '../types'
import { resolveVoucherRegarding } from './voucherRegarding'
import { visibleVouchers } from '../accounting/voucherDedupe'
-export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual'
+export type VoucherCategoryFilter = 'all' | 'manual' | 'manager'
export type VoucherStatusFilter = 'all' | 'draft' | 'posted'
export type VoucherOriginFilter = 'all' | 'funzone' | 'manual'
@@ -25,51 +25,20 @@ export const defaultVoucherFilters: VoucherFilters = {
}
const categoryLabels: Record, string> = {
- customer: 'مشتریان',
- supplier: 'تأمینکنندگان',
manual: 'دستی',
+ manager: 'مدیر',
}
export function voucherCategoryLabel(category: Exclude): string {
return categoryLabels[category]
}
-/** Classifies a voucher as customer-related, supplier-related, or manually entered. */
+/** Classifies vouchers to only two categories: imported (دستی) or manager-created (مدیر). */
export function classifyVoucher(
voucher: Voucher,
- data: Pick,
+ _data: Pick,
): Exclude {
- const src = voucher.source ?? ''
-
- if (src.startsWith('funzone:cust') || src.startsWith('withdrawal:')) return 'customer'
- if (src.startsWith('funzone:owner-txn')) return 'supplier'
-
- if (src.startsWith('invoice:')) {
- const inv = data.invoices.find((i) => i.id === src.slice('invoice:'.length))
- if (inv?.kind === 'purchase') return 'supplier'
- if (inv?.kind === 'sale') return 'customer'
- }
-
- if (src.startsWith('adjustment:')) {
- const adj = data.partyAdjustments.find((a) => a.id === src.slice('adjustment:'.length))
- const party = adj ? data.parties.find((p) => p.id === adj.partyId) : undefined
- return party?.kind === 'supplier' ? 'supplier' : 'customer'
- }
-
- if (src.startsWith('treasury:')) {
- const txn = data.treasury.find((t) => t.id === src.slice('treasury:'.length))
- if (txn) {
- if (txn.source?.startsWith('funzone:owner-txn')) return 'supplier'
- if (txn.source?.startsWith('funzone:cust')) return 'customer'
- if (txn.partyId) {
- const party = data.parties.find((p) => p.id === txn.partyId)
- if (party?.kind === 'supplier') return 'supplier'
- if (party?.kind === 'customer') return 'customer'
- }
- }
- }
-
- return 'manual'
+ return isAutomatedVoucher(voucher) ? 'manual' : 'manager'
}
export function isAutomatedVoucher(voucher: Voucher): boolean {
@@ -126,7 +95,7 @@ export function voucherFilterSummary(
vouchers: Voucher[],
data: AppData,
): Record, number> {
- const summary = { customer: 0, supplier: 0, manual: 0 }
+ const summary = { manual: 0, manager: 0 }
for (const voucher of visibleVouchers(vouchers, data)) {
summary[classifyVoucher(voucher, data)] += 1
}