Files
docs/archive/03-BACKEND/CMS/payment-gateway.md
T
masoodafar-web 5965b98728 update
2026-01-03 18:27:49 +03:30

21 KiB
Raw Blame History

Payment Gateway Integration Guide

📋 Overview

🔄 جریان پرداخت در سیستم

1️⃣ دریافت پول از کاربر (Payment IN)

کاربر → Gateway/PYMS → بانک → پرداخت موفق
                              ↓
                      Callback به CMS
                              ↓
          CMS: VerifyTransaction + فعال‌سازی عضویت

توضیح:

  • درگاه اینترنتی در Gateway/PYMS است (نه CMS)
  • CMS فقط نتیجه پرداخت را دریافت می‌کند (از طریق Callback)
  • سپس عملیات بعدی (فعال‌سازی، اضافه PV، Wallet) را انجام می‌دهد
  • Transaction System در CMS برای این کار طراحی شده

2️⃣ پرداخت به کاربر (Payout)

ادمین تایید برداشت → CMS → DayaPaymentService → واریز به حساب کاربر

توضیح:

  • این سند فقط برای Payout است
  • سیستم از دو پیاده‌سازی پشتیبانی می‌کند:
  1. MockPaymentGatewayService - برای Development و Testing
  2. DayaPaymentService - API واقعی Daya (برای واریز به حساب کاربران)

🏗️ Architecture

Interface Design

public interface IPaymentGatewayService
{
    // پرداخت (خرید بسته)
    Task<PaymentInitiateResult> InitiatePaymentAsync(
        PaymentRequest request, 
        CancellationToken cancellationToken = default);

    // تایید پرداخت (Callback)
    Task<PaymentVerificationResult> VerifyPaymentAsync(
        string refId, 
        string verificationToken, 
        CancellationToken cancellationToken = default);

    // برداشت/پرداخت به کاربر (Withdrawal)
    Task<PayoutResult> ProcessPayoutAsync(
        PayoutRequest request, 
        CancellationToken cancellationToken = default);
}

DTO Models

PaymentRequest

public class PaymentRequest
{
    public long UserId { get; set; }
    public string Mobile { get; set; }
    public decimal Amount { get; set; }
    public string Description { get; set; }
    public string CallbackUrl { get; set; }
}

PaymentInitiateResult

public class PaymentInitiateResult
{
    public bool IsSuccess { get; set; }
    public string? RefId { get; set; }
    public string? GatewayUrl { get; set; }
    public string? ErrorMessage { get; set; }
}

PaymentVerificationResult

public class PaymentVerificationResult
{
    public bool IsSuccess { get; set; }
    public string RefId { get; set; }
    public string? TrackingCode { get; set; }
    public decimal Amount { get; set; }
    public string? Message { get; set; }
}

PayoutRequest

public class PayoutRequest
{
    public long UserId { get; set; }
    public string Iban { get; set; }
    public decimal Amount { get; set; }
    public string? Description { get; set; }
}

PayoutResult

public class PayoutResult
{
    public bool IsSuccess { get; set; }
    public string? TransactionId { get; set; }
    public string Message { get; set; }
    public DateTime ProcessedAt { get; set; }
}

🔧 Implementation Details

1. MockPaymentGatewayService

Purpose: Development و Testing بدون نیاز به API واقعی

Features:

  • IBAN validation (IR prefix, 26 characters)
  • Amount validation (min 10,000 Toman)
  • Mock RefId generation (MockRef_{timestamp})
  • Simulated network delay (500ms)
  • Comprehensive logging
  • Gateway URL generation (mock://payment)

Usage:

{
  "UseRealPaymentGateway": false
}

Example:

var result = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
{
    UserId = 123,
    Mobile = "09123456789",
    Amount = 100000,
    Description = "خرید بسته طلایی",
    CallbackUrl = "https://yoursite.com/payment/callback"
});

// result.IsSuccess = true
// result.RefId = "MockRef_1701619200"
// result.GatewayUrl = "mock://payment/MockRef_1701619200"

2. DayaPaymentService

Purpose: یکپارچه‌سازی با API واقعی Daya برای پرداخت و برداشت

Configuration:

