feat: initial project setup

- Add .NET 8 backend with Clean Architecture
- Add React + Vite + TypeScript frontend
- Implement authentication with JWT
- Implement Azure Blob Storage client
- Implement OCR integration
- Implement supplier matching service
- Implement voucher generation
- Implement Fortnox provider
- Add unit and integration tests
- Add Docker Compose configuration
This commit is contained in:
Invoice Master
2026-02-04 20:14:34 +01:00
commit 05ea67144f
250 changed files with 50402 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
using InvoiceMaster.Application.DTOs;
using InvoiceMaster.Core.Entities;
using InvoiceMaster.Core.Interfaces;
using InvoiceMaster.Integrations.Accounting;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace InvoiceMaster.API.Controllers;
[ApiController]
[Route("api/v1/accounting")]
[Authorize]
public class AccountingController : ControllerBase
{
private readonly IAccountingSystemFactory _factory;
private readonly IRepository<AccountingConnection> _connectionRepository;
private readonly IRepository<User> _userRepository;
public AccountingController(
IAccountingSystemFactory factory,
IRepository<AccountingConnection> connectionRepository,
IRepository<User> userRepository)
{
_factory = factory;
_connectionRepository = connectionRepository;
_userRepository = userRepository;
}
[HttpGet("providers")]
public IActionResult GetProviders()
{
var userId = GetUserId();
var connections = _connectionRepository.GetAllAsync().Result
.Where(c => c.UserId == userId && c.IsActive)
.ToList();
var providers = new[]
{
new ProviderInfoDto("fortnox", "Fortnox", "Swedish accounting software", true,
connections.Any(c => c.Provider == "fortnox")),
new ProviderInfoDto("visma", "Visma eAccounting", "Nordic accounting software", false, false),
new ProviderInfoDto("hogia", "Hogia Smart", "Swedish accounting software", false, false)
};
return Ok(new { success = true, data = new { providers } });
}
[HttpGet("{provider}/auth/url")]
public IActionResult GetAuthUrl(string provider)
{
var redirectUri = $"{Request.Scheme}://{Request.Host}/api/v1/accounting/{provider}/auth/callback";
var state = Guid.NewGuid().ToString("N");
string authUrl = provider.ToLower() switch
{
"fortnox" => $"https://apps.fortnox.se/oauth-v1/auth?client_id=&redirect_uri={Uri.EscapeDataString(redirectUri)}&scope=supplier+voucher+account&state={state}",
_ => throw new NotSupportedException($"Provider '{provider}' is not supported")
};
return Ok(new
{
success = true,
data = new { provider, authorizationUrl = authUrl, state }
});
}
[HttpGet("{provider}/auth/callback")]
public async Task<IActionResult> AuthCallback(string provider, [FromQuery] string code, [FromQuery] string state)
{
var accounting = _factory.Create(provider);
var result = await accounting.AuthenticateAsync(code);
if (!result.Success)
{
return BadRequest(new { success = false, error = result.ErrorMessage });
}
var userId = GetUserId();
var connection = AccountingConnection.Create(
userId,
provider,
result.AccessToken ?? string.Empty,
result.RefreshToken ?? string.Empty,
result.ExpiresAt ?? DateTime.UtcNow.AddHours(1),
result.Scope,
result.CompanyInfo?.Name,
result.CompanyInfo?.OrganisationNumber);
await _connectionRepository.AddAsync(connection);
return Ok(new
{
success = true,
data = new
{
provider,
connected = true,
companyName = result.CompanyInfo?.Name,
companyOrgNumber = result.CompanyInfo?.OrganisationNumber,
connectedAt = connection.CreatedAt
}
});
}
[HttpGet("connections")]
public async Task<IActionResult> GetConnections()
{
var userId = GetUserId();
var connections = (await _connectionRepository.GetAllAsync())
.Where(c => c.UserId == userId && c.IsActive)
.Select(c => new ConnectionDto(
c.Provider,
true,
c.CompanyName,
c.CompanyOrgNumber,
c.Scope?.Split(' ').ToList(),
c.ExpiresAt,
new ConnectionSettingsDto(
c.DefaultVoucherSeries,
c.DefaultAccountCode,
c.AutoAttachPdf,
c.AutoCreateSupplier)))
.ToList();
return Ok(new { success = true, data = new { connections } });
}
[HttpDelete("connections/{provider}")]
public async Task<IActionResult> Disconnect(string provider)
{
var userId = GetUserId();
var connections = await _connectionRepository.GetAllAsync();
var connection = connections.FirstOrDefault(c =>
c.UserId == userId &&
c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase) &&
c.IsActive);
if (connection == null)
{
return NotFound(new { success = false, error = "Connection not found" });
}
connection.Deactivate();
return Ok(new { success = true });
}
private Guid GetUserId()
{
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.Parse(userId!);
}
}