Phase 3: Package layer overhaul — fix critical bugs + proto enhancement

Proto (package.proto):
- Add 11 new Package fields to Create/Update/Get/GetAll messages
- Add 8 new fields to CustomerPackageModel for frontend

Critical Bug Fixes:
- VerifyGoldenPackagePurchase: Replace hardcoded ×2 with package.DiscountMultiplier from DB

Query Fixes:
- GetAllPackageByFilter: Add IsDeleted filter (was returning soft-deleted packages)
- GetCustomerPackages: Apply IsDeleted+IncludeInactive filters, sort by SortOrder, return new fields
- GetCustomerPackageDetails: Load PackageFeatures from DB (was hardcoded), add IsDeleted filter, return new fields
- GetCustomerPurchaseHistory: Include Transaction (was null → ReferenceCode always empty)

UpdatePackage: Add 12 new fields (SortOrder, IsActive, IsBasePackage, etc.)

Build: 0 errors
This commit is contained in:
masoodafar-web
2026-02-25 23:50:09 +03:30
parent 8e5c7c5205
commit ccb938e9ba
11 changed files with 271 additions and 105 deletions
@@ -11,5 +11,17 @@ public record UpdatePackageCommand : IRequest<Unit>
public string ImagePath { get; init; } public string ImagePath { get; init; }
//قیمت //قیمت
public long Price { get; init; } public long Price { get; init; }
// فیلدهای جدید پکیج
public int SortOrder { get; init; }
public bool IsActive { get; init; }
public bool IsBasePackage { get; init; }
public bool SupportsDayaPurchase { get; init; }
public bool SupportsDirectPurchase { get; init; }
public long ActivationFee { get; init; }
public double DiscountMultiplier { get; init; }
public double MagicWalletMultiplier { get; init; }
public int MaxBalancesPerLeg { get; init; }
public int MaxNetworkLevel { get; init; }
public long MagicWalletMaxDeposit { get; init; }
public long MagicWalletMaxCredit { get; init; }
} }
@@ -100,7 +100,14 @@ public class VerifyGoldenPackagePurchaseCommandHandler : IRequestHandler<VerifyG
throw new ValidationException($"تراکنش ناموفق: {verifyResult.Message}"); throw new ValidationException($"تراکنش ناموفق: {verifyResult.Message}");
} }
// 4. شارژ کیف پول (Balance فقط طبق سناریوی پکیج) // 4. بارگذاری پکیج برای ضریب تخفیف
var package = order.PackageId.HasValue
? await _context.Packages.FirstOrDefaultAsync(p => p.Id == order.PackageId.Value, cancellationToken)
: await _context.Packages.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
var discountMultiplier = package?.DiscountMultiplier ?? 2.0m;
// 5. شارژ کیف پول (Balance فقط طبق سناریوی پکیج)
var wallet = await _context.UserWallets var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken); .FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
@@ -113,9 +120,10 @@ public class VerifyGoldenPackagePurchaseCommandHandler : IRequestHandler<VerifyG
var oldBalance = wallet.Balance; var oldBalance = wallet.Balance;
wallet.Balance += order.Amount; wallet.Balance += order.Amount;
// شارژ DiscountBalance (موجودی اعتباری) — دو برابر مبلغ سفارش // شارژ DiscountBalance (موجودی اعتباری) — ضریب تخفیف از پکیج
var oldDiscountBalance = wallet.DiscountBalance; var oldDiscountBalance = wallet.DiscountBalance;
wallet.DiscountBalance += order.Amount * 2; var discountAmount = (long)(order.Amount * discountMultiplier);
wallet.DiscountBalance += discountAmount;
_logger.LogInformation( _logger.LogInformation(
"Charging wallet for user {UserId}. Balance: {OldBalance} -> {NewBalance}, DiscountBalance: {OldDiscount} -> {NewDiscount}", "Charging wallet for user {UserId}. Balance: {OldBalance} -> {NewBalance}, DiscountBalance: {OldDiscount} -> {NewDiscount}",
@@ -163,7 +171,7 @@ public class VerifyGoldenPackagePurchaseCommandHandler : IRequestHandler<VerifyG
CurrentNetworkBalance = wallet.NetworkBalance, CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0, ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance, CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = order.Amount * 2, ChangeDiscountValue = discountAmount,
IsIncrease = true, IsIncrease = true,
RefrenceId = transaction.Id RefrenceId = transaction.Id
}; };
@@ -11,6 +11,7 @@ public class GetAllPackageByFilterQueryHandler : IRequestHandler<GetAllPackageBy
public async Task<GetAllPackageByFilterResponseDto> Handle(GetAllPackageByFilterQuery request, CancellationToken cancellationToken) public async Task<GetAllPackageByFilterResponseDto> Handle(GetAllPackageByFilterQuery request, CancellationToken cancellationToken)
{ {
var query = _context.Packages var query = _context.Packages
.Where(x => !x.IsDeleted)
.ApplyOrder(sortBy: request.SortBy) .ApplyOrder(sortBy: request.SortBy)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
@@ -18,44 +18,60 @@ public class GetCustomerPackageDetailsQueryHandler : IRequestHandler<GetCustomer
{ {
var package = await _context.Packages var package = await _context.Packages
.AsNoTracking() .AsNoTracking()
.Where(x => x.Id == request.PackageId) .Include(p => p.PackageFeatures)
.ProjectToType<GetCustomerPackageDetailsResponseDto>() .ThenInclude(pf => pf.ClubFeature)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.PackageId && !x.IsDeleted, cancellationToken);
if (package == null) if (package == null)
throw new NotFoundException(nameof(Package), request.PackageId); throw new NotFoundException(nameof(Package), request.PackageId);
// Add features based on package (this could be stored in DB in future) var response = new GetCustomerPackageDetailsResponseDto
package.Features = new List<PackageFeatureDto>
{ {
new PackageFeatureDto Id = package.Id,
{ Title = package.Title,
Title = "درآمد کمیسیون", Description = package.Description,
Description = "دریافت کمیسیون از فروش محصولات", Price = package.Price,
Icon = "commission", ImagePath = package.ImagePath,
IsHighlighted = true ActivationFee = package.ActivationFee,
}, DiscountMultiplier = (double)package.DiscountMultiplier,
new PackageFeatureDto MagicWalletMultiplier = (double)package.MagicWalletMultiplier,
{ MagicWalletMaxDeposit = package.MagicWalletMaxDeposit,
Title = "پشتیبانی 24/7", MagicWalletMaxCredit = package.MagicWalletMaxCredit,
Description = "دسترسی به پشتیبانی در تمام ساعات شبانه روز", IsBasePackage = package.IsBasePackage,
Icon = "support", SupportsDayaPurchase = package.SupportsDayaPurchase,
IsHighlighted = false SupportsDirectPurchase = package.SupportsDirectPurchase
},
new PackageFeatureDto
{
Title = "آموزش‌های تخصصی",
Description = "دسترسی به دوره‌های آموزشی و وبینارها",
Icon = "education",
IsHighlighted = true
}
}; };
// Set purchase requirements // بارگذاری ویژگی‌ها از DB (PackageFeatures → ClubFeature)
package.Requirements = new PurchaseRequirementsDto if (package.PackageFeatures?.Any() == true)
{
response.Features = package.PackageFeatures
.Where(pf => pf.ClubFeature != null)
.Select(pf => new PackageFeatureDto
{
Title = pf.ClubFeature.Title ?? string.Empty,
Description = pf.ClubFeature.Description ?? string.Empty,
Icon = "feature",
IsHighlighted = pf.ClubFeature.IsActive
})
.ToList();
}
else
{
// فال‌بک به ویژگی‌های پیش‌فرض
response.Features = new List<PackageFeatureDto>
{
new() { Title = "درآمد کمیسیون", Description = "دریافت کمیسیون از فروش محصولات", Icon = "commission", IsHighlighted = true },
new() { Title = "پشتیبانی 24/7", Description = "دسترسی به پشتیبانی در تمام ساعات شبانه روز", Icon = "support", IsHighlighted = false },
new() { Title = "آموزش‌های تخصصی", Description = "دسترسی به دوره‌های آموزشی و وبینارها", Icon = "education", IsHighlighted = true }
};
}
// شرایط خرید
response.Requirements = new PurchaseRequirementsDto
{ {
RequiresMembership = false, RequiresMembership = false,
MinimumWalletBalance = package.Price / 10, // 10% minimum MinimumWalletBalance = package.Price / 10,
Restrictions = new List<string> Restrictions = new List<string>
{ {
"باید حداقل 18 سال سن داشته باشید", "باید حداقل 18 سال سن داشته باشید",
@@ -63,6 +79,6 @@ public class GetCustomerPackageDetailsQueryHandler : IRequestHandler<GetCustomer
} }
}; };
return package; return response;
} }
} }
@@ -9,6 +9,15 @@ public class GetCustomerPackageDetailsResponseDto
public string ImagePath { get; set; } public string ImagePath { get; set; }
public List<PackageFeatureDto> Features { get; set; } = new(); public List<PackageFeatureDto> Features { get; set; } = new();
public PurchaseRequirementsDto Requirements { get; set; } public PurchaseRequirementsDto Requirements { get; set; }
// New package-based fields
public long ActivationFee { get; set; }
public double DiscountMultiplier { get; set; }
public double MagicWalletMultiplier { get; set; }
public long MagicWalletMaxDeposit { get; set; }
public long MagicWalletMaxCredit { get; set; }
public bool IsBasePackage { get; set; }
public bool SupportsDayaPurchase { get; set; }
public bool SupportsDirectPurchase { get; set; }
} }
public class PackageFeatureDto public class PackageFeatureDto
@@ -17,35 +17,43 @@ public class GetCustomerPackagesQueryHandler : IRequestHandler<GetCustomerPackag
{ {
var query = _context.Packages var query = _context.Packages
.AsNoTracking() .AsNoTracking()
.Where(p => !p.IsDeleted)
.AsQueryable(); .AsQueryable();
// Filter by PackageType if specified // فیلتر IsActive
if (request.PackageTypeFilter.HasValue) if (!request.IncludeInactive)
query = query.Where(p => p.IsActive);
// مرتب‌سازی بر اساس SortOrder
query = query.OrderBy(p => p.SortOrder);
var packages = await query.ToListAsync(cancellationToken);
return packages.Select(p => new GetCustomerPackagesResponseDto
{ {
// Note: Package entity doesn't have PackageType enum, so we filter by convention Id = p.Id,
// Assuming Title or Description contains the package type indicator Name = p.Title,
// If Package entity needs PackageType field, it should be added to migration Title = p.Title,
} Description = p.Description,
Price = p.Price,
// Get all packages (assuming all are available unless marked otherwise) ImageUrl = p.ImagePath,
var packages = await query ImagePath = p.ImagePath,
.ProjectToType<GetCustomerPackagesResponseDto>() Currency = "IRR",
.ToListAsync(cancellationToken); IsAvailable = p.IsActive,
ValidityDays = 365,
// Map additional fields IsPopular = p.IsBasePackage,
foreach (var package in packages) ShortDescription = p.Description?.Length > 100
{ ? p.Description.Substring(0, 100) + "..."
package.Name = package.Title; : p.Description,
package.ImageUrl = package.ImagePath; // New fields
package.Currency = "IRR"; ActivationFee = p.ActivationFee,
package.IsAvailable = true; DiscountMultiplier = (double)p.DiscountMultiplier,
package.ValidityDays = 365; // Default validity MagicWalletMultiplier = (double)p.MagicWalletMultiplier,
package.IsPopular = false; MagicWalletMaxDeposit = p.MagicWalletMaxDeposit,
package.ShortDescription = package.Description?.Length > 100 MagicWalletMaxCredit = p.MagicWalletMaxCredit,
? package.Description.Substring(0, 100) + "..." IsBasePackage = p.IsBasePackage,
: package.Description; SupportsDayaPurchase = p.SupportsDayaPurchase,
} SupportsDirectPurchase = p.SupportsDirectPurchase
}).ToList();
return packages;
} }
} }
@@ -15,4 +15,13 @@ public class GetCustomerPackagesResponseDto
public int ValidityDays { get; set; } public int ValidityDays { get; set; }
public bool IsPopular { get; set; } public bool IsPopular { get; set; }
public string ShortDescription { get; set; } public string ShortDescription { get; set; }
// New package-based fields
public long ActivationFee { get; set; }
public double DiscountMultiplier { get; set; }
public double MagicWalletMultiplier { get; set; }
public long MagicWalletMaxDeposit { get; set; }
public long MagicWalletMaxCredit { get; set; }
public bool IsBasePackage { get; set; }
public bool SupportsDayaPurchase { get; set; }
public bool SupportsDirectPurchase { get; set; }
} }
@@ -33,6 +33,7 @@ public class GetCustomerPurchaseHistoryQueryHandler : IRequestHandler<GetCustome
.AsNoTracking() .AsNoTracking()
.Where(x => x.UserId == userId && x.PackageId != null) .Where(x => x.UserId == userId && x.PackageId != null)
.Include(x => x.Package) .Include(x => x.Package)
.Include(x => x.Transaction)
.AsQueryable(); .AsQueryable();
// Apply date filters if specified // Apply date filters if specified
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
using MediatR; using MediatR;
@@ -15,47 +16,49 @@ public class GetUserPackageStatusQueryHandler : IRequestHandler<GetUserPackageSt
public async Task<UserPackageStatusDto> Handle(GetUserPackageStatusQuery request, CancellationToken cancellationToken) public async Task<UserPackageStatusDto> Handle(GetUserPackageStatusQuery request, CancellationToken cancellationToken)
{ {
// TODO: پیاده‌سازی دریافت وضعیت پکیج کاربر // 1. دریافت اطلاعات کاربر
// var user = await _context.Users
// 1. دریافت اطلاعات کاربر: .Include(u => u.UserWallets)
// - var user = await _context.Users .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
// .Include(u => u.UserWallet)
// .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken)
// - if (user == null) throw new NotFoundException("کاربر یافت نشد")
//
// 2. دریافت عضویت باشگاه:
// - var clubMembership = await _context.ClubMemberships
// .FirstOrDefaultAsync(c => c.UserId == user.Id && c.IsActive, cancellationToken)
//
// 3. دریافت آخرین سفارش پکیج:
// - var lastPackageOrder = await _context.UserOrders
// .Where(o => o.UserId == user.Id && o.PackageId != null)
// .OrderByDescending(o => o.Created)
// .FirstOrDefaultAsync(cancellationToken)
//
// 4. بررسی شرایط فعالسازی باشگاه:
// - var wallet = user.UserWallet
// - bool canActivate =
// user.PackagePurchaseMethod != PackagePurchaseMethod.None &&
// clubMembership == null &&
// wallet != null &&
// wallet.Balance >= 56_000_000
//
// 5. برگشت DTO:
// - return new UserPackageStatusDto {
// UserId = user.Id,
// PackagePurchaseMethod = user.PackagePurchaseMethod.ToString(),
// HasPurchasedPackage = user.PackagePurchaseMethod != PackagePurchaseMethod.None,
// IsClubMemberActive = clubMembership != null,
// WalletBalance = wallet?.Balance ?? 0,
// DiscountBalance = wallet?.DiscountBalance ?? 0,
// CanActivateClubMembership = canActivate,
// LastOrderNumber = lastPackageOrder?.OrderNumber,
// LastPurchaseDate = lastPackageOrder?.Created
// }
//
// نکته: این query برای UI مفید است تا وضعیت کاربر را نمایش دهد
throw new NotImplementedException("GetUserPackageStatus needs implementation"); if (user == null)
throw new NotFoundException(nameof(User), request.UserId);
// 2. دریافت عضویت باشگاه
var clubMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == user.Id && c.IsActive, cancellationToken);
// 3. دریافت آخرین سفارش پکیج
var lastPackageOrder = await _context.UserOrders
.Where(o => o.UserId == user.Id && o.PackageId != null)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
// 4. بارگذاری پکیج پایه برای بررسی شرایط فعالسازی
var basePackage = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
var wallet = user.UserWallets.FirstOrDefault();
// 5. بررسی شرایط فعالسازی باشگاه
var canActivate =
user.PackagePurchaseMethod != PackagePurchaseMethod.None &&
clubMembership == null &&
wallet != null &&
basePackage != null &&
wallet.Balance >= basePackage.Price;
return new UserPackageStatusDto
{
UserId = user.Id,
PackagePurchaseMethod = user.PackagePurchaseMethod.ToString(),
HasPurchasedPackage = user.PackagePurchaseMethod != PackagePurchaseMethod.None,
IsClubMemberActive = clubMembership?.IsActive ?? false,
WalletBalance = wallet?.Balance ?? 0,
DiscountBalance = wallet?.DiscountBalance ?? 0,
CanActivateClubMembership = canActivate,
LastOrderNumber = lastPackageOrder?.Id.ToString(),
LastPurchaseDate = lastPackageOrder?.Created
};
} }
} }
@@ -114,6 +114,18 @@ message CreateNewPackageRequest
string image_path = 3; string image_path = 3;
int64 price = 4; int64 price = 4;
BoostCardFileModel image_file = 5; BoostCardFileModel image_file = 5;
int32 sort_order = 6;
bool is_active = 7;
bool is_base_package = 8;
bool supports_daya_purchase = 9;
bool supports_direct_purchase = 10;
int64 activation_fee = 11;
double discount_multiplier = 12;
double magic_wallet_multiplier = 13;
int32 max_balances_per_leg = 14;
int32 max_network_level = 15;
int64 magic_wallet_max_deposit = 16;
int64 magic_wallet_max_credit = 17;
} }
message CreateNewPackageResponse message CreateNewPackageResponse
{ {
@@ -127,6 +139,18 @@ message UpdatePackageRequest
string image_path = 4; string image_path = 4;
int64 price = 5; int64 price = 5;
BoostCardFileModel image_file = 6; BoostCardFileModel image_file = 6;
int32 sort_order = 7;
bool is_active = 8;
bool is_base_package = 9;
bool supports_daya_purchase = 10;
bool supports_direct_purchase = 11;
int64 activation_fee = 12;
double discount_multiplier = 13;
double magic_wallet_multiplier = 14;
int32 max_balances_per_leg = 15;
int32 max_network_level = 16;
int64 magic_wallet_max_deposit = 17;
int64 magic_wallet_max_credit = 18;
} }
message DeletePackageRequest message DeletePackageRequest
{ {
@@ -143,6 +167,18 @@ message GetPackageResponse
string description = 3; string description = 3;
string image_path = 4; string image_path = 4;
int64 price = 5; int64 price = 5;
int32 sort_order = 6;
bool is_active = 7;
bool is_base_package = 8;
bool supports_daya_purchase = 9;
bool supports_direct_purchase = 10;
int64 activation_fee = 11;
double discount_multiplier = 12;
double magic_wallet_multiplier = 13;
int32 max_balances_per_leg = 14;
int32 max_network_level = 15;
int64 magic_wallet_max_deposit = 16;
int64 magic_wallet_max_credit = 17;
} }
message GetAllPackageByFilterRequest message GetAllPackageByFilterRequest
{ {
@@ -170,6 +206,18 @@ message GetAllPackageByFilterResponseModel
string description = 3; string description = 3;
string image_path = 4; string image_path = 4;
int64 price = 5; int64 price = 5;
int32 sort_order = 6;
bool is_active = 7;
bool is_base_package = 8;
bool supports_daya_purchase = 9;
bool supports_direct_purchase = 10;
int64 activation_fee = 11;
double discount_multiplier = 12;
double magic_wallet_multiplier = 13;
int32 max_balances_per_leg = 14;
int32 max_network_level = 15;
int64 magic_wallet_max_deposit = 16;
int64 magic_wallet_max_credit = 17;
} }
// Package Purchase Messages // Package Purchase Messages
@@ -350,8 +398,17 @@ message CustomerPackageModel
int32 validity_days = 9; int32 validity_days = 9;
bool is_popular = 10; bool is_popular = 10;
string short_description = 11; string short_description = 11;
string title = 12; // Alias for frontend compatibility (uses name) string title = 12;
string image_path = 13; // Alias for frontend compatibility (uses image_url) string image_path = 13;
// New package-based fields
int64 activation_fee = 14;
double discount_multiplier = 15;
double magic_wallet_multiplier = 16;
int64 magic_wallet_max_deposit = 17;
int64 magic_wallet_max_credit = 18;
bool is_base_package = 19;
bool supports_daya_purchase = 20;
bool supports_direct_purchase = 21;
} }
message PackageFeature message PackageFeature
@@ -317,6 +317,48 @@ public class PackageService : PackageContract.PackageContractBase
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success; transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow; transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId; transaction.RefId = verifyResult.RefId;
// شارژ کیف پول کاربر
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == purchase.UserId, context.CancellationToken);
if (wallet == null)
{
wallet = new CMSMicroservice.Domain.Entities.UserWallet
{
UserId = purchase.UserId,
Balance = 0,
DiscountBalance = 0,
NetworkBalance = 0
};
_context.UserWallets.Add(wallet);
await _context.SaveChangesAsync(context.CancellationToken);
}
var discountAmount = (long)(purchase.Amount * (double)(purchase.Package?.DiscountMultiplier ?? 2.0m));
wallet.Balance += purchase.Amount;
wallet.DiscountBalance += discountAmount;
// ثبت لاگ کیف پول
var walletLog = new CMSMicroservice.Domain.Entities.UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = purchase.Amount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = discountAmount,
IsIncrease = true,
RefrenceId = transaction.Id
};
_context.UserWalletChangeLogs.Add(walletLog);
// به‌روزرسانی کاربر
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == purchase.UserId, context.CancellationToken);
if (user != null)
user.PackagePurchaseMethod = Domain.Enums.PackagePurchaseMethod.DirectPurchase;
} }
else if (transaction != null) else if (transaction != null)
{ {