{
  "UseRealPaymentGateway": true,
  "PaymentProvider": "Daya",
  "DayaPayment": {
    "BaseUrl": "https://api.daya.ir",
    "ApiKey": "YOUR_DAYA_API_KEY"
  }
}

API Endpoints:

Initiate Payment

POST {BaseUrl}/api/v1/payment/initiate
Content-Type: application/json
X-API-Key: {ApiKey}

{
  "userId": 123,
  "mobile": "09123456789",
  "amount": 100000,
  "description": "خرید بسته طلایی",
  "callbackUrl": "https://yoursite.com/payment/callback"
}

Response:
{
  "success": true,
  "refId": "DAYA123456789",
  "gatewayUrl": "https://gateway.daya.ir/pay/DAYA123456789",
  "errorMessage": null
}

Verify Payment

POST {BaseUrl}/api/v1/payment/verify
Content-Type: application/json
X-API-Key: {ApiKey}

{
  "refId": "DAYA123456789",
  "token": "DAYA123456789"
}

Response:
{
  "success": true,
  "refId": "DAYA123456789",
  "trackingCode": "TRACK987654321",
  "amount": 100000,
  "message": "تراکنش موفق"
}

Process Payout

POST {BaseUrl}/api/v1/payout/process
Content-Type: application/json
X-API-Key: {ApiKey}

{
  "userId": 123,
  "iban": "IR123456789012345678901234",
  "amount": 50000,
  "description": "برداشت کمیسیون"
}

Response:
{
  "success": true,
  "transactionId": "TXN_123456789",
  "message": "پرداخت با موفقیت انجام شد",
  "processedAt": "2024-12-02T10:30:00Z"
}

Error Handling:

try
{
    var response = await _httpClient.PostAsJsonAsync(url, request, cancellationToken);
    
    if (!response.IsSuccessStatusCode)
    {
        _logger.LogError("Daya API error: StatusCode={StatusCode}", response.StatusCode);
        return new PaymentInitiateResult
        {
            IsSuccess = false,
            ErrorMessage = $"خطا در ارتباط با سرویس پرداخت: {response.StatusCode}"
        };
    }
    
    var result = await response.Content.ReadFromJsonAsync<DayaInitiateResponse>(cancellationToken);
    // Process result...
}
catch (Exception ex)
{
    _logger.LogError(ex, "Error in InitiatePaymentAsync");
    return new PaymentInitiateResult
    {
        IsSuccess = false,
        ErrorMessage = "خطای غیرمنتظره در برقراری ارتباط با سرویس پرداخت"
    };
}

3. BankMellatPaymentService

Purpose: یکپارچه‌سازی با IPG بانک ملت (SOAP Web Service)

Configuration:

{
  "UseRealPaymentGateway": true,
  "PaymentProvider": "BankMellat",
  "BankMellat": {
    "ServiceUrl": "https://bpm.shaparak.ir/pgwchannel/services/pgw",
    "TerminalId": "YOUR_TERMINAL_ID",
    "Username": "YOUR_USERNAME",
    "Password": "YOUR_PASSWORD"
  }
}

SOAP Operations:

bpPayRequest (Initiate Payment)

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" 
               xmlns:ns="http://interfaces.core.sw.bps.com/">
    <soap:Body>
        <ns:bpPayRequest>
            <terminalId>{TERMINAL_ID}</terminalId>
            <userName>{USERNAME}</userName>
            <userPassword>{PASSWORD}</userPassword>
            <orderId>{ORDER_ID}</orderId>
            <amount>{AMOUNT_IN_RIALS}</amount>
            <localDate>{yyyyMMdd}</localDate>
            <localTime>{HHmmss}</localTime>
            <additionalData>{DESCRIPTION}</additionalData>
            <callBackUrl>{CALLBACK_URL}</callBackUrl>
            <payerId>0</payerId>
        </ns:bpPayRequest>
    </soap:Body>
</soap:Envelope>

Response:

<soap:Envelope>
    <soap:Body>
        <ns:bpPayRequestResponse>
            <return>{REF_ID}</return>  <!-- Success: positive number, Error: negative number -->
        </ns:bpPayRequestResponse>
    </soap:Body>
