Backend (17 files): - VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY), IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue - SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback, credit note auto-numbering (§52) - Security: BankingController CSRF state token + company auth check, attachment canonical path traversal check, discount 0-100% validation, deactivated product/customer update guard - Quality: redact bank API logs, remove dead code (VatCalcService, PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding Frontend (15 files): - Fix double "kr." in AmountText and Dashboard Statistic components - Fix UserSettings Switch defaultChecked desync with Form state - Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank) - Correct SKAT VAT deadline calculation per period type - Add half-yearly/yearly VAT period options - Guard console.error with import.meta.env.DEV - Use shared formatDate in BankConnectionsTab - Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union - Migrate S25→U25, K25→I25 across all pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
9.8 KiB
C#
285 lines
9.8 KiB
C#
using System.Security.Claims;
|
|
using Books.Api.Authorization;
|
|
using Books.Api.Commands.Attachments;
|
|
using Books.Api.Domain;
|
|
using Books.Api.Domain.Attachments;
|
|
using Books.Api.EventFlow.ReadModels;
|
|
using Books.Api.EventFlow.Repositories;
|
|
using Books.Api.Infrastructure.FileStorage;
|
|
using EventFlow;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace Books.Api.Controllers;
|
|
|
|
/// <summary>
|
|
/// REST API for attachment (bilag) file operations.
|
|
/// GraphQL doesn't handle file uploads well, so we use REST for this.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/attachments")]
|
|
[Authorize]
|
|
public class AttachmentController(
|
|
ICommandBus commandBus,
|
|
IAttachmentRepository attachmentRepository,
|
|
IFileStorageService fileStorage,
|
|
ICompanyAccessService companyAccess,
|
|
ILogger<AttachmentController> logger) : ControllerBase
|
|
{
|
|
/// <summary>
|
|
/// Upload one or more attachments for a company.
|
|
/// </summary>
|
|
/// <param name="companyId">Company ID</param>
|
|
/// <param name="draftId">Optional draft ID to link attachments to</param>
|
|
/// <param name="files">Files to upload</param>
|
|
[HttpPost("upload")]
|
|
[RequestSizeLimit(50 * 1024 * 1024)] // 50MB max total
|
|
public async Task<IActionResult> Upload(
|
|
[FromQuery] string companyId,
|
|
[FromQuery] string? draftId,
|
|
[FromForm] List<IFormFile> files,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Validate company access
|
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
return Unauthorized(new { error = "NOT_AUTHENTICATED", message = "You must be authenticated to upload attachments" });
|
|
}
|
|
|
|
// Check if user can write (Accountant or Owner role)
|
|
var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken);
|
|
if (!canWrite)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
if (files.Count == 0)
|
|
{
|
|
return BadRequest(new { error = "NO_FILES", message = "No files provided" });
|
|
}
|
|
|
|
var results = new List<object>();
|
|
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
// Validate file size (10MB per file)
|
|
if (file.Length > 10 * 1024 * 1024)
|
|
{
|
|
results.Add(new
|
|
{
|
|
success = false,
|
|
fileName = file.FileName,
|
|
error = "FILE_TOO_LARGE",
|
|
message = $"File '{file.FileName}' exceeds 10MB limit"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Store file
|
|
await using var stream = file.OpenReadStream();
|
|
var storageResult = await fileStorage.StoreAsync(
|
|
companyId,
|
|
file.FileName,
|
|
file.ContentType,
|
|
stream,
|
|
cancellationToken);
|
|
|
|
// Create attachment aggregate
|
|
var attachmentId = AttachmentId.New;
|
|
var command = new UploadAttachmentCommand(
|
|
attachmentId,
|
|
companyId,
|
|
storageResult.StoredFileName,
|
|
file.FileName,
|
|
file.ContentType,
|
|
storageResult.FileSize,
|
|
storageResult.StoragePath,
|
|
userId,
|
|
draftId);
|
|
|
|
await commandBus.PublishAsync(command, cancellationToken);
|
|
|
|
// Wait briefly for read model to be updated (eventual consistency)
|
|
await Task.Delay(100, cancellationToken);
|
|
|
|
var attachment = await attachmentRepository.GetByIdAsync(attachmentId.Value, cancellationToken);
|
|
|
|
results.Add(new
|
|
{
|
|
success = true,
|
|
id = attachmentId.Value,
|
|
fileName = file.FileName,
|
|
fileType = file.ContentType,
|
|
fileSize = storageResult.FileSize,
|
|
uploadedAt = attachment?.UploadedAt ?? DateTimeOffset.UtcNow,
|
|
url = fileStorage.GetDownloadUrl(storageResult.StoragePath)
|
|
});
|
|
}
|
|
catch (DomainException ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to upload file {FileName}", file.FileName);
|
|
results.Add(new
|
|
{
|
|
success = false,
|
|
fileName = file.FileName,
|
|
error = ex.Code,
|
|
message = ex.MessageDanish
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Unexpected error uploading file {FileName}", file.FileName);
|
|
results.Add(new
|
|
{
|
|
success = false,
|
|
fileName = file.FileName,
|
|
error = "UPLOAD_FAILED",
|
|
message = "An unexpected error occurred"
|
|
});
|
|
}
|
|
}
|
|
|
|
return Ok(new { attachments = results });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get attachments for a draft.
|
|
/// </summary>
|
|
[HttpGet("draft/{draftId}")]
|
|
public async Task<IActionResult> GetByDraft(string draftId, CancellationToken cancellationToken)
|
|
{
|
|
var attachments = await attachmentRepository.GetByDraftIdAsync(draftId, cancellationToken);
|
|
|
|
if (attachments.Count == 0)
|
|
{
|
|
return Ok(new { attachments = Array.Empty<object>() });
|
|
}
|
|
|
|
// Validate company access (use first attachment's company)
|
|
var access = await companyAccess.GetAccessAsync(attachments[0].CompanyId, cancellationToken);
|
|
if (access == null)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
var result = attachments.Select(a => new
|
|
{
|
|
id = a.Id,
|
|
fileName = a.OriginalFileName,
|
|
fileType = a.ContentType,
|
|
fileSize = a.FileSize,
|
|
uploadedAt = a.UploadedAt.ToString("O"),
|
|
uploadedBy = a.UploadedBy,
|
|
url = fileStorage.GetDownloadUrl(a.StoragePath)
|
|
});
|
|
|
|
return Ok(new { attachments = result });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Download an attachment by storage path.
|
|
/// </summary>
|
|
[HttpGet("{*storagePath}")]
|
|
public async Task<IActionResult> Download(string storagePath, CancellationToken cancellationToken)
|
|
{
|
|
// Validate path to prevent directory traversal attacks using canonical path resolution
|
|
if (string.IsNullOrWhiteSpace(storagePath))
|
|
{
|
|
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
|
}
|
|
|
|
var basePath = fileStorage.GetBasePath();
|
|
var fullPath = Path.GetFullPath(Path.Combine(basePath, storagePath));
|
|
if (!fullPath.StartsWith(Path.GetFullPath(basePath), StringComparison.Ordinal))
|
|
{
|
|
logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath);
|
|
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
|
}
|
|
|
|
// Look up the attachment to verify company access
|
|
var attachment = await attachmentRepository.GetByStoragePathAsync(storagePath, cancellationToken);
|
|
if (attachment == null)
|
|
{
|
|
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
|
|
}
|
|
|
|
// Verify the user has access to the company that owns this attachment
|
|
var access = await companyAccess.GetAccessAsync(attachment.CompanyId, cancellationToken);
|
|
if (access == null)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
var file = await fileStorage.GetAsync(storagePath, cancellationToken);
|
|
|
|
if (file == null)
|
|
{
|
|
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
|
|
}
|
|
|
|
return File(file.Content, file.ContentType, file.FileName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete an attachment.
|
|
/// Note: Per Bogføringsloven § 6, attachments linked to transactions
|
|
/// cannot be deleted within the 5-year retention period.
|
|
/// </summary>
|
|
[HttpDelete("{id}")]
|
|
public async Task<IActionResult> Delete(
|
|
string id,
|
|
[FromQuery] string reason,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var attachment = await attachmentRepository.GetByIdAsync(id, cancellationToken);
|
|
|
|
if (attachment == null)
|
|
{
|
|
return NotFound(new { error = "ATTACHMENT_NOT_FOUND", message = "Attachment not found" });
|
|
}
|
|
|
|
// Validate company access - need write permission to delete
|
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var canWrite = await companyAccess.CanWriteAsync(attachment.CompanyId, cancellationToken);
|
|
if (!canWrite)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
{
|
|
return BadRequest(new
|
|
{
|
|
error = "REASON_REQUIRED",
|
|
message = "A reason for deletion is required (Bogføringsloven § 6)"
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var command = new DeleteAttachmentCommand(
|
|
new AttachmentId(id),
|
|
userId,
|
|
reason);
|
|
|
|
await commandBus.PublishAsync(command, cancellationToken);
|
|
|
|
// Also delete the physical file
|
|
await fileStorage.DeleteAsync(attachment.StoragePath, cancellationToken);
|
|
|
|
return Ok(new { success = true, message = "Attachment deleted" });
|
|
}
|
|
catch (DomainException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Code, message = ex.MessageDanish });
|
|
}
|
|
}
|
|
}
|