feat(tax): implement tax rate history tracking and API endpoints

This commit is contained in:
hamed
2026-06-24 13:17:08 +03:30
parent 148d033114
commit 89e4a424f8
7 changed files with 186 additions and 82 deletions
+34 -77
View File
@@ -5,18 +5,21 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDateTime } from '../lib/utils';
import { Cog6ToothIcon, CheckCircleIcon } from '@heroicons/react/24/outline';
interface TaxHistoryRow {
tax_percent: number;
enabled: boolean;
changed_by_name: string | null;
changed_at: number;
}
// ── Schema ────────────────────────────────────────────────────────────────
const schema = z.object({
site_name: z.string().min(1, 'نام سایت الزامی است'),
support_phone: z.string(),
commission_enabled: z.string(),
commission_percent: z.string().refine(v => {
const n = Number(v);
return !isNaN(n) && n >= 0 && n <= 100;
}, 'درصد باید بین ۰ تا ۱۰۰ باشد'),
max_cancel_hours_before: z.string(),
appointment_reminder_hours: z.string(),
// financial engine
@@ -44,8 +47,6 @@ type FormValues = z.infer<typeof schema>;
interface Settings {
site_name: string;
support_phone: string;
commission_enabled: string;
commission_percent: string;
max_cancel_hours_before: string;
appointment_reminder_hours: string;
appointment_commission_enabled: string;
@@ -81,6 +82,14 @@ export default function SettingsPage() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const settings: Settings | undefined = (data?.data as any)?.data ?? data?.data;
const taxHistoryQ = useQuery({
queryKey: ['tax-history'],
queryFn: () => api.get<ApiResponse<TaxHistoryRow[]>>('/api/v1/admin/settings/tax-history'),
staleTime: 30_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const taxHistory: TaxHistoryRow[] = (taxHistoryQ.data?.data as any)?.data ?? taxHistoryQ.data?.data ?? [];
const {
register,
handleSubmit,
@@ -95,8 +104,6 @@ export default function SettingsPage() {
reset({
site_name: settings.site_name ?? 'ClinicPro',
support_phone: settings.support_phone ?? '',
commission_enabled: settings.commission_enabled ?? '0',
commission_percent: settings.commission_percent ?? '0',
max_cancel_hours_before: settings.max_cancel_hours_before ?? '24',
appointment_reminder_hours: settings.appointment_reminder_hours ?? '2',
appointment_commission_enabled: settings.appointment_commission_enabled ?? '0',
@@ -123,10 +130,10 @@ export default function SettingsPage() {
api.patch<ApiResponse<Settings>>('/api/v1/admin/settings', values),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-settings'] });
qc.invalidateQueries({ queryKey: ['tax-history'] });
},
});
const commissionEnabled = watch('commission_enabled') === '1';
const paymentTestMode = watch('payment_test_mode') === '1';
const apptCommissionEnabled = watch('appointment_commission_enabled') === '1';
const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1';
@@ -210,71 +217,6 @@ export default function SettingsPage() {
</div>
</div>
{/* تنظیمات کمیسیون */}
<div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
<div className="ico" style={{ background: 'var(--success-bg)', color: 'var(--success)', width: 36, height: 36, borderRadius: 10 }}>
<span style={{ fontSize: 18 }}>٪</span>
</div>
<h3 style={{ fontSize: 15 }}>کمیسیون سایت</h3>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* toggle فعال/غیرفعال */}
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
<input type="hidden" {...register('commission_enabled')} />
<div
style={{ position: 'relative', width: 44, height: 24, flexShrink: 0, cursor: 'pointer' }}
onClick={() => setValue('commission_enabled', commissionEnabled ? '0' : '1', { shouldDirty: true })}
>
<div style={{
width: 44, height: 24, borderRadius: 12,
background: commissionEnabled ? 'var(--primary)' : 'var(--border)',
transition: 'background .2s', position: 'relative',
}}>
<div style={{
position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)',
transition: 'right .2s', right: commissionEnabled ? 2 : 22,
boxShadow: '0 1px 3px rgba(0,0,0,.2)',
}} />
</div>
</div>
<span style={{ fontSize: 14 }}>
{commissionEnabled ? 'کمیسیون فعال است' : 'کمیسیون غیرفعال است'}
</span>
</label>
{commissionEnabled && (
<div style={{ maxWidth: 280 }}>
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
درصد کمیسیون از کاربر (۰۱۰۰)
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
{...register('commission_percent')}
type="number"
min={0}
max={100}
style={{
width: 120, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
border: `1px solid ${errors.commission_percent ? 'var(--danger)' : 'var(--border)'}`,
background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
boxSizing: 'border-box',
}}
/>
<span className="muted" style={{ fontSize: 13 }}>درصد</span>
</div>
{errors.commission_percent && (
<p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 4 }}>{errors.commission_percent.message}</p>
)}
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
کمیسیون فقط از کاربر دریافت میشود. منشیها کمیسیون ندارند.
</p>
</div>
)}
</div>
</div>
{/* موتور مالی نمایندگی */}
<div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
@@ -352,6 +294,23 @@ export default function SettingsPage() {
</div>
</div>
)}
{taxHistory.length > 0 && (
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 8 }}>تاریخچه تغییرات مالیات</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxWidth: 460 }}>
{taxHistory.map((h, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 12.5, padding: '6px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-3)' }}>
<span>
{h.enabled ? `${h.tax_percent}٪` : 'غیرفعال'}
{h.changed_by_name && <span className="muted"> {h.changed_by_name}</span>}
</span>
<span className="muted">{formatDateTime(h.changed_at)}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* هزینه پنل پیامک */}
@@ -555,8 +514,6 @@ export default function SettingsPage() {
onClick={() => settings && reset({
site_name: settings.site_name,
support_phone: settings.support_phone,
commission_enabled: settings.commission_enabled,
commission_percent: settings.commission_percent,
max_cancel_hours_before: settings.max_cancel_hours_before,
appointment_reminder_hours: settings.appointment_reminder_hours,
appointment_commission_enabled: settings.appointment_commission_enabled,
+16
View File
@@ -984,6 +984,8 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
**ترتیب محاسبه** (در `CommissionService`): ۱) کسر `sms_panel_fee_rials` ۲) مالیاتِ استخراجی `afterSms × tax/(100+tax)` ۳) پورسانت = `netAfterTax × percent/100`. سهم نماینده به کیف‌پولش (`WalletTransaction` credit) واریز و یک ردیف `FinancialBreakdown` ثبت می‌شود (idempotent بر اساس `payment_id`).
> هر تغییر `tax_percent`/`tax_enabled` در یک ردیف `TaxRateHistory` ثبت و از `GET /api/v1/admin/settings/tax-history` قابل مشاهده است.
### GET `/api/v1/admin/financial-breakdowns`
لیست تفکیک مالی تراکنش‌ها (paginated). **Permission:** `ROLE_ADMIN`
@@ -1035,3 +1037,17 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
}
}
```
### GET `/api/v1/admin/settings/tax-history`
تاریخچه‌ی تغییرات مالیات بر ارزش افزوده (۵۰ ردیف آخر، نزولی). هر بار که `tax_percent` یا `tax_enabled` از طریق `PATCH /api/v1/admin/settings` تغییر کند، یک ردیف با کاربرِ تغییردهنده ثبت می‌شود. **Permission:** `ROLE_ADMIN`
**Response `200`:**
```json
{
"success": true,
"data": [
{ "tax_percent": 10, "enabled": true, "changed_by_name": "مدیر سیستم", "changed_at": 1782800000 }
]
}
```
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260624093738 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE tax_rate_history (id INT AUTO_INCREMENT NOT NULL, tax_percent NUMERIC(5, 2) NOT NULL, enabled TINYINT NOT NULL, changed_at INT NOT NULL, changed_by INT DEFAULT NULL, INDEX IDX_6D489C3210BC6D9F (changed_by), INDEX idx_tax_history_date (changed_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE tax_rate_history ADD CONSTRAINT FK_6D489C3210BC6D9F FOREIGN KEY (changed_by) REFERENCES users (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE tax_rate_history DROP FOREIGN KEY FK_6D489C3210BC6D9F');
$this->addSql('DROP TABLE tax_rate_history');
}
}
+28 -3
View File
@@ -16,8 +16,6 @@ use OpenApi\Attributes as OA;
class SiteConfigController extends BaseController
{
private const ALLOWED_KEYS = [
'commission_enabled',
'commission_percent',
// financial engine
'appointment_commission_enabled',
'upgrade_commission_enabled',
@@ -47,6 +45,7 @@ class SiteConfigController extends BaseController
public function __construct(
private readonly SiteConfigRepository $configRepo,
private readonly \App\Config\Repository\TaxRateHistoryRepository $taxHistoryRepo,
private readonly EntityManagerInterface $em,
) {}
@@ -57,10 +56,14 @@ class SiteConfigController extends BaseController
}
#[Route('/api/v1/admin/settings', methods: ['PATCH'])]
public function patch(Request $request): JsonResponse
public function patch(Request $request, #[\Symfony\Component\Security\Http\Attribute\CurrentUser] \App\Auth\Entity\User $admin): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
// مقادیر فعلی مالیات برای تشخیص تغییر و ثبت تاریخچه.
$prevTaxPercent = $this->configRepo->get('tax_percent');
$prevTaxEnabled = $this->configRepo->get('tax_enabled');
foreach ($data as $key => $value) {
if (!in_array($key, self::ALLOWED_KEYS, true)) {
continue;
@@ -70,6 +73,28 @@ class SiteConfigController extends BaseController
$this->em->flush();
$newTaxPercent = $this->configRepo->get('tax_percent');
$newTaxEnabled = $this->configRepo->get('tax_enabled');
if ($newTaxPercent !== $prevTaxPercent || $newTaxEnabled !== $prevTaxEnabled) {
$this->taxHistoryRepo->save(new \App\Config\Entity\TaxRateHistory(
(string) $newTaxPercent,
$newTaxEnabled === '1',
$admin,
));
}
return $this->success($this->configRepo->getAll());
}
#[Route('/api/v1/admin/settings/tax-history', methods: ['GET'])]
public function taxHistory(): JsonResponse
{
$rows = $this->taxHistoryRepo->findBy([], ['changedAt' => 'DESC'], 50);
return $this->success(array_map(
fn(\App\Config\Entity\TaxRateHistory $h) => $h->toArray(),
$rows,
));
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Config\Entity;
use App\Auth\Entity\User;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'tax_rate_history')]
#[ORM\Index(columns: ['changed_at'], name: 'idx_tax_history_date')]
class TaxRateHistory
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'tax_percent', type: 'decimal', precision: 5, scale: 2)]
private string $taxPercent;
#[ORM\Column(name: 'enabled', type: 'boolean')]
private bool $enabled;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'changed_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?User $changedBy = null;
#[ORM\Column(name: 'changed_at', type: 'integer')]
private int $changedAt;
public function __construct(string $taxPercent, bool $enabled, ?User $changedBy)
{
$this->taxPercent = $taxPercent;
$this->enabled = $enabled;
$this->changedBy = $changedBy;
$this->changedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getTaxPercent(): string { return $this->taxPercent; }
public function isEnabled(): bool { return $this->enabled; }
public function getChangedBy(): ?User { return $this->changedBy; }
public function getChangedAt(): int { return $this->changedAt; }
public function toArray(): array
{
return [
'tax_percent' => (float) $this->taxPercent,
'enabled' => $this->enabled,
'changed_by_name' => $this->changedBy?->getRealName(),
'changed_at' => $this->changedAt,
];
}
}
@@ -10,8 +10,6 @@ class SiteConfigRepository extends ServiceEntityRepository
{
// Default values returned when a key is missing from DB
private const DEFAULTS = [
'commission_enabled' => '0',
'commission_percent' => '0',
// financial engine
'appointment_commission_enabled' => '0',
'upgrade_commission_enabled' => '0',
@@ -0,0 +1,21 @@
<?php
namespace App\Config\Repository;
use App\Config\Entity\TaxRateHistory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaxRateHistoryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaxRateHistory::class);
}
public function save(TaxRateHistory $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) $this->getEntityManager()->flush();
}
}