Files
docs/business/BUSINESS-02-PAYMENT-FINANCE.md
T
masoodafar-web e3850f9dd8 docs: فاز ۱۱ — فیکس‌های پرداخت ZarinPal + تصحیح تومان/ریال + امنیت Callback URL
- CHANGELOG: Phase 11 (11a-11f) — ZarinPal verify fix, تومان/ریال مدل, صفحه موفقیت, حذف ×۱۰ دوبار, callback URL امنیت
- BUSINESS-02: تصحیح مدل ارزی (DB=تومان نه ریال), ZarinPal verify fix, جدول callback URL امنیت
- TECH-01: اضافه CmsBaseUrl/FrontOfficeBaseUrl به appsettings, توضیح امنیت Open Redirect
- TECH-02: اضافه PaymentCallback.razor, وضعیت‌های جدید
- ROADMAP: بروزرسانی Payment 97→99%, اضافه فاز ۱۱ به DONE list
2026-02-27 22:33:35 +03:30

13 KiB
Raw Blame History

💰 سیستم مالی، پرداخت و درگاه‌ها

منابع ادغام‌شده: payment-gateway.md, payment-architecture-pyms.md, daya-loan-integration.md, manual-payment-system.md, discount-shop-business.md
آخرین بروزرسانی: اسفند ۱۴۰۴ (بروزرسانی: تصحیح مدل تومان/ریال + فیکس ZarinPal Verify + امنیت Callback URL)


۱. معماری کلی مالی

flowchart TD
    subgraph GATEWAYS["درگاه‌ها"]
        ZP["ZarinPal\nIPG"]
        DL["Daya Loan\nAPI"]
        MP["Manual Pay\nCard2Card"]
        DW["Discount Wallet\nInternal"]
    end

    ZP --> PYMS["PYMS — Payment Service\ngRPC ↔ CMS ↔ FrontOffice/BackOffice"]
    DL --> PYMS
    MP --> PYMS
    DW --> PYMS

    PYMS --> WALLETS

    subgraph WALLETS["3 Wallet System"]
        W1["💰 Balance\nکیف پول اصلی"]
        W2["🌟 NetworkBalance\nپاداش تیمی"]
        W3["🏷️ DiscountBalance\nکیف پول اعتباری"]
    end

۲. درگاه ZarinPal (IPG)

محل استفاده: ZarinPal در چهار جا فعال است:

  1. فروشگاه اعتباری — باقیمانده بعد از کسر DiscountBalance (اگر > 0)
  2. شارژ کیف‌پول اعتباری — واریز مستقیم از پروفایل کاربر
  3. خرید پکیج — پرداخت مستقیم با کارت بانکی (هر دو شاخه فعال)
  4. شارژ کیف‌پول جادویی — واریز با ضریب ×2.5 (فقط در حالت Magic)

فروشگاه عادی (Regular Store) از ZarinPal استفاده نمی‌کند — فقط کسر از Balance کیف‌پول

۲.۱ فلوی پرداخت

flowchart TD
    A["کاربر → انتخاب محصول\nدرخواست پرداخت"] --> B["CMS → CreatePaymentRequest\ngRPC to PYMS"]
    B --> C["PYMS → ZarinPal API\nمبلغ ×۱۰ (تومان→ریال)\nدریافت Authority"]
    C --> D["Redirect کاربر\nصفحه پرداخت ZarinPal"]
    D --> E["بازگشت با Authority\nCMS VerifyPayment (مبلغ ×۱۰)"]
    E -->|موفق| F["✅ ثبت سفارش\n+ شارژ کیف‌پول"]
    E -->|ناموفق| G["❌ نمایش پیام خطا"]