</soap:Envelope>

bpVerifyRequest (Verify Payment)

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" 
               xmlns:ns="http://interfaces.core.sw.bps.com/">
    <soap:Body>
        <ns:bpVerifyRequest>
            <terminalId>{TERMINAL_ID}</terminalId>
            <userName>{USERNAME}</userName>
            <userPassword>{PASSWORD}</userPassword>
            <orderId>{ORDER_ID}</orderId>
            <saleOrderId>{ORDER_ID}</saleOrderId>
            <saleReferenceId>{REF_ID}</saleReferenceId>
        </ns:bpVerifyRequest>
    </soap:Body>
</soap:Envelope>

bpSettleRequest (Settle Payment)

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" 
               xmlns:ns="http://interfaces.core.sw.bps.com/">
    <soap:Body>
        <ns:bpSettleRequest>
            <terminalId>{TERMINAL_ID}</terminalId>
            <userName>{USERNAME}</userName>
            <userPassword>{PASSWORD}</userPassword>
            <orderId>{ORDER_ID}</orderId>
            <saleOrderId>{ORDER_ID}</saleOrderId>
            <saleReferenceId>{REF_ID}</saleReferenceId>
        </ns:bpSettleRequest>
    </soap:Body>
</soap:Envelope>

Error Codes:

Code Description (Persian)
0 تراکنش موفق
11 شماره کارت نامعتبر است
12 موجودی کافی نیست
13 رمز نادرست است
14 تعداد دفعات وارد کردن رمز بیش از حد مجاز است
15 کارت نامعتبر است
17 کاربر از انجام تراکنش منصرف شده است
18 تاریخ انقضای کارت گذشته است
21 پذیرنده نامعتبر است
23 خطای امنیتی رخ داده است
24 اطلاعات کاربری پذیرنده نامعتبر است
25 مبلغ نامعتبر است
41 شماره درخواست تکراری است
43 قبلا درخواست Verify داده شده است
51 تراکنش تکراری است

Limitations:

  • ⚠️ Direct payout (ProcessPayoutAsync) not supported by Bank Mellat IPG
  • For withdrawals, use Shaparak Paya or third-party services like Fanapay, IPG.ir

⚙️ Service Registration (ConfigureServices.cs)

// Payment Gateway Service - برای Development از Mock استفاده می‌شود
var useRealPaymentGateway = configuration.GetValue<bool>("UseRealPaymentGateway", false);

if (useRealPaymentGateway)
{
    var paymentProvider = configuration.GetValue<string>("PaymentProvider", "BankMellat");
    
    if (paymentProvider == "Daya")
    {
        services.AddHttpClient<IPaymentGatewayService, DayaPaymentService>()
            .SetHandlerLifetime(TimeSpan.FromMinutes(5));
    }
    else if (paymentProvider == "BankMellat")
    {
        services.AddHttpClient<IPaymentGatewayService, BankMellatPaymentService>()
            .SetHandlerLifetime(TimeSpan.FromMinutes(5));
    }
    else
    {
        throw new InvalidOperationException($"Invalid PaymentProvider: {paymentProvider}");
    }
}
else
{
    // Mock برای Development و Testing
    services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>();
}

📝 Usage Examples

Purchase Package (InitiatePaymentAsync)

// In Command Handler
public class PurchaseGoldenPackageCommandHandler : IRequestHandler<PurchaseGoldenPackageCommand, long>
{
    private readonly IPaymentGatewayService _paymentGateway;

    public async Task<long> Handle(PurchaseGoldenPackageCommand request, CancellationToken ct)
    {
        // Initiate payment
        var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
        {
            UserId = request.UserId,
            Mobile = user.Mobile,
            Amount = packagePrice,
            Description = "خرید بسته طلایی",
            CallbackUrl = "https://yoursite.com/payment/callback"
        }, ct);

        if (!paymentResult.IsSuccess)
        {
            throw new InvalidOperationException(paymentResult.ErrorMessage);
        }

        // Create transaction record
        var transaction = new Transaction
        {
            UserId = request.UserId,
            Type = TransactionType.PackagePurchase,
            Amount = packagePrice,
            Status = TransactionStatus.Pending,
            RefId = paymentResult.RefId,
            Description = "خرید بسته طلایی"
        };

