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 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}");
}
// 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
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
@@ -113,9 +120,10 @@ public class VerifyGoldenPackagePurchaseCommandHandler : IRequestHandler<VerifyG
var oldBalance = wallet.Balance;
wallet.Balance += order.Amount;
// شارژ DiscountBalance (موجودی اعتباری) — دو برابر مبلغ سفارش
// شارژ DiscountBalance (موجودی اعتباری) — ضریب تخفیف از پکیج
var oldDiscountBalance = wallet.DiscountBalance;
wallet.DiscountBalance += order.Amount * 2;
var discountAmount = (long)(order.Amount * discountMultiplier);
wallet.DiscountBalance += discountAmount;
_logger.LogInformation(
"Charging wallet for user {UserId}. Balance: {OldBalance} -> {NewBalance}, DiscountBalance: {OldDiscount} -> {NewDiscount}",
@@ -163,7 +171,7 @@ public class VerifyGoldenPackagePurchaseCommandHandler : IRequestHandler<VerifyG
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = order.Amount * 2,
ChangeDiscountValue = discountAmount,
IsIncrease = true,
RefrenceId = transaction.Id
};
@@ -11,6 +11,7 @@ public class GetAllPackageByFilterQueryHandler : IRequestHandler<GetAllPackageBy
public async Task<GetAllPackageByFilterResponseDto> Handle(GetAllPackageByFilterQuery request, CancellationToken cancellationToken)
{
var query = _context.Packages
.Where(x => !x.IsDeleted)
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
@@ -18,44 +18,60 @@ public class GetCustomerPackageDetailsQueryHandler : IRequestHandler<GetCustomer
{
var package = await _context.Packages
.AsNoTracking()
.Where(x => x.Id == request.PackageId)
.ProjectToType<GetCustomerPackageDetailsResponseDto>()
.FirstOrDefaultAsync(cancellationToken);
.Include(p => p.PackageFeatures)
.ThenInclude(pf => pf.ClubFeature)
.FirstOrDefaultAsync(x => x.Id == request.PackageId && !x.IsDeleted, cancellationToken);
if (package == null)
throw new NotFoundException(nameof(Package), request.PackageId);
// Add features based on package (this could be stored in DB in future)
package.Features = new List<PackageFeatureDto>
var response = new GetCustomerPackageDetailsResponseDto
{
new PackageFeatureDto
{
Title = "درآمد کمیسیون",
Description = "دریافت کمیسیون از فروش محصولات",
Icon = "commission",
IsHighlighted = true
},
new PackageFeatureDto
{
Title = "پشتیبانی 24/7",
Description = "دسترسی به پشتیبانی در تمام ساعات شبانه روز",
Icon = "support",
IsHighlighted = false
},
new PackageFeatureDto
{
Title = "آموزش‌های تخصصی",
Description = "دسترسی به دوره‌های آموزشی و وبینارها",
Icon = "education",
IsHighlighted = true
}
Id = package.Id,
Title = package.Title,
Description = package.Description,
Price = package.Price,
ImagePath = package.ImagePath,
ActivationFee = package.ActivationFee,
DiscountMultiplier = (double)package.DiscountMultiplier,
MagicWalletMultiplier = (double)package.MagicWalletMultiplier,
MagicWalletMaxDeposit = package.MagicWalletMaxDeposit,
MagicWalletMaxCredit = package.MagicWalletMaxCredit,
IsBasePackage = package.IsBasePackage,
SupportsDayaPurchase = package.SupportsDayaPurchase,
SupportsDirectPurchase = package.SupportsDirectPurchase
};
// Set purchase requirements
package.Requirements = new PurchaseRequirementsDto
// بارگذاری ویژگی‌ها از DB (PackageFeatures → ClubFeature)
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,
MinimumWalletBalance = package.Price / 10, // 10% minimum
MinimumWalletBalance = package.Price / 10,
Restrictions = new List<string>
{
"باید حداقل 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 List<PackageFeatureDto> Features { get; set; } = new();
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
@@ -17,35 +17,43 @@ public class GetCustomerPackagesQueryHandler : IRequestHandler<GetCustomerPackag
{
var query = _context.Packages
.AsNoTracking()
.Where(p => !p.IsDeleted)
.AsQueryable();
// Filter by PackageType if specified
if (request.PackageTypeFilter.HasValue)
// فیلتر IsActive
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
// Assuming Title or Description contains the package type indicator
// If Package entity needs PackageType field, it should be added to migration
}
// Get all packages (assuming all are available unless marked otherwise)
var packages = await query
.ProjectToType<GetCustomerPackagesResponseDto>()
.ToListAsync(cancellationToken);
// Map additional fields
foreach (var package in packages)
{
package.Name = package.Title;
package.ImageUrl = package.ImagePath;
package.Currency = "IRR";
package.IsAvailable = true;
package.ValidityDays = 365; // Default validity
package.IsPopular = false;
package.ShortDescription = package.Description?.Length > 100
? package.Description.Substring(0, 100) + "..."
: package.Description;
}
return packages;
Id = p.Id,
Name = p.Title,
Title = p.Title,
Description = p.Description,
Price = p.Price,
ImageUrl = p.ImagePath,
ImagePath = p.ImagePath,
Currency = "IRR",
IsAvailable = p.IsActive,
ValidityDays = 365,
IsPopular = p.IsBasePackage,
ShortDescription = p.Description?.Length > 100
? p.Description.Substring(0, 100) + "..."
: p.Description,
// New fields
ActivationFee = p.ActivationFee,
DiscountMultiplier = (double)p.DiscountMultiplier,
MagicWalletMultiplier = (double)p.MagicWalletMultiplier,
MagicWalletMaxDeposit = p.MagicWalletMaxDeposit,
MagicWalletMaxCredit = p.MagicWalletMaxCredit,
IsBasePackage = p.IsBasePackage,
SupportsDayaPurchase = p.SupportsDayaPurchase,
SupportsDirectPurchase = p.SupportsDirectPurchase
}).ToList();
}
}
@@ -15,4 +15,13 @@ public class GetCustomerPackagesResponseDto
public int ValidityDays { get; set; }
public bool IsPopular { 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()
.Where(x => x.UserId == userId && x.PackageId != null)
.Include(x => x.Package)
.Include(x => x.Transaction)
.AsQueryable();
// Apply date filters if specified
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
@@ -15,47 +16,49 @@ public class GetUserPackageStatusQueryHandler : IRequestHandler<GetUserPackageSt
public async Task<UserPackageStatusDto> Handle(GetUserPackageStatusQuery request, CancellationToken cancellationToken)
{
// TODO: پیاده‌سازی دریافت وضعیت پکیج کاربر
//
// 1. دریافت اطلاعات کاربر:
// - var user = await _context.Users
// .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 مفید است تا وضعیت کاربر را نمایش دهد
// 1. دریافت اطلاعات کاربر
var user = await _context.Users
.Include(u => u.UserWallets)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
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
};
}
}