diff --git a/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/ChargeAdvertiserCommandHandler.cs b/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/ChargeAdvertiserCommandHandler.cs index fea377c6..220a35f2 100644 --- a/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/ChargeAdvertiserCommandHandler.cs +++ b/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/ChargeAdvertiserCommandHandler.cs @@ -1,4 +1,7 @@ using AdsBillingService.Domain.AggregatesModel.ChargeAggregate; +using AdsBillingService.Infrastructure; +using AdsBillingService.Domain.Exceptions; +using Microsoft.EntityFrameworkCore; using MediatR; namespace AdsBillingService.API.Application.Commands; @@ -9,17 +12,19 @@ namespace AdsBillingService.API.Application.Commands; /// public class ChargeAdvertiserCommandHandler : IRequestHandler { + private readonly AdsBillingServiceContext _context; private readonly ILogger _logger; - // private readonly IWalletService _walletService; - public ChargeAdvertiserCommandHandler(ILogger logger) + public ChargeAdvertiserCommandHandler( + AdsBillingServiceContext context, + ILogger logger) { + _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task Handle(ChargeAdvertiserCommand request, CancellationToken cancellationToken) { - // Step 1: Create charge record var chargeType = request.ChargeType.ToLower() == "impression" ? ChargeType.Impression : ChargeType.Click; @@ -32,26 +37,33 @@ public class ChargeAdvertiserCommandHandler : IRequestHandler account.AdvertiserId == request.AdvertiserId, cancellationToken); - // if (!walletDebitResult.Success) - // { - // _logger.LogWarning("Failed to debit wallet for advertiser {AdvertiserId}", request.AdvertiserId); - // return false; - // } + if (billingAccount == null) + { + _logger.LogWarning( + "No billing account found for advertiser {AdvertiserId}", + request.AdvertiserId); + return false; + } - // Step 3: Mark as processed - charge.MarkAsProcessed(); + try + { + billingAccount.ApplyCharge(request.Amount); + charge.MarkAsProcessed(); + } + catch (AdsBillingDomainException ex) + { + _logger.LogWarning( + ex, + "Failed to apply charge for advertiser {AdvertiserId}", + request.AdvertiserId); + return false; + } - // TODO: Step 4 - Save to repository - // _adChargeRepository.Add(charge); - // await _adChargeRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + _context.AdCharges.Add(charge); + await _context.SaveEntitiesAsync(cancellationToken); _logger.LogInformation( "Charged advertiser {AdvertiserId} amount {Amount} for {ChargeType}", diff --git a/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/CreateBillingAccountCommandHandler.cs b/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/CreateBillingAccountCommandHandler.cs index 6157ae33..1fdbdfbd 100644 --- a/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/CreateBillingAccountCommandHandler.cs +++ b/services/ads-billing-service-net/src/AdsBillingService.API/Application/Commands/CreateBillingAccountCommandHandler.cs @@ -1,19 +1,38 @@ using AdsBillingService.Domain.AggregatesModel.BillingAccountAggregate; +using AdsBillingService.Infrastructure; +using Microsoft.EntityFrameworkCore; using MediatR; namespace AdsBillingService.API.Application.Commands; public class CreateBillingAccountCommandHandler : IRequestHandler { + private readonly AdsBillingServiceContext _context; private readonly ILogger _logger; - public CreateBillingAccountCommandHandler(ILogger logger) + public CreateBillingAccountCommandHandler( + AdsBillingServiceContext context, + ILogger logger) { + _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task Handle(CreateBillingAccountCommand request, CancellationToken cancellationToken) { + var existingAccount = await _context.BillingAccounts + .AsNoTracking() + .FirstOrDefaultAsync(account => account.AdvertiserId == request.AdvertiserId, cancellationToken); + + if (existingAccount != null) + { + _logger.LogInformation( + "Billing account already exists for advertiser {AdvertiserId}. Returning existing account {AccountId}", + request.AdvertiserId, + existingAccount.Id); + return existingAccount.Id; + } + var paymentMethod = request.PaymentMethod.ToLower() switch { "prepaid" => PaymentMethodType.Prepaid, @@ -28,9 +47,8 @@ public class CreateBillingAccountCommandHandler : IRequestHandler _logger; public AdminInvoicesController( - IMediator mediator, AdsBillingServiceContext context, ILogger logger) { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -142,8 +139,8 @@ public class AdminInvoicesController : ControllerBase } /// - /// EN: Regenerate invoice (placeholder). - /// VI: Tạo lại hóa đơn (placeholder). + /// EN: Regenerate invoice from charge data for a period. + /// VI: Tạo lại hóa đơn từ dữ liệu charge theo khoảng thời gian. /// [HttpPost("regenerate")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -153,11 +150,70 @@ public class AdminInvoicesController : ControllerBase _logger.LogInformation("Regenerating invoice for account {AccountId}, period {StartDate} to {EndDate}", request.BillingAccountId, request.StartDate, request.EndDate); - // TODO: Implement invoice regeneration logic + if (request.EndDate < request.StartDate) + { + return BadRequest(new { message = "EndDate must be greater than or equal to StartDate" }); + } + + var account = await _context.BillingAccounts + .AsNoTracking() + .FirstOrDefaultAsync(account => account.Id == request.BillingAccountId); + + if (account == null) + { + return BadRequest(new { message = $"Billing account {request.BillingAccountId} not found" }); + } + + var charges = await _context.AdCharges + .AsNoTracking() + .Where(charge => + charge.AdvertiserId == account.AdvertiserId && + charge.ChargedAt >= request.StartDate && + charge.ChargedAt <= request.EndDate) + .ToListAsync(); + + if (!charges.Any()) + { + return BadRequest(new + { + message = "No charges found for the provided period", + billingAccountId = request.BillingAccountId, + period = new { request.StartDate, request.EndDate } + }); + } + + var invoice = new Invoice( + request.BillingAccountId, + issueDate: DateTime.UtcNow, + dueDate: DateTime.UtcNow.AddDays(30)); + + foreach (var group in charges.GroupBy(charge => new { charge.CampaignId, charge.ChargeType })) + { + var quantity = group.Count(); + var totalAmount = group.Sum(charge => charge.Amount); + var unitPrice = quantity == 0 ? 0m : totalAmount / quantity; + var description = $"{group.Key.ChargeType} charges for campaign {group.Key.CampaignId}"; + + invoice.AddLineItem( + group.Key.CampaignId, + description, + quantity, + unitPrice); + } + + invoice.Issue(); + _context.Invoices.Add(invoice); + await _context.SaveEntitiesAsync(); + return Ok(new { - message = "Invoice regeneration initiated", + message = "Invoice regenerated successfully", + invoiceId = invoice.Id, + invoiceNumber = invoice.InvoiceNumber, + status = invoice.Status.ToString(), + totalAmount = invoice.TotalAmount, billingAccountId = request.BillingAccountId, + chargesIncluded = charges.Count, period = new { request.StartDate, request.EndDate } }); } diff --git a/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/CreditLinesController.cs b/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/CreditLinesController.cs index 4ed96fa7..e5b82406 100644 --- a/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/CreditLinesController.cs +++ b/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/CreditLinesController.cs @@ -1,5 +1,6 @@ -using MediatR; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using AdsBillingService.Infrastructure; namespace AdsBillingService.API.Controllers; @@ -12,12 +13,14 @@ namespace AdsBillingService.API.Controllers; [Produces("application/json")] public class CreditLinesController : ControllerBase { - private readonly IMediator _mediator; + private readonly AdsBillingServiceContext _context; private readonly ILogger _logger; - public CreditLinesController(IMediator mediator, ILogger logger) + public CreditLinesController( + AdsBillingServiceContext context, + ILogger logger) { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -28,19 +31,32 @@ public class CreditLinesController : ControllerBase [HttpGet("{advertiserId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetCreditLine(Guid advertiserId) + public async Task GetCreditLine(Guid advertiserId, CancellationToken cancellationToken) { _logger.LogInformation("Getting credit line for advertiser {AdvertiserId}", advertiserId); - // TODO: Implement GetCreditLineQuery - // EN: For now return placeholder / VI: Tạm thời trả về placeholder + var account = await _context.BillingAccounts + .AsNoTracking() + .FirstOrDefaultAsync(a => a.AdvertiserId == advertiserId, cancellationToken); + + if (account == null) + { + return NotFound(new { message = $"Billing account for advertiser {advertiserId} not found" }); + } + + var availableCredit = account.CreditLimit <= 0 + ? 0m + : Math.Max(0m, account.CreditLimit - account.Balance); + return Ok(new { advertiserId, - creditLimit = 0m, - availableCredit = 0m, - status = "Active", - message = "Credit line query not yet implemented" + accountId = account.Id, + creditLimit = account.CreditLimit, + balance = account.Balance, + availableCredit, + paymentMethod = account.PaymentMethod.ToString(), + status = account.Status.ToString() }); } @@ -51,7 +67,9 @@ public class CreditLinesController : ControllerBase [HttpPost("request")] [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task RequestCreditIncrease([FromBody] CreditIncreaseRequest request) + public async Task RequestCreditIncrease( + [FromBody] CreditIncreaseRequest request, + CancellationToken cancellationToken) { _logger.LogInformation("Credit increase request for advertiser {AdvertiserId}, amount {Amount}", request.AdvertiserId, request.RequestedAmount); @@ -61,14 +79,27 @@ public class CreditLinesController : ControllerBase return BadRequest(new { message = "Requested amount must be positive" }); } - // TODO: Implement RequestCreditIncreaseCommand - // EN: For now return accepted / VI: Tạm thời trả về accepted + var account = await _context.BillingAccounts + .FirstOrDefaultAsync(a => a.AdvertiserId == request.AdvertiserId, cancellationToken); + + if (account == null) + { + return NotFound(new { message = $"Billing account for advertiser {request.AdvertiserId} not found" }); + } + + var oldCreditLimit = account.CreditLimit; + account.SetCreditLimit(oldCreditLimit + request.RequestedAmount); + await _context.SaveEntitiesAsync(cancellationToken); + return Accepted(new { advertiserId = request.AdvertiserId, + accountId = account.Id, requestedAmount = request.RequestedAmount, - status = "Pending", - message = "Credit increase request submitted for review" + oldCreditLimit, + newCreditLimit = account.CreditLimit, + status = account.Status.ToString(), + message = "Credit limit increased successfully" }); } } diff --git a/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/InvoicesController.cs b/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/InvoicesController.cs index 7aa4478e..fba5e92e 100644 --- a/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/InvoicesController.cs +++ b/services/ads-billing-service-net/src/AdsBillingService.API/Controllers/InvoicesController.cs @@ -72,8 +72,8 @@ public class InvoicesController : ControllerBase } /// - /// EN: Download invoice as PDF (placeholder). - /// VI: Tải hóa đơn dạng PDF (placeholder). + /// EN: Download invoice summary as a text file. + /// VI: Tải tóm tắt hóa đơn dưới dạng file văn bản. /// [HttpGet("{id}/download")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -91,12 +91,27 @@ public class InvoicesController : ControllerBase return NotFound(new { message = $"Invoice {id} not found" }); } - // TODO: Generate PDF using a PDF library - // EN: For now, return invoice data as JSON / VI: Tạm thời trả về dữ liệu JSON - return Ok(new - { - message = "PDF generation not yet implemented", - invoice = invoice - }); + var lineItemsContent = invoice.LineItems.Any() + ? string.Join( + Environment.NewLine, + invoice.LineItems.Select(item => + $"- Campaign: {item.CampaignId} | Qty: {item.Quantity} | Unit: {item.UnitPrice} | Total: {item.TotalAmount}")) + : "- No line items"; + + var text = string.Join( + Environment.NewLine, + [ + $"Invoice: {invoice.InvoiceNumber}", + $"Status: {invoice.Status}", + $"Issue Date: {invoice.IssueDate:O}", + $"Due Date: {invoice.DueDate:O}", + $"Total Amount: {invoice.TotalAmount}", + "Line Items:", + lineItemsContent, + ]); + + var fileBytes = System.Text.Encoding.UTF8.GetBytes(text); + var fileName = $"{invoice.InvoiceNumber}.txt"; + return File(fileBytes, "text/plain", fileName); } } diff --git a/services/ads-billing-service-net/src/AdsBillingService.Domain/AggregatesModel/BillingAccountAggregate/BillingAccount.cs b/services/ads-billing-service-net/src/AdsBillingService.Domain/AggregatesModel/BillingAccountAggregate/BillingAccount.cs index e38e2194..0d7535e4 100644 --- a/services/ads-billing-service-net/src/AdsBillingService.Domain/AggregatesModel/BillingAccountAggregate/BillingAccount.cs +++ b/services/ads-billing-service-net/src/AdsBillingService.Domain/AggregatesModel/BillingAccountAggregate/BillingAccount.cs @@ -90,6 +90,66 @@ public class BillingAccount : Entity, IAggregateRoot _UpdatedAt = DateTime.UtcNow; } + /// + /// EN: Reactivate account after suspension. + /// VI: Kích hoạt lại tài khoản sau khi bị tạm ngưng. + /// + public void Reactivate() + { + if (_status == AccountStatus.Closed) + throw new AdsBillingDomainException("Cannot reactivate a closed account"); + + _status = AccountStatus.Active; + _UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update credit limit for credit-based payment methods. + /// VI: Cập nhật hạn mức tín dụng cho phương thức thanh toán theo tín dụng. + /// + public void SetCreditLimit(decimal creditLimit) + { + if (creditLimit < 0) + throw new AdsBillingDomainException("Credit limit cannot be negative"); + + _creditLimit = creditLimit; + _UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Apply an advertising charge to this account. + /// VI: Áp dụng một khoản charge quảng cáo lên tài khoản. + /// + public void ApplyCharge(decimal amount) + { + if (amount <= 0) + throw new AdsBillingDomainException("Charge amount must be positive"); + + if (_status != AccountStatus.Active) + throw new AdsBillingDomainException("Account is not active"); + + switch (_paymentMethod) + { + case PaymentMethodType.Prepaid: + if (_balance < amount) + throw new AdsBillingDomainException("Insufficient prepaid balance"); + _balance -= amount; + break; + + case PaymentMethodType.Postpaid: + case PaymentMethodType.CreditCard: + if (_creditLimit > 0 && _balance + amount > _creditLimit) + throw new AdsBillingDomainException("Credit limit exceeded"); + _balance += amount; + break; + + default: + throw new AdsBillingDomainException("Unsupported payment method"); + } + + _UpdatedAt = DateTime.UtcNow; + } + private DateTime? _UpdatedAt { get => UpdatedAt;