Files
CMS/src/CMSMicroservice.WebApi/Services/UserWalletService.cs
T
masoodafar-web 2d23dbc798
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m38s
feat(withdrawal): normalize IBAN format in withdrawal requests
- Added IbanNormalizer to validate and normalize IBAN numbers in RequestWithdrawalCommandHandler and UserWalletService.
- Implemented error handling for invalid IBAN formats, ensuring compliance with expected standards.
- Updated relevant methods to handle normalized IBANs for cash withdrawal requests.
2026-08-23 23:01:17 +03:30

657 lines
27 KiB
C#

using CMSMicroservice.Protobuf.Protos.UserWallet;
using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.UserWalletCQ.Commands.CreateNewUserWallet;
using CMSMicroservice.Application.UserWalletCQ.Commands.UpdateUserWallet;
using CMSMicroservice.Application.UserWalletCQ.Commands.DeleteUserWallet;
using CMSMicroservice.Application.WalletCQ.Commands.ChargeMagicWallet;
using CMSMicroservice.Application.WalletCQ.Commands.ChargeDiscountWallet;
using CMSMicroservice.Application.WalletCQ.Commands.ChargeCreditWallet;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyMagicWalletCharge;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyCreditWalletCharge;
using CMSMicroservice.Application.WalletCQ.Commands.AdminManualCreditCharge;
using CMSMicroservice.Application.WalletCQ.Queries.GetManualCreditCharges;
using AppManualChargeFilter = CMSMicroservice.Application.WalletCQ.Queries.GetManualCreditCharges.GetManualCreditChargesFilter;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetUserWallet;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetAllUserWalletByFilter;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletHistory;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Enums;
using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Linq;
namespace CMSMicroservice.WebApi.Services;
public class UserWalletService : UserWalletContract.UserWalletContractBase
{
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ISender _sender;
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUserService;
private readonly ILogger<UserWalletService> _logger;
public UserWalletService(
IDispatchRequestToCQRS dispatchRequestToCQRS,
ISender sender,
IApplicationDbContext context,
ICurrentUserService currentUserService,
ILogger<UserWalletService> logger)
{
_dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender;
_context = context;
_currentUserService = currentUserService;
_logger = logger;
}
public override async Task<CreateNewUserWalletResponse> CreateNewUserWallet(CreateNewUserWalletRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<CreateNewUserWalletRequest, CreateNewUserWalletCommand, CreateNewUserWalletResponse>(request, context);
}
public override async Task<Empty> UpdateUserWallet(UpdateUserWalletRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<UpdateUserWalletRequest, UpdateUserWalletCommand>(request, context);
}
public override async Task<Empty> DeleteUserWallet(DeleteUserWalletRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<DeleteUserWalletRequest, DeleteUserWalletCommand>(request, context);
}
public override async Task<GetUserWalletResponse> GetUserWallet(GetUserWalletRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetUserWalletRequest, GetUserWalletQuery, GetUserWalletResponse>(request, context);
}
public override async Task<GetAllUserWalletByFilterResponse> GetAllUserWalletByFilter(GetAllUserWalletByFilterRequest request, ServerCallContext context)
{
var response = await _dispatchRequestToCQRS.Handle<GetAllUserWalletByFilterRequest, GetAllUserWalletByFilterQuery, GetAllUserWalletByFilterResponse>(request, context);
// Enrich response with user names
if (response?.Models != null && response.Models.Any())
{
var userIds = response.Models.Select(m => m.UserId).Distinct().ToList();
var users = await _context.Users
.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.FirstName, u.LastName })
.ToDictionaryAsync(u => u.Id, context.CancellationToken);
foreach (var model in response.Models)
{
if (users.TryGetValue(model.UserId, out var user))
{
model.UserName = $"{user.FirstName} {user.LastName}".Trim();
}
}
}
return response;
}
// ============= Customer-specific Methods =============
public override async Task<GetCustomerWalletResponse> GetCustomerWallet(Google.Protobuf.WellKnownTypes.Empty request, ServerCallContext context)
{
// Customer endpoint: resolve userId from JWT
if (!long.TryParse(_currentUserService.UserId, out var userId) || userId <= 0)
throw new RpcException(new Status(StatusCode.Unauthenticated, "کاربر احراز هویت نشده است"));
var walletQuery = new GetUserWalletQuery { Id = userId };
var wallet = await _sender.Send(walletQuery, context.CancellationToken);
var walletEntity = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId, context.CancellationToken);
return new GetCustomerWalletResponse
{
Balance = wallet.Balance,
NetworkBalance = wallet.NetworkBalance,
DiscountBalance = wallet.DiscountBalance,
WalletMode = (int)(walletEntity?.WalletMode ?? WalletMode.Normal)
};
}
public override async Task<GetCustomerWalletHistoryResponse> GetCustomerWalletHistory(GetCustomerWalletHistoryRequest request, ServerCallContext context)
{
var query = new GetCustomerWalletHistoryQuery
{
ReferenceId = request.ReferenceId,
IsIncrease = request.IsIncrease
};
var changeLogs = await _sender.Send(query, context.CancellationToken);
var response = new GetCustomerWalletHistoryResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
CurrentPage = 1,
TotalPage = 1,
PageSize = changeLogs.Count,
TotalCount = changeLogs.Count,
HasPrevious = false,
HasNext = false
}
};
foreach (var log in changeLogs)
{
response.Models.Add(new CustomerWalletHistoryModel
{
CurrentBalance = log.CurrentBalance,
ChangeValue = log.ChangeValue,
CurrentNetworkBalance = log.CurrentNetworkBalance,
ChangeNerworkValue = log.ChangeNerworkValue,
CurrentDiscountBalance = log.CurrentDiscountBalance,
ChangeDiscountValue = log.ChangeDiscountValue,
IsIncrease = log.IsIncrease,
RefrenceId = log.RefrenceId,
CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.SpecifyKind(log.Created, DateTimeKind.Utc))
});
}
return response;
}
public override async Task<Google.Protobuf.WellKnownTypes.Empty> CustomerWithdrawBalance(CustomerWithdrawBalanceRequest request, ServerCallContext context)
{
var userId = GetCurrentUserId();
// Find the commission payout record
var payout = await _context.UserCommissionPayouts
.Where(p => p.Id == request.PayoutId && p.UserId == userId && !p.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken);
if (payout == null)
throw new RpcException(new Status(StatusCode.NotFound, "رکورد پرداخت کمیسیون یافت نشد"));
// Validate status - can only withdraw if already paid to wallet
if (payout.Status != Domain.Enums.CommissionPayoutStatus.Paid)
throw new RpcException(new Status(StatusCode.FailedPrecondition,
"فقط کمیسیون‌های واریز شده به کیف پول قابل برداشت هستند"));
// Update payout with withdrawal info
payout.WithdrawalMethod = (Domain.Enums.WithdrawalMethod)request.WithdrawalMethod;
if (payout.WithdrawalMethod == Domain.Enums.WithdrawalMethod.Cash)
{
var normalizedIban = IbanNormalizer.TryNormalize(request.IbanNumber);
if (normalizedIban is null)
throw new RpcException(new Status(StatusCode.InvalidArgument,
"فرمت شماره شبا معتبر نیست. باید IR و ۲۴ رقم باشد."));
payout.IbanNumber = normalizedIban;
}
else
{
payout.IbanNumber = null;
}
payout.Status = Domain.Enums.CommissionPayoutStatus.WithdrawRequested;
await _context.SaveChangesAsync(context.CancellationToken);
return new Google.Protobuf.WellKnownTypes.Empty();
}
// ============= Magic Wallet Methods =============
public override async Task<InitiateMagicChargeResponse> InitiateMagicCharge(
InitiateMagicChargeRequest request, ServerCallContext context)
{
var userId = GetCurrentUserId();
try
{
var result = await _sender.Send(new ChargeMagicWalletCommand
{
UserId = userId,
Amount = request.Amount
}, context.CancellationToken);
return new InitiateMagicChargeResponse
{
IsSuccess = result.IsSuccess,
GatewayUrl = result.GatewayUrl ?? "",
ErrorMessage = result.ErrorMessage ?? ""
};
}
catch (PaymentInProgressException ex)
{
return new InitiateMagicChargeResponse
{
IsSuccess = false,
ErrorMessage = ex.Message
};
}
}
// ============= Discount Wallet Methods =============
public override async Task<InitiateDiscountChargeResponse> InitiateDiscountCharge(
InitiateDiscountChargeRequest request, ServerCallContext context)
{
var userId = GetCurrentUserId();
try
{
var result = await _sender.Send(new ChargeDiscountWalletCommand
{
UserId = userId,
Amount = request.Amount
}, context.CancellationToken);
return new InitiateDiscountChargeResponse
{
IsSuccess = result.IsSuccess,
GatewayUrl = result.GatewayUrl ?? "",
ErrorMessage = result.ErrorMessage ?? ""
};
}
catch (PaymentInProgressException ex)
{
return new InitiateDiscountChargeResponse
{
IsSuccess = false,
ErrorMessage = ex.Message
};
}
}
// ============= Credit (Main) Wallet Methods =============
public override async Task<InitiateCreditChargeResponse> InitiateCreditCharge(
InitiateCreditChargeRequest request, ServerCallContext context)
{
var userId = GetCurrentUserId();
try
{
var result = await _sender.Send(new ChargeCreditWalletCommand
{
UserId = userId,
Amount = request.Amount
}, context.CancellationToken);
return new InitiateCreditChargeResponse
{
IsSuccess = result.IsSuccess,
GatewayUrl = result.GatewayUrl ?? "",
ErrorMessage = result.ErrorMessage ?? ""
};
}
catch (PaymentInProgressException ex)
{
return new InitiateCreditChargeResponse
{
IsSuccess = false,
ErrorMessage = ex.Message
};
}
catch (BadRequestException ex)
{
return new InitiateCreditChargeResponse
{
IsSuccess = false,
ErrorMessage = ex.Message
};
}
}
// ============= Wallet Verify Methods =============
public override async Task<VerifyWalletChargeResponse> VerifyMagicCharge(
VerifyWalletChargeRequest request, ServerCallContext context)
{
_logger.LogInformation("VerifyMagicCharge called: Authority={Authority}, Status={Status}",
request.Authority, request.Status);
try
{
if (string.IsNullOrEmpty(request.Authority))
return new VerifyWalletChargeResponse { Success = false, Message = "کد Authority نامعتبر است" };
var result = await _sender.Send(new VerifyMagicWalletChargeCommand
{
Authority = request.Authority,
Status = request.Status ?? "NOK"
}, context.CancellationToken);
_logger.LogInformation("VerifyMagicCharge result: {Result}, Authority={Authority}", result, request.Authority);
return new VerifyWalletChargeResponse
{
Success = result,
Message = result
? "شارژ کیف‌پول جادویی با موفقیت انجام شد"
: "شارژ کیف‌پول جادویی ناموفق بود"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "VerifyMagicCharge error: Authority={Authority}", request.Authority);
return new VerifyWalletChargeResponse { Success = false, Message = ex.Message };
}
}
public override async Task<VerifyWalletChargeResponse> VerifyDiscountCharge(
VerifyWalletChargeRequest request, ServerCallContext context)
{
_logger.LogInformation("VerifyDiscountCharge called: Authority={Authority}, Status={Status}",
request.Authority, request.Status);
try
{
if (string.IsNullOrEmpty(request.Authority))
return new VerifyWalletChargeResponse { Success = false, Message = "کد Authority نامعتبر است" };
// پیدا کردن PaymentTransaction برای استخراج UserId و Amount
var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken);
if (paymentTx == null || !paymentTx.UserId.HasValue)
{
_logger.LogError("VerifyDiscountCharge: PaymentTransaction not found for Authority={Authority}", request.Authority);
return new VerifyWalletChargeResponse { Success = false, Message = "تراکنش یافت نشد" };
}
if (!string.Equals(request.Status, "OK", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("VerifyDiscountCharge: Payment cancelled by user. Authority={Authority}", request.Authority);
return new VerifyWalletChargeResponse { Success = false, Message = "پرداخت توسط کاربر لغو شد" };
}
var result = await _sender.Send(new VerifyDiscountWalletChargeCommand
{
UserId = paymentTx.UserId.Value,
Amount = paymentTx.Amount,
Authority = request.Authority
}, context.CancellationToken);
_logger.LogInformation("VerifyDiscountCharge result: {Result}, Authority={Authority}, UserId={UserId}",
result, request.Authority, paymentTx.UserId.Value);
return new VerifyWalletChargeResponse
{
Success = result,
Message = result
? "شارژ کیف پول تخفیفی با موفقیت انجام شد"
: "شارژ کیف پول تخفیفی ناموفق بود"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "VerifyDiscountCharge error: Authority={Authority}", request.Authority);
return new VerifyWalletChargeResponse { Success = false, Message = ex.Message };
}
}
public override async Task<VerifyWalletChargeResponse> VerifyCreditCharge(
VerifyWalletChargeRequest request, ServerCallContext context)
{
_logger.LogInformation("VerifyCreditCharge called: Authority={Authority}, Status={Status}",
request.Authority, request.Status);
try
{
if (string.IsNullOrEmpty(request.Authority))
return new VerifyWalletChargeResponse { Success = false, Message = "کد Authority نامعتبر است" };
var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken);
if (paymentTx == null || !paymentTx.UserId.HasValue)
{
_logger.LogError("VerifyCreditCharge: PaymentTransaction not found for Authority={Authority}", request.Authority);
return new VerifyWalletChargeResponse { Success = false, Message = "تراکنش یافت نشد" };
}
if (!string.Equals(request.Status, "OK", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("VerifyCreditCharge: Payment cancelled by user. Authority={Authority}", request.Authority);
return new VerifyWalletChargeResponse { Success = false, Message = "پرداخت توسط کاربر لغو شد" };
}
var result = await _sender.Send(new VerifyCreditWalletChargeCommand
{
UserId = paymentTx.UserId.Value,
Amount = paymentTx.Amount,
Authority = request.Authority
}, context.CancellationToken);
_logger.LogInformation("VerifyCreditCharge result: {Result}, Authority={Authority}, UserId={UserId}",
result, request.Authority, paymentTx.UserId.Value);
return new VerifyWalletChargeResponse
{
Success = result,
Message = result
? "شارژ کیف پول اصلی با موفقیت انجام شد"
: "شارژ کیف پول اصلی ناموفق بود"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "VerifyCreditCharge error: Authority={Authority}", request.Authority);
return new VerifyWalletChargeResponse { Success = false, Message = ex.Message };
}
}
public override async Task<GetMagicWalletStatusResponse> GetMagicWalletStatus(
Google.Protobuf.WellKnownTypes.Empty request, ServerCallContext context)
{
var userId = GetCurrentUserId();
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId, context.CancellationToken);
if (wallet == null)
throw new RpcException(new Status(StatusCode.NotFound, "کیف پول یافت نشد"));
var cycleCount = await _context.ClubMembershipCycles
.CountAsync(c => c.UserId == userId, context.CancellationToken);
// بارگذاری پکیج کاربر برای سقف کیف‌پول جادویی
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsCurrentCycle, context.CancellationToken);
var package = currentCycle != null
? await _context.Packages.FirstOrDefaultAsync(p => p.Id == currentCycle.PackageId, context.CancellationToken)
: await _context.Packages.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, context.CancellationToken);
var magicMaxDeposit = package?.MagicWalletMaxDeposit ?? SystemConstants.MagicWalletDefaultMaxDeposit;
var magicMultiplier = package != null ? (double)package.MagicWalletMultiplier : SystemConstants.MagicWalletDefaultMultiplier;
var magicMaxCredit = package?.MagicWalletMaxCredit ?? SystemConstants.MagicWalletDefaultMaxCredit;
var response = new GetMagicWalletStatusResponse
{
WalletMode = (int)wallet.WalletMode,
MagicTotalDeposited = wallet.MagicTotalDeposited,
MagicTotalCredited = wallet.MagicTotalCredited,
MagicMaxDeposit = magicMaxDeposit,
MagicRemainingDeposit = Math.Max(0, magicMaxDeposit - wallet.MagicTotalDeposited),
Balance = wallet.Balance,
PurchaseCycleCount = cycleCount,
MagicMultiplier = magicMultiplier,
MagicMaxCredit = magicMaxCredit
};
if (wallet.MagicActivatedAt.HasValue)
{
response.MagicActivatedAt = Timestamp.FromDateTime(
DateTime.SpecifyKind(wallet.MagicActivatedAt.Value, DateTimeKind.Utc));
}
return response;
}
private long GetCurrentUserId()
{
if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0)
return userId;
throw new RpcException(new Status(StatusCode.Unauthenticated, "لطفاً وارد حساب کاربری خود شوید"));
}
public override async Task<GetCustomerWithdrawalsResponse> GetCustomerWithdrawals(GetCustomerWithdrawalsRequest request, ServerCallContext context)
{
var query = new GetCustomerWithdrawalsQuery
{
Status = request.Status
};
var withdrawals = await _sender.Send(query, context.CancellationToken);
var response = new GetCustomerWithdrawalsResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
CurrentPage = 1,
TotalPage = 1,
PageSize = withdrawals.Count,
TotalCount = withdrawals.Count,
HasPrevious = false,
HasNext = false
}
};
foreach (var withdrawal in withdrawals)
{
response.Models.Add(new CustomerWithdrawalModel
{
Id = withdrawal.Id,
WeekDefinitionId = withdrawal.WeekDefinitionId,
WeekDisplayName = withdrawal.WeekDisplayName,
TotalAmount = withdrawal.TotalAmount,
Status = withdrawal.Status,
WithdrawalMethod = withdrawal.WithdrawalMethod,
IbanNumber = withdrawal.IbanNumber,
Created = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.SpecifyKind(withdrawal.Created, DateTimeKind.Utc))
});
}
return response;
}
public override async Task<GetCustomerWithdrawalSettingsResponse> GetCustomerWithdrawalSettings(Google.Protobuf.WellKnownTypes.Empty request, ServerCallContext context)
{
var query = new GetCustomerWithdrawalSettingsQuery();
var settings = await _sender.Send(query, context.CancellationToken);
return new GetCustomerWithdrawalSettingsResponse
{
MinWithdrawalAmount = settings.MinWithdrawalAmount
};
}
// ============= Admin Manual Credit Charge =============
public override async Task<AdminManualCreditChargeResponse> AdminManualCreditCharge(
AdminManualCreditChargeRequest request, ServerCallContext context)
{
try
{
var result = await _sender.Send(new AdminManualCreditChargeCommand
{
UserId = request.UserId,
Amount = request.Amount,
Note = request.Note,
PerformedByAdminId = _currentUserService.GetPerformedBy()
}, context.CancellationToken);
return new AdminManualCreditChargeResponse
{
Success = result.Success,
Message = result.Message ?? string.Empty,
TransactionId = result.TransactionId,
NewBalance = result.NewBalance
};
}
catch (PaymentInProgressException ex)
{
return new AdminManualCreditChargeResponse
{
Success = false,
Message = ex.Message
};
}
catch (NotFoundException ex)
{
return new AdminManualCreditChargeResponse
{
Success = false,
Message = ex.Message
};
}
catch (Exception ex)
{
_logger.LogError(ex, "AdminManualCreditCharge failed for UserId={UserId}", request.UserId);
return new AdminManualCreditChargeResponse
{
Success = false,
Message = ex.Message
};
}
}
public override async Task<GetManualCreditChargesResponse> GetManualCreditCharges(
GetManualCreditChargesRequest request, ServerCallContext context)
{
var result = await _sender.Send(new GetManualCreditChargesQuery
{
SortBy = request.SortBy,
PaginationState = request.PaginationState != null
? new Application.Common.Models.PaginationState
{
PageNumber = request.PaginationState.PageNumber,
PageSize = request.PaginationState.PageSize
}
: new Application.Common.Models.PaginationState { PageNumber = 1, PageSize = 20 },
Filter = new AppManualChargeFilter
{
UserId = request.Filter?.UserId,
TransactionId = request.Filter?.TransactionId,
RefId = request.Filter?.RefId,
CreatedFrom = request.Filter?.CreatedFrom?.ToDateTime(),
CreatedTo = request.Filter?.CreatedTo?.ToDateTime()
}
}, context.CancellationToken);
var response = new GetManualCreditChargesResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
CurrentPage = result.MetaData.CurrentPage,
TotalPage = result.MetaData.TotalPage,
PageSize = result.MetaData.PageSize,
TotalCount = result.MetaData.TotalCount,
HasPrevious = result.MetaData.HasPrevious,
HasNext = result.MetaData.HasNext
}
};
foreach (var m in result.Models)
{
var model = new ManualCreditChargeModel
{
TransactionId = m.TransactionId,
UserId = m.UserId,
UserName = m.UserName ?? string.Empty,
Amount = m.Amount,
Description = m.Description ?? string.Empty,
RefId = m.RefId ?? string.Empty,
NewBalance = m.NewBalance,
Created = Timestamp.FromDateTime(DateTime.SpecifyKind(m.Created, DateTimeKind.Utc))
};
if (m.PaymentDate.HasValue)
{
model.PaymentDate = Timestamp.FromDateTime(
DateTime.SpecifyKind(m.PaymentDate.Value, DateTimeKind.Utc));
}
response.Models.Add(model);
}
return response;
}
}