Yuyuqt commited on
Commit
ee79726
·
1 Parent(s): ace6185

add: user balance

Browse files
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
- var latestSubscription = await _context.UserSubscriptions
114
- .Where(s => s.UserId == userId && s.IsActive)
 
 
 
 
 
 
 
 
 
 
115
  .OrderByDescending(s => s.ExpiryDate)
116
- .FirstOrDefaultAsync();
117
 
118
  DateTime startDate = DateTime.UtcNow;
119
- if (latestSubscription != null && latestSubscription.ExpiryDate > startDate)
 
 
120
  {
121
- startDate = latestSubscription.ExpiryDate;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)membership.Price,
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
- using System;
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;