        await _context.Transactions.AddAsync(transaction, ct);
        await _context.SaveChangesAsync(ct);

        // Redirect user to gateway
        return transaction.Id; // Return transaction ID for frontend to track
    }
}

Verify Payment (Callback)

public class VerifyGoldenPackagePurchaseCommandHandler : IRequestHandler<VerifyGoldenPackagePurchaseCommand>
{
    private readonly IPaymentGatewayService _paymentGateway;

    public async Task Handle(VerifyGoldenPackagePurchaseCommand request, CancellationToken ct)
    {
        // Verify payment
        var verifyResult = await _paymentGateway.VerifyPaymentAsync(
            request.Authority, 
            request.Authority, 
            ct);

        if (!verifyResult.IsSuccess)
        {
            transaction.Status = TransactionStatus.Failed;
            transaction.ErrorMessage = verifyResult.Message;
            throw new InvalidOperationException(verifyResult.Message);
        }

        // Update transaction
        transaction.Status = TransactionStatus.Completed;
        transaction.CompletedAt = DateTime.UtcNow;

        // Activate club membership
        var clubMembership = new ClubMembership
        {
            UserId = transaction.UserId,
            Status = ClubMembershipStatus.Active,
            StartDate = DateTime.UtcNow,
            EndDate = DateTime.UtcNow.AddMonths(1),
            PurchaseMethod = PackagePurchaseMethod.DirectPurchase
        };

        await _context.ClubMemberships.AddAsync(clubMembership, ct);
        await _context.SaveChangesAsync(ct);
    }
}

Process Withdrawal (ProcessPayoutAsync)

public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawalCommand>
{
    private readonly IPaymentGatewayService _paymentGateway;

    public async Task Handle(ProcessWithdrawalCommand request, CancellationToken ct)
    {
        if (request.IsApproved)
        {
            if (payout.WithdrawalMethod == WithdrawalMethod.Diamond)
            {
                // Credit user wallet
                userWallet.DiscountBalance += payout.TotalAmount;
            }
            else if (payout.WithdrawalMethod == WithdrawalMethod.Cash)
            {
                // Process bank transfer
                var payoutResult = await _paymentGateway.ProcessPayoutAsync(new PayoutRequest
                {
                    UserId = payout.UserId,
                    Iban = payout.Iban,
                    Amount = payout.TotalAmount,
                    Description = $"برداشت کمیسیون هفته {payout.WeekNumber}"
                }, ct);

                if (payoutResult.IsSuccess)
                {
                    payout.Status = CommissionStatus.Withdrawn;
                    payout.CompletedAt = DateTime.UtcNow;
                    payout.TransactionId = payoutResult.TransactionId;
                }
                else
                {
                    payout.Status = CommissionStatus.PaymentFailed;
                    payout.ErrorMessage = payoutResult.Message;
                }
            }

            // Record history
            await _context.CommissionPayoutHistories.AddAsync(new CommissionPayoutHistory
            {
                PayoutId = payout.Id,
                TransactionType = payout.Status == CommissionStatus.Withdrawn 
                    ? TransactionType.Withdrawn 
                    : TransactionType.PaymentFailed,
                Amount = payout.TotalAmount,
                ProcessedBy = _currentUserService.UserId,
                ProcessedAt = DateTime.UtcNow
            }, ct);

            await _context.SaveChangesAsync(ct);
        }
    }
}

🧪 Testing Guide

Unit Testing with Mock

[Fact]
public async Task InitiatePayment_Should_Return_Success_With_Valid_Data()
{
    // Arrange
    var mockLogger = new Mock<ILogger<MockPaymentGatewayService>>();
    var service = new MockPaymentGatewayService(mockLogger.Object);
    
    var request = new PaymentRequest
    {
        UserId = 123,
        Mobile = "09123456789",
        Amount = 100000,
        Description = "Test payment",
        CallbackUrl = "https://test.com/callback"
    };

    // Act
    var result = await service.InitiatePaymentAsync(request);

    // Assert
    Assert.True(result.IsSuccess);
    Assert.NotNull(result.RefId);
    Assert.StartsWith("MockRef_", result.RefId);
    Assert.NotNull(result.GatewayUrl);
}

