Spaces:
Sleeping
Sleeping
Yuyuqt commited on
Commit ·
ee79726
1
Parent(s): ace6185
add: user balance
Browse files- Backend/Features/Subscriptions/SubscriptionController.cs +29 -1
- Backend/Features/Subscriptions/SubscriptionService.cs +124 -8
- Backend/Features/Subscriptions/SubscriptionUpgradePreviewDto.cs +11 -0
- Backend/Features/Wallet/IWalletService.cs +21 -0
- Backend/Features/Wallet/WalletController.cs +64 -0
- Backend/Features/Wallet/WalletService.cs +79 -0
- Backend/Program.cs +4 -0
- BlazorFrontend/Components/Pages/Members.razor +66 -0
- BlazorFrontend/Components/Pages/Membership.razor +69 -1
- BlazorFrontend/Models/MembershipViewModel.cs +2 -0
- BlazorFrontend/Services/LibraryApiClient.cs +42 -0
- DbConnect/Data/AppDbContext.cs +19 -0
- DbConnect/Entities/User.cs +4 -1
- DbConnect/Entities/WalletTransaction.cs +16 -0
- LibraryManagement.Shared/Models/LibraryDtos.cs +28 -2
Backend/Features/Subscriptions/SubscriptionController.cs
CHANGED
|
@@ -100,6 +100,34 @@ namespace Backend.Features.Subscriptions
|
|
| 100 |
return BadRequest(new { message = ex.Message });
|
| 101 |
}
|
| 102 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
}
|
| 105 |
-
|
|
|
|
| 100 |
return BadRequest(new { message = ex.Message });
|
| 101 |
}
|
| 102 |
}
|
| 103 |
+
|
| 104 |
+
[HttpGet("subscriptions/preview-upgrade/{membershipId}")]
|
| 105 |
+
[Authorize]
|
| 106 |
+
public async Task<ActionResult<SubscriptionUpgradePreviewDto>> GetUpgradePreview(int membershipId)
|
| 107 |
+
{
|
| 108 |
+
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
| 109 |
+
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
| 110 |
+
|
| 111 |
+
var preview = await _subscriptionService.GetUpgradePreviewAsync(Guid.Parse(userIdStr), membershipId);
|
| 112 |
+
return Ok(preview);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
[HttpPost("subscriptions/subscribe-wallet")]
|
| 116 |
+
[Authorize]
|
| 117 |
+
public async Task<ActionResult<SubscriptionDto>> SubscribeWithWallet([FromBody] SubscribeRequest request)
|
| 118 |
+
{
|
| 119 |
+
try
|
| 120 |
+
{
|
| 121 |
+
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
| 122 |
+
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
| 123 |
+
|
| 124 |
+
var subscription = await _subscriptionService.SubscribeWithWalletAsync(Guid.Parse(userIdStr), request.MembershipId);
|
| 125 |
+
return Ok(subscription);
|
| 126 |
+
}
|
| 127 |
+
catch (Exception ex)
|
| 128 |
+
{
|
| 129 |
+
return BadRequest(new { message = ex.Message });
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
}
|
| 133 |
}
|
|
|
Backend/Features/Subscriptions/SubscriptionService.cs
CHANGED
|
@@ -3,6 +3,8 @@ using DbConnect.Data;
|
|
| 3 |
using DbConnect.Entities;
|
| 4 |
using Microsoft.EntityFrameworkCore;
|
| 5 |
using Backend.Features.Loyalty;
|
|
|
|
|
|
|
| 6 |
|
| 7 |
namespace Backend.Features.Subscriptions
|
| 8 |
{
|
|
@@ -14,19 +16,25 @@ namespace Backend.Features.Subscriptions
|
|
| 14 |
Task<bool> HandleLoyaltyRedemptionAsync(Guid userId, string rewardId, string rewardName, string redemptionId);
|
| 15 |
Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null);
|
| 16 |
Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync();
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
|
|
|
| 19 |
public class SubscriptionService : ISubscriptionService
|
| 20 |
{
|
| 21 |
private readonly AppDbContext _context;
|
| 22 |
private readonly ILoyaltyService _loyaltyService;
|
|
|
|
| 23 |
|
| 24 |
-
public SubscriptionService(AppDbContext context, ILoyaltyService loyaltyService)
|
| 25 |
{
|
| 26 |
_context = context;
|
| 27 |
_loyaltyService = loyaltyService;
|
|
|
|
| 28 |
}
|
| 29 |
|
|
|
|
| 30 |
public async Task<IEnumerable<MembershipDto>> GetMembershipsAsync()
|
| 31 |
{
|
| 32 |
return await _context.Memberships
|
|
@@ -110,15 +118,41 @@ namespace Backend.Features.Subscriptions
|
|
| 110 |
var membership = await _context.Memberships.FindAsync(membershipId);
|
| 111 |
if (membership == null) throw new Exception("Membership plan not found.");
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
.OrderByDescending(s => s.ExpiryDate)
|
| 116 |
-
.
|
| 117 |
|
| 118 |
DateTime startDate = DateTime.UtcNow;
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
{
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
|
| 124 |
var subscription = new UserSubscription
|
|
@@ -138,12 +172,16 @@ namespace Backend.Features.Subscriptions
|
|
| 138 |
await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
|
| 139 |
await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
await _loyaltyService.ProcessEventAsync(
|
| 142 |
externalUserId: userId.ToString(),
|
| 143 |
eventKey: "SUBSCRIBE",
|
| 144 |
-
eventValue: (double)
|
| 145 |
referenceId: $"SUB-{subscription.Id}",
|
| 146 |
-
description: $"Purchased Membership: {membership.Type}",
|
| 147 |
email: subscription.User?.Email ?? "No Email",
|
| 148 |
mobile: subscription.User?.PhoneNumber ?? "0000000000"
|
| 149 |
);
|
|
@@ -151,6 +189,84 @@ namespace Backend.Features.Subscriptions
|
|
| 151 |
return MapToDto(subscription);
|
| 152 |
}
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
public async Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync()
|
| 155 |
{
|
| 156 |
var subscriptions = await _context.UserSubscriptions
|
|
|
|
| 3 |
using DbConnect.Entities;
|
| 4 |
using Microsoft.EntityFrameworkCore;
|
| 5 |
using Backend.Features.Loyalty;
|
| 6 |
+
using Backend.Features.Wallet;
|
| 7 |
+
|
| 8 |
|
| 9 |
namespace Backend.Features.Subscriptions
|
| 10 |
{
|
|
|
|
| 16 |
Task<bool> HandleLoyaltyRedemptionAsync(Guid userId, string rewardId, string rewardName, string redemptionId);
|
| 17 |
Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null);
|
| 18 |
Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync();
|
| 19 |
+
Task<SubscriptionUpgradePreviewDto> GetUpgradePreviewAsync(Guid userId, int newMembershipId);
|
| 20 |
+
Task<SubscriptionDto> SubscribeWithWalletAsync(Guid userId, int membershipId);
|
| 21 |
}
|
| 22 |
|
| 23 |
+
|
| 24 |
public class SubscriptionService : ISubscriptionService
|
| 25 |
{
|
| 26 |
private readonly AppDbContext _context;
|
| 27 |
private readonly ILoyaltyService _loyaltyService;
|
| 28 |
+
private readonly IWalletService _walletService;
|
| 29 |
|
| 30 |
+
public SubscriptionService(AppDbContext context, ILoyaltyService loyaltyService, IWalletService walletService)
|
| 31 |
{
|
| 32 |
_context = context;
|
| 33 |
_loyaltyService = loyaltyService;
|
| 34 |
+
_walletService = walletService;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
|
| 38 |
public async Task<IEnumerable<MembershipDto>> GetMembershipsAsync()
|
| 39 |
{
|
| 40 |
return await _context.Memberships
|
|
|
|
| 118 |
var membership = await _context.Memberships.FindAsync(membershipId);
|
| 119 |
if (membership == null) throw new Exception("Membership plan not found.");
|
| 120 |
|
| 121 |
+
// Cooldown check: Max 1 active and 1 queued subscription
|
| 122 |
+
var activeAndQueued = await _context.UserSubscriptions
|
| 123 |
+
.Where(s => s.UserId == userId && s.IsActive && s.ExpiryDate > DateTime.UtcNow)
|
| 124 |
+
.ToListAsync();
|
| 125 |
+
|
| 126 |
+
if (activeAndQueued.Count >= 2)
|
| 127 |
+
{
|
| 128 |
+
throw new Exception("You already have an active and a queued subscription. Please wait until one expires.");
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
var currentActive = activeAndQueued
|
| 132 |
+
.Where(s => s.StartDate <= DateTime.UtcNow)
|
| 133 |
.OrderByDescending(s => s.ExpiryDate)
|
| 134 |
+
.FirstOrDefault();
|
| 135 |
|
| 136 |
DateTime startDate = DateTime.UtcNow;
|
| 137 |
+
decimal discount = 0;
|
| 138 |
+
|
| 139 |
+
if (currentActive != null)
|
| 140 |
{
|
| 141 |
+
// If it's the same membership, we queue it
|
| 142 |
+
if (currentActive.MembershipId == membershipId)
|
| 143 |
+
{
|
| 144 |
+
startDate = currentActive.ExpiryDate;
|
| 145 |
+
}
|
| 146 |
+
else
|
| 147 |
+
{
|
| 148 |
+
// If it's a different membership, we treat it as an upgrade/change
|
| 149 |
+
// Calculate discount and deactivate old one
|
| 150 |
+
var preview = await GetUpgradePreviewAsync(userId, membershipId);
|
| 151 |
+
discount = preview.DiscountAmount;
|
| 152 |
+
currentActive.IsActive = false;
|
| 153 |
+
currentActive.Status = "Upgraded";
|
| 154 |
+
startDate = DateTime.UtcNow;
|
| 155 |
+
}
|
| 156 |
}
|
| 157 |
|
| 158 |
var subscription = new UserSubscription
|
|
|
|
| 172 |
await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
|
| 173 |
await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
|
| 174 |
|
| 175 |
+
// Only award loyalty points for the amount actually paid (Price - Discount)
|
| 176 |
+
decimal paidAmount = membership.Price - discount;
|
| 177 |
+
if (paidAmount < 0) paidAmount = 0;
|
| 178 |
+
|
| 179 |
await _loyaltyService.ProcessEventAsync(
|
| 180 |
externalUserId: userId.ToString(),
|
| 181 |
eventKey: "SUBSCRIBE",
|
| 182 |
+
eventValue: (double)paidAmount,
|
| 183 |
referenceId: $"SUB-{subscription.Id}",
|
| 184 |
+
description: $"Purchased Membership: {membership.Type} (Discount applied: {discount})",
|
| 185 |
email: subscription.User?.Email ?? "No Email",
|
| 186 |
mobile: subscription.User?.PhoneNumber ?? "0000000000"
|
| 187 |
);
|
|
|
|
| 189 |
return MapToDto(subscription);
|
| 190 |
}
|
| 191 |
|
| 192 |
+
public async Task<SubscriptionUpgradePreviewDto> GetUpgradePreviewAsync(Guid userId, int newMembershipId)
|
| 193 |
+
{
|
| 194 |
+
var newMembership = await _context.Memberships.FindAsync(newMembershipId);
|
| 195 |
+
if (newMembership == null) return new SubscriptionUpgradePreviewDto { CanUpgrade = false, Message = "Plan not found" };
|
| 196 |
+
|
| 197 |
+
var currentSubscription = await _context.UserSubscriptions
|
| 198 |
+
.Include(s => s.Membership)
|
| 199 |
+
.Where(s => s.UserId == userId && s.IsActive && s.StartDate <= DateTime.UtcNow && s.ExpiryDate > DateTime.UtcNow)
|
| 200 |
+
.OrderByDescending(s => s.StartDate)
|
| 201 |
+
.FirstOrDefaultAsync();
|
| 202 |
+
|
| 203 |
+
if (currentSubscription == null)
|
| 204 |
+
{
|
| 205 |
+
return new SubscriptionUpgradePreviewDto
|
| 206 |
+
{
|
| 207 |
+
OriginalPrice = newMembership.Price,
|
| 208 |
+
DiscountAmount = 0,
|
| 209 |
+
FinalPrice = newMembership.Price,
|
| 210 |
+
CanUpgrade = true
|
| 211 |
+
};
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
if (currentSubscription.MembershipId == newMembershipId)
|
| 215 |
+
{
|
| 216 |
+
return new SubscriptionUpgradePreviewDto
|
| 217 |
+
{
|
| 218 |
+
OriginalPrice = newMembership.Price,
|
| 219 |
+
DiscountAmount = 0,
|
| 220 |
+
FinalPrice = newMembership.Price,
|
| 221 |
+
CanUpgrade = true,
|
| 222 |
+
Message = "Same plan selected. This will be queued after your current subscription."
|
| 223 |
+
};
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// Calculate unused value
|
| 227 |
+
var totalDuration = (currentSubscription.ExpiryDate - currentSubscription.StartDate).TotalDays;
|
| 228 |
+
var daysRemaining = (currentSubscription.ExpiryDate - DateTime.UtcNow).TotalDays;
|
| 229 |
+
|
| 230 |
+
if (daysRemaining < 0) daysRemaining = 0;
|
| 231 |
+
|
| 232 |
+
decimal dailyRate = currentSubscription.Membership.Price / (decimal)totalDuration;
|
| 233 |
+
decimal unusedValue = dailyRate * (decimal)daysRemaining;
|
| 234 |
+
|
| 235 |
+
// Cap discount at new price
|
| 236 |
+
decimal discount = Math.Min(unusedValue, newMembership.Price);
|
| 237 |
+
|
| 238 |
+
return new SubscriptionUpgradePreviewDto
|
| 239 |
+
{
|
| 240 |
+
OriginalPrice = newMembership.Price,
|
| 241 |
+
DiscountAmount = Math.Round(discount, 2),
|
| 242 |
+
FinalPrice = Math.Round(newMembership.Price - discount, 2),
|
| 243 |
+
CanUpgrade = true,
|
| 244 |
+
Message = $"Pro-rated discount applied for your current {currentSubscription.Membership.Type} plan."
|
| 245 |
+
};
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
public async Task<SubscriptionDto> SubscribeWithWalletAsync(Guid userId, int membershipId)
|
| 249 |
+
{
|
| 250 |
+
var preview = await GetUpgradePreviewAsync(userId, membershipId);
|
| 251 |
+
if (!preview.CanUpgrade) throw new Exception(preview.Message ?? "Cannot purchase this plan.");
|
| 252 |
+
|
| 253 |
+
var balance = await _walletService.GetBalanceAsync(userId);
|
| 254 |
+
if (balance < preview.FinalPrice)
|
| 255 |
+
{
|
| 256 |
+
throw new Exception($"Insufficient balance. You need {preview.FinalPrice - balance:N0} more MMK.");
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
var membership = await _context.Memberships.FindAsync(membershipId);
|
| 260 |
+
|
| 261 |
+
// Deduct from wallet
|
| 262 |
+
bool deducted = await _walletService.DeductAsync(userId, preview.FinalPrice, $"Subscription: {membership!.Type}");
|
| 263 |
+
if (!deducted) throw new Exception("Failed to deduct from wallet.");
|
| 264 |
+
|
| 265 |
+
// Create subscription
|
| 266 |
+
return await SubscribeUserAsync(userId, membershipId);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
public async Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync()
|
| 271 |
{
|
| 272 |
var subscriptions = await _context.UserSubscriptions
|
Backend/Features/Subscriptions/SubscriptionUpgradePreviewDto.cs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
namespace Backend.Features.Subscriptions
|
| 2 |
+
{
|
| 3 |
+
public class SubscriptionUpgradePreviewDto
|
| 4 |
+
{
|
| 5 |
+
public decimal OriginalPrice { get; set; }
|
| 6 |
+
public decimal DiscountAmount { get; set; }
|
| 7 |
+
public decimal FinalPrice { get; set; }
|
| 8 |
+
public string? Message { get; set; }
|
| 9 |
+
public bool CanUpgrade { get; set; }
|
| 10 |
+
}
|
| 11 |
+
}
|
Backend/Features/Wallet/IWalletService.cs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using LibraryManagement.Shared.Models;
|
| 2 |
+
|
| 3 |
+
namespace Backend.Features.Wallet
|
| 4 |
+
{
|
| 5 |
+
public interface IWalletService
|
| 6 |
+
{
|
| 7 |
+
Task<decimal> GetBalanceAsync(Guid userId);
|
| 8 |
+
Task<IEnumerable<WalletTransactionDto>> GetHistoryAsync(Guid userId);
|
| 9 |
+
Task<bool> TopUpAsync(Guid userId, decimal amount, Guid librarianId, string? description = null);
|
| 10 |
+
Task<bool> DeductAsync(Guid userId, decimal amount, string description, Guid? referenceId = null);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
public class WalletTransactionDto
|
| 14 |
+
{
|
| 15 |
+
public Guid Id { get; set; }
|
| 16 |
+
public decimal Amount { get; set; }
|
| 17 |
+
public string Type { get; set; } = null!;
|
| 18 |
+
public string? Description { get; set; }
|
| 19 |
+
public DateTime CreatedAt { get; set; }
|
| 20 |
+
}
|
| 21 |
+
}
|
Backend/Features/Wallet/WalletController.cs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Authorization;
|
| 2 |
+
using Microsoft.AspNetCore.Mvc;
|
| 3 |
+
using System.Security.Claims;
|
| 4 |
+
|
| 5 |
+
namespace Backend.Features.Wallet
|
| 6 |
+
{
|
| 7 |
+
[ApiController]
|
| 8 |
+
[Route("api/[controller]")]
|
| 9 |
+
[Authorize]
|
| 10 |
+
public class WalletController : ControllerBase
|
| 11 |
+
{
|
| 12 |
+
private readonly IWalletService _walletService;
|
| 13 |
+
|
| 14 |
+
public WalletController(IWalletService walletService)
|
| 15 |
+
{
|
| 16 |
+
_walletService = walletService;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
[HttpGet("balance")]
|
| 20 |
+
public async Task<ActionResult<decimal>> GetBalance()
|
| 21 |
+
{
|
| 22 |
+
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
| 23 |
+
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
| 24 |
+
|
| 25 |
+
var balance = await _walletService.GetBalanceAsync(Guid.Parse(userIdStr));
|
| 26 |
+
return Ok(balance);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
[HttpGet("history")]
|
| 30 |
+
public async Task<ActionResult<IEnumerable<WalletTransactionDto>>> GetHistory()
|
| 31 |
+
{
|
| 32 |
+
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
| 33 |
+
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
| 34 |
+
|
| 35 |
+
var history = await _walletService.GetHistoryAsync(Guid.Parse(userIdStr));
|
| 36 |
+
return Ok(history);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
[HttpPost("topup")]
|
| 40 |
+
[Authorize(Roles = "Librarian")]
|
| 41 |
+
public async Task<IActionResult> TopUp([FromBody] TopUpRequest request)
|
| 42 |
+
{
|
| 43 |
+
var librarianIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
| 44 |
+
if (string.IsNullOrEmpty(librarianIdStr)) return Unauthorized();
|
| 45 |
+
|
| 46 |
+
var success = await _walletService.TopUpAsync(
|
| 47 |
+
request.UserId,
|
| 48 |
+
request.Amount,
|
| 49 |
+
Guid.Parse(librarianIdStr),
|
| 50 |
+
request.Description);
|
| 51 |
+
|
| 52 |
+
if (!success) return BadRequest(new { message = "Failed to top up wallet." });
|
| 53 |
+
|
| 54 |
+
return Ok(new { message = "Wallet topped up successfully." });
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
public class TopUpRequest
|
| 59 |
+
{
|
| 60 |
+
public Guid UserId { get; set; }
|
| 61 |
+
public decimal Amount { get; set; }
|
| 62 |
+
public string? Description { get; set; }
|
| 63 |
+
}
|
| 64 |
+
}
|
Backend/Features/Wallet/WalletService.cs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using DbConnect.Data;
|
| 2 |
+
using DbConnect.Entities;
|
| 3 |
+
using Microsoft.EntityFrameworkCore;
|
| 4 |
+
|
| 5 |
+
namespace Backend.Features.Wallet
|
| 6 |
+
{
|
| 7 |
+
public class WalletService : IWalletService
|
| 8 |
+
{
|
| 9 |
+
private readonly AppDbContext _context;
|
| 10 |
+
|
| 11 |
+
public WalletService(AppDbContext context)
|
| 12 |
+
{
|
| 13 |
+
_context = context;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
public async Task<decimal> GetBalanceAsync(Guid userId)
|
| 17 |
+
{
|
| 18 |
+
var user = await _context.Users.FindAsync(userId);
|
| 19 |
+
return user?.Balance ?? 0;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
public async Task<IEnumerable<WalletTransactionDto>> GetHistoryAsync(Guid userId)
|
| 23 |
+
{
|
| 24 |
+
return await _context.WalletTransactions
|
| 25 |
+
.Where(t => t.UserId == userId)
|
| 26 |
+
.OrderByDescending(t => t.CreatedAt)
|
| 27 |
+
.Select(t => new WalletTransactionDto
|
| 28 |
+
{
|
| 29 |
+
Id = t.Id,
|
| 30 |
+
Amount = t.Amount,
|
| 31 |
+
Type = t.Type,
|
| 32 |
+
Description = t.Description,
|
| 33 |
+
CreatedAt = t.CreatedAt
|
| 34 |
+
})
|
| 35 |
+
.ToListAsync();
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
public async Task<bool> TopUpAsync(Guid userId, decimal amount, Guid librarianId, string? description = null)
|
| 39 |
+
{
|
| 40 |
+
var user = await _context.Users.FindAsync(userId);
|
| 41 |
+
if (user == null) return false;
|
| 42 |
+
|
| 43 |
+
user.Balance += amount;
|
| 44 |
+
|
| 45 |
+
var transaction = new WalletTransaction
|
| 46 |
+
{
|
| 47 |
+
UserId = userId,
|
| 48 |
+
Amount = amount,
|
| 49 |
+
Type = "Deposit",
|
| 50 |
+
Description = description ?? "Cash Deposit",
|
| 51 |
+
ProcessedBy = librarianId
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
_context.WalletTransactions.Add(transaction);
|
| 55 |
+
await _context.SaveChangesAsync();
|
| 56 |
+
return true;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
public async Task<bool> DeductAsync(Guid userId, decimal amount, string description, Guid? referenceId = null)
|
| 60 |
+
{
|
| 61 |
+
var user = await _context.Users.FindAsync(userId);
|
| 62 |
+
if (user == null || user.Balance < amount) return false;
|
| 63 |
+
|
| 64 |
+
user.Balance -= amount;
|
| 65 |
+
|
| 66 |
+
var transaction = new WalletTransaction
|
| 67 |
+
{
|
| 68 |
+
UserId = userId,
|
| 69 |
+
Amount = -amount, // Negative for deduction
|
| 70 |
+
Type = "Purchase",
|
| 71 |
+
Description = description
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
_context.WalletTransactions.Add(transaction);
|
| 75 |
+
await _context.SaveChangesAsync();
|
| 76 |
+
return true;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
Backend/Program.cs
CHANGED
|
@@ -15,7 +15,9 @@ using Backend.Features.Borrowings;
|
|
| 15 |
using Backend.Features.Loyalty;
|
| 16 |
using Backend.Features.Notification;
|
| 17 |
using Backend.Features.Wishlist;
|
|
|
|
| 18 |
using FirebaseAdmin;
|
|
|
|
| 19 |
using Google.Apis.Auth.OAuth2;
|
| 20 |
|
| 21 |
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
|
@@ -95,8 +97,10 @@ builder.Services.AddScoped<ICategoryService, CategoryService>();
|
|
| 95 |
builder.Services.AddScoped<IBorrowingService, BorrowingService>();
|
| 96 |
builder.Services.AddScoped<INotificationService, NotificationService>();
|
| 97 |
builder.Services.AddScoped<IWishlistService, WishlistService>();
|
|
|
|
| 98 |
builder.Services.AddHostedService<NotificationBackgroundService>();
|
| 99 |
|
|
|
|
| 100 |
// Register Loyalty API Client
|
| 101 |
builder.Services.AddHttpClient<ILoyaltyService, LoyaltyService>(client =>
|
| 102 |
{
|
|
|
|
| 15 |
using Backend.Features.Loyalty;
|
| 16 |
using Backend.Features.Notification;
|
| 17 |
using Backend.Features.Wishlist;
|
| 18 |
+
using Backend.Features.Wallet;
|
| 19 |
using FirebaseAdmin;
|
| 20 |
+
|
| 21 |
using Google.Apis.Auth.OAuth2;
|
| 22 |
|
| 23 |
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
|
|
|
| 97 |
builder.Services.AddScoped<IBorrowingService, BorrowingService>();
|
| 98 |
builder.Services.AddScoped<INotificationService, NotificationService>();
|
| 99 |
builder.Services.AddScoped<IWishlistService, WishlistService>();
|
| 100 |
+
builder.Services.AddScoped<IWalletService, WalletService>();
|
| 101 |
builder.Services.AddHostedService<NotificationBackgroundService>();
|
| 102 |
|
| 103 |
+
|
| 104 |
// Register Loyalty API Client
|
| 105 |
builder.Services.AddHttpClient<ILoyaltyService, LoyaltyService>(client =>
|
| 106 |
{
|
BlazorFrontend/Components/Pages/Members.razor
CHANGED
|
@@ -232,6 +232,36 @@ else if (_currentView == ViewMode.Details && _selectedUser != null)
|
|
| 232 |
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
| 233 |
</button>
|
| 234 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
<!-- Subscription Management -->
|
| 237 |
<div class="mt-8 pt-8 border-t border-mystic-100">
|
|
@@ -409,6 +439,8 @@ else if (_currentView == ViewMode.Create || _currentView == ViewMode.Edit)
|
|
| 409 |
private UserCreateRequest _createRequest = new();
|
| 410 |
private UserUpdateRequest _updateRequest = new();
|
| 411 |
private UserDto? _editUser;
|
|
|
|
|
|
|
| 412 |
|
| 413 |
protected override async Task OnInitializedAsync()
|
| 414 |
{
|
|
@@ -630,6 +662,40 @@ else if (_currentView == ViewMode.Create || _currentView == ViewMode.Edit)
|
|
| 630 |
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 631 |
}
|
| 632 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
}
|
| 634 |
|
| 635 |
|
|
|
|
|
|
| 232 |
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
| 233 |
</button>
|
| 234 |
</div>
|
| 235 |
+
|
| 236 |
+
<!-- Wallet Management Section -->
|
| 237 |
+
<div class="mt-8 pt-8 border-t border-mystic-100">
|
| 238 |
+
<div class="flex items-center justify-between mb-6">
|
| 239 |
+
<h3 class="text-2xl font-bold text-mystic-900">Library Wallet</h3>
|
| 240 |
+
<div class="bg-indigo-50 border border-indigo-100 px-4 py-2 rounded-xl">
|
| 241 |
+
<span class="text-xs font-bold text-indigo-400 uppercase tracking-widest block">Current Balance</span>
|
| 242 |
+
<span class="text-xl font-black text-indigo-900">@_selectedUser.User.Balance.ToString("N0") MMK</span>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<div class="bg-white border border-mystic-200 rounded-2xl p-6 shadow-sm">
|
| 247 |
+
<h4 class="text-sm font-bold text-mystic-700 mb-4">Add Credits (Cash Deposit)</h4>
|
| 248 |
+
<div class="flex flex-col sm:flex-row gap-4">
|
| 249 |
+
<div class="flex-1">
|
| 250 |
+
<div class="relative">
|
| 251 |
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
| 252 |
+
<span class="text-mystic-400 text-sm font-bold">MMK</span>
|
| 253 |
+
</div>
|
| 254 |
+
<input type="number" @bind="_topUpAmount" class="block w-full pl-12 pr-3 py-3 border border-mystic-300 rounded-xl focus:ring-mystic-900 focus:border-mystic-900 sm:text-sm" placeholder="Amount (e.g. 5000)" />
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
<button @onclick="TopUpWallet" class="btn-primary px-8 whitespace-nowrap" disabled="@(_topUpAmount <= 0)">
|
| 258 |
+
Confirm Deposit
|
| 259 |
+
</button>
|
| 260 |
+
</div>
|
| 261 |
+
<p class="mt-3 text-xs text-mystic-400 italic">This will instantly add credits to the user's library wallet.</p>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
|
| 266 |
<!-- Subscription Management -->
|
| 267 |
<div class="mt-8 pt-8 border-t border-mystic-100">
|
|
|
|
| 439 |
private UserCreateRequest _createRequest = new();
|
| 440 |
private UserUpdateRequest _updateRequest = new();
|
| 441 |
private UserDto? _editUser;
|
| 442 |
+
private decimal _topUpAmount;
|
| 443 |
+
|
| 444 |
|
| 445 |
protected override async Task OnInitializedAsync()
|
| 446 |
{
|
|
|
|
| 662 |
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 663 |
}
|
| 664 |
}
|
| 665 |
+
|
| 666 |
+
private async Task TopUpWallet()
|
| 667 |
+
{
|
| 668 |
+
if (_selectedUser == null || _topUpAmount <= 0) return;
|
| 669 |
+
|
| 670 |
+
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Add {_topUpAmount:N0} MMK to {_selectedUser.User.FullName}'s wallet?");
|
| 671 |
+
if (!confirmed) return;
|
| 672 |
+
|
| 673 |
+
try
|
| 674 |
+
{
|
| 675 |
+
var success = await ApiClient.TopUpWalletAsync(new TopUpRequest
|
| 676 |
+
{
|
| 677 |
+
UserId = _selectedUser.User.Id,
|
| 678 |
+
Amount = _topUpAmount,
|
| 679 |
+
Description = "Librarian Cash Top-up"
|
| 680 |
+
});
|
| 681 |
+
|
| 682 |
+
if (success)
|
| 683 |
+
{
|
| 684 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Wallet topped up successfully!", "success");
|
| 685 |
+
_topUpAmount = 0;
|
| 686 |
+
await ShowDetails(_selectedUser.User.Id); // Refresh details to show new balance
|
| 687 |
+
}
|
| 688 |
+
else
|
| 689 |
+
{
|
| 690 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Failed to top up wallet.", "error");
|
| 691 |
+
}
|
| 692 |
+
}
|
| 693 |
+
catch (Exception ex)
|
| 694 |
+
{
|
| 695 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 696 |
+
}
|
| 697 |
+
}
|
| 698 |
}
|
| 699 |
|
| 700 |
|
| 701 |
+
|
BlazorFrontend/Components/Pages/Membership.razor
CHANGED
|
@@ -38,8 +38,19 @@ else if (_viewModel != null)
|
|
| 38 |
</div>
|
| 39 |
</div>
|
| 40 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</div>
|
| 42 |
|
|
|
|
| 43 |
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 44 |
<!-- Current Subscription Card -->
|
| 45 |
<div class="lg:col-span-2 space-y-8">
|
|
@@ -232,7 +243,13 @@ else if (_viewModel != null)
|
|
| 232 |
<div class="space-y-3">
|
| 233 |
<a href="Borrowings" class="block w-full py-4 rounded-2xl bg-white border-2 border-mystic-900 text-mystic-900 text-center font-bold text-sm transition-all duration-200 hover:bg-mystic-900 hover:text-white">Pay Cash</a>
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
@if (!string.IsNullOrEmpty(plan.RewardId) && _viewModel.RewardPointCosts.ContainsKey(plan.RewardId))
|
|
|
|
| 236 |
{
|
| 237 |
<button @onclick="() => RedeemMembership(plan.RewardId, plan.Type, _viewModel.RewardPointCosts[plan.RewardId])"
|
| 238 |
class="w-full py-4 rounded-2xl bg-amber-400 text-mystic-900 font-black text-sm hover:bg-amber-500 transition-all shadow-lg shadow-amber-400/20">
|
|
@@ -279,8 +296,10 @@ else if (_viewModel != null)
|
|
| 279 |
AvailableMemberships = memberships.ToList(),
|
| 280 |
QueuedMemberships = queuedMemberships,
|
| 281 |
LoyaltyAccount = loyaltyAccount,
|
| 282 |
-
RewardPointCosts = activeRewards.ToDictionary(r => r.Id, r => r.PointCost)
|
|
|
|
| 283 |
};
|
|
|
|
| 284 |
}
|
| 285 |
catch (Exception ex)
|
| 286 |
{
|
|
@@ -316,6 +335,55 @@ else if (_viewModel != null)
|
|
| 316 |
await JSRuntime.InvokeVoidAsync("showToast", "An unexpected error occurred: " + ex.Message, "error");
|
| 317 |
}
|
| 318 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
}
|
| 320 |
|
| 321 |
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
</div>
|
| 40 |
}
|
| 41 |
+
|
| 42 |
+
<div class="flex items-center gap-4 bg-mystic-50 px-6 py-4 rounded-xl border border-mystic-200/60">
|
| 43 |
+
<div class="bg-indigo-100 p-2.5 rounded-lg text-indigo-600">
|
| 44 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="12" x="2" y="6" rx="2"/><circle cx="12" cy="12" r="2"/><path d="M6 12h.01M18 12h.01"/></svg>
|
| 45 |
+
</div>
|
| 46 |
+
<div>
|
| 47 |
+
<div class="text-xs font-bold text-mystic-400 uppercase tracking-widest">Wallet Balance</div>
|
| 48 |
+
<div class="text-2xl font-black text-mystic-900 leading-none">@_viewModel.WalletBalance.ToString("N0") <span class="text-xs font-bold text-mystic-400">MMK</span></div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
</div>
|
| 52 |
|
| 53 |
+
|
| 54 |
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 55 |
<!-- Current Subscription Card -->
|
| 56 |
<div class="lg:col-span-2 space-y-8">
|
|
|
|
| 243 |
<div class="space-y-3">
|
| 244 |
<a href="Borrowings" class="block w-full py-4 rounded-2xl bg-white border-2 border-mystic-900 text-mystic-900 text-center font-bold text-sm transition-all duration-200 hover:bg-mystic-900 hover:text-white">Pay Cash</a>
|
| 245 |
|
| 246 |
+
<button @onclick="() => BuyWithWallet(plan)"
|
| 247 |
+
class="w-full py-4 rounded-2xl bg-mystic-900 text-white font-bold text-sm hover:bg-mystic-800 transition-all shadow-lg shadow-mystic-900/20">
|
| 248 |
+
Buy with Wallet
|
| 249 |
+
</button>
|
| 250 |
+
|
| 251 |
@if (!string.IsNullOrEmpty(plan.RewardId) && _viewModel.RewardPointCosts.ContainsKey(plan.RewardId))
|
| 252 |
+
|
| 253 |
{
|
| 254 |
<button @onclick="() => RedeemMembership(plan.RewardId, plan.Type, _viewModel.RewardPointCosts[plan.RewardId])"
|
| 255 |
class="w-full py-4 rounded-2xl bg-amber-400 text-mystic-900 font-black text-sm hover:bg-amber-500 transition-all shadow-lg shadow-amber-400/20">
|
|
|
|
| 296 |
AvailableMemberships = memberships.ToList(),
|
| 297 |
QueuedMemberships = queuedMemberships,
|
| 298 |
LoyaltyAccount = loyaltyAccount,
|
| 299 |
+
RewardPointCosts = activeRewards.ToDictionary(r => r.Id, r => r.PointCost),
|
| 300 |
+
WalletBalance = await ApiClient.GetWalletBalanceAsync()
|
| 301 |
};
|
| 302 |
+
|
| 303 |
}
|
| 304 |
catch (Exception ex)
|
| 305 |
{
|
|
|
|
| 335 |
await JSRuntime.InvokeVoidAsync("showToast", "An unexpected error occurred: " + ex.Message, "error");
|
| 336 |
}
|
| 337 |
}
|
| 338 |
+
|
| 339 |
+
private async Task BuyWithWallet(MembershipDto plan)
|
| 340 |
+
{
|
| 341 |
+
try
|
| 342 |
+
{
|
| 343 |
+
var preview = await ApiClient.GetUpgradePreviewAsync(plan.Id);
|
| 344 |
+
if (preview == null || !preview.CanUpgrade)
|
| 345 |
+
{
|
| 346 |
+
await JSRuntime.InvokeVoidAsync("showToast", preview?.Message ?? "Cannot upgrade to this plan.", "error");
|
| 347 |
+
return;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
string confirmMsg = $"Are you sure you want to purchase {plan.Type} membership?\n\n" +
|
| 351 |
+
$"Original Price: {preview.OriginalPrice:N0} MMK\n";
|
| 352 |
+
|
| 353 |
+
if (preview.DiscountAmount > 0)
|
| 354 |
+
{
|
| 355 |
+
confirmMsg += $"Upgrade Discount: -{preview.DiscountAmount:N0} MMK\n";
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
confirmMsg += $"Final Price: {preview.FinalPrice:N0} MMK\n\n" +
|
| 359 |
+
$"Your Balance: {_viewModel?.WalletBalance:N0} MMK";
|
| 360 |
+
|
| 361 |
+
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm", confirmMsg);
|
| 362 |
+
if (!confirmed) return;
|
| 363 |
+
|
| 364 |
+
if (_viewModel?.WalletBalance < preview.FinalPrice)
|
| 365 |
+
{
|
| 366 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Insufficient wallet balance. Please top up at the library.", "error");
|
| 367 |
+
return;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
var result = await ApiClient.SubscribeWithWalletAsync(new SubscribeRequest { MembershipId = plan.Id });
|
| 371 |
+
if (result != null)
|
| 372 |
+
{
|
| 373 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Subscription purchased successfully!", "success");
|
| 374 |
+
await OnInitializedAsync();
|
| 375 |
+
}
|
| 376 |
+
else
|
| 377 |
+
{
|
| 378 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Failed to complete purchase.", "error");
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
catch (Exception ex)
|
| 382 |
+
{
|
| 383 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
}
|
| 387 |
|
| 388 |
|
| 389 |
+
|
BlazorFrontend/Models/MembershipViewModel.cs
CHANGED
|
@@ -10,6 +10,8 @@ namespace BlazorFrontend.Models
|
|
| 10 |
public List<SubscriptionDto> QueuedMemberships { get; set; } = new List<SubscriptionDto>();
|
| 11 |
public LoyaltyAccountDto? LoyaltyAccount { get; set; }
|
| 12 |
public Dictionary<string, double> RewardPointCosts { get; set; } = new Dictionary<string, double>();
|
|
|
|
| 13 |
}
|
|
|
|
| 14 |
}
|
| 15 |
|
|
|
|
| 10 |
public List<SubscriptionDto> QueuedMemberships { get; set; } = new List<SubscriptionDto>();
|
| 11 |
public LoyaltyAccountDto? LoyaltyAccount { get; set; }
|
| 12 |
public Dictionary<string, double> RewardPointCosts { get; set; } = new Dictionary<string, double>();
|
| 13 |
+
public decimal WalletBalance { get; set; }
|
| 14 |
}
|
| 15 |
+
|
| 16 |
}
|
| 17 |
|
BlazorFrontend/Services/LibraryApiClient.cs
CHANGED
|
@@ -171,8 +171,50 @@ namespace BlazorFrontend.Services
|
|
| 171 |
{
|
| 172 |
return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/all") ?? Enumerable.Empty<SubscriptionDto>();
|
| 173 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
#endregion
|
| 175 |
|
|
|
|
| 176 |
public async Task<LoyaltyAccountDto?> GetMyLoyaltyAccountAsync()
|
| 177 |
{
|
| 178 |
try
|
|
|
|
| 171 |
{
|
| 172 |
return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/all") ?? Enumerable.Empty<SubscriptionDto>();
|
| 173 |
}
|
| 174 |
+
|
| 175 |
+
public async Task<SubscriptionUpgradePreviewDto?> GetUpgradePreviewAsync(int membershipId)
|
| 176 |
+
{
|
| 177 |
+
try
|
| 178 |
+
{
|
| 179 |
+
return await _httpClient.GetFromJsonAsync<SubscriptionUpgradePreviewDto>($"api/subscriptions/preview-upgrade/{membershipId}");
|
| 180 |
+
}
|
| 181 |
+
catch { return null; }
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
public async Task<SubscriptionDto?> SubscribeWithWalletAsync(SubscribeRequest request)
|
| 185 |
+
{
|
| 186 |
+
var response = await _httpClient.PostAsJsonAsync("api/subscriptions/subscribe-wallet", request);
|
| 187 |
+
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<SubscriptionDto>() : null;
|
| 188 |
+
}
|
| 189 |
+
#endregion
|
| 190 |
+
|
| 191 |
+
#region Wallet
|
| 192 |
+
public async Task<decimal> GetWalletBalanceAsync()
|
| 193 |
+
{
|
| 194 |
+
try
|
| 195 |
+
{
|
| 196 |
+
return await _httpClient.GetFromJsonAsync<decimal>("api/wallet/balance");
|
| 197 |
+
}
|
| 198 |
+
catch { return 0; }
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
public async Task<IEnumerable<WalletTransactionDto>> GetWalletHistoryAsync()
|
| 202 |
+
{
|
| 203 |
+
try
|
| 204 |
+
{
|
| 205 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<WalletTransactionDto>>("api/wallet/history") ?? Enumerable.Empty<WalletTransactionDto>();
|
| 206 |
+
}
|
| 207 |
+
catch { return Enumerable.Empty<WalletTransactionDto>(); }
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
public async Task<bool> TopUpWalletAsync(TopUpRequest request)
|
| 211 |
+
{
|
| 212 |
+
var response = await _httpClient.PostAsJsonAsync("api/wallet/topup", request);
|
| 213 |
+
return response.IsSuccessStatusCode;
|
| 214 |
+
}
|
| 215 |
#endregion
|
| 216 |
|
| 217 |
+
|
| 218 |
public async Task<LoyaltyAccountDto?> GetMyLoyaltyAccountAsync()
|
| 219 |
{
|
| 220 |
try
|
DbConnect/Data/AppDbContext.cs
CHANGED
|
@@ -27,6 +27,8 @@ public partial class AppDbContext : DbContext
|
|
| 27 |
public virtual DbSet<UserSubscription> UserSubscriptions { get; set; }
|
| 28 |
|
| 29 |
public virtual DbSet<WishlistItem> WishlistItems { get; set; }
|
|
|
|
|
|
|
| 30 |
|
| 31 |
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
| 32 |
{
|
|
@@ -152,8 +154,10 @@ public partial class AppDbContext : DbContext
|
|
| 152 |
entity.Property(e => e.StudentId).HasMaxLength(20);
|
| 153 |
entity.Property(e => e.SuspensionEndDate).HasColumnType("timestamp");
|
| 154 |
entity.Property(e => e.UpdatedAt).HasColumnType("timestamp");
|
|
|
|
| 155 |
});
|
| 156 |
|
|
|
|
| 157 |
modelBuilder.Entity<UserSubscription>(entity =>
|
| 158 |
{
|
| 159 |
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()");
|
|
@@ -195,6 +199,21 @@ public partial class AppDbContext : DbContext
|
|
| 195 |
.HasConstraintName("FK_WishlistItems_Users");
|
| 196 |
});
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
OnModelCreatingPartial(modelBuilder);
|
| 199 |
}
|
| 200 |
|
|
|
|
| 27 |
public virtual DbSet<UserSubscription> UserSubscriptions { get; set; }
|
| 28 |
|
| 29 |
public virtual DbSet<WishlistItem> WishlistItems { get; set; }
|
| 30 |
+
public virtual DbSet<WalletTransaction> WalletTransactions { get; set; }
|
| 31 |
+
|
| 32 |
|
| 33 |
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
| 34 |
{
|
|
|
|
| 154 |
entity.Property(e => e.StudentId).HasMaxLength(20);
|
| 155 |
entity.Property(e => e.SuspensionEndDate).HasColumnType("timestamp");
|
| 156 |
entity.Property(e => e.UpdatedAt).HasColumnType("timestamp");
|
| 157 |
+
entity.Property(e => e.Balance).HasColumnType("decimal(18, 2)").HasDefaultValue(0);
|
| 158 |
});
|
| 159 |
|
| 160 |
+
|
| 161 |
modelBuilder.Entity<UserSubscription>(entity =>
|
| 162 |
{
|
| 163 |
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()");
|
|
|
|
| 199 |
.HasConstraintName("FK_WishlistItems_Users");
|
| 200 |
});
|
| 201 |
|
| 202 |
+
modelBuilder.Entity<WalletTransaction>(entity =>
|
| 203 |
+
{
|
| 204 |
+
entity.HasKey(e => e.Id);
|
| 205 |
+
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()");
|
| 206 |
+
entity.Property(e => e.Amount).HasColumnType("decimal(18, 2)");
|
| 207 |
+
entity.Property(e => e.Type).HasMaxLength(20);
|
| 208 |
+
entity.Property(e => e.CreatedAt).HasColumnType("timestamp").HasDefaultValueSql("now()");
|
| 209 |
+
|
| 210 |
+
entity.HasOne(d => d.User)
|
| 211 |
+
.WithMany()
|
| 212 |
+
.HasForeignKey(d => d.UserId)
|
| 213 |
+
.OnDelete(DeleteBehavior.Cascade);
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
|
| 217 |
OnModelCreatingPartial(modelBuilder);
|
| 218 |
}
|
| 219 |
|
DbConnect/Entities/User.cs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
using System.Collections.Generic;
|
| 3 |
|
| 4 |
namespace DbConnect.Entities;
|
|
@@ -32,6 +32,9 @@ public partial class User
|
|
| 32 |
public string? Address { get; set; }
|
| 33 |
|
| 34 |
public string? FcmToken { get; set; }
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
public virtual ICollection<Borrowing> Borrowings { get; set; } = new List<Borrowing>();
|
| 37 |
|
|
|
|
| 1 |
+
using System;
|
| 2 |
using System.Collections.Generic;
|
| 3 |
|
| 4 |
namespace DbConnect.Entities;
|
|
|
|
| 32 |
public string? Address { get; set; }
|
| 33 |
|
| 34 |
public string? FcmToken { get; set; }
|
| 35 |
+
|
| 36 |
+
public decimal Balance { get; set; }
|
| 37 |
+
|
| 38 |
|
| 39 |
public virtual ICollection<Borrowing> Borrowings { get; set; } = new List<Borrowing>();
|
| 40 |
|
DbConnect/Entities/WalletTransaction.cs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System;
|
| 2 |
+
|
| 3 |
+
namespace DbConnect.Entities;
|
| 4 |
+
|
| 5 |
+
public class WalletTransaction
|
| 6 |
+
{
|
| 7 |
+
public Guid Id { get; set; }
|
| 8 |
+
public Guid UserId { get; set; }
|
| 9 |
+
public decimal Amount { get; set; }
|
| 10 |
+
public string Type { get; set; } = null!; // Deposit, Purchase, Refund
|
| 11 |
+
public string? Description { get; set; }
|
| 12 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 13 |
+
public Guid? ProcessedBy { get; set; } // Librarian ID
|
| 14 |
+
|
| 15 |
+
public virtual User User { get; set; } = null!;
|
| 16 |
+
}
|
LibraryManagement.Shared/Models/LibraryDtos.cs
CHANGED
|
@@ -138,12 +138,20 @@ namespace LibraryManagement.Shared.Models
|
|
| 138 |
public int MembershipId { get; set; }
|
| 139 |
}
|
| 140 |
|
| 141 |
-
public class AdminSubscribeRequest
|
| 142 |
-
{
|
| 143 |
public Guid UserId { get; set; }
|
| 144 |
public int MembershipId { get; set; }
|
| 145 |
}
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
// Loyalty DTOs
|
| 148 |
public class LoyaltyAccountDto
|
| 149 |
{
|
|
@@ -238,8 +246,26 @@ namespace LibraryManagement.Shared.Models
|
|
| 238 |
public DateTime? UpdatedAt { get; set; }
|
| 239 |
public bool? BanStatus { get; set; }
|
| 240 |
public DateTime? SuspensionEndDate { get; set; }
|
|
|
|
| 241 |
}
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
public class UserCreateRequest
|
| 244 |
{
|
| 245 |
public string FullName { get; set; } = string.Empty;
|
|
|
|
| 138 |
public int MembershipId { get; set; }
|
| 139 |
}
|
| 140 |
|
|
|
|
|
|
|
| 141 |
public Guid UserId { get; set; }
|
| 142 |
public int MembershipId { get; set; }
|
| 143 |
}
|
| 144 |
|
| 145 |
+
public class SubscriptionUpgradePreviewDto
|
| 146 |
+
{
|
| 147 |
+
public decimal OriginalPrice { get; set; }
|
| 148 |
+
public decimal DiscountAmount { get; set; }
|
| 149 |
+
public decimal FinalPrice { get; set; }
|
| 150 |
+
public string? Message { get; set; }
|
| 151 |
+
public bool CanUpgrade { get; set; }
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
// Loyalty DTOs
|
| 156 |
public class LoyaltyAccountDto
|
| 157 |
{
|
|
|
|
| 246 |
public DateTime? UpdatedAt { get; set; }
|
| 247 |
public bool? BanStatus { get; set; }
|
| 248 |
public DateTime? SuspensionEndDate { get; set; }
|
| 249 |
+
public decimal Balance { get; set; }
|
| 250 |
}
|
| 251 |
|
| 252 |
+
public class WalletTransactionDto
|
| 253 |
+
{
|
| 254 |
+
public Guid Id { get; set; }
|
| 255 |
+
public decimal Amount { get; set; }
|
| 256 |
+
public string Type { get; set; } = null!;
|
| 257 |
+
public string? Description { get; set; }
|
| 258 |
+
public DateTime CreatedAt { get; set; }
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
public class TopUpRequest
|
| 262 |
+
{
|
| 263 |
+
public Guid UserId { get; set; }
|
| 264 |
+
public decimal Amount { get; set; }
|
| 265 |
+
public string? Description { get; set; }
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
public class UserCreateRequest
|
| 270 |
{
|
| 271 |
public string FullName { get; set; } = string.Empty;
|