feat: accounting

This commit is contained in:
2026-08-02 22:32:08 +03:30
parent dd5b6d4616
commit ba7147ff59
2 changed files with 88 additions and 30 deletions
+78 -30
View File
@@ -10,7 +10,7 @@ import PersianDatePicker from '@/components/admin/PersianDatePicker';
import { Purchase, PAYMENT_GATEWAYS } from '@/types/purchase'; import { Purchase, PAYMENT_GATEWAYS } from '@/types/purchase';
import { PackageName } from '@/types/package'; import { PackageName } from '@/types/package';
import { Product } from '@/types/product'; import { Product } from '@/types/product';
import { formatPrice, getPurchaseFinalPrice, parseStoreRate } from '@/lib/utils'; import { formatPrice, getPurchaseFinalPrice, parseStoreRate, parseFee } from '@/lib/utils';
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
ArrowPathIcon, ArrowPathIcon,
@@ -29,7 +29,7 @@ interface StoreSummary {
count: number; count: number;
gross: number; gross: number;
rate: number; rate: number;
commission: number; fee: number;
net: number; net: number;
} }
@@ -55,24 +55,42 @@ interface AccountingResult {
const computeAccounting = ( const computeAccounting = (
purchases: Purchase[], purchases: Purchase[],
myketRate: number, myketRate: number,
cafeRate: number cafeRate: number,
transactionFee: number,
): AccountingResult => { ): AccountingResult => {
const rows = new Map<number, ProductRow>(); const rows = new Map<number, ProductRow>();
let totalCount = 0; let totalCount = 0;
let totalGross = 0; let totalGross = 0;
let totalCommission = 0; let totalCommission = 0;
const makeStore = (rate: number): StoreSummary => ({ count: 0, gross: 0, rate, commission: 0, net: 0 }); // درصد سهم توسعه‌دهنده به‌صورت کسر (0.85) و کارمزد تراکنش به ریال
const makeStore = (rate: number): StoreSummary => ({ count: 0, gross: 0, rate, fee: 0, net: 0 });
const myket: StoreSummary = makeStore(myketRate); const myket: StoreSummary = makeStore(myketRate);
const cafe: StoreSummary = makeStore(cafeRate); const cafe: StoreSummary = makeStore(cafeRate);
const other: StoreSummary = makeStore(0); const other: StoreSummary = makeStore(0);
const collect = (store: StoreSummary, price: number, rate: number) => { // سهم توسعه‌دهنده برای مایکت/کافه‌بازار:
const commission = (price * rate) / 100; // (P / 1.1 - T) × R
// P = قیمت فروش (با احتساب ۱۰٪ مالیات بر ارزش افزوده)
// /1.1 = حذف ارزش افزوده، T = کارمزد تراکنش به ریال، R = درصد سهم
const developerShare = (price: number, rate: number, feePerTransaction: number) => {
const beforeVat = price / 1.1;
const afterVatAndFee = beforeVat - feePerTransaction;
if (afterVatAndFee <= 0) return 0;
return afterVatAndFee * (rate / 100);
};
const collect = (store: StoreSummary, price: number, rate: number, feePerTransaction: number) => {
let net: number;
if (rate > 0) {
net = developerShare(price, rate, feePerTransaction);
} else {
net = price; // سایر درگاه‌ها: بدون کارمزد فروشگاه
}
store.count += 1; store.count += 1;
store.gross += price; store.gross += price;
store.commission += commission; store.fee += price - net;
store.net += price - commission; store.net += net;
}; };
for (const purchase of purchases) { for (const purchase of purchases) {
@@ -81,22 +99,26 @@ const computeAccounting = (
let store: StoreSummary; let store: StoreSummary;
let rate: number; let rate: number;
let feePerTransaction: number;
if (gateway === PAYMENT_GATEWAYS.cafe.code) { if (gateway === PAYMENT_GATEWAYS.cafe.code) {
store = cafe; store = cafe;
rate = cafeRate; rate = cafeRate;
feePerTransaction = transactionFee;
} else if (gateway === PAYMENT_GATEWAYS.myket.code) { } else if (gateway === PAYMENT_GATEWAYS.myket.code) {
store = myket; store = myket;
rate = myketRate; rate = myketRate;
feePerTransaction = transactionFee;
} else { } else {
store = other; store = other;
rate = 0; rate = 0;
feePerTransaction = 0;
} }
collect(store, price, rate); collect(store, price, rate, feePerTransaction);
totalCount += 1; totalCount += 1;
totalGross += price; totalGross += price;
totalCommission += (price * rate) / 100; totalCommission += price - developerShare(price, rate, feePerTransaction);
const productId = purchase.product_id ?? purchase.product?.id ?? -1; const productId = purchase.product_id ?? purchase.product?.id ?? -1;
let row = rows.get(productId); let row = rows.get(productId);
@@ -117,8 +139,8 @@ const computeAccounting = (
} }
row.count += 1; row.count += 1;
row.gross += price; row.gross += price;
row.commission += (price * rate) / 100; row.commission += price - developerShare(price, rate, feePerTransaction);
row.net += price - (price * rate) / 100; row.net += developerShare(price, rate, feePerTransaction);
} }
return { return {
@@ -151,9 +173,11 @@ export default function AccountingPage() {
// Status filter — both paid statuses selected by default // Status filter — both paid statuses selected by default
const [statuses, setStatuses] = useState<number[]>([...PAID_STATUSES]); const [statuses, setStatuses] = useState<number[]>([...PAID_STATUSES]);
// Store commission rates (editable percentages) // درصد سهم توسعه‌دهنده (R در فرمول) — مایکت و کافه‌بازار به‌طور پیش‌فرض ۸۵٪
const [myketRate, setMyketRate] = useState<string>('15'); const [myketRate, setMyketRate] = useState<string>('85');
const [cafeRate, setCafeRate] = useState<string>('15'); const [cafeRate, setCafeRate] = useState<string>('85');
// کارمزد تراکنش به ریال (T در فرمول سهم توسعه‌دهنده)
const [transactionFee, setTransactionFee] = useState<string>('1200');
const [allPurchases, setAllPurchases] = useState<Purchase[] | null>(null); const [allPurchases, setAllPurchases] = useState<Purchase[] | null>(null);
const [result, setResult] = useState<AccountingResult | null>(null); const [result, setResult] = useState<AccountingResult | null>(null);
@@ -198,19 +222,27 @@ export default function AccountingPage() {
// Fetch all purchases once, then filter/aggregate in-memory. // Fetch all purchases once, then filter/aggregate in-memory.
const purchases = await purchasesApi.getAllPurchases({ per_page: 500 }, token); const purchases = await purchasesApi.getAllPurchases({ per_page: 500 }, token);
setAllPurchases(purchases); setAllPurchases(purchases);
setResult(computeAccounting(purchases, parseStoreRate(myketRate), parseStoreRate(cafeRate))); setResult(
computeAccounting(
purchases,
parseStoreRate(myketRate),
parseStoreRate(cafeRate),
parseFee(transactionFee)
)
);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'خطا در دریافت داده‌ها'); setError(err instanceof Error ? err.message : 'خطا در دریافت داده‌ها');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [token, myketRate, cafeRate]); }, [token, myketRate, cafeRate, transactionFee]);
// Recompute whenever filters or commission rates change (uses cached data) // Recompute whenever filters or commission rates change (uses cached data)
const runAnalysis = useCallback(() => { const runAnalysis = useCallback(() => {
if (!allPurchases) return; if (!allPurchases) return;
const myket = parseStoreRate(myketRate); const myket = parseStoreRate(myketRate);
const cafe = parseStoreRate(cafeRate); const cafe = parseStoreRate(cafeRate);
const fee = parseFee(transactionFee);
const filtered = allPurchases.filter((p) => { const filtered = allPurchases.filter((p) => {
// Status // Status
@@ -233,8 +265,8 @@ export default function AccountingPage() {
return true; return true;
}); });
setResult(computeAccounting(filtered, myket, cafe)); setResult(computeAccounting(filtered, myket, cafe, fee));
}, [allPurchases, statuses, selectedProductId, selectedPackageName, dateFrom, dateTo, myketRate, cafeRate]); }, [allPurchases, statuses, selectedProductId, selectedPackageName, dateFrom, dateTo, myketRate, cafeRate, transactionFee]);
// Recompute on filter/rate changes (no refetch needed once data is cached) // Recompute on filter/rate changes (no refetch needed once data is cached)
useEffect(() => { useEffect(() => {
@@ -247,8 +279,9 @@ export default function AccountingPage() {
setDateFrom(''); setDateFrom('');
setDateTo(''); setDateTo('');
setStatuses([...PAID_STATUSES]); setStatuses([...PAID_STATUSES]);
setMyketRate('15'); setMyketRate('85');
setCafeRate('15'); setCafeRate('85');
setTransactionFee('1200');
}; };
const toggleStatus = (code: number) => { const toggleStatus = (code: number) => {
@@ -368,11 +401,11 @@ export default function AccountingPage() {
</div> </div>
</div> </div>
{/* Commission rates */} {/* Commission settings */}
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div> <div>
<label htmlFor="myket_rate" className={labelClass}> <label htmlFor="myket_rate" className={labelClass}>
سهم مایکت (%) سهم توسعهدهنده مایکت (٪)
</label> </label>
<input <input
type="text" type="text"
@@ -381,12 +414,12 @@ export default function AccountingPage() {
value={myketRate} value={myketRate}
onChange={(e) => setMyketRate(e.target.value)} onChange={(e) => setMyketRate(e.target.value)}
className={inputClass} className={inputClass}
placeholder="۱۵" placeholder="۸۵"
/> />
</div> </div>
<div> <div>
<label htmlFor="cafe_rate" className={labelClass}> <label htmlFor="cafe_rate" className={labelClass}>
سهم کافه بازار (%) سهم توسعهدهنده کافه بازار (٪)
</label> </label>
<input <input
type="text" type="text"
@@ -395,12 +428,27 @@ export default function AccountingPage() {
value={cafeRate} value={cafeRate}
onChange={(e) => setCafeRate(e.target.value)} onChange={(e) => setCafeRate(e.target.value)}
className={inputClass} className={inputClass}
placeholder="۱۵" placeholder="۸۵"
/>
</div>
<div>
<label htmlFor="transaction_fee" className={labelClass}>
کارمزد تراکنش (ریال)
</label>
<input
type="text"
id="transaction_fee"
inputMode="numeric"
value={transactionFee}
onChange={(e) => setTransactionFee(e.target.value)}
className={inputClass}
placeholder="۱۲۰۰"
/> />
</div> </div>
</div> </div>
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500"> <p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
مایکت معمولاً ۱۵٪ یا ۳۰٪ و کافه بازار ۱۵٪ (زیر یک میلیارد تومان) یا ۳۰٪ کارمزد میگیرد. سهم توسعهدهنده = (قیمت ÷ ۱٫۱ کارمزد تراکنش) × درصد سهم. پیشفرض مایکت و
کافهبازار ۸۵٪ (فروش زیر ۱۰ میلیارد ریال) و ۷۰٪ (بالای آن) است.
</p> </p>
<div className="flex items-center gap-3 mt-5"> <div className="flex items-center gap-3 mt-5">
@@ -566,7 +614,7 @@ function StoreCard({ title, summary }: { title: string; summary: StoreSummary })
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h4> <h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h4>
{summary.rate > 0 && ( {summary.rate > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-lg bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-400"> <span className="px-2 py-0.5 text-xs font-medium rounded-lg bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-400">
کارمزد {summary.rate}٪ سهم توسعهدهنده {summary.rate}٪
</span> </span>
)} )}
</div> </div>
@@ -580,8 +628,8 @@ function StoreCard({ title, summary }: { title: string; summary: StoreSummary })
<span className="text-gray-900 dark:text-gray-100 font-medium">{formatPrice(summary.gross)} تومان</span> <span className="text-gray-900 dark:text-gray-100 font-medium">{formatPrice(summary.gross)} تومان</span>
</div> </div>
<div className="flex justify-between text-gray-500 dark:text-gray-400"> <div className="flex justify-between text-gray-500 dark:text-gray-400">
<span>کارمزد</span> <span>سهم فروشگاهها</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatPrice(summary.commission)} تومان</span> <span className="text-amber-600 dark:text-amber-400 font-medium">{formatPrice(summary.fee)} تومان</span>
</div> </div>
<div className="flex justify-between pt-1.5 border-t border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400"> <div className="flex justify-between pt-1.5 border-t border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400">
<span>سود خالص</span> <span>سود خالص</span>
+10
View File
@@ -35,6 +35,16 @@ export const parseStoreRate = (value: string | number): number => {
return Math.min(100, Math.max(0, n)); return Math.min(100, Math.max(0, n));
}; };
// Parse a non-negative integer input (e.g. transaction fee in Rial).
export const parseFee = (value: string | number): number => {
if (value === null || value === undefined || value === '') return 0;
const str = String(value).replace(/[^\d]/g, '');
if (!str) return 0;
const n = parseInt(str, 10);
if (Number.isNaN(n)) return 0;
return Math.max(0, n);
};
export const formatDate = (dateString: string | null): string => { export const formatDate = (dateString: string | null): string => {
if (!dateString) return 'نامشخص'; if (!dateString) return 'نامشخص';