[Fact]
public async Task ProcessPayout_Should_Fail_With_Invalid_IBAN()
{
    // Arrange
    var mockLogger = new Mock<ILogger<MockPaymentGatewayService>>();
    var service = new MockPaymentGatewayService(mockLogger.Object);
    
    var request = new PayoutRequest
    {
        UserId = 123,
        Iban = "INVALID_IBAN",
        Amount = 50000,
        Description = "Test payout"
    };

    // Act
    var result = await service.ProcessPayoutAsync(request);

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Contains("فرمت شماره شبا نامعتبر", result.Message);
}

Integration Testing

public class PaymentGatewayIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public PaymentGatewayIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task PurchaseGoldenPackage_Should_Initiate_Payment()
    {
        // Arrange
        var command = new PurchaseGoldenPackageCommand
        {
            UserId = 123,
            PaymentMethod = PackagePurchaseMethod.DirectPurchase
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/package/purchase", command);

        // Assert
        response.EnsureSuccessStatusCode();
        var transactionId = await response.Content.ReadFromJsonAsync<long>();
        Assert.True(transactionId > 0);
    }
}

🔒 Security Best Practices

  1. Configuration Security:

    • Store API keys in appsettings.json (excluded from git)
    • Use Azure Key Vault or AWS Secrets Manager in production
    • Never hardcode credentials in code
  2. HTTPS Only:

    • Enforce HTTPS for all payment callbacks
    • Validate SSL certificates
  3. Amount Validation:

    • Validate min/max amounts before API call
    • Verify amounts match on callback
  4. IBAN Validation:

    • Format: IR + 24 digits = 26 characters
    • Validate before payout processing
  5. Idempotency:

    • Use unique OrderId for each payment
    • Store RefId to prevent duplicate processing
  6. Error Handling:

    • Never expose internal errors to users
    • Log detailed errors for debugging
    • Return user-friendly error messages

📊 Monitoring & Logging

// Success
_logger.LogInformation(
    "Payment initiated successfully: UserId={UserId}, Amount={Amount}, RefId={RefId}",
    request.UserId, request.Amount, result.RefId);

// Failure
_logger.LogError(
    "Payment initiation failed: UserId={UserId}, Amount={Amount}, Error={Error}",
    request.UserId, request.Amount, result.ErrorMessage);

// API Error
_logger.LogError(
    "Payment gateway API error: StatusCode={StatusCode}, Response={Response}",
    response.StatusCode, responseContent);

Sentry Integration

try
{
    var result = await _paymentGateway.InitiatePaymentAsync(request, ct);
}
catch (Exception ex)
{
    SentrySdk.CaptureException(ex, scope =>
    {
        scope.SetTag("payment_provider", "Daya");
        scope.SetExtra("user_id", request.UserId);
        scope.SetExtra("amount", request.Amount);
    });
    throw;
}

🚀 Production Deployment Checklist

  • Obtain Daya API credentials (BaseUrl + ApiKey)
  • Obtain Bank Mellat credentials (TerminalId, Username, Password)
  • Test in sandbox environment
  • Update appsettings.Production.json with credentials
  • Set UseRealPaymentGateway = true
  • Configure HTTPS callback URLs
  • Set up monitoring (Sentry/Application Insights)
  • Configure retry policies (Polly)
  • Test full payment flow (Initiate → Callback → Verify)
  • Test withdrawal flow (Request → Approve → Payout)
  • Document production URLs and credentials (secure location)

📞 Support & Troubleshooting

Common Issues

Issue: "Payment gateway API error: 401 Unauthorized"

  • Solution: Check API key in appsettings.json, verify credentials

Issue: "IBAN validation failed"

  • Solution: Ensure IBAN starts with "IR" and is exactly 26 characters

Issue: "Bank Mellat returns negative RefId"

  • Solution: Check error code mapping, verify TerminalId/Username/Password

Issue: "HttpClient timeout"

  • Solution: Increase timeout in ConfigureServices.cs, check network connectivity

📚 References


Last Updated: 2024-12-02
Version: 1.0
Status: Production Ready