feat: complete ads billing command and credit workflows

Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-02-23 12:38:59 +00:00
parent 1e131adbf3
commit d68a47f93a
7 changed files with 260 additions and 65 deletions

View File

@@ -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}",

View File

@@ -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);

View File

@@ -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"
});
}
}

View File

@@ -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 }
});
}

View File

@@ -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"
});
}
}

View File

@@ -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);
}
}

View File

@@ -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;