Improve FunZone sales/treasury sync and keep IBAN out of treasury reference.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shayan Azadi
2026-07-11 16:53:03 +03:30
parent 367dc83ecb
commit bcd7a71c4f
35 changed files with 2912 additions and 965 deletions

View File

@@ -0,0 +1,511 @@
# مستند کامل سامانه حسابداری FunZone
این سند توضیح می‌دهد **اعداد و شماره‌ها چطور ساخته می‌شوند**، و **هر بخش منو چطور به بقیه وصل است** — از داشبورد تا تنظیمات.
---
## ۱. نمای کلی معماری
| لایه | تکنولوژی | آدرس تقریبی |
|------|----------|-------------|
| فرانت‌اند حسابداری | React + TypeScript + Vite | اپ `funzone-accounting` |
| API حسابداری | Django REST | معمولاً `localhost:8010/api` |
| API اصلی FunZone | Django (مشتری، پرداخت، مالک، رویداد) | معمولاً `localhost:8000/api` |
**منبع حقیقت داده‌ها:** `AppStore` همه موجودیت‌ها را از API حسابداری بارگذاری می‌کند و CRUD را از همان‌جا انجام می‌دهد. همگام‌سازی با FunZone (بلیت، کیف پول، برداشت) از API اصلی می‌خواند و در دفتر حسابداری ثبت می‌کند.
```
┌─────────────────┐ همگام‌سازی ┌──────────────────────┐
│ FunZone API │ ──────────────────► │ Accounting Frontend │
│ پرداخت/کیف‌پول │ │ AppStore + صفحات │
└─────────────────┘ └──────────┬───────────┘
│ REST
┌──────────────────────┐
│ Accounting Backend │
│ جداول دفتر / شماره سند│
└──────────────────────┘
```
---
## ۲. ساختار منو و مسیرها
| گروه | برچسب منو | مسیر | صفحه اصلی |
|------|-----------|------|-----------|
| اصلی | داشبورد | `/` | `Dashboard.tsx` |
| حسابداری | حساب‌ها (کدینگ) | `/accounts` | `Accounts.tsx` |
| حسابداری | اسناد حسابداری | `/vouchers` | `Vouchers.tsx` |
| عملیات | مشتریان و فروش | `/sales/*` | `Sales.tsx` + زیرصفحات |
| عملیات | تأمین‌کنندگان و خرید | `/purchases/*` | `Purchases.tsx` |
| عملیات | انبارداری | `/inventory` | `Inventory.tsx` |
| عملیات | دریافت و پرداخت | `/treasury` | `Treasury.tsx` |
| عملیات | حقوق و دستمزد | `/payroll` | `Payroll.tsx` |
| تحلیل | گزارش‌ها | `/reports` | `Reports.tsx` |
| سیستم | تنظیمات | `/settings` | `Settings.tsx` |
### زیرمنوی فروش
| مسیر | نقش |
|------|-----|
| `/sales` | فرایند فروش |
| `/sales/proforma` | پیش‌فاکتور |
| `/sales/invoice` | فاکتور فروش |
| `/sales/return` | برداشت‌ها و لغو شده |
| `/sales/adjustments` | اعلامیه بدهکار/بستانکار |
| `/sales/sales-types` | نوع فروش |
| `/sales/customers` | مشتریان |
| `/sales/review` | مرور فروش |
تعریف منو: `src/components/layout/navigation.ts`
---
## ۳. تولید اعداد و شماره‌ها (قلب سیستم)
### ۳.۱ الگوریتم مشترک فرانت‌اند
فایل: `src/utils/id.ts`
```ts
// شناسه موقت کلاینت (قبل از UUID سرور)
createId(prefix) مثلاً "v-" + زمان + تصادفی
// شماره ترتیبی سند/فاکتور/…
nextNumber(items) max(item.number) + 1
```
**نکات مهم:**
- هیچ جدول Sequence / سری شماره‌گذاری جدا در دیتابیس نیست.
- **سال مالی (`fiscalYear`) فقط نمایشی است** و شماره‌ها را ریست یا جدا نمی‌کند.
- برای بیشتر اسناد، شماره را **کلاینت** با `nextNumber` روی لیست فیلترشده می‌سازد و سرور همان عدد را ذخیره می‌کند.
- **استثنا: شماره سند حسابداری (`Voucher.number`)** — سرور شماره کلاینت را نادیده می‌گیرد و خودش اختصاص می‌دهد.
---
### ۳.۲ ماتریس شماره‌گذاری
| نوع سند | فیلد | محدوده سری | کی می‌سازد؟ | فایل / منطق |
|---------|------|------------|-------------|-------------|
| **سند حسابداری** | `number` | همه اسناد | **سرور** (`_next_number` + قفل ردیف) | `serializers.py` |
| **پیش‌فاکتور فروش** | `number` | فقط `documentType=proforma` و `kind=sale` | کلاینت | `SalesDocumentSection` |
| **فاکتور فروش** | `number` | فقط `documentType=invoice` و `kind=sale` | کلاینت | `SalesDocumentSection` + سینک FunZone |
| **فاکتور خرید** | `number` | همه `kind=purchase` | کلاینت | `TradeModule` |
| **دریافت** | `number` | فقط `kind=receipt` | کلاینت | `Treasury.tsx` / سینک خزانه |
| **پرداخت** | `number` | فقط `kind=payment` | کلاینت | `Treasury.tsx` / سینک |
| **برداشت کیف پول** | `number` | **کل** لیست `treasury` (نه فقط payment) | کلاینت | `withdrawalTreasury.ts` |
| **اعلامیه بدهکار/بستانکار** | `number` | همه اعلامیه‌ها | کلاینت | `PartyAdjustmentsPage` |
| **کد حساب** | `code` | دستی / یکتا در DB | کاربر | `Accounts.tsx` + seed |
| **کد کالا / نوع فروش / پرسنل** | `code` | دستی | کاربر | بدون auto |
| **فیش حقوقی** | — | ندارد | — | با `id` و `period` |
---
### ۳.۳ شماره سند حسابداری (تنها شماره قطعی سمت سرور)
در `funzone-accounting-backend/accounting/serializers.py`:
1. فیلد `number` در `VoucherSerializer` **read-only** است.
2. هنگام `create`، سرور با `select_for_update` آخرین شماره را می‌گیرد و `+1` می‌کند.
3. بعد از ایجاد، شماره تغییر نمی‌کند (`update` فیلد `number` را حذف می‌کند).
4. اگر سندی با `number <= 0` باشد، در update می‌تواند شماره بگیرد.
فرانت قبل از POST ممکن است شماره موقت با `nextNumber(data.vouchers)` نشان دهد؛ بعد از ذخیره، شماره واقعی همانی است که سرور برمی‌گرداند.
---
### ۳.۴ شناسه‌های داخلی (`createId`)
| پیشوند | موجودیت |
|--------|---------|
| `v-` | سند |
| `vl-` | سطر سند |
| `inv-` | فاکتور |
| `il-` | سطر فاکتور |
| `tr-` | دریافت/پرداخت |
| `acc-` | حساب |
| `prod-` | کالا |
| `party-` | طرف‌حساب |
| `st-` | نوع فروش |
| `adj-` | اعلامیه |
| `emp-` | کارمند |
| `ps-` | فیش حقوقی |
این‌ها شناسه موقت UI هستند؛ بعد از ایجاد در بک‌اند معمولاً با UUID سرور جایگزین می‌شوند.
---
### ۳.۵ تگ‌های پایدار `source` (برای جلوگیری از دوباره‌ثبتی)
| الگو | معنی |
|------|------|
| `invoice:{uuid}` | فاکتور دستی |
| `funzone:invoice:{paymentId}` | سند بلیت درگاه |
| `funzone:invoice:wallet:{paymentId}` | سند بلیت از کیف پول |
| `adjustment:{id}` | اعلامیه |
| `treasury:{id}` | خزانه دستی |
| `funzone:cust-pay:{id}` | پرداخت مشتری (قدیمی/خزانه) |
| `funzone:wallet-pay:{id}` | تراکنش کیف پول |
| `funzone:owner-txn:{id}` | تراکنش مالک |
| `withdrawal:{id}` | برداشت IBAN |
فایل کلیدی لینک بلیت↔خزانه: `src/pages/sales/funzoneVoucherLink.ts`
منبع حقیقت تقسیم کارمزد بلیت = **سند فاکتور فروش**؛ اگر سند خزانه تکراری برای همان پرداخت بماند، حذف می‌شود.
---
## ۴. کدینگ حساب‌ها و نقش هر کد
حساب‌های پیش‌فرض از `seeding.py` (نمونه مهم‌ها):
| کد | نام | نقش در سیستم |
|----|-----|--------------|
| 1001 | صندوق | نقدینگی داشبورد |
| 1002 | بانک ملت | بدهکار/بستانکار نقد در اسناد خودکار |
| 1003 | بانک سامان | نقدینگی |
| 1004 | دریافتنی مشتریان | فروش عادی، اعلامیه، تسویه خزانه |
| 1005 | موجودی کالا | فاکتور خرید |
| 2001 | پرداختنی تأمین‌کنندگان | خرید |
| 2002 | مالیات پرداختنی | ۱۰٪ بلیت FunZone |
| 2003 / 2004 | حقوق / بیمه | در کدینگ هست؛ حقوق هنوز سند نمی‌زند |
| 2005 | بدهی به مالکان (کیف پول) | ۷۶٪ سهم مالک |
| 2006 | بدهی به مشتریان (کیف پول) | شارژ/برداشت/بلیت از کیف |
| 4001 | درآمد فروش | فروش عادی + ۱۴٪ سود پلتفرم |
| 5002 | هزینه حقوق | رزرو شده؛ Payroll وصل نیست |
| 5007 | سهم مالکان | در تنظیمات مپ شده؛ مسیر فعلی بلیت بیشتر از 2005/4001/2002 استفاده می‌کند |
**تنظیمات نگاشت حساب‌ها** (`Settings``voucherAccounts`): بانک، بدهی مالک، بدهی مشتری، سهم مالک، درآمد فروش.
بدون کدینگ درست، `buildSalesVoucher` / `buildPurchaseVoucher` / `buildTreasuryVoucher` سند نمی‌سازند (`null`).
---
## ۵. فلوچارت اتصال ماژول‌ها
### ۵.۱ نمای کلی ارتباطات
```mermaid
flowchart TB
subgraph سیستم["سیستم"]
SET[تنظیمات<br/>نگاشت حساب‌ها / سال مالی نمایشی]
end
subgraph حسابداری["حسابداری"]
ACC[حساب‌ها / کدینگ]
VOC[اسناد حسابداری]
end
subgraph عملیات["عملیات"]
SAL[مشتریان و فروش]
PUR[تأمین‌کنندگان و خرید]
INV[انبارداری]
TRE[دریافت و پرداخت]
PAY[حقوق و دستمزد]
end
subgraph تحلیل["تحلیل"]
REP[گزارش‌ها]
DAS[داشبورد]
end
subgraph خارجی["خارج از اپ"]
FZ[FunZone API]
end
SET --> ACC
SET --> SAL
SET --> TRE
ACC --> VOC
ACC --> SAL
ACC --> PUR
ACC --> TRE
FZ -->|سینک طرف‌حساب| SAL
FZ -->|سینک بلیت / لغو| SAL
FZ -->|شارژ کیف / پرداخت مالک| TRE
FZ -->|رویداد / رزرو| INV
SAL -->|buildSalesVoucher + موجودی| VOC
SAL -->|اعلامیه| VOC
PUR -->|buildPurchaseVoucher + موجودی| VOC
PUR -->|افزایش stock| INV
SAL -->|کاهش stock| INV
TRE -->|buildTreasuryVoucher + تسویه فاکتور| VOC
TRE -->|reconcile paid/confirmed| SAL
PAY -.->|فعلاً بدون سند| VOC
VOC --> REP
VOC --> DAS
SAL --> DAS
PUR --> DAS
INV --> DAS
TRE --> DAS
```
### ۵.۲ جریان تولید سند از عملیات
```mermaid
flowchart LR
A[عملیات کاربر یا سینک FunZone] --> B{نوع؟}
B -->|فاکتور فروش/خرید| C[ذخیره Invoice]
B -->|دریافت/پرداخت| D[ذخیره TreasuryTxn]
B -->|اعلامیه| E[ذخیره PartyAdjustment]
B -->|سند دستی| F[Vouchers UI]
C --> G[invoiceSideEffects]
G --> H[تغییر موجودی کالا]
G --> I[buildInvoiceVoucher]
D --> J[buildTreasuryVoucher]
D --> K[reconcileInvoicePaymentStatus]
E --> L[buildAdjustmentVoucher]
F --> M[createDraft + nextNumber کلاینت]
I --> N[POST سند]
J --> N
L --> N
M --> N
N --> O[سرور: شماره قطعی Voucher]
O --> P[دفتر کل / تراز / داشبورد / گزارش]
```
### ۵.۳ جریان بلیت FunZone (تقسیم مبالغ)
برای مبلغ ناخالص `G`:
| جزء | نرخ | حساب بستانکار معمول |
|-----|-----|---------------------|
| مالیات | ۱۰٪ | 2002 |
| سود پلتفرم | ۱۴٪ | 4001 |
| سهم مالک | ۷۶٪ (باقیمانده) | 2005 |
**بدهکار:**
- درگاه: 1002 بانک
- کیف پول مشتری: 2006
تابع: `splitEventPayment` در `src/utils/funzoneFees.ts`
سند: `buildSalesVoucher` وقتی note شامل `funzone:pay:` است.
```mermaid
flowchart TB
P[پرداخت بلیت در FunZone] --> S[funzoneSalesSync]
S --> I[فاکتور فروش status=paid]
I --> V[سند با source=funzone:invoice:…]
V --> L1[بدهکار بانک یا کیف 2006]
V --> L2[بستانکار مالک 2005]
V --> L3[بستانکار درآمد 4001]
V --> L4[بستانکار مالیات 2002]
T[سند خزانه تکراری همان پرداخت] -.->|حذف توسط funzoneVoucherLink| X[حذف]
```
### ۵.۴ ترتیب همگام‌سازی یکپارچه
`unifiedVoucherSync.ts`:
1. سینک خزانه از FunZone (شارژ کیف، تراکنش مالک، …)
2. سینک فاکتورهای فروش بلیت + ثبت سند فاکتور
3. حذف اسناد تکراری / منسوخ (`supersededVoucherIds` + لینک بلیت)
---
## ۶. جزئیات هر بخش منو
### ۶.۱ داشبورد
**می‌خواند از:** اسناد posted، فاکتورها، موجودی کالا، حساب‌های نقدی.
**نمایش تقریبی:**
- نقدینگی (کدهای 10011003)
- جمع فروش / خرید از فاکتورها
- سود و زیان و تراز از `trialBalance` روی اسناد
- موجودی کم / فاکتورهای تسویه‌نشده
- نمودار چندماهه
**تولید عدد:** خودش شماره سند نمی‌سازد؛ فقط تجمیع می‌کند.
---
### ۶.۲ حساب‌ها (کدینگ)
**نقش:** درخت حساب‌ها (`parentId`)، مانده افتتاحیه، نوع (دارایی/بدهی/درآمد/هزینه).
**اتصال:** پایه همه سطرهای سند (`VoucherLine.accountId`). بدون حساب‌های اجباری، اسناد خودکار ساخته نمی‌شوند.
**شماره:** کد حساب دستی و یکتا — بدون `nextNumber`.
---
### ۶.۳ اسناد حسابداری
**ورودی‌ها:**
| منبع | چگونه |
|------|--------|
| دستی در همین صفحه | پیش‌نویس + ثبت |
| فروش | `buildSalesVoucher` |
| خرید | `buildPurchaseVoucher` |
| خزانه | `buildTreasuryVoucher` |
| اعلامیه | `buildAdjustmentVoucher` |
| دکمه همگام FunZone | `unifiedVoucherSync` |
**شماره:** سرور قطعی می‌کند.
**وضعیت:** `draft` | `posted` — گزارش‌ها معمولاً روی posted (منهای اسناد حذف‌شده/منسوخ).
---
### ۶.۴ مشتریان و فروش
**موجودیت‌ها:** Party (مشتری)، Invoice فروش، نوع فروش، اعلامیه، سینک FunZone.
| عمل | اثر حسابداری | اثر انبار |
|-----|--------------|-----------|
| پیش‌فاکتور | بدون سند | معمولاً بدون تغییر قطعی موجودی مسیر اصلی سند |
| فاکتور فروش عادی confirmed | بدهکار 1004 / بستانکار درآمد | کاهش stock |
| بلیت FunZone | تقسیم ۱۰/۱۴/۷۶ | — |
| لغو بلیت درگاه | بانک بدهکار / 2006 بستانکار | — |
| لغو بلیت کیف پول | حذف/بازگردانی سند قبلی | — |
| اعلامیه بدهکار | ↑ مطالبات / ↑ درآمد | — |
| اعلامیه بستانکار | عکس بالا | — |
**شماره فاکتور:** سری جدا برای پیش‌فاکتور و فاکتور (`nextNumber` روی همان `documentType`).
**تسویه:** خزانه با `reconcileInvoicePaymentStatus` به صورت FIFO وضعیت `confirmed``paid` را عوض می‌کند (بلیت‌های FunZone prepaid می‌مانند).
---
### ۶.۵ تأمین‌کنندگان و خرید
**موجودیت‌ها:** Party (supplier)، Invoice خرید.
**سند خودکار (confirmed):**
- بدهکار 1005 موجودی
- بستانکار 2001 پرداختنی
**شماره:** یک سری برای همه فاکتورهای خرید.
**اتصال به خزانه:** پرداخت به تأمین‌کننده از ماژول دریافت/پرداخت؛ تسویه فاکتور خرید از همان منطق reconcile.
---
### ۶.۶ انبارداری
**دو لایه:**
1. **موجودی کالا در حسابداری** — فیلد `Product.stock`؛ با تأیید فاکتور خرید/فروش عوض می‌شود (`applyStockChanges` / `stockDeltas`).
2. **نمای عملیاتی FunZone** — رویداد/رزرو زنده (`funzoneInventory.ts`**سند حسابداری نمی‌زند**.
یعنی انبار «دفتر انبار جدا» ندارد؛ موجودی از فاکتورها مشتق می‌شود + نمای FunZone برای عملیات.
---
### ۶.۷ دریافت و پرداخت (خزانه)
| نوع تراکنش | سند تقریبی |
|------------|------------|
| شارژ کیف پول مشتری | بدهکار بانک / بستانکار 2006 |
| برداشت کیف پول | بدهکار 2006 / بستانکار بانک |
| دریافت عادی از مشتری | بدهکار بانک / بستانکار 1004 |
| پرداخت به تأمین‌کننده/مالک | بدهکار 2005 یا 2001 / بستانکار بانک |
| مسیر قدیمی split روی خزانه | همان ۱۰/۱۴/۷۶ (امروز ترجیح با سند فاکتور بلیت است) |
**شماره:** دریافت و پرداخت سری جدا در UI؛ مسیر برداشت ممکن است از `nextNumber` روی **کل** خزانه استفاده کند.
---
### ۶.۸ حقوق و دستمزد
CRUD کارمند و فیش حقوقی.
**اتصال به اسناد: فعلاً وجود ندارد.** حساب‌های 2003/2004/5002 در کدینگ آماده هستند ولی Payroll سند ثبت نمی‌کند.
---
### ۶.۹ گزارش‌ها
از توابع `src/utils/accounting.ts`:
- تراز آزمایشی (`trialBalance`)
- دفتر حساب (`accountLedger`)
- سود و زیان (`incomeStatement`)
- ترازنامه (`balanceSheet`)
ورودی: حساب‌ها (مانده افتتاحیه) + اسناد posted (فیلتر اسناد منسوخ).
---
### ۶.۱۰ تنظیمات
- اطلاعات شرکت و **سال مالی نمایشی**
- نگاشت حساب‌های پیش‌فرض اسناد خودکار
- ریست/seed داده‌ها (در صورت فعال بودن در UI)
بدون نگاشت درست، زنجیره فروش/خزانه/بلیت می‌شکند.
---
## ۷. موجودیت‌ها و روابط
```
Account (درخت)
└── VoucherLine.accountId → Voucher
Party (customer | supplier ، گاهی externalId از FunZone)
├── Invoice.partyId
├── PartyAdjustment.partyId
└── TreasuryTxn.partyId
Invoice (sale | purchase)
└── Voucher با source پایدار
TreasuryTxn
└── Voucher با source خزانه / FunZone / withdrawal
Employee → Payslip (بدون Voucher)
CompanySettings (singleton) → FKs به Account
```
**وضعیت فاکتور:** `draft` | `confirmed` | `paid` | `cancelled`
**وضعیت سند:** `draft` | `posted`
---
## ۸. فایل‌های کلیدی (مرجع سریع)
| فایل | نقش |
|------|-----|
| `src/utils/id.ts` | `createId` / `nextNumber` |
| `src/pages/sales/salesUtils.ts` | همه سازنده‌های سند + تسویه + source |
| `src/pages/sales/invoiceEffects.ts` | اثر جانبی ذخیره فاکتور |
| `src/utils/funzoneFees.ts` | تقسیم ۱۰٪ / ۱۴٪ / ۷۶٪ |
| `src/pages/sales/funzoneVoucherLink.ts` | لینک بلیت و حذف سند تکراری خزانه |
| `src/accounting/unifiedVoucherSync.ts` | ارکستراسیون سینک |
| `src/accounting/voucherDedupe.ts` | حذف/مخفی اسناد تکراری |
| `src/pages/sales/funzoneSalesSync.ts` | پرداخت FunZone → فاکتور |
| `src/pages/treasury/funzoneTreasurySync.ts` | کیف پول / مالک → خزانه |
| `src/utils/accounting.ts` | محاسبات گزارش و تراز |
| `src/store/AppStore.tsx` | بارگذاری و CRUD |
| `backend/.../serializers.py` | شماره قطعی سند |
| `backend/.../seeding.py` | کدینگ اولیه |
> ⚠️ `voucherSync.ts` مسیر قدیمی است؛ برای جلوگیری از دوباره‌ثبتی از مسیر یکپارچه (`unifiedVoucherSync`) استفاده شود.
---
## ۹. جمع‌بندی قوانین شماره‌گذاری (برای تیم)
1. **فقط شماره سند حسابداری روی سرور قطعی است**؛ بقیه `max+1` سمت کلاینت روی لیست فیلترشده.
2. **سال مالی شماره‌ها را جدا یا ریست نمی‌کند.**
3. **پیش‌فاکتور و فاکتور فروش سری جدا** دارند؛ **خرید یک سری** دارد.
4. **دریافت و پرداخت در UI سری جدا** دارند؛ مسیر برداشت ممکن است با سری دیگر هم‌پوشانی عددی داشته باشد.
5. **بلیت FunZone:** فاکتور + سند با تگ `funzone:invoice:…`؛ تقسیم کارمزد روی همان سند؛ خزانه برای همان بلیت نباید دوبار ثبت شود.
6. **حقوق هنوز به دفتر کل وصل نیست.**
---
*آخرین به‌روزرسانی بر اساس کدبیس `funzone-accounting` و `funzone-accounting-backend`.*

View File

@@ -1,7 +1,7 @@
import { useCallback, useRef, useState } from 'react'
import { apiGet } from '../api/client'
import { apiGet, asList } from '../api/client'
import { ENDPOINTS } from '../api/config'
import type { ApiPaymentOrRefund } from '../api/types'
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
import type { AppData, Invoice } from '../types'
import { useStore } from '../store/AppStore'
import { useFunZoneTreasurySync } from '../pages/treasury/useFunZoneTreasurySync'
@@ -26,7 +26,14 @@ export interface UnifiedFunZoneSyncStats {
}
export function useUnifiedFunZoneVoucherSync() {
const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore()
const {
data,
upsertInvoiceAsync,
upsertVoucherAsync,
removeVoucherAsync,
removeInvoiceAsync,
reload,
} = useStore()
const treasury = useFunZoneTreasurySync()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -37,33 +44,50 @@ export function useUnifiedFunZoneVoucherSync() {
dataRef.current = data
const applyInvoiceIfChanged = useCallback(
(working: AppData, invoice: Invoice): AppData => {
async (working: AppData, invoice: Invoice): Promise<AppData> => {
const previous = working.invoices.find((i) => i.id === invoice.id)
if (previous && invoiceUnchanged(previous, invoice) && previous.status === invoice.status) {
return working
}
// Build effects against the invoice we intend to save (stable FunZone voucher source).
const effects = invoiceSideEffects(invoice, previous ?? null, working)
upsertInvoice(invoice)
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
if (effects.voucher) upsertVoucher(effects.voucher)
const saved = await upsertInvoiceAsync(invoice)
const savedInvoice = { ...invoice, id: saved.id }
// Rebuild voucher with saved id so legacy invoice:{uuid} fallback also works.
const reboundEffects = invoiceSideEffects(savedInvoice, previous ?? null, {
...working,
invoices: [...working.invoices.filter((i) => i.id !== previous?.id && i.id !== saved.id), savedInvoice],
})
const voucherBuilt = reboundEffects.voucher ?? effects.voucher
const removeVoucherId = reboundEffects.removeVoucherId ?? effects.removeVoucherId
const voucherToSave =
voucherBuilt && removeVoucherId
? { ...voucherBuilt, id: removeVoucherId }
: voucherBuilt
if (!voucherToSave && removeVoucherId) {
await removeVoucherAsync(removeVoucherId)
}
if (voucherToSave) await upsertVoucherAsync(voucherToSave)
let next: AppData = {
...working,
invoices: [...working.invoices.filter((i) => i.id !== invoice.id), invoice],
invoices: [...working.invoices.filter((i) => i.id !== previous?.id && i.id !== saved.id), saved],
vouchers: [
...working.vouchers.filter(
(v) => v.id !== effects.removeVoucherId && v.id !== effects.voucher?.id,
(v) => v.id !== removeVoucherId && v.id !== voucherToSave?.id && v.source !== voucherToSave?.source,
),
...(effects.voucher ? [effects.voucher] : []),
...(voucherToSave ? [voucherToSave] : []),
],
}
if (invoice.partyId && invoice.status !== 'draft') {
for (const settled of reconcileInvoicePaymentStatus(invoice.partyId, next)) {
if (savedInvoice.partyId && savedInvoice.status !== 'draft' && savedInvoice.status !== 'cancelled') {
for (const settled of reconcileInvoicePaymentStatus(savedInvoice.partyId, next)) {
const current = next.invoices.find((i) => i.id === settled.id)
if (current?.status === settled.status) continue
upsertInvoice(settled)
await upsertInvoiceAsync(settled)
next = {
...next,
invoices: next.invoices.map((i) => (i.id === settled.id ? settled : i)),
@@ -73,28 +97,43 @@ export function useUnifiedFunZoneVoucherSync() {
return next
},
[upsertInvoice, upsertVoucher, removeVoucher],
[upsertInvoiceAsync, upsertVoucherAsync, removeVoucherAsync],
)
const syncSalesInvoices = useCallback(async () => {
const syncSalesInvoices = useCallback(async (customerPayments: ApiPaymentOrRefund[]) => {
const current = dataRef.current
const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
).then((res) => res.payments_and_refunds ?? [])
const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } =
buildFunZoneSalesSync(current, customerPayments)
const {
toUpsert,
toRemoveIds,
toRemoveVoucherIds,
skipped,
unmatchedCustomers,
paymentCount,
cancellationCount,
} = buildFunZoneSalesSync(current, customerPayments)
for (const voucherId of toRemoveVoucherIds) {
await removeVoucherAsync(voucherId)
}
for (const invoiceId of toRemoveIds) {
await removeInvoiceAsync(invoiceId)
}
const afterCleanup = await reload()
if (afterCleanup) dataRef.current = afterCleanup
const base = dataRef.current
const saved = assignInvoiceIds(toUpsert)
let created = 0
let updated = 0
let working = { ...current }
let working = { ...base }
for (const invoice of saved) {
const isNew = !current.invoices.some((i) => i.id === invoice.id)
const isNew = !base.invoices.some((i) => i.id === invoice.id)
if (isNew) created += 1
else updated += 1
working = applyInvoiceIfChanged(working, invoice)
working = await applyInvoiceIfChanged(working, invoice)
}
return {
@@ -105,7 +144,13 @@ export function useUnifiedFunZoneVoucherSync() {
funzonePayments: paymentCount,
funzoneCancellations: cancellationCount,
}
}, [applyInvoiceIfChanged])
}, [applyInvoiceIfChanged, removeInvoiceAsync, removeVoucherAsync, reload])
const legacyFunZoneVoucherIds = useCallback((working: AppData): string[] => {
return working.vouchers
.filter((voucher) => Boolean(voucher.source?.match(/^funzone:(cust|owner-txn):/)))
.map((voucher) => voucher.id)
}, [])
const sync = useCallback(async () => {
if (syncingRef.current) return
@@ -113,19 +158,27 @@ export function useUnifiedFunZoneVoucherSync() {
setLoading(true)
setError(null)
try {
const treasuryStats = await treasury.sync()
const [customerPayments, ownerTransactions] = await Promise.all([
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
(res) => res.payments_and_refunds ?? [],
),
apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
])
const treasuryStats = await treasury.sync({ customerPayments, ownerTransactions })
const freshAfterTreasury = await reload()
if (freshAfterTreasury) dataRef.current = freshAfterTreasury
const salesResult = await syncSalesInvoices()
const salesResult = await syncSalesInvoices(customerPayments)
const freshAfterSales = await reload()
const working = freshAfterSales ?? dataRef.current
const duplicateIds = supersededVoucherIds(working)
for (const voucherId of duplicateIds) {
removeVoucher(voucherId)
const staleLegacyIds = legacyFunZoneVoucherIds(working)
const toDeleteIds = new Set([...duplicateIds, ...staleLegacyIds])
for (const voucherId of toDeleteIds) {
await removeVoucherAsync(voucherId)
}
await reload()
setStats({
treasuryCreated: treasuryStats?.created ?? 0,
@@ -133,7 +186,7 @@ export function useUnifiedFunZoneVoucherSync() {
treasurySkipped: treasuryStats?.skipped ?? 0,
treasuryRemoved: treasuryStats?.removed ?? 0,
...salesResult,
duplicatesRemoved: duplicateIds.length,
duplicatesRemoved: toDeleteIds.size,
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
})
setLastSyncedAt(new Date().toISOString())
@@ -143,7 +196,7 @@ export function useUnifiedFunZoneVoucherSync() {
syncingRef.current = false
setLoading(false)
}
}, [treasury, syncSalesInvoices, reload, removeVoucher])
}, [treasury, syncSalesInvoices, reload, removeVoucherAsync, legacyFunZoneVoucherIds])
return { sync, loading, error, stats, lastSyncedAt }
}

View File

@@ -13,7 +13,7 @@ function treasuryVoucherForPaymentSource(
): Voucher | undefined {
const txn = data.treasury.find((t) => t.source === paymentSource)
if (!txn) return undefined
return data.vouchers.find((v) => v.source === voucherSourceForTreasury(txn.id))
return data.vouchers.find((v) => v.source === voucherSourceForTreasury(txn))
}
/** Voucher ids superseded by a more complete treasury or invoice-linked record. */

View File

@@ -1,3 +1,7 @@
/**
* LEGACY — superseded by `useUnifiedFunZoneVoucherSync` (sales invoices + treasury).
* Do not wire this hook into the UI; it can double-book ticket/cancel/withdraw flows.
*/
import { useCallback } from 'react'
import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types'
import {

View File

@@ -44,6 +44,8 @@ export const ENDPOINTS = {
? `/payments/admin/customer-payments-refunds/?customer_id=${customerId}`
: '/payments/admin/customer-payments-refunds/',
WITHDRAWALS: '/payments/admin/withdrawals/',
UPDATE_WITHDRAWAL_STATUS: (withdrawalId: string) =>
`/payments/admin/withdrawal-status/${withdrawalId}/`,
} as const
/** Endpoints served by the standalone accounting backend (relative to its base URL). */

View File

@@ -37,12 +37,14 @@ const statusLabels: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
confirmed: 'قطعی',
paid: 'تسویه‌شده',
cancelled: 'لغو شده',
}
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green' | 'rose'> = {
draft: 'amber',
confirmed: 'blue',
paid: 'green',
cancelled: 'rose',
}
export function TradeModule({ config }: { config: TradeConfig }) {

View File

@@ -18,7 +18,12 @@ interface MonthBucket {
}
function isSaleInvoice(invoice: ReturnType<typeof useStore>['data']['invoices'][number]): boolean {
return invoice.kind === 'sale' && (invoice.documentType ?? 'invoice') === 'invoice' && invoice.status !== 'draft'
return (
invoice.kind === 'sale' &&
(invoice.documentType ?? 'invoice') === 'invoice' &&
invoice.status !== 'draft' &&
invoice.status !== 'cancelled'
)
}
function monthlyBuckets(

View File

@@ -1,664 +1,432 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useStore } from '../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
JalaliDateInput,
Modal,
PageHeader,
Select,
StatCard,
Textarea,
type Column,
} from '../components/ui'
import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types'
import { createId, nextNumber } from '../utils/id'
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
import { buildTreasuryVoucher, customers, reconcileInvoicePaymentStatus, suppliers, voucherSourceForTreasury } from './sales/salesUtils'
import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync'
import {
buildTreasuryVoucher,
customers,
reconcileInvoicePaymentStatus,
suppliers,
voucherSourceForTreasury,
} from './sales/salesUtils'
import { isFunZoneTreasuryTxn, isMisplacedTicketTreasuryTxn } from './treasury/funzoneTreasurySync'
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' }
export function Treasury() {
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
const partyName = (id: string | null) =>
id ? data.parties.find((p) => p.id === id)?.name ?? '—' : '—'
const funZoneCounts = useMemo(() => {
const fz = data.treasury.filter(isFunZoneTreasuryTxn)
return {
receipts: fz.filter((t) => t.kind === 'receipt').length,
payments: fz.filter((t) => t.kind === 'payment').length,
}
}, [data.treasury])
const summary = useMemo(() => {
const receipts = data.treasury.filter((t) => t.kind === 'receipt').reduce((s, t) => s + t.amount, 0)
const payments = data.treasury.filter((t) => t.kind === 'payment').reduce((s, t) => s + t.amount, 0)
const platformProfit = data.treasury.reduce((s, t) => s + (t.platformProfitAmount ?? 0), 0)
return { receipts, payments, net: receipts - payments, platformProfit }
}, [data.treasury])
const sorted = useMemo(
() => [...data.treasury].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
/** Hide legacy ticket/refund rows until همگام‌سازی removes them. */
const visibleTreasury = useMemo(
() => data.treasury.filter((t) => !isMisplacedTicketTreasuryTxn(t)),
[data.treasury],
)
const funZoneCounts = useMemo(() => {
const fz = visibleTreasury.filter(isFunZoneTreasuryTxn)
return {
receipts: fz.filter((t) => t.kind === 'receipt').length,
payments: fz.filter((t) => t.kind === 'payment').length,
}
}, [visibleTreasury])
const summary = useMemo(() => {
const receipts = visibleTreasury
.filter((t) => t.kind === 'receipt')
.reduce((s, t) => s + t.amount, 0)
const payments = visibleTreasury
.filter((t) => t.kind === 'payment')
.reduce((s, t) => s + t.amount, 0)
return { receipts, payments, net: receipts - payments }
}, [visibleTreasury])
const sorted = useMemo(
() => [...visibleTreasury].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
[visibleTreasury],
)
const treasuryParties = useMemo(() => {
if (!draft) return []
const list = draft.kind === 'receipt' ? customers(data.parties) : suppliers(data.parties)
return list.filter((p) => p.id)
const list =
draft.kind === 'receipt'
? customers(data.parties)
: [...customers(data.parties), ...suppliers(data.parties)]
const seen = new Set<string>()
return list.filter((p) => {
if (!p.id || seen.has(p.id)) return false
seen.add(p.id)
return true
})
}, [data.parties, draft])
const createDraft = (kind: TreasuryKind): TreasuryTxn => ({
id: '',
number: nextNumber(data.treasury.filter((t) => t.kind === kind)),
number: nextNumber(visibleTreasury.filter((t) => t.kind === kind)),
kind,
method: 'bank',
date: todayISO(),
partyId: null,
amount: 0,
reference: '',
description: '',
source: '',
})
const handleSave = () => {
if (!draft || draft.amount <= 0) return
const saved: TreasuryTxn = {
...draft,
id: draft.id || createId('tr-'),
source: draft.source ?? '',
}
upsertTreasury(saved)
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved.id))
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved))
const newVoucher = buildTreasuryVoucher(saved, data)
if (existingVoucher) removeVoucher(existingVoucher.id)
if (newVoucher) upsertVoucher(newVoucher)
if (saved.partyId) {
const dataAfterSave = {
...data,
treasury: [...data.treasury.filter((t) => t.id !== saved.id), saved],
}
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
upsertInvoice(inv)
}
}
setDraft(null)
}
const columns: Array<Column<TreasuryTxn>> = [
{ key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) },
{
key: 'kind',
header: 'نوع',
render: (t) => <Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>,
render: (t) => (
<Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>
),
},
{
key: 'source',
header: 'منبع',
render: (t) =>
isFunZoneTreasuryTxn(t) ? (
<Badge tone="indigo">فانزون</Badge>
) : (
<Badge tone="slate">سپیدار</Badge>
<Badge tone="slate">دستی</Badge>
),
},
{ key: 'method', header: 'روش', render: (t) => <Badge tone="slate">{methodLabels[t.method]}</Badge> },
{
key: 'method',
header: 'روش',
render: (t) => <Badge tone="slate">{methodLabels[t.method]}</Badge>,
},
{ key: 'party', header: 'طرف حساب', render: (t) => partyName(t.partyId) },
{
key: 'details',
header: 'شرح',
render: (t) => (
<span className="line-clamp-2 max-w-xs text-xs text-slate-500" title={t.description}>
{t.description.split('\n')[0] || '—'}
</span>
),
},
{ key: 'ref', header: 'مرجع', render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span> },
{
key: 'ref',
header: 'مرجع',
render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span>,
},
{
key: 'amount',
header: 'مبلغ',
align: 'end',
render: (t) => (
<span className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}>
<span
className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}
>
{t.kind === 'receipt' ? '+' : ''} {formatMoney(t.amount)}
</span>
),
},
{
key: 'actions',
header: '',
align: 'end',
render: (t) => (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setDraft({ ...t })}
>
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}>
{isFunZoneTreasuryTxn(t) ? 'جزئیات' : 'ویرایش'}
</Button>
{!isFunZoneTreasuryTxn(t) && (
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
<Button
size="sm"
variant="ghost"
className="text-rose-600"
onClick={() => setToDelete(t)}
>
حذف
</Button>
)}
</div>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="دریافت و پرداخت (خزانه‌داری)"
subtitle="دریافت از مشتریان (فان‌زون) و پرداخت به مالکان — همگام با سپیدار (سند + تسویه فاکتور)"
subtitle="شارژ کیف پول مشتری و پرداخت به مالکان — همگام‌سازی از اسناد حسابداری"
actions={
<>
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
دریافت دستی
</Button>
<Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}>
پرداخت دستی
</Button>
</>
}
/>
<Card className="border-brand-200 bg-brand-50/40 p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="font-bold text-slate-800">اتصال فانزون خزانه</h3>
<ul className="mt-2 space-y-1 text-sm text-slate-600">
<li> <strong>دریافت:</strong> پرداخت مشتری مبلغ کل رویداد (رزرو/بلیت)</li>
<li> تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ سود در حساب درآمد (۴۰۰۱)</li>
<li> <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li>
<li> واریز کیف پول مالک و سهم رزرو در خزانه نیست یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li>
<li>
برای همگامسازی خودکار از فانزون به{' '}
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
اسناد حسابداری
</Link>{' '}
بروید.
</li>
</ul>
</div>
</div>
<h3 className="font-bold text-slate-800">اتصال فانزون خزانه</h3>
<ul className="mt-2 space-y-1 text-sm text-slate-600">
<li>
<strong>دریافت:</strong> شارژ کیف پول مشتری (بدون مالیات و ۱۴٪) بدهی به مشتری تا
زمان برداشت
</li>
<li>
<strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (وضعیت انجامشده)
</li>
<li>
فروش بلیت {' '}
<Link
to="/sales/invoice"
className="font-medium text-brand-700 underline decoration-dotted underline-offset-4"
>
فاکتور فروش
</Link>{' '}
(مالیات ۱۰٪ + سود ۱۴٪ + سهم تامینکننده)
</li>
<li>
برداشت کیف پول مشتری به شبا {' '}
<Link
to="/sales"
className="font-medium text-brand-700 underline decoration-dotted underline-offset-4"
>
فروش / لغو و برداشت
</Link>
</li>
<li>
همگامسازی خودکار در{' '}
<Link
to="/vouchers"
className="font-medium text-brand-700 underline decoration-dotted underline-offset-4"
>
اسناد حسابداری
</Link>
</li>
</ul>
</Card>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="جمع دریافت‌ها" value={formatMoney(summary.receipts)} icon="↓" tone="green" />
<StatCard label="جمع پرداخت‌ها" value={formatMoney(summary.payments)} icon="↑" tone="rose" />
<StatCard
label="مانده خالص"
value={formatMoney(summary.net)}
icon="💰"
tone={summary.net >= 0 ? 'brand' : 'rose'}
/>
<StatCard label="دریافت فان‌زون" value={toFa(funZoneCounts.receipts)} icon="👤" tone="brand" />
<StatCard label="سود پلتفرم (۱۴٪)" value={formatMoney(summary.platformProfit)} icon="📈" tone="green" />
<StatCard
label="فان‌زون"
value={`${toFa(funZoneCounts.receipts)} ↓ / ${toFa(funZoneCounts.payments)}`}
icon="👤"
tone="brand"
/>
</div>
<Card className="p-4">
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است — همگام‌سازی فان‌زون را بزنید" />
<DataTable
columns={columns}
rows={sorted}
rowKey={(t) => t.id}
emptyTitle="تراکنشی ثبت نشده — از اسناد حسابداری همگام‌سازی کنید"
/>
</Card>
<Modal
open={draft !== null}
title={
draft
? isFunZoneTreasuryTxn(draft)
? `${kindLabels[draft.kind]} #${toFa(draft.number)} (فان‌زون)`
: draft.id
? `${kindLabels[draft.kind]} #${toFa(draft.number)}`
: `${kindLabels[draft.kind]} جدید`
: ''
}
onClose={() => setDraft(null)}
footer={
<>
<Button variant="secondary" onClick={() => setDraft(null)}>
{draft && isFunZoneTreasuryTxn(draft) ? 'بستن' : 'انصراف'}
</Button>
{draft && !isFunZoneTreasuryTxn(draft) && <Button onClick={handleSave}>ذخیره</Button>}
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{isFunZoneTreasuryTxn(draft) ? (
<div className="sm:col-span-2">
<pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
{draft.description}
</pre>
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-slate-400">طرف حساب</dt>
<dd className="font-medium">{partyName(draft.partyId)}</dd>
</div>
<div>
<dt className="text-slate-400">مبلغ</dt>
<dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
</div>
<div>
<dt className="text-slate-400">تاریخ</dt>
<dd>{formatDate(draft.date)}</dd>
</div>
<div>
<dt className="text-slate-400">مرجع</dt>
<dd className="font-mono">{draft.reference || '—'}</dd>
</div>
</dl>
</div>
) : (
<>
<Field label="تاریخ">
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
</Field>
<Field label="روش">
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
{Object.entries(methodLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</Field>
<Field label="طرف حساب">
<Select
value={draft.partyId ?? ''}
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
>
<option value=""> بدون طرف حساب </option>
{treasuryParties.map((party) => (
<option key={party.id} value={party.id}>
{party.name}
</option>
))}
</Select>
</Field>
<Field label="مبلغ (تومان)">
<Input type="number" value={draft.amount} onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })} />
</Field>
<Field label="شماره مرجع / چک">
<Input value={draft.reference} onChange={(e) => setDraft({ ...draft, reference: e.target.value })} />
</Field>
<div className="sm:col-span-2">
<Field label="توضیحات">
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
</Field>
</div>
</>
{draft && !isFunZoneTreasuryTxn(draft) && (
<Button onClick={handleSave}>ذخیره</Button>
)}
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{isFunZoneTreasuryTxn(draft) ? (
<div className="sm:col-span-2">
<pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
{draft.description}
</pre>
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-slate-400">طرف حساب</dt>
<dd className="font-medium">{partyName(draft.partyId)}</dd>
</div>
<div>
<dt className="text-slate-400">مبلغ</dt>
<dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
</div>
<div>
<dt className="text-slate-400">تاریخ</dt>
<dd>{formatDate(draft.date)}</dd>
</div>
<div>
<dt className="text-slate-400">مرجع</dt>
<dd className="font-mono">{draft.reference || '—'}</dd>
</div>
</dl>
</div>
) : (
<>
<Field label="تاریخ">
<JalaliDateInput
value={draft.date}
onChange={(date) => setDraft({ ...draft, date })}
/>
</Field>
<Field label="روش">
<Select
value={draft.method}
onChange={(e) =>
setDraft({ ...draft, method: e.target.value as TreasuryMethod })
}
>
{Object.entries(methodLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</Field>
<Field label="طرف حساب">
<Select
value={draft.partyId ?? ''}
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
>
<option value=""> بدون طرف حساب </option>
{treasuryParties.map((party) => (
<option key={party.id} value={party.id}>
{party.name}
{party.kind === 'supplier' ? ' (تامین‌کننده)' : ' (مشتری)'}
</option>
))}
</Select>
</Field>
<Field label="مبلغ (تومان)">
<Input
type="number"
value={draft.amount}
onChange={(e) =>
setDraft({ ...draft, amount: Number(e.target.value) || 0 })
}
/>
</Field>
<Field label="شماره مرجع / چک">
<Input
value={draft.reference}
onChange={(e) => setDraft({ ...draft, reference: e.target.value })}
/>
</Field>
<div className="sm:col-span-2">
<Field label="توضیحات">
<Textarea
rows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
/>
</Field>
</div>
</>
)}
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف تراکنش"
message="این تراکنش خزانه حذف شود؟"
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) {
const voucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(toDelete.id))
const voucher = data.vouchers.find(
(v) => v.source === voucherSourceForTreasury(toDelete) || v.source === voucherSourceForTreasury(toDelete.id),
)
if (voucher) removeVoucher(voucher.id)
removeTreasury(toDelete.id)
if (toDelete.partyId) {
const dataAfterDelete = {
...data,
treasury: data.treasury.filter((t) => t.id !== toDelete.id),
}
for (const inv of reconcileInvoicePaymentStatus(toDelete.partyId, dataAfterDelete)) {
for (const inv of reconcileInvoicePaymentStatus(
toDelete.partyId,
dataAfterDelete,
)) {
upsertInvoice(inv)
}
}
}
setToDelete(null)
}}
/>
</div>
)
}

View File

@@ -139,7 +139,7 @@ export function Vouchers() {
className: 'w-[7rem] whitespace-nowrap',
render: (v) => {
const cat = classifyVoucher(v, data)
const tone = cat === 'customer' ? 'blue' : cat === 'supplier' ? 'amber' : 'slate'
const tone = cat === 'manager' ? 'blue' : 'slate'
return <Badge tone={tone}>{voucherCategoryLabel(cat)}</Badge>
},
},
@@ -250,8 +250,8 @@ export function Vouchers() {
<div className="min-w-0 flex-1">
<h3 className="font-bold text-slate-800">صدور خودکار از فانزون</h3>
<p className="mt-1 text-sm leading-relaxed text-slate-600">
یکبار همگام‌سازی: دریافت/پرداخت خزانه، فاکتور فروش، و سند حسابداری بدون تکرار.
برای هر تراکنش فقط سند کامل (با تفکیک مالیات و سود) نگه داشته میشود.
یکبار همگام‌سازی: شارژ/برداشت کیف پول در خزانه، فروش بلیت بهصورت فاکتور فروش
(مالیات ۱۰٪ + سود ۱۴٪ + سهم تامینکننده)، و سند حسابداری بدون تکرار.
</p>
{lastSyncedAt ? (
<p className="mt-2 text-xs text-slate-500">
@@ -272,9 +272,8 @@ export function Vouchers() {
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard label="همه اسناد" value={toFa(displayedVouchers.length)} />
<StatCard label="مشتریان" value={toFa(summary.customer)} tone="brand" />
<StatCard label="تأمین‌کنندگان" value={toFa(summary.supplier)} tone="amber" />
<StatCard label="دستی" value={toFa(summary.manual)} />
<StatCard label="مدیر" value={toFa(summary.manager)} tone="brand" />
</div>
<Card className="p-4">
@@ -282,9 +281,8 @@ export function Vouchers() {
<Field label="دسته">
<Select value={category} onChange={(e) => setCategory(e.target.value as VoucherCategoryFilter)}>
<option value="all">همه</option>
<option value="customer">مشتریان</option>
<option value="supplier">تأمینکنندگان</option>
<option value="manual">دستی</option>
<option value="manager">مدیر</option>
</Select>
</Field>
<Field label="از تاریخ">
@@ -378,7 +376,8 @@ export function Vouchers() {
<Input
type="number"
value={draft.number}
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
readOnly
disabled
/>
</Field>
<Field label="تاریخ">

View File

@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'
/**
* Smoke tests for FunZone category inventory logic.
* Run: npx tsx src/pages/inventory/funzoneInventory.test.ts
*/
import assert from 'node:assert/strict'
import {
buildBuyerRows,
buildCategoryInventoryRows,
eventLifecycle,
type InventoryEvent,
type InventoryReservation,
} from './funzoneInventory'
} from './funzoneInventory.ts'
const categories = [{ id: 'cat-1', name: 'بازی' }]
@@ -17,6 +21,7 @@ const events: InventoryEvent[] = [
price: 100,
category: { id: 'cat-1', name: 'بازی' },
start_time: '2099-01-01T10:00:00Z',
event_status: 'upcoming',
},
{
id: 'evt-done',
@@ -25,6 +30,7 @@ const events: InventoryEvent[] = [
price: 100,
category: { id: 'cat-1', name: 'بازی' },
start_time: '2020-01-01T10:00:00Z',
event_status: 'completed',
},
{
id: 'evt-failed',
@@ -33,6 +39,7 @@ const events: InventoryEvent[] = [
price: 100,
category: { id: 'cat-1', name: 'بازی' },
start_time: '2020-01-01T10:00:00Z',
event_status: 'cancelled',
},
]
@@ -69,37 +76,46 @@ const reservations: InventoryReservation[] = [
},
]
describe('funzoneInventory', () => {
it('classifies lifecycle by start time and minimum seats', () => {
const now = Date.parse('2025-01-01T00:00:00Z')
expect(eventLifecycle(events[0], 3, now)).toBe('inProcess')
expect(eventLifecycle(events[1], 2, now)).toBe('completed')
expect(eventLifecycle(events[2], 4, now)).toBe('canceled')
})
assert.equal(eventLifecycle(events[0], 3), 'inProcess')
assert.equal(eventLifecycle(events[1], 2), 'completed')
assert.equal(eventLifecycle(events[2], 4), 'canceled')
it('aggregates category counts and bought/canceled tickets', () => {
const now = Date.parse('2025-01-01T00:00:00Z')
const rows = buildCategoryInventoryRows(categories, events, reservations, now)
expect(rows).toHaveLength(1)
expect(rows[0].inProcessCount).toBe(1)
expect(rows[0].completedCount).toBe(1)
expect(rows[0].canceledCount).toBe(1)
expect(rows[0].boughtTickets).toBe(5)
expect(rows[0].canceledTickets).toBe(4)
})
const now = Date.parse('2025-01-01T00:00:00Z')
assert.equal(
eventLifecycle({ id: 'a', start_time: '2099-01-01T10:00:00Z', minimum: 5 }, 3, now),
'inProcess',
)
assert.equal(
eventLifecycle(
{
id: 'closed-success',
start_time: '2099-06-01T10:00:00Z',
minimum: 10,
event_status: 'completed',
},
2,
now,
),
'completed',
)
it('shows confirmed buyers for failed events in canceled lifecycle', () => {
const now = Date.parse('2025-01-01T00:00:00Z')
const buyers = buildBuyerRows('cat-1', events, reservations, new Map(), {
lifecycle: 'canceled',
dateFrom: '',
dateTo: '',
search: '',
sort: 'mostTickets',
nowMs: now,
})
expect(buyers).toHaveLength(1)
expect(buyers[0].customerName).toBe('رضا کریمی')
expect(buyers[0].tickets).toBe(4)
})
const rows = buildCategoryInventoryRows(categories, events, reservations)
assert.equal(rows.length, 1)
assert.equal(rows[0].inProcessCount, 1)
assert.equal(rows[0].completedCount, 1)
assert.equal(rows[0].canceledCount, 1)
assert.equal(rows[0].boughtTickets, 5)
assert.equal(rows[0].canceledTickets, 4)
const buyers = buildBuyerRows('cat-1', events, reservations, new Map(), {
lifecycle: 'canceled',
dateFrom: '',
dateTo: '',
search: '',
sort: 'mostTickets',
})
assert.equal(buyers.length, 1)
assert.equal(buyers[0].customerName, 'رضا کریمی')
assert.equal(buyers[0].tickets, 4)
console.log('funzoneInventory tests passed')

View File

@@ -12,6 +12,8 @@ export interface InventoryEvent {
price?: number
category?: { id?: string; name?: string } | null
start_time?: string | null
/** Backend event status: upcoming | ongoing | completed | cancelled */
event_status?: string | null
}
export interface InventoryReservation {
@@ -58,6 +60,12 @@ export function eventLifecycle(
seatsForMinimum: number,
nowMs: number = Date.now(),
): EventLifecycle {
const status = (event.event_status ?? '').toLowerCase()
if (status === 'completed') return 'completed'
if (status === 'cancelled' || status === 'canceled') return 'canceled'
if (status === 'upcoming' || status === 'ongoing') return 'inProcess'
// Fallback when event_status is missing: start_time + minimum seats
const startMs = event.start_time ? Date.parse(event.start_time) : NaN
const started = Number.isFinite(startMs) && startMs <= nowMs
if (!started) return 'inProcess'

View File

@@ -6,7 +6,7 @@ export function FunZoneSalesSyncBanner() {
<Card className="border-slate-200 bg-slate-50/80 p-4">
<h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
<p className="mt-1 text-sm leading-relaxed text-slate-600">
برای دریافت پرداختهای رویداد، فاکتور فروش و سند حسابداری از فانزون، از صفحه{' '}
برای همگامسازی فروش بلیت (فاکتور با مالیات و ۱۴٪)، لغو بلیت (فاکتور لغو شده)، و اسناد حسابداری از فانزون، از صفحه{' '}
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
اسناد حسابداری
</Link>{' '}

View File

@@ -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<string>
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 (
<button
type="button"
disabled={busy || withdrawal.status === 'failed'}
onClick={() => 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)}
</button>
)
}
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<string>
showBookPayment: boolean
onToggleStatus: (withdrawal: ApiWithdrawal) => void
onBookPayment: (withdrawal: ApiWithdrawal) => void
}) {
const columns: Array<Column<ApiWithdrawal>> = [
{
key: 'customer',
header: شتری',
key: 'user',
header: 'نام',
headerClassName: 'w-[11rem]',
className: 'w-[11rem]',
render: (w) => <CustomerCell withdrawal={w} />,
@@ -90,6 +142,19 @@ export function PendingWithdrawalsPanel({
<span className="tabular-nums text-base font-bold text-brand-700">{formatMoney(w.amount)}</span>
),
},
{
key: 'status',
header: 'وضعیت',
headerClassName: 'w-[7rem]',
className: 'w-[7rem] whitespace-nowrap',
render: (w) => (
<StatusToggleButton
withdrawal={w}
busy={updatingId === w.id}
onToggle={onToggleStatus}
/>
),
},
{
key: 'iban',
header: 'شماره شبا',
@@ -97,6 +162,17 @@ export function PendingWithdrawalsPanel({
className: 'min-w-[14rem]',
render: (w) => <IbanCell iban={w.iban} />,
},
{
key: 'description',
header: 'توضیحات',
headerClassName: 'min-w-[10rem]',
className: 'min-w-[10rem]',
render: (w) => (
<span className="text-sm text-slate-600" title={w.description || undefined}>
{w.description?.trim() || '—'}
</span>
),
},
{
key: 'requestedAt',
header: 'تاریخ درخواست',
@@ -104,14 +180,10 @@ export function PendingWithdrawalsPanel({
className: 'w-[6.5rem]',
render: (w) => <RequestDateCell iso={w.created_at} />,
},
{
key: 'status',
header: 'وضعیت',
headerClassName: 'w-[5.5rem]',
className: 'w-[5.5rem] whitespace-nowrap',
render: () => <Badge tone="amber">در انتظار</Badge>,
},
{
]
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 <Badge tone="green">پرداخت ثبت شد</Badge>
}
if (w.status === 'completed') {
return (
<Button
size="sm"
variant="secondary"
disabled={!customerList.find((p) => p.externalId === w.user_id) || updatingId === w.id}
onClick={() => onBookPayment(w)}
title="وضعیت تکمیل است ولی سند خزانه هنوز ثبت نشده"
>
ثبت پرداخت
</Button>
)
}
const party = customerList.find((p) => p.externalId === w.user_id)
return (
<Button
size="sm"
variant="secondary"
disabled={!party}
onClick={() => onCreateReturn(w)}
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
disabled={!party || updatingId === w.id}
onClick={() => onBookPayment(w)}
title={party ? 'ثبت پرداخت خزانه بدون فاکتور برگشتی' : 'ابتدا مشتری را همگام کنید'}
>
ثبت فاکتور برگشتی
ثبت پرداخت
</Button>
)
},
},
]
})
}
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 (
<Card className="border-amber-200 bg-gradient-to-b from-amber-50/60 to-white p-4 sm:p-5">
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-amber-100 pb-4">
<Card className="border-slate-200 bg-white p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 pb-4">
<div>
<h3 className="text-lg font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-500">
درخواستهای برداشت کیف پول مشتریان شبا، مبلغ و تاریخ هر درخواست جدا نمایش داده میشود.
</p>
<h3 className="text-lg font-bold text-slate-800">{title}</h3>
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-500">{subtitle}</p>
</div>
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
بروزرسانی
</Button>
</div>
<div className="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<StatCard
label="برداشت در انتظار"
value={toFa(openWithdrawals.length)}
label="در انتظار"
value={toFa(pendingCount)}
icon="⏳"
tone={openWithdrawals.length > 0 ? 'amber' : 'green'}
/>
<StatCard
label="جمع مبالغ"
value={formatMoney(openWithdrawals.reduce((s, w) => s + w.amount, 0))}
icon="💳"
tone="brand"
tone={pendingCount > 0 ? 'amber' : 'green'}
/>
<StatCard label="جمع در انتظار" value={formatMoney(pendingSum)} icon="💳" tone="brand" />
</div>
{error && (
<p className="mb-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</p>
)}
{loading ? (
<p className="py-8 text-center text-sm text-slate-400">در حال بارگذاری برداشتها</p>
) : (
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-sm">
<DataTable
columns={columns}
rows={openWithdrawals}
rows={rows}
rowKey={(w) => w.id}
emptyTitle="برداشت در انتظاری ثبت نشده است"
emptyTitle="برداشتی یافت نشد"
minWidth="52rem"
size="comfortable"
className="rounded-xl"
@@ -188,3 +264,73 @@ export function PendingWithdrawalsPanel({
</Card>
)
}
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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-slate-800">برداشتها</h2>
<p className="mt-1 text-sm text-slate-500">
وضعیت را بین «در انتظار» و «تکمیل شده» عوض کنید. تکمیلشده برای مشتری و مالک،
پرداخت خزانه ثبت میکند.
</p>
</div>
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
بروزرسانی
</Button>
</div>
{error && (
<p className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</p>
)}
<WithdrawalsTable
title="برداشت‌ها (مشتریان)"
subtitle="برداشت کیف پول — با تکمیل، پرداخت از خزانه ثبت می‌شود (بدون فاکتور برگشتی)."
rows={customerWithdrawals}
loading={loading}
updatingId={updatingId}
customerList={customerList}
bookedWithdrawalIds={bookedWithdrawalIds}
showBookPayment
onToggleStatus={onToggleStatus}
onBookPayment={onBookPayment}
/>
<WithdrawalsTable
title="برداشت‌ها (مالکان)"
subtitle="پس از تکمیل، پرداخت خزانه (پرداخت به مالک) همان لحظه ثبت می‌شود."
rows={ownerWithdrawals}
loading={loading}
updatingId={updatingId}
customerList={customerList}
bookedWithdrawalIds={bookedWithdrawalIds}
showBookPayment={false}
onToggleStatus={onToggleStatus}
onBookPayment={onBookPayment}
/>
</div>
)
}

View File

@@ -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<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green' | 'rose'> = {
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 && (
<PendingWithdrawalsPanel
withdrawals={pendingWithdrawals}
loading={pendingLoading}
error={pendingError}
returnInvoices={returnDocuments}
customerList={customerList}
onReload={() => void reloadPending()}
onCreateReturn={loadFromWithdrawal}
/>
)}
<Card className="p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
@@ -417,21 +349,6 @@ export function SalesDocumentSection({
</Field>
)}
{allowRelatedInvoice && !draft.id && saleInvoices.length > 0 && (
<Field label="برگشت از فاکتور">
<Select value="" onChange={(e) => e.target.value && loadFromInvoice(e.target.value)}>
<option value=""> انتخاب فاکتور فروش </option>
{saleInvoices
.filter((i) => i.status !== 'draft')
.map((i) => (
<option key={i.id} value={i.id}>
#{toFa(i.number)} · {partyName(i.partyId)} · {formatMoney(invoiceTotals(i).net)}
</option>
))}
</Select>
</Field>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="شماره">
<Input
@@ -457,7 +374,9 @@ export function SalesDocumentSection({
value={draft.status}
onChange={(e) => setDraft({ ...draft, status: e.target.value as InvoiceStatus })}
>
{Object.entries(statusLabels).map(([value, label]) => (
{Object.entries(statusLabels)
.filter(([value]) => value !== 'cancelled')
.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
@@ -502,7 +421,7 @@ export function SalesDocumentSection({
line={line}
products={data.products}
showEventName
withdrawalReturn={isWithdrawalReturn(draft)}
withdrawalReturn={false}
onChange={(patch) => updateLine(line.id, patch)}
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
/>
@@ -579,9 +498,7 @@ export function SalesDocumentSection({
{documentType !== 'proforma' && draft.status !== 'draft' && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
{isWithdrawalReturn(draft)
? 'برداشت کیف پول — موجودی انبار تغییر نمی‌کند؛ سند حسابداری برداشت ثبت می‌شود.'
: 'با وضعیت «قطعی» یا «تسویه‌شده»، موجودی انبار و سند حسابداری به‌روزرسانی می‌شود.'}
با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.
</p>
)}
</div>

View File

@@ -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 (
<SalesDocumentSection
documentType="return"
title="فاکتور برگشتی جدید"
subtitle="برگشت کالا یا ثبت فاکتور برگشتی برای برداشت‌های در انتظار مشتریان (از پنل مدیریت)"
allowRelatedInvoice
/>
)
return <SalesReturnPage />
}

View File

@@ -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() {
<Card className="p-5">
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
<p className="mb-6 text-sm text-slate-500">
جریان استاندارد فروش مشابه سپیدار: از پیشفاکتور تا فاکتور، دریافت و برگشت.
جریان استاندارد فروش: از پیشفاکتور تا فاکتور، دریافت، و لغو/برداشت.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -61,7 +61,7 @@ export function SalesProcess() {
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
<StatCard label="پیش‌فاکتور" value={toFa(summary.proformaCount)} hint={`${toFa(summary.openProformas)} باز`} />
<StatCard label="فاکتور فروش" value={toFa(summary.invoiceCount)} hint={formatMoney(summary.totalSales)} />
<StatCard label="فاکتور برگشتی" value={toFa(summary.returnCount)} hint={formatMoney(summary.totalReturns)} />
<StatCard label="فاکتور لغو شده" value={toFa(summary.cancelledCount)} hint={formatMoney(summary.totalCancelled)} />
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویه‌نشده`} />
<StatCard label="همگام فان‌زون" value={toFa(funzoneSyncedCount)} hint="فاکتور از پرداخت رویداد" tone="brand" />
</div>
@@ -73,7 +73,7 @@ export function SalesProcess() {
<li className="flex items-start gap-2">
<span>📦</span>
<span>
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش و فاکتور برگشتی آن را افزایش میدهد.
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش میدهد؛ فاکتور لغو شده اثر موجودی را برنمیگرداند اگر قبلاً لغو نشده باشد.
</span>
</li>
<li className="flex items-start gap-2">

View File

@@ -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<string | null>(null)
const [bookingId, setBookingId] = useState<string | null>(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<Column<Invoice>> = [
{
key: 'number',
header: 'شماره',
render: (i) => <span className="font-mono">{toFa(i.number)}</span>,
},
{ 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) => <span className="tabular-nums">{formatMoney(invoiceTotals(i).net)}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: () => <Badge tone="rose">{statusLabels.cancelled}</Badge>,
},
{
key: 'cancelledAt',
header: 'جزئیات',
render: (i) => {
const cancelLine = i.note.split('\n').find((l) => l.startsWith('تاریخ لغو:'))
return (
<span className="text-xs text-slate-500">
{cancelLine?.replace('تاریخ لغو: ', '') ?? '—'}
</span>
)
},
},
]
return (
<div className="space-y-4">
<FunZoneSalesSyncBanner />
<Card className="p-4 sm:p-5">
<div className="mb-4">
<h2 className="text-lg font-bold text-slate-800">برداشتها و فاکتورهای لغو شده</h2>
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-slate-500">
با لغو بلیت، همان فاکتور فروش «لغو شده» میشود (بدون مالیات/۱۴٪). برداشت مشتری فقط بهصورت
پرداخت خزانه ثبت میشود.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label="فاکتور لغو شده"
value={toFa(cancelledInvoices.length)}
icon="🚫"
tone={cancelledInvoices.length > 0 ? 'rose' : 'green'}
/>
<StatCard
label="جمع لغو شده"
value={formatMoney(cancelledInvoices.reduce((s, i) => s + invoiceTotals(i).net, 0))}
icon="↩️"
tone="amber"
/>
<StatCard label="برداشت‌ها" value={toFa(withdrawals.length)} icon="💳" tone="brand" />
</div>
</Card>
{(actionError || error) && (
<p className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{actionError || error}
</p>
)}
<PendingWithdrawalsPanel
withdrawals={withdrawals}
loading={loading}
updatingId={updatingId ?? bookingId}
error={null}
customerList={customerList}
bookedWithdrawalIds={bookedWithdrawalIds}
onReload={() => void reloadWithdrawals()}
onToggleStatus={(w) => void handleToggleStatus(w)}
onBookPayment={(w) => void bookWithdrawalPayment(w)}
/>
<Card className="p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 pb-4">
<div>
<h3 className="text-lg font-bold text-slate-800">فاکتورهای فروش لغو شده</h3>
<p className="mt-1 text-sm text-slate-500">
بلیتهایی که کاربر یا رویداد لغو کرده همان فاکتور فروش با وضعیت لغو شده.
</p>
</div>
<Link to="/sales/invoice" className="text-sm text-brand-600 hover:underline">
مشاهده فاکتورهای فروش
</Link>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-sm">
<DataTable
columns={cancelledColumns}
rows={cancelledInvoices}
rowKey={(i) => i.id}
emptyTitle="فاکتور لغو‌شده‌ای ثبت نشده است"
minWidth="48rem"
size="comfortable"
className="rounded-xl"
/>
</div>
</Card>
</div>
)
}

View File

@@ -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) => (
<div className="flex flex-col items-start gap-1">
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : i.status === 'cancelled' ? 'rose' : 'amber'}>
{statusLabels[i.status]}
</Badge>
{isFunZoneSalesInvoice(i) ? <Badge tone="slate">فانزون</Badge> : null}
@@ -96,7 +96,7 @@ export function SalesReview() {
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
<StatCard label="برگشت" value={formatMoney(summary.totalReturns)} />
<StatCard label="لغو شده" value={formatMoney(summary.totalCancelled)} />
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} />
<StatCard label="مشتریان" value={toFa(summary.customerCount)} />
</div>
@@ -106,7 +106,9 @@ export function SalesReview() {
<Field label="نوع سند">
<Select value={filterType} onChange={(e) => setFilterType(e.target.value as SalesDocumentType | 'all')}>
<option value="all">همه</option>
{Object.entries(documentTypeLabels).map(([value, label]) => (
{Object.entries(documentTypeLabels)
.filter(([value]) => value !== 'return')
.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>

View File

@@ -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<AppData, 'parties' | 'invoices' | 'vouchers'> => ({
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')

View File

@@ -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}\اکتور خودکار از فان‌زون — ${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<AppData, 'parties' | 'invoices'>,
data: Pick<AppData, 'parties' | 'invoices' | 'vouchers'>,
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[] {

View File

@@ -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')

View File

@@ -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<AppData, 'treasury'>,
): 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<AppData, 'treasury'>,
): 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

View File

@@ -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: '👤' },

View File

@@ -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()

View File

@@ -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<SalesDocumentType, string> = {
proforma: 'پیش‌فاکتور',
invoice: 'فاکتور فروش',
return: 'فاکتور برگشتی',
/** Legacy only — app no longer creates return documents. */
return: 'فاکتور برگشتی (قدیمی)',
}
export const statusLabels: Record<InvoiceStatus, string> = {
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,

View File

@@ -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<ApiWithdrawal[]>([])
const [loading, setLoading] = useState(false)
const [updatingId, setUpdatingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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}`

View File

@@ -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')

View File

@@ -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<AppData, 'invoices' | 'treasury'>,
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<AppData, 'treasury'>,
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)
}

View File

@@ -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.')

View File

@@ -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<string, string> = {
@@ -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<string, TreasuryTxn>()
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

View File

@@ -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<string | null>(null)
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(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<FunZoneTreasurySyncStats> => {
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<FunZoneTreasurySyncStats | null> => {
const sync = useCallback(async (input?: FunZoneTreasurySyncInput): Promise<FunZoneTreasurySyncStats | null> => {
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<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
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<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
])
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 : 'خطا در همگام‌سازی خزانه')

View File

@@ -94,22 +94,28 @@ interface AppStoreValue {
upsertAccount: (item: Account) => void
removeAccount: (id: string) => void
upsertVoucher: (item: Voucher) => void
upsertVoucherAsync: (item: Voucher) => Promise<Voucher>
removeVoucher: (id: string) => void
removeVoucherAsync: (id: string) => Promise<void>
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
createVouchers: (items: Voucher[]) => Promise<number>
upsertParty: (item: Party) => void
upsertPartyAsync: (item: Party) => Promise<void>
upsertPartyAsync: (item: Party) => Promise<Party>
removeParty: (id: string) => void
upsertProduct: (item: Product) => void
removeProduct: (id: string) => void
upsertInvoice: (item: Invoice) => void
upsertInvoiceAsync: (item: Invoice) => Promise<Invoice>
removeInvoice: (id: string) => void
removeInvoiceAsync: (id: string) => Promise<void>
upsertSalesType: (item: SalesType) => void
removeSalesType: (id: string) => void
upsertPartyAdjustment: (item: PartyAdjustment) => void
removePartyAdjustment: (id: string) => void
upsertTreasury: (item: TreasuryTxn) => void
upsertTreasuryAsync: (item: TreasuryTxn) => Promise<TreasuryTxn>
removeTreasury: (id: string) => void
removeTreasuryAsync: (id: string) => Promise<void>
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<Promise<AppData | null> | null>(null)
useEffect(() => {
dataRef.current = data
}, [data])
@@ -164,33 +171,62 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
)
const load = useCallback(async (): Promise<AppData | null> => {
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<AppData | null> => {
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<Account>),
acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
acct.get(ENDPOINT.parties).then(asList<Party>),
])
const [products, invoices, salesTypes] = await Promise.all([
acct.get(ENDPOINT.products).then(asList<Product>),
acct.get(ENDPOINT.invoices).then(asList<Invoice>),
acct.get(ENDPOINT.salesTypes).then(asList<SalesType>),
])
const [partyAdjustments, treasury, employees] = await Promise.all([
acct.get(ENDPOINT.partyAdjustments).then(asList<PartyAdjustment>),
acct.get(ENDPOINT.treasury).then(asList<TreasuryTxn>),
acct.get(ENDPOINT.employees).then(asList<Employee>),
])
const [payslips, settings] = await Promise.all([
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
acct.get<CompanySettings>(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 <K extends CollectionKey>(key: K, item: AppData[K][number]) => {
async <K extends CollectionKey>(key: K, item: AppData[K][number]): Promise<AppData[K][number]> => {
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<AppData[K][number]>(ENDPOINT[key], payload)
setData((prev) => ({ ...prev, [key]: [...(prev[key] as Identifiable[]), saved as Identifiable] }))
return saved
}
const saved = await acct.post<AppData[K][number]>(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),

View File

@@ -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

View File

@@ -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'

View File

@@ -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<Exclude<VoucherCategoryFilter, 'all'>, string> = {
customer: 'مشتریان',
supplier: 'تأمین‌کنندگان',
manual: 'دستی',
manager: 'مدیر',
}
export function voucherCategoryLabel(category: Exclude<VoucherCategoryFilter, 'all'>): 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<AppData, 'invoices' | 'partyAdjustments' | 'parties' | 'treasury'>,
_data: Pick<AppData, 'invoices' | 'partyAdjustments' | 'parties' | 'treasury'>,
): Exclude<VoucherCategoryFilter, 'all'> {
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<Exclude<VoucherCategoryFilter, 'all'>, 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
}