فیکس ZarinPal Verify (اسفند ۱۴۰۴ — 721661a):

  • باگ: VerifyPaymentAsync(authority) با ۲ آرگومان → amount=0 → ZarinPal Code=-1
  • فیکس: lookup PaymentTransaction.Amount از DB + استفاده از overload ۳ آرگومانه VerifyPaymentAsync(authority, orderId, amount)
  • IPaymentGatewayService — default impl ۳ آرگومانه اضافه شد
  • ۷ فایل تغییر: PackageService, TransactionsService, VerifyDiscountWalletChargeCommandHandler, VerifyPackagePurchaseCommandHandler, IPaymentGatewayService, MockPaymentGatewayService, DayaPaymentService

۲.۲ تنظیمات ZarinPal

پارامتر مقدار
MerchantId 4225d555-5fa9-4df0-9b61-1ce152cbbba8
CallbackUrl از appsettings.json خوانده می‌شود (نه از ورودی کاربر)
Sandbox true (staging) / false (production)
Currency DB: تومان — ZarinPal: ریال (×۱۰ هنگام ارسال)

مدل ارزی (تصحیح اسفند ۱۴۰۴):

  • DB: Package.Price و همه مبالغ مالی به تومان ذخیره می‌شوند
  • CMS → ZarinPal: ZarinPalPaymentService مبلغ را ×۱۰ می‌کند (amountInRials = amount * 10)
  • FrontOffice UI: مبالغ مستقیم به تومان نمایش داده می‌شوند (بدون تبدیل)
  • FrontOffice → CMS: مبالغ به تومان ارسال می‌شوند (FO هیچ تبدیلی انجام نمی‌دهد)
  • باگ قبلی ۱: FO مبلغ تومان را ×۱۰ تبدیل می‌کرد + CMS/ZarinPal دوباره ×۱۰ → مبلغ ۱۰۰ برابر (فیکس: 2f9ef15)
  • باگ قبلی ۲: FormattedPrice = Price / 10 اشتباه بود — Price از قبل تومان است (فیکس: 3c1a8ff اصلاح شد)

امنیت Callback URL (اسفند ۱۴۰۴):

  • هیچ callback URL از ورودی کاربر خوانده نمی‌شود — همه از appsettings.json خوانده می‌شوند
  • PackageService و TransactionsService: از FrontOfficeBaseUrl config
  • MagicWallet و DiscountWallet: از CmsBaseUrl config
  • جلوگیری از حمله Open Redirect

تنظیمات محیطی:

  • appsettings.json + appsettings.Staging.json: UseSandbox: true (تست)
  • appsettings.Production.json: UseSandbox: false (واقعی)
  • Production URL: cms.kbs1.ir | FrontOffice GwUrl: cms.kbs2.ir

۳. سیستم وام دایا (DayaLoan)

۳.۱ معماری

flowchart TD
    A["Hangfire Recurring Job\nهر ۲۰ دقیقه — */20 * * * *"] --> B["DayaLoanProcessorJob.Execute"]
    B --> C["بررسی LoanRequests\nStatus = Pending"]
    C --> D["برای هر درخواست:"]
    D --> E["ارسال به DayaLoan API\nAutomaticRetry Attempts=3"]
    E -->|تأیید| F["✅ Balance += 56M\nDiscountBalance += 112M\n+ ثبت Transaction + Log"]
    E -->|رد| G["❌ Status = Rejected\n+ ارسال SMS"]

۳.۲ Mock Mode

// appsettings.json
"DayaLoan": {
    "UseMock": true,         // staging
    "BaseUrl": "https://api.dayaloan.ir",
    "ApiKey": "***",
    "AutoApproveInMock": true
}

۳.۳ مقادیر

آیتم مقدار
مبلغ وام (DayaLoanAmount) ۵۶,۰۰۰,۰۰۰ ریال
شارژ Balance ۵۶,۰۰۰,۰۰۰ ریال
شارژ DiscountBalance ۱۱۲,۰۰۰,۰۰۰ ریال (دو برابر — DayaLoanAmount × 2)
مجموع شارژ ۱۶۸,۰۰۰,۰۰۰ ریال
NetworkBalance شارژ نمی‌شود
بازپرداخت طبق شرایط دایا

