feat(tax): implement tax rate history tracking and API endpoints
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user