ASP.NET Core with Angular Invoice Management System
This comprehensive guide will walk you through building a complete Invoice Management System using ASP.NET Core Web API with Angular, focusing on best practices for performance, security, maintainability, and clean architecture.
Table of Contents
Technology Stack Selection
Based on your requirements (performance, security, maintainability, DB-first approach), here's my recommendation:
Database Access: Dapper (with raw SQL/Stored Procedures)
Why Dapper over EF Core?
Better performance for complex queries
More control over SQL
Easier to work with existing stored procedures
Less abstraction layer
Why not pure ADO.NET? Dapper provides a nice balance between raw performance and developer productivity
Database Approach: Stored Procedures for complex operations (like full CRUD in one proc)
Benefits:
Better security (SQL injection protection)
Performance optimization at DB level
DB-first friendly
Can change implementation without redeploying app
Authentication: JWT with refresh tokens
System Architecture
We'll use a Clean Architecture-inspired approach with these layers:
API Layer: ASP.NET Core Web API (Controllers, DTOs)
Application Layer: Services, Business Logic
Infrastructure Layer: Data Access (Dapper), File Storage, etc.
Domain Layer: Core Entities, Interfaces
InvoiceManagement/ ├── API/ # ASP.NET Core Web API ├── Application/ # Business logic, services ├── Domain/ # Entities, interfaces ├── Infrastructure/ # Data access, external services └── AngularApp/ # Angular frontend
Database Design
For the Invoice Management system, we'll need these tables:
CREATE TABLE Customers ( CustomerId INT PRIMARY KEY IDENTITY(1,1), Name NVARCHAR(100) NOT NULL, Email NVARCHAR(100), Phone NVARCHAR(20), Address NVARCHAR(200), IsActive BIT DEFAULT 1, CreatedDate DATETIME DEFAULT GETDATE() ); CREATE TABLE Products ( ProductId INT PRIMARY KEY IDENTITY(1,1), Name NVARCHAR(100) NOT NULL, Description NVARCHAR(500), Price DECIMAL(18,2) NOT NULL, IsActive BIT DEFAULT 1, CreatedDate DATETIME DEFAULT GETDATE() ); CREATE TABLE Invoices ( InvoiceId INT PRIMARY KEY IDENTITY(1,1), CustomerId INT NOT NULL, InvoiceNumber NVARCHAR(20) NOT NULL UNIQUE, Date DATETIME NOT NULL DEFAULT GETDATE(), DueDate DATETIME NOT NULL, TotalAmount DECIMAL(18,2) NOT NULL, TaxAmount DECIMAL(18,2) NOT NULL, DiscountAmount DECIMAL(18,2) DEFAULT 0, Notes NVARCHAR(500), Status NVARCHAR(20) NOT NULL, -- Draft, Sent, Paid, Cancelled CreatedDate DATETIME DEFAULT GETDATE(), ModifiedDate DATETIME, FOREIGN KEY (CustomerId) REFERENCES Customers(CustomerId) ); CREATE TABLE InvoiceItems ( InvoiceItemId INT PRIMARY KEY IDENTITY(1,1), InvoiceId INT NOT NULL, ProductId INT NOT NULL, Quantity INT NOT NULL, UnitPrice DECIMAL(18,2) NOT NULL, Discount DECIMAL(18,2) DEFAULT 0, TaxRate DECIMAL(5,2) DEFAULT 0, Notes NVARCHAR(200), FOREIGN KEY (InvoiceId) REFERENCES Invoices(InvoiceId), FOREIGN KEY (ProductId) REFERENCES Products(ProductId) );
API Project Structure
Let's start with the .NET Core API project structure:
1. Domain Layer
Entities/Invoice.cs
namespace InvoiceManagement.Domain.Entities { public class Invoice { public int InvoiceId { get; set; } public int CustomerId { get; set; } public string InvoiceNumber { get; set; } public DateTime Date { get; set; } public DateTime DueDate { get; set; } public decimal TotalAmount { get; set; } public decimal TaxAmount { get; set; } public decimal DiscountAmount { get; set; } public string Notes { get; set; } public string Status { get; set; } public DateTime CreatedDate { get; set; } public DateTime? ModifiedDate { get; set; } public Customer Customer { get; set; } public List<InvoiceItem> Items { get; set; } = new List<InvoiceItem>(); } public class InvoiceItem { public int InvoiceItemId { get; set; } public int InvoiceId { get; set; } public int ProductId { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal Discount { get; set; } public decimal TaxRate { get; set; } public string Notes { get; set; } public Product Product { get; set; } } public class Customer { public int CustomerId { get; set; } public string Name { get; set; } public string Email { get; set; } public string Phone { get; set; } public string Address { get; set; } public bool IsActive { get; set; } public DateTime CreatedDate { get; set; } } public class Product { public int ProductId { get; set; } public string Name { get; set; } public string Description { get; set; } public decimal Price { get; set; } public bool IsActive { get; set; } public DateTime CreatedDate { get; set; } } }
Interfaces/IRepository.cs
namespace InvoiceManagement.Domain.Interfaces { public interface IRepository<T> where T : class { Task<T> GetByIdAsync(int id); Task<IEnumerable<T>> GetAllAsync(); Task AddAsync(T entity); Task UpdateAsync(T entity); Task DeleteAsync(T entity); } public interface IInvoiceRepository : IRepository<Invoice> { Task<Invoice> GetInvoiceWithItemsAsync(int id); Task<IEnumerable<Invoice>> GetInvoicesByCustomerAsync(int customerId); Task<string> GenerateInvoiceNumberAsync(); } }
2. Infrastructure Layer
Data/InvoiceDbContext.cs
namespace InvoiceManagement.Infrastructure.Data { public class InvoiceDbContext { private readonly string _connectionString; public InvoiceDbContext(IConfiguration configuration) { _connectionString = configuration.GetConnectionString("DefaultConnection"); } public IDbConnection CreateConnection() => new SqlConnection(_connectionString); } }
Repositories/InvoiceRepository.cs
namespace InvoiceManagement.Infrastructure.Repositories { public class InvoiceRepository : IInvoiceRepository { private readonly InvoiceDbContext _context; public InvoiceRepository(InvoiceDbContext context) { _context = context; } public async Task<Invoice> GetByIdAsync(int id) { using var connection = _context.CreateConnection(); var sql = @" SELECT i.*, c.* FROM Invoices i JOIN Customers c ON i.CustomerId = c.CustomerId WHERE i.InvoiceId = @Id"; var invoice = await connection.QueryAsync<Invoice, Customer, Invoice>( sql, (invoice, customer) => { invoice.Customer = customer; return invoice; }, new { Id = id }, splitOn: "CustomerId"); return invoice.FirstOrDefault(); } public async Task<Invoice> GetInvoiceWithItemsAsync(int id) { using var connection = _context.CreateConnection(); var sql = @" SELECT i.*, c.*, ii.*, p.* FROM Invoices i JOIN Customers c ON i.CustomerId = c.CustomerId LEFT JOIN InvoiceItems ii ON i.InvoiceId = ii.InvoiceId LEFT JOIN Products p ON ii.ProductId = p.ProductId WHERE i.InvoiceId = @Id"; var invoiceDict = new Dictionary<int, Invoice>(); await connection.QueryAsync<Invoice, Customer, InvoiceItem, Product, Invoice>( sql, (invoice, customer, item, product) => { if (!invoiceDict.TryGetValue(invoice.InvoiceId, out var currentInvoice)) { currentInvoice = invoice; currentInvoice.Customer = customer; currentInvoice.Items = new List<InvoiceItem>(); invoiceDict.Add(currentInvoice.InvoiceId, currentInvoice); } if (item != null) { item.Product = product; currentInvoice.Items.Add(item); } return currentInvoice; }, new { Id = id }, splitOn: "CustomerId,InvoiceItemId,ProductId"); return invoiceDict.Values.FirstOrDefault(); } public async Task<string> GenerateInvoiceNumberAsync() { using var connection = _context.CreateConnection(); var result = await connection.ExecuteScalarAsync<string>( "SELECT 'INV-' + FORMAT(GETDATE(), 'yyyyMMdd') + '-' + RIGHT('0000' + CAST(ISNULL(MAX(CAST(RIGHT(InvoiceNumber, 4) AS INT)), 0) + 1 AS VARCHAR(4)), 4) FROM Invoices WHERE InvoiceNumber LIKE 'INV-' + FORMAT(GETDATE(), 'yyyyMMdd') + '%'"); return result ?? $"INV-{DateTime.Now:yyyyMMdd}-0001"; } // Other CRUD operations... } }
3. Application Layer
Services/InvoiceService.cs
namespace InvoiceManagement.Application.Services { public interface IInvoiceService { Task<InvoiceDto> GetInvoiceAsync(int id); Task<IEnumerable<InvoiceListDto>> GetInvoicesAsync(); Task<InvoiceDto> CreateInvoiceAsync(InvoiceCreateDto invoiceDto); Task UpdateInvoiceAsync(InvoiceUpdateDto invoiceDto); Task DeleteInvoiceAsync(int id); } public class InvoiceService : IInvoiceService { private readonly IInvoiceRepository _invoiceRepository; private readonly IMapper _mapper; public InvoiceService(IInvoiceRepository invoiceRepository, IMapper mapper) { _invoiceRepository = invoiceRepository; _mapper = mapper; } public async Task<InvoiceDto> GetInvoiceAsync(int id) { var invoice = await _invoiceRepository.GetInvoiceWithItemsAsync(id); return _mapper.Map<InvoiceDto>(invoice); } public async Task<IEnumerable<InvoiceListDto>> GetInvoicesAsync() { var invoices = await _invoiceRepository.GetAllAsync(); return _mapper.Map<IEnumerable<InvoiceListDto>>(invoices); } public async Task<InvoiceDto> CreateInvoiceAsync(InvoiceCreateDto invoiceDto) { var invoice = _mapper.Map<Invoice>(invoiceDto); invoice.InvoiceNumber = await _invoiceRepository.GenerateInvoiceNumberAsync(); invoice.Status = "Draft"; invoice.CreatedDate = DateTime.UtcNow; foreach (var item in invoice.Items) { item.UnitPrice = item.Product.Price; } await _invoiceRepository.AddAsync(invoice); return _mapper.Map<InvoiceDto>(invoice); } public async Task UpdateInvoiceAsync(InvoiceUpdateDto invoiceDto) { var existingInvoice = await _invoiceRepository.GetInvoiceWithItemsAsync(invoiceDto.InvoiceId); if (existingInvoice == null) { throw new NotFoundException("Invoice not found"); } _mapper.Map(invoiceDto, existingInvoice); existingInvoice.ModifiedDate = DateTime.UtcNow; await _invoiceRepository.UpdateAsync(existingInvoice); } public async Task DeleteInvoiceAsync(int id) { var invoice = await _invoiceRepository.GetByIdAsync(id); if (invoice == null) { throw new NotFoundException("Invoice not found"); } await _invoiceRepository.DeleteAsync(invoice); } } }
4. API Layer
DTOs/InvoiceDtos.cs
namespace InvoiceManagement.API.DTOs { public class InvoiceDto { public int InvoiceId { get; set; } public string InvoiceNumber { get; set; } public DateTime Date { get; set; } public DateTime DueDate { get; set; } public decimal TotalAmount { get; set; } public decimal TaxAmount { get; set; } public decimal DiscountAmount { get; set; } public string Notes { get; set; } public string Status { get; set; } public CustomerDto Customer { get; set; } public List<InvoiceItemDto> Items { get; set; } = new List<InvoiceItemDto>(); } public class InvoiceListDto { public int InvoiceId { get; set; } public string InvoiceNumber { get; set; } public DateTime Date { get; set; } public DateTime DueDate { get; set; } public decimal TotalAmount { get; set; } public string Status { get; set; } public string CustomerName { get; set; } } public class InvoiceCreateDto { public int CustomerId { get; set; } public DateTime Date { get; set; } = DateTime.UtcNow; public DateTime DueDate { get; set; } = DateTime.UtcNow.AddDays(30); public decimal DiscountAmount { get; set; } public string Notes { get; set; } public List<InvoiceItemCreateDto> Items { get; set; } = new List<InvoiceItemCreateDto>(); } public class InvoiceUpdateDto { public int InvoiceId { get; set; } public int CustomerId { get; set; } public DateTime Date { get; set; } public DateTime DueDate { get; set; } public decimal DiscountAmount { get; set; } public string Notes { get; set; } public string Status { get; set; } public List<InvoiceItemUpdateDto> Items { get; set; } = new List<InvoiceItemUpdateDto>(); } // Other DTOs for Customer, Product, InvoiceItem... }
Controllers/InvoicesController.cs
namespace InvoiceManagement.API.Controllers { [ApiController] [Route("api/[controller]")] [Authorize] public class InvoicesController : ControllerBase { private readonly IInvoiceService _invoiceService; private readonly IMapper _mapper; public InvoicesController(IInvoiceService invoiceService, IMapper mapper) { _invoiceService = invoiceService; _mapper = mapper; } [HttpGet] public async Task<ActionResult<IEnumerable<InvoiceListDto>>> GetInvoices() { var invoices = await _invoiceService.GetInvoicesAsync(); return Ok(invoices); } [HttpGet("{id}")] public async Task<ActionResult<InvoiceDto>> GetInvoice(int id) { var invoice = await _invoiceService.GetInvoiceAsync(id); if (invoice == null) { return NotFound(); } return Ok(invoice); } [HttpPost] public async Task<ActionResult<InvoiceDto>> CreateInvoice(InvoiceCreateDto invoiceDto) { var invoice = await _invoiceService.CreateInvoiceAsync(invoiceDto); return CreatedAtAction(nameof(GetInvoice), new { id = invoice.InvoiceId }, invoice); } [HttpPut("{id}")] public async Task<IActionResult> UpdateInvoice(int id, InvoiceUpdateDto invoiceDto) { if (id != invoiceDto.InvoiceId) { return BadRequest(); } await _invoiceService.UpdateInvoiceAsync(invoiceDto); return NoContent(); } [HttpDelete("{id}")] public async Task<IActionResult> DeleteInvoice(int id) { await _invoiceService.DeleteInvoiceAsync(id); return NoContent(); } } }
Stored Procedure Implementation
For the full CRUD in a single stored procedure approach:
CREATE PROCEDURE [dbo].[usp_Invoice_CRUD] @Action VARCHAR(10), -- 'CREATE', 'READ', 'UPDATE', 'DELETE' @InvoiceId INT = NULL, @CustomerId INT = NULL, @InvoiceNumber NVARCHAR(20) = NULL, @Date DATETIME = NULL, @DueDate DATETIME = NULL, @DiscountAmount DECIMAL(18,2) = 0, @Notes NVARCHAR(500) = NULL, @Status NVARCHAR(20) = NULL, @ItemsJson NVARCHAR(MAX) = NULL, -- JSON array of items @UserId INT = NULL -- For audit purposes AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- CREATE action IF @Action = 'CREATE' BEGIN -- Generate invoice number if not provided IF @InvoiceNumber IS NULL BEGIN SET @InvoiceNumber = 'INV-' + FORMAT(GETDATE(), 'yyyyMMdd') + '-' + RIGHT('0000' + CAST(ISNULL((SELECT MAX(CAST(RIGHT(InvoiceNumber, 4) AS INT)) FROM Invoices WHERE InvoiceNumber LIKE 'INV-' + FORMAT(GETDATE(), 'yyyyMMdd') + '%'), 0) + 1 AS VARCHAR(4)), 4); END -- Insert invoice header INSERT INTO Invoices (CustomerId, InvoiceNumber, Date, DueDate, DiscountAmount, Notes, Status, CreatedDate) VALUES (@CustomerId, @InvoiceNumber, @Date, @DueDate, @DiscountAmount, @Notes, ISNULL(@Status, 'Draft'), GETDATE()); SET @InvoiceId = SCOPE_IDENTITY(); -- Insert items if provided IF @ItemsJson IS NOT NULL BEGIN INSERT INTO InvoiceItems (InvoiceId, ProductId, Quantity, UnitPrice, Discount, TaxRate, Notes) SELECT @InvoiceId, ProductId, Quantity, (SELECT Price FROM Products WHERE ProductId = json.ProductId) AS UnitPrice, ISNULL(Discount, 0), ISNULL(TaxRate, 0), Notes FROM OPENJSON(@ItemsJson) WITH ( ProductId INT '$.productId', Quantity INT '$.quantity', Discount DECIMAL(18,2) '$.discount', TaxRate DECIMAL(5,2) '$.taxRate', Notes NVARCHAR(200) '$.notes' ) AS json; -- Calculate totals DECLARE @TotalAmount DECIMAL(18,2) = 0; DECLARE @TaxAmount DECIMAL(18,2) = 0; SELECT @TotalAmount = SUM((ii.UnitPrice * ii.Quantity) * (1 - ISNULL(ii.Discount, 0))), @TaxAmount = SUM((ii.UnitPrice * ii.Quantity) * (1 - ISNULL(ii.Discount, 0))) * (ISNULL(ii.TaxRate, 0)/100)) FROM InvoiceItems ii WHERE ii.InvoiceId = @InvoiceId; -- Update invoice with totals UPDATE Invoices SET TotalAmount = @TotalAmount, TaxAmount = @TaxAmount, ModifiedDate = GETDATE() WHERE InvoiceId = @InvoiceId; END -- Return the created invoice SELECT @InvoiceId AS InvoiceId, @InvoiceNumber AS InvoiceNumber; END -- READ action ELSE IF @Action = 'READ' BEGIN -- Return invoice header SELECT i.InvoiceId, i.InvoiceNumber, i.Date, i.DueDate, i.TotalAmount, i.TaxAmount, i.DiscountAmount, i.Notes, i.Status, c.CustomerId, c.Name AS CustomerName, c.Email, c.Phone, c.Address FROM Invoices i JOIN Customers c ON i.CustomerId = c.CustomerId WHERE i.InvoiceId = @InvoiceId; -- Return invoice items SELECT ii.InvoiceItemId, ii.ProductId, p.Name AS ProductName, p.Description, ii.Quantity, ii.UnitPrice, ii.Discount, ii.TaxRate, ii.Notes FROM InvoiceItems ii JOIN Products p ON ii.ProductId = p.ProductId WHERE ii.InvoiceId = @InvoiceId; END -- UPDATE action ELSE IF @Action = 'UPDATE' BEGIN -- Update invoice header UPDATE Invoices SET CustomerId = @CustomerId, Date = @Date, DueDate = @DueDate, DiscountAmount = @DiscountAmount, Notes = @Notes, Status = @Status, ModifiedDate = GETDATE() WHERE InvoiceId = @InvoiceId; -- Handle items (delete all and reinsert for simplicity) DELETE FROM InvoiceItems WHERE InvoiceId = @InvoiceId; IF @ItemsJson IS NOT NULL BEGIN INSERT INTO InvoiceItems (InvoiceId, ProductId, Quantity, UnitPrice, Discount, TaxRate, Notes) SELECT @InvoiceId, ProductId, Quantity, (SELECT Price FROM Products WHERE ProductId = json.ProductId) AS UnitPrice, ISNULL(Discount, 0), ISNULL(TaxRate, 0), Notes FROM OPENJSON(@ItemsJson) WITH ( ProductId INT '$.productId', Quantity INT '$.quantity', Discount DECIMAL(18,2) '$.discount', TaxRate DECIMAL(5,2) '$.taxRate', Notes NVARCHAR(200) '$.notes' ) AS json; -- Recalculate totals DECLARE @NewTotalAmount DECIMAL(18,2) = 0; DECLARE @NewTaxAmount DECIMAL(18,2) = 0; SELECT @NewTotalAmount = SUM((ii.UnitPrice * ii.Quantity) * (1 - ISNULL(ii.Discount, 0))), @NewTaxAmount = SUM((ii.UnitPrice * ii.Quantity) * (1 - ISNULL(ii.Discount, 0))) * (ISNULL(ii.TaxRate, 0)/100)) FROM InvoiceItems ii WHERE ii.InvoiceId = @InvoiceId; -- Update invoice with new totals UPDATE Invoices SET TotalAmount = @NewTotalAmount, TaxAmount = @NewTaxAmount, ModifiedDate = GETDATE() WHERE InvoiceId = @InvoiceId; END END -- DELETE action ELSE IF @Action = 'DELETE' BEGIN -- First delete items DELETE FROM InvoiceItems WHERE InvoiceId = @InvoiceId; -- Then delete invoice DELETE FROM Invoices WHERE InvoiceId = @InvoiceId; END COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; THROW; END CATCH END
Dapper Integration with Stored Procedure
Update the InvoiceRepository
to use the stored procedure:
public async Task<Invoice> GetInvoiceWithItemsAsync(int id) { using var connection = _context.CreateConnection(); var parameters = new DynamicParameters(); parameters.Add("Action", "READ"); parameters.Add("InvoiceId", id); using var multi = await connection.QueryMultipleAsync( "usp_Invoice_CRUD", parameters, commandType: CommandType.StoredProcedure); var invoice = await multi.ReadSingleOrDefaultAsync<Invoice>(); if (invoice != null) { invoice.Items = (await multi.ReadAsync<InvoiceItem>()).ToList(); } return invoice; } public async Task<int> CreateInvoiceAsync(Invoice invoice) { using var connection = _context.CreateConnection(); var parameters = new DynamicParameters(); parameters.Add("Action", "CREATE"); parameters.Add("CustomerId", invoice.CustomerId); parameters.Add("Date", invoice.Date); parameters.Add("DueDate", invoice.DueDate); parameters.Add("DiscountAmount", invoice.DiscountAmount); parameters.Add("Notes", invoice.Notes); parameters.Add("Status", invoice.Status); parameters.Add("ItemsJson", JsonConvert.SerializeObject(invoice.Items.Select(i => new { productId = i.ProductId, quantity = i.Quantity, discount = i.Discount, taxRate = i.TaxRate, notes = i.Notes }))); var result = await connection.QueryFirstOrDefaultAsync<dynamic>( "usp_Invoice_CRUD", parameters, commandType: CommandType.StoredProcedure); return result?.InvoiceId ?? 0; } // Similar methods for Update and Delete
JWT Authentication
Configuration in Program.cs
// Add JWT Authentication var jwtSettings = builder.Configuration.GetSection("JwtSettings"); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings["validIssuer"], ValidAudience = jwtSettings["validAudience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["secretKey"])) }; }); // Add Authorization builder.Services.AddAuthorization(options => { options.AddPolicy("RequireAdminRole", policy => policy.RequireRole("Admin")); options.AddPolicy("RequireUserRole", policy => policy.RequireRole("User")); });
AuthController.cs
[ApiController] [Route("api/[controller]")] public class AuthController : ControllerBase { private readonly IAuthService _authService; public AuthController(IAuthService authService) { _authService = authService; } [HttpPost("login")] public async Task<IActionResult> Login(LoginDto loginDto) { var result = await _authService.LoginAsync(loginDto); if (!result.Success) { return Unauthorized(result.Message); } return Ok(new { token = result.Token, refreshToken = result.RefreshToken, expiresIn = result.ExpiresIn }); } [HttpPost("refresh")] public async Task<IActionResult> RefreshToken(RefreshTokenDto refreshTokenDto) { var result = await _authService.RefreshTokenAsync(refreshTokenDto); if (!result.Success) { return Unauthorized(result.Message); } return Ok(new { token = result.Token, refreshToken = result.RefreshToken, expiresIn = result.ExpiresIn }); } [HttpPost("register")] public async Task<IActionResult> Register(RegisterDto registerDto) { var result = await _authService.RegisterAsync(registerDto); if (!result.Success) { return BadRequest(result.Message); } return Ok(result); } }
AuthService.cs
public class AuthService : IAuthService { private readonly UserManager<ApplicationUser> _userManager; private readonly IConfiguration _configuration; private readonly JwtSettings _jwtSettings; public AuthService(UserManager<ApplicationUser> userManager, IConfiguration configuration) { _userManager = userManager; _configuration = configuration; _jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>(); } public async Task<AuthResult> LoginAsync(LoginDto loginDto) { var user = await _userManager.FindByEmailAsync(loginDto.Email); if (user == null) { return AuthResult.Failure("User not found"); } if (!await _userManager.CheckPasswordAsync(user, loginDto.Password)) { return AuthResult.Failure("Invalid credentials"); } var token = GenerateJwtToken(user); var refreshToken = GenerateRefreshToken(); user.RefreshToken = refreshToken; user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryInDays); await _userManager.UpdateAsync(user); return AuthResult.Success(token, refreshToken, _jwtSettings.TokenExpiryInMinutes * 60); } public async Task<AuthResult> RefreshTokenAsync(RefreshTokenDto refreshTokenDto) { var principal = GetPrincipalFromExpiredToken(refreshTokenDto.Token); var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); var user = await _userManager.FindByIdAsync(userId); if (user == null || user.RefreshToken != refreshTokenDto.RefreshToken || user.RefreshTokenExpiryTime <= DateTime.UtcNow) { return AuthResult.Failure("Invalid refresh token"); } var newToken = GenerateJwtToken(user); var newRefreshToken = GenerateRefreshToken(); user.RefreshToken = newRefreshToken; user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryInDays); await _userManager.UpdateAsync(user); return AuthResult.Success(newToken, newRefreshToken, _jwtSettings.TokenExpiryInMinutes * 60); } private string GenerateJwtToken(ApplicationUser user) { var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Email, user.Email), new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.Role, "User") // Add actual roles from user }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _jwtSettings.ValidIssuer, audience: _jwtSettings.ValidAudience, claims: claims, expires: DateTime.UtcNow.AddMinutes(_jwtSettings.TokenExpiryInMinutes), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } private string GenerateRefreshToken() { var randomNumber = new byte[32]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(randomNumber); return Convert.ToBase64String(randomNumber); } private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) { var tokenValidationParameters = new TokenValidationParameters { ValidateAudience = false, ValidateIssuer = false, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)), ValidateLifetime = false }; var tokenHandler = new JwtSecurityTokenHandler(); var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) { throw new SecurityTokenException("Invalid token"); } return principal; } }
Error Handling
Global Exception Handling Middleware
public class ExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger<ExceptionMiddleware> _logger; public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext httpContext) { try { await _next(httpContext); } catch (Exception ex) { _logger.LogError(ex, "An unhandled exception has occurred"); await HandleExceptionAsync(httpContext, ex); } } private static Task HandleExceptionAsync(HttpContext context, Exception exception) { context.Response.ContentType = "application/json"; var statusCode = exception switch { NotFoundException => StatusCodes.Status404NotFound, BadRequestException => StatusCodes.Status400BadRequest, UnauthorizedException => StatusCodes.Status401Unauthorized, _ => StatusCodes.Status500InternalServerError }; context.Response.StatusCode = statusCode; return context.Response.WriteAsync(new ErrorDetails { StatusCode = context.Response.StatusCode, Message = exception.Message, StackTrace = context.Response.StatusCode == StatusCodes.Status500InternalServerError ? exception.StackTrace : null }.ToString()); } } public class ErrorDetails { public int StatusCode { get; set; } public string Message { get; set; } public string StackTrace { get; set; } public override string ToString() { return JsonSerializer.Serialize(this); } } // Custom exceptions public class NotFoundException : Exception { public NotFoundException(string message) : base(message) { } } public class BadRequestException : Exception { public BadRequestException(string message) : base(message) { } } public class UnauthorizedException : Exception { public UnauthorizedException(string message) : base(message) { } }
Register the middleware in Program.cs:
app.UseMiddleware<ExceptionMiddleware>();
Transaction Management
For complex operations that span multiple repositories or services:
public class UnitOfWork : IUnitOfWork { private readonly InvoiceDbContext _context; private readonly IInvoiceRepository _invoiceRepository; private readonly ICustomerRepository _customerRepository; public UnitOfWork(InvoiceDbContext context, IInvoiceRepository invoiceRepository, ICustomerRepository customerRepository) { _context = context; _invoiceRepository = invoiceRepository; _customerRepository = customerRepository; } public IInvoiceRepository Invoices => _invoiceRepository; public ICustomerRepository Customers => _customerRepository; public async Task<bool> CommitAsync() { using var transaction = await _context.CreateConnection().BeginTransactionAsync(); try { // If repositories have changes to save, they would do it here await transaction.CommitAsync(); return true; } catch { await transaction.RollbackAsync(); throw; } } }
Example usage in a service:
public async Task<InvoiceDto> CreateInvoiceWithCustomerAsync(InvoiceCreateWithCustomerDto dto) { using (var transaction = await _unitOfWork.BeginTransactionAsync()) { try { // Create customer if not exists var customer = await _unitOfWork.Customers.GetByEmailAsync(dto.CustomerEmail); if (customer == null) { customer = new Customer { Name = dto.CustomerName, Email = dto.CustomerEmail, Phone = dto.CustomerPhone, Address = dto.CustomerAddress }; await _unitOfWork.Customers.AddAsync(customer); } // Create invoice var invoice = _mapper.Map<Invoice>(dto.Invoice); invoice.CustomerId = customer.CustomerId; invoice.InvoiceNumber = await _unitOfWork.Invoices.GenerateInvoiceNumberAsync(); await _unitOfWork.Invoices.AddAsync(invoice); await _unitOfWork.CommitAsync(); return _mapper.Map<InvoiceDto>(invoice); } catch { await transaction.RollbackAsync(); throw; } } }
Angular Implementation
Project Structure
angular-app/ ├── src/ │ ├── app/ │ │ ├── core/ # Core modules (auth, interceptors, guards) │ │ ├── modules/ # Feature modules │ │ │ ├── invoices/ # Invoice management │ │ │ ├── customers/ # Customer management │ │ │ └── products/ # Product management │ │ ├── shared/ # Shared components, models, services │ │ ├── app-routing.module.ts │ │ └── app.component.ts │ ├── assets/ │ └── environments/ └── angular.json
Core Services
auth.service.ts
@Injectable({ providedIn: 'root' }) export class AuthService { private readonly apiUrl = environment.apiUrl + '/auth'; private currentUserSubject: BehaviorSubject<User>; public currentUser: Observable<User>; constructor(private http: HttpClient) { this.currentUserSubject = new BehaviorSubject<User>( JSON.parse(localStorage.getItem('currentUser')) ); this.currentUser = this.currentUserSubject.asObservable(); } public get currentUserValue(): User { return this.currentUserSubject.value; } login(email: string, password: string): Observable<AuthResponse> { return this.http.post<AuthResponse>(`${this.apiUrl}/login`, { email, password }) .pipe( map(response => { if (response.token) { const user = this.decodeToken(response.token); user.token = response.token; user.refreshToken = response.refreshToken; localStorage.setItem('currentUser', JSON.stringify(user)); this.currentUserSubject.next(user); } return response; }) ); } logout(): void { localStorage.removeItem('currentUser'); this.currentUserSubject.next(null); } refreshToken(): Observable<AuthResponse> { const currentUser = this.currentUserValue; if (!currentUser || !currentUser.refreshToken) { return throwError('No refresh token available'); } return this.http.post<AuthResponse>(`${this.apiUrl}/refresh`, { token: currentUser.token, refreshToken: currentUser.refreshToken }).pipe( map(response => { if (response.token) { const user = this.decodeToken(response.token); user.token = response.token; user.refreshToken = response.refreshToken; localStorage.setItem('currentUser', JSON.stringify(user)); this.currentUserSubject.next(user); } return response; }) ); } private decodeToken(token: string): User { const decoded = jwtDecode(token); return { id: decoded.sub, email: decoded.email, name: decoded.name, roles: decoded.roles || [], token: token, refreshToken: null }; } }
http-interceptor.ts
@Injectable() export class JwtInterceptor implements HttpInterceptor { private isRefreshing = false; private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null); constructor(private authService: AuthService) {} intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { // Add auth header with jwt if user is logged in const currentUser = this.authService.currentUserValue; if (currentUser && currentUser.token) { request = this.addToken(request, currentUser.token); } return next.handle(request).pipe( catchError(error => { if (error instanceof HttpErrorResponse && error.status === 401) { return this.handle401Error(request, next); } else { return throwError(error); } }) ); } private addToken(request: HttpRequest<any>, token: string) { return request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); } private handle401Error(request: HttpRequest<any>, next: HttpHandler) { if (!this.isRefreshing) { this.isRefreshing = true; this.refreshTokenSubject.next(null); return this.authService.refreshToken().pipe( switchMap((response: any) => { this.isRefreshing = false; this.refreshTokenSubject.next(response.token); return next.handle(this.addToken(request, response.token)); }), catchError(err => { this.isRefreshing = false; this.authService.logout(); return throwError(err); }) ); } else { return this.refreshTokenSubject.pipe( filter(token => token != null), take(1), switchMap(token => { return next.handle(this.addToken(request, token)); }) ); } } }
Invoice Module
invoice.service.ts
@Injectable({ providedIn: 'root' }) export class InvoiceService { private apiUrl = environment.apiUrl + '/invoices'; constructor(private http: HttpClient) {} getInvoices(params?: any): Observable<InvoiceListDto[]> { return this.http.get<InvoiceListDto[]>(this.apiUrl, { params }); } getInvoice(id: number): Observable<InvoiceDto> { return this.http.get<InvoiceDto>(`${this.apiUrl}/${id}`); } createInvoice(invoice: InvoiceCreateDto): Observable<InvoiceDto> { return this.http.post<InvoiceDto>(this.apiUrl, invoice); } updateInvoice(id: number, invoice: InvoiceUpdateDto): Observable<void> { return this.http.put<void>(`${this.apiUrl}/${id}`, invoice); } deleteInvoice(id: number): Observable<void> { return this.http.delete<void>(`${this.apiUrl}/${id}`); } generatePdf(id: number): Observable<Blob> { return this.http.get(`${this.apiUrl}/${id}/pdf`, { responseType: 'blob' }); } }
invoice-list.component.ts
@Component({ selector: 'app-invoice-list', templateUrl: './invoice-list.component.html', styleUrls: ['./invoice-list.component.scss'] }) export class InvoiceListComponent implements OnInit, OnDestroy { invoices: InvoiceListDto[] = []; isLoading = false; searchForm: FormGroup; private destroy$ = new Subject<void>(); constructor( private invoiceService: InvoiceService, private fb: FormBuilder, private router: Router, private dialog: MatDialog ) {} ngOnInit(): void { this.initForm(); this.loadInvoices(); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } private initForm(): void { this.searchForm = this.fb.group({ search: [''], status: [''], fromDate: [''], toDate: [''] }); this.searchForm.valueChanges.pipe( debounceTime(500), distinctUntilChanged(), takeUntil(this.destroy$) ).subscribe(() => this.loadInvoices()); } private loadInvoices(): void { this.isLoading = true; const params = this.buildSearchParams(); this.invoiceService.getInvoices(params).pipe( finalize(() => this.isLoading = false), takeUntil(this.destroy$) ).subscribe( invoices => this.invoices = invoices, error => console.error('Failed to load invoices', error) ); } private buildSearchParams(): any { const formValue = this.searchForm.value; const params: any = {}; if (formValue.search) params.search = formValue.search; if (formValue.status) params.status = formValue.status; if (formValue.fromDate) params.fromDate = formValue.fromDate; if (formValue.toDate) params.toDate = formValue.toDate; return params; } onEdit(invoiceId: number): void { this.router.navigate(['/invoices', invoiceId, 'edit']); } onView(invoiceId: number): void { this.router.navigate(['/invoices', invoiceId]); } onDelete(invoiceId: number): void { const dialogRef = this.dialog.open(ConfirmDialogComponent, { data: { title: 'Delete Invoice', message: 'Are you sure you want to delete this invoice?' } }); dialogRef.afterClosed().pipe( filter(result => result), switchMap(() => this.invoiceService.deleteInvoice(invoiceId)), takeUntil(this.destroy$) ).subscribe( () => { this.loadInvoices(); // Show success message }, error => console.error('Failed to delete invoice', error) ); } onCreate(): void { this.router.navigate(['/invoices', 'create']); } onExportPdf(invoiceId: number): void { this.invoiceService.generatePdf(invoiceId).pipe( takeUntil(this.destroy$) ).subscribe(blob => { saveAs(blob, `invoice_${invoiceId}.pdf`); }); } }
invoice-form.component.ts
@Component({ selector: 'app-invoice-form', templateUrl: './invoice-form.component.html', styleUrls: ['./invoice-form.component.scss'] }) export class InvoiceFormComponent implements OnInit, OnDestroy { invoiceForm: FormGroup; isEditMode = false; isLoading = false; invoiceId: number; customers: CustomerDto[] = []; products: ProductDto[] = []; private destroy$ = new Subject<void>(); constructor( private fb: FormBuilder, private invoiceService: InvoiceService, private customerService: CustomerService, private productService: ProductService, private route: ActivatedRoute, private router: Router, private snackBar: MatSnackBar ) {} ngOnInit(): void { this.initForm(); this.loadCustomers(); this.loadProducts(); this.route.params.pipe( takeUntil(this.destroy$) ).subscribe(params => { if (params['id']) { this.isEditMode = true; this.invoiceId = +params['id']; this.loadInvoice(this.invoiceId); } }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } private initForm(): void { this.invoiceForm = this.fb.group({ customerId: ['', Validators.required], date: [new Date(), Validators.required], dueDate: [this.getDefaultDueDate(), Validators.required], discountAmount: [0, [Validators.min(0)]], notes: [''], status: ['Draft', Validators.required], items: this.fb.array([], Validators.minLength(1)) }); } private getDefaultDueDate(): Date { const date = new Date(); date.setDate(date.getDate() + 30); return date; } get items(): FormArray { return this.invoiceForm.get('items') as FormArray; } addItem(product?: ProductDto): void { this.items.push(this.fb.group({ productId: [product?.productId || '', Validators.required], quantity: [1, [Validators.required, Validators.min(1)]], unitPrice: [{value: product?.price || 0, disabled: true}, Validators.required], discount: [0, [Validators.min(0), Validators.max(100)]], taxRate: [product?.taxRate || 0, [Validators.min(0), Validators.max(100)]], notes: [''] })); } removeItem(index: number): void { this.items.removeAt(index); } private loadCustomers(): void { this.customerService.getCustomers().pipe( takeUntil(this.destroy$) ).subscribe( customers => this.customers = customers, error => console.error('Failed to load customers', error) ); } private loadProducts(): void { this.productService.getProducts().pipe( takeUntil(this.destroy$) ).subscribe( products => this.products = products, error => console.error('Failed to load products', error) ); } private loadInvoice(id: number): void { this.isLoading = true; this.invoiceService.getInvoice(id).pipe( finalize(() => this.isLoading = false), takeUntil(this.destroy$) ).subscribe( invoice => this.populateForm(invoice), error => console.error('Failed to load invoice', error) ); } private populateForm(invoice: InvoiceDto): void { this.invoiceForm.patchValue({ customerId: invoice.customer.customerId, date: new Date(invoice.date), dueDate: new Date(invoice.dueDate), discountAmount: invoice.discountAmount, notes: invoice.notes, status: invoice.status }); this.items.clear(); invoice.items.forEach(item => { const product = this.products.find(p => p.productId === item.product.productId); this.addItem(product); const lastItem = this.items.at(this.items.length - 1) as FormGroup; lastItem.patchValue({ productId: item.product.productId, quantity: item.quantity, unitPrice: item.unitPrice, discount: item.discount, taxRate: item.taxRate, notes: item.notes }); }); } onSubmit(): void { if (this.invoiceForm.invalid) { return; } this.isLoading = true; const formValue = this.invoiceForm.getRawValue(); const invoiceData = { ...formValue, items: formValue.items.map(item => ({ productId: item.productId, quantity: item.quantity, discount: item.discount, taxRate: item.taxRate, notes: item.notes })) }; const operation = this.isEditMode ? this.invoiceService.updateInvoice(this.invoiceId, invoiceData) : this.invoiceService.createInvoice(invoiceData); operation.pipe( finalize(() => this.isLoading = false), takeUntil(this.destroy$) ).subscribe( () => { this.snackBar.open(`Invoice ${this.isEditMode ? 'updated' : 'created'} successfully`, 'Close', { duration: 3000 }); this.router.navigate(['/invoices']); }, error => { console.error('Failed to save invoice', error); this.snackBar.open('Failed to save invoice', 'Close', { duration: 3000, panelClass: ['error-snackbar'] }); } ); } onProductSelected(index: number): void { const itemGroup = this.items.at(index) as FormGroup; const productId = itemGroup.get('productId').value; const product = this.products.find(p => p.productId === productId); if (product) { itemGroup.patchValue({ unitPrice: product.price, taxRate: product.taxRate || 0 }); } } calculateItemTotal(index: number): number { const item = this.items.at(index).value; const subtotal = item.quantity * item.unitPrice; const discountAmount = subtotal * (item.discount / 100); const taxableAmount = subtotal - discountAmount; const taxAmount = taxableAmount * (item.taxRate / 100); return taxableAmount + taxAmount; } calculateInvoiceTotal(): number { const itemsTotal = this.items.controls .map((_, index) => this.calculateItemTotal(index)) .reduce((sum, current) => sum + current, 0); return itemsTotal - (this.invoiceForm.value.discountAmount || 0); } }
Deployment Considerations
Backend (ASP.NET Core)
Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base WORKDIR /app EXPOSE 80 EXPOSE 443 FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build WORKDIR /src COPY ["InvoiceManagement.API/InvoiceManagement.API.csproj", "InvoiceManagement.API/"] COPY ["InvoiceManagement.Application/InvoiceManagement.Application.csproj", "InvoiceManagement.Application/"] COPY ["InvoiceManagement.Domain/InvoiceManagement.Domain.csproj", "InvoiceManagement.Domain/"] COPY ["InvoiceManagement.Infrastructure/InvoiceManagement.Infrastructure.csproj", "InvoiceManagement.Infrastructure/"] RUN dotnet restore "InvoiceManagement.API/InvoiceManagement.API.csproj" COPY . . WORKDIR "/src/InvoiceManagement.API" RUN dotnet build "InvoiceManagement.API.csproj" -c Release -o /app/build FROM build AS publish RUN dotnet publish "InvoiceManagement.API.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "InvoiceManagement.API.dll"]
Database Migrations
For stored procedure approach, you'll need to:
Create a SQL migration script folder in your project
Use Flyway or DbUp for database versioning
Include all your stored procedures in the migration scripts
Frontend (Angular)
Dockerfile
# Stage 1: Build the Angular app FROM node:16 as build WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build --prod # Stage 2: Serve the app with Nginx FROM nginx:alpine COPY --from=build /app/dist/angular-app /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]
nginx.conf
server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://api:80/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
docker-compose.yml
version: '3.8' services: api: build: context: . dockerfile: InvoiceManagement.API/Dockerfile ports: - "5000:80" environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__DefaultConnection=Server=db;Database=InvoiceManagement;User=sa;Password=YourStrong@Passw0rd; depends_on: - db db: image: mcr.microsoft.com/mssql/server:2019-latest environment: - ACCEPT_EULA=Y - SA_PASSWORD=YourStrong@Passw0rd - MSSQL_PID=Express ports: - "1433:1433" volumes: - sql_data:/var/opt/mssql web: build: context: . dockerfile: AngularApp/Dockerfile ports: - "4200:80" depends_on: - api volumes: sql_data:
Performance Optimization
Database Level:
Index critical columns (InvoiceNumber, CustomerId, Date)
Use table-valued parameters for bulk operations
Implement proper transaction isolation levels
API Level:
Implement caching for frequently accessed data
Use pagination for large datasets
Compress responses
Angular Level:
Lazy load modules
Use OnPush change detection strategy
Implement virtual scrolling for large lists
Security Best Practices
API Security:
Always validate input parameters
Use parameterized queries (Dapper handles this)
Implement rate limiting
Use HTTPS in production
Sanitize all outputs
Angular Security:
Sanitize all user inputs
Use Content Security Policy (CSP)
Implement XSS protection
Use HttpOnly cookies for tokens
Database Security:
Least privilege principle for DB users
Encrypt sensitive data
Regular backups
Audit sensitive operations
This comprehensive guide provides a solid foundation for building an Invoice Management System with ASP.NET Core and Angular using best practices for performance, security, and maintainability. The architecture is designed to be flexible enough to accommodate future requirements while maintaining a clean separation of concerns.
0 comments:
Post a Comment