۴. پرداخت دستی (کارت‌به‌کارت)

⚠️ وضعیت: طراحی‌شده — پیاده‌سازی نشده

flowchart TD
    A["کاربر → انتخاب کارت‌به‌کارت"] --> B["نمایش شماره‌کارت مقصد\n+ مبلغ"]
    B --> C["کاربر → واریز\n+ آپلود تصویر رسید"]
    C --> D["ادمین BackOffice\nمشاهده لیست درخواست‌ها"]
    D --> E{"تأیید / رد؟"}
    E -->|تأیید| F["✅ شارژ خودکار کیف‌پول"]
    E -->|رد| G["❌ اطلاع‌رسانی به کاربر"]

موجودیت‌های مورد نیاز:

  • ManualPaymentRequest (UserId, Amount, ReceiptImage, Status, AdminNote)
  • ManualPaymentStatus enum: Pending, Approved, Rejected

۵. پرداخت ترکیبی فروشگاه اعتباری (Hybrid Payment)

۵.۱ فرمول

قیمت محصول = 1,000,000 ریال
MaxDiscountPercent محصول = 40%  (هر محصول درصد تخفیف مخصوص خود را دارد)

سهم تخفیف = قیمت × MaxDiscountPercent% = 400,000

⚠️ اگر DiscountBalance < سهم تخفیف → خطا: «موجودی کیف پول اعتباری کافی نیست»
   (دیگر MIN استفاده نمی‌شود — کاربر باید موجودی کافی داشته باشد)

باقیمانده → ZarinPal IPG = 1,000,000 - 400,000 = 600,000
                                            ─────────
                          مجموع             = 1,000,000

ℹ️ تخفیف ثابت ۳۰% نیست — فیلد Product.MaxDiscountPercent (0-100) تعیین‌کننده است.

۵.۲ فلوی خرید فروشگاه اعتباری

flowchart TD
    A["کاربر عضو باشگاه\nمشاهده محصول"] --> B["قیمت تخفیف‌خورده نمایش داده می‌شود"]
    B --> C["افزودن به سبد\nمحاسبه MaxDiscount% هر محصول"]
    C --> D["سهم تخفیف = قیمت × MaxDiscount%"]
    D --> V{"DiscountBalance >= سهم تخفیف?"}
    V -->|خیر| X["❌ خطا: موجودی کیف پول اعتباری کافی نیست"]
    V -->|بله| E["باقیمانده = مجموع - سهم تخفیف"]
    E --> F{"باقیمانده > 0?"}
    F -->|بله| G["کسر DiscountBalance\n+ Redirect → ZarinPal IPG\nباقیمانده + 10% VAT"]
    F -->|خیر| H["فقط کسر از DiscountBalance\nبدون درگاه → ثبت مستقیم"]
    G --> LOG["ثبت UserWalletChangeLog"]
    H --> LOG

۵.۳ دسترسی فروشگاه اعتباری

شرط نتیجه
IsClubMember = true دسترسی به Discount Store
IsClubMember = false فقط Regular Store
DiscountBalance >= سهم تخفیف خرید مجاز
DiscountBalance < سهم تخفیف خطا: موجودی کیف پول اعتباری کافی نیست

۵.۴ UserWalletChangeLog (اسفند ۱۴۰۴ — فیکس)

باگ: هنگام خرید از فروشگاه اعتباری، DiscountBalance در دیتابیس کم می‌شد ولی هیچ UserWalletChangeLog ثبت نمی‌شد → کاربر در تاریخچه کیف‌پول چیزی نمی‌دید.

فیکس در ۳ هندلر:

