feat: complete ads billing command and credit workflows
Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||
/// </summary>
|
||||
public class ChargeAdvertiserCommandHandler : IRequestHandler<ChargeAdvertiserCommand, bool>
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
private readonly ILogger<ChargeAdvertiserCommandHandler> _logger;
|
||||
// private readonly IWalletService _walletService;
|
||||
|
||||
public ChargeAdvertiserCommandHandler(ILogger<ChargeAdvertiserCommandHandler> logger)
|
||||
public ChargeAdvertiserCommandHandler(
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<ChargeAdvertiserCommandHandler> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> 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<ChargeAdvertiserCo
|
||||
request.Amount
|
||||
);
|
||||
|
||||
// TODO: Step 2 - Debit from Wallet Service
|
||||
// var walletDebitResult = await _walletService.DebitBalance(
|
||||
// advertiserId: request.AdvertiserId,
|
||||
// amount: request.Amount,
|
||||
// reference: charge.Id,
|
||||
// description: $"Ad {chargeType} charge"
|
||||
// );
|
||||
var billingAccount = await _context.BillingAccounts
|
||||
.FirstOrDefaultAsync(account => 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}",
|
||||
|
||||
@@ -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<CreateBillingAccountCommand, Guid>
|
||||
{
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
private readonly ILogger<CreateBillingAccountCommandHandler> _logger;
|
||||
|
||||
public CreateBillingAccountCommandHandler(ILogger<CreateBillingAccountCommandHandler> logger)
|
||||
public CreateBillingAccountCommandHandler(
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<CreateBillingAccountCommandHandler> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<Guid> 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<CreateBillingA
|
||||
paymentMethod
|
||||
);
|
||||
|
||||
// TODO: Add repository save
|
||||
// _billingAccountRepository.Add(billingAccount);
|
||||
// await _billingAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
_context.BillingAccounts.Add(billingAccount);
|
||||
await _context.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Billing account created for advertiser {AdvertiserId}", request.AdvertiserId);
|
||||
|
||||
|
||||
@@ -147,12 +147,13 @@ public class AdminBillingAccountsController : ControllerBase
|
||||
return NotFound(new { message = $"Account {id} not found" });
|
||||
}
|
||||
|
||||
// TODO: Add Reactivate method to BillingAccount aggregate
|
||||
// EN: For now, directly set status / VI: Tạm thời set status trực tiếp
|
||||
account.Reactivate();
|
||||
await _context.SaveEntitiesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
accountId = id,
|
||||
status = "Active",
|
||||
status = account.Status.ToString(),
|
||||
message = "Account reactivated successfully"
|
||||
});
|
||||
}
|
||||
@@ -182,14 +183,16 @@ public class AdminBillingAccountsController : ControllerBase
|
||||
return NotFound(new { message = $"Account {id} not found" });
|
||||
}
|
||||
|
||||
// TODO: Add SetCreditLimit method to BillingAccount aggregate
|
||||
// EN: For now, acknowledge the request / VI: Tạm thời acknowledge request
|
||||
var oldCreditLimit = account.CreditLimit;
|
||||
account.SetCreditLimit(request.NewCreditLimit);
|
||||
await _context.SaveEntitiesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
accountId = id,
|
||||
oldCreditLimit = account.CreditLimit,
|
||||
newCreditLimit = request.NewCreditLimit,
|
||||
message = "Credit limit update request acknowledged"
|
||||
oldCreditLimit,
|
||||
newCreditLimit = account.CreditLimit,
|
||||
message = "Credit limit updated successfully"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using AdsBillingService.API.Application.Queries;
|
||||
using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate;
|
||||
using AdsBillingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -15,16 +15,13 @@ namespace AdsBillingService.API.Controllers.Admin;
|
||||
[Produces("application/json")]
|
||||
public class AdminInvoicesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly AdsBillingServiceContext _context;
|
||||
private readonly ILogger<AdminInvoicesController> _logger;
|
||||
|
||||
public AdminInvoicesController(
|
||||
IMediator mediator,
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<AdminInvoicesController> 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<CreditLinesController> _logger;
|
||||
|
||||
public CreditLinesController(IMediator mediator, ILogger<CreditLinesController> logger)
|
||||
public CreditLinesController(
|
||||
AdsBillingServiceContext context,
|
||||
ILogger<CreditLinesController> 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<IActionResult> GetCreditLine(Guid advertiserId)
|
||||
public async Task<IActionResult> 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<IActionResult> RequestCreditIncrease([FromBody] CreditIncreaseRequest request)
|
||||
public async Task<IActionResult> 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ public class InvoicesController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,66 @@ public class BillingAccount : Entity, IAggregateRoot
|
||||
_UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reactivate account after suspension.
|
||||
/// VI: Kích hoạt lại tài khoản sau khi bị tạm ngưng.
|
||||
/// </summary>
|
||||
public void Reactivate()
|
||||
{
|
||||
if (_status == AccountStatus.Closed)
|
||||
throw new AdsBillingDomainException("Cannot reactivate a closed account");
|
||||
|
||||
_status = AccountStatus.Active;
|
||||
_UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetCreditLimit(decimal creditLimit)
|
||||
{
|
||||
if (creditLimit < 0)
|
||||
throw new AdsBillingDomainException("Credit limit cannot be negative");
|
||||
|
||||
_creditLimit = creditLimit;
|
||||
_UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user