هندلر سناریو فیکس
PlaceOrderCommandHandler پرداخت کامل با DiscountBalance (بدون درگاه) ثبت log با ChangeDiscountValue = -amount
CompleteOrderPaymentCommandHandler پرداخت ترکیبی (درگاه + DiscountBalance) ثبت log بعد از verify موفق درگاه
VerifyDiscountWalletChargeCommandHandler شارژ کیف‌پول اعتباری ثبت log با ChangeDiscountValue = +amount

۶. PYMS — سرویس پرداخت مرکزی

۶.۱ gRPC Services

service PaymentService {
    rpc CreatePayment (CreatePaymentRequest) returns (CreatePaymentResponse);
    rpc VerifyPayment (VerifyPaymentRequest) returns (VerifyPaymentResponse);
    rpc GetPaymentStatus (GetPaymentStatusRequest) returns (PaymentStatusResponse);
    rpc RefundPayment (RefundPaymentRequest) returns (RefundPaymentResponse);
}

۶.۲ Transaction Types

نوع کد توضیح
PackagePurchase 1 خرید پکیج طلایی
StorePurchase 2 خرید از فروشگاه
DiscountStorePurchase 3 خرید از فروشگاه اعتباری
CommissionPayout 4 واریز کمیسیون هفتگی
WalletCharge 5 شارژ مستقیم کیف‌پول
ActivationFee 6 هزینه فعالسازی
DayaLoanCharge 7 شارژ از وام دایا
MagicWalletDeposit 14 واریز به کیف‌پول جادویی
MagicWalletBonus 15 بونوس ضریب ×2.5 کیف‌پول جادویی

۷. مالیات و VAT

هر دو فروشگاه از نرخ 10% استفاده می‌کنند:

  Regular Store  → const vatRate = 0.10m  (hardcoded در SubmitShopBuyOrderCommandHandler)
  Discount Store → VatCalculator.VAT_RATE = 0.10m
  SystemConstants.ShopVAT = 0.1 (10%)

قیمت نمایشی = قیمت پایه × (1 + 0.10)
در صورتحساب: قیمت پایه + مالیات جداگانه نمایش داده می‌شود

۸. خلاصه وضعیت پیاده‌سازی

ماژول وضعیت یادداشت
ZarinPal IPG کامل Production فعال — MerchantId: 4225d555...
ZarinPal Verify فیکس شده رفع amount=0 با overload ۳ آرگومانه (721661a)
Callback URL امنیت فیکس شده همه از config خوانده می‌شوند — جلوگیری از Open Redirect
وام دایا کامل Mock mode فعال در staging
پرداخت ترکیبی کامل Discount + IPG
Pool هفتگی کامل SP + Hangfire
WalletChangeLog فیکس شده لاگ تغییرات کیف‌پول در ۳ هندلر اضافه شد
Validation کیف‌پول اعتباری فیکس شده ارور اگر موجودی کافی نباشد
Toman/Rial مدل تصحیح شده DB=تومان، فقط ZarinPal ریال (×۱۰) — FO بدون تبدیل
صفحه موفقیت پرداخت بهبود TransactionId + موجودی واقعی + دکمه بازگشت
PackagePurchaseDialog کامل دیالوگ داینامیک کاشی‌ای با انتخاب روش پرداخت
پرداخت دستی طراحی نیاز به تصمیم مدیریت
Refund طراحی فقط در PYMS تعریف‌شده
کیف‌پول جادویی (Magic) کامل فاز 1-6 پیاده‌سازی شده — Production فعال

۸.۱ نام‌گذاری استاندارد کیف‌پول‌ها (اسفند ۱۴۰۴)

فیلد دیتابیس نام قدیم (UI) نام جدید (UI)
Balance عادی / اعتباری / نقدی کیف پول اصلی
DiscountBalance تخفیفی / تخفیف کیف پول اعتباری
NetworkBalance شبکه / طلایی / پورسانت پاداش تیمی

تغییرات UI در ۱۲ فایل (FrontOffice: 5, BackOffice: 7) اعمال شد.