fix(invoices): send invoice amounts as exact cent-rounded decimals
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
Stornorechnung saves still failed: the VAT amount was formatted invariantly
("-615.3757999999999") and re-parsed under the server's de-DE culture as
-6153757999999999, overflowing numeric(10,3) in fds__setInvoice.
- FdsInvoiceData.BuildInvoiceParams: amount params are decimal(10,x) via
AmountParam/ParseAmount (culture-independent, rounded to cents); amounts
beyond +/-9,999,999.99 throw InvoiceAmountOutOfRangeException.
- InvoiceService.RegisterInvoiceAsync: refuses such an invoice with a logged
error (EventId InvoiceAmountRejected) instead of storing a wrong amount.
- Invoice editor: vat_val is cent-rounded decimal, no float noise.
- Tests: InvoiceAmountParamTests replaces the float-based Storno test.
- Bump Azure.Storage.Blobs 12.30.0; OCORE/OCORE_web submodules (AngleSharp
1.8.3, Azure.Storage.Files.Shares 12.28.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0a8384ab33
commit
d0002ccdad
@@ -475,7 +475,9 @@ public partial class IntranetController
|
||||
{
|
||||
["Id"] = d["Id"],
|
||||
["net_val"] = net,
|
||||
["vat_val"] = net * vat * 0.01,
|
||||
// Cent-rounded in decimal like InvoiceDraftCalculator; the former double product
|
||||
// (e.g. -615.3757999999999) carried float noise into the draft's VAT sums.
|
||||
["vat_val"] = Math.Round((decimal)net * (decimal)vat / 100m, 2, MidpointRounding.AwayFromZero),
|
||||
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
|
||||
["svcnet_val"] = d.no("value_service", 0),
|
||||
["net"] = d.no("value", 0),
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.2" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="App_Data\cache\" />
|
||||
|
||||
@@ -70,6 +70,8 @@ public class InvoiceService : IInvoiceService
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
private static readonly EventId InvoiceAmountRejected = new(0, "InvoiceAmountRejected");
|
||||
|
||||
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
|
||||
@@ -179,7 +181,18 @@ public class InvoiceService : IInvoiceService
|
||||
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
|
||||
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||
try
|
||||
{
|
||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||
}
|
||||
catch (InvoiceAmountOutOfRangeException ex)
|
||||
{
|
||||
// Refuse rather than store a wrong amount; an empty registration makes the caller report the save as failed.
|
||||
_logger.LogError(InvoiceAmountRejected, ex,
|
||||
"RegisterInvoiceAsync: refused invoice {InvId} - amount {Parameter} = {Value} is out of range (user={User})",
|
||||
invId, ex.Parameter, ex.Value, userAccountId);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
if (change) pl.Add(SQL_VarChar("@InvId", invId));
|
||||
bool hasProvisionLocation = invoice.RawProvisionLocation.Length > 0;
|
||||
|
||||
@@ -173,9 +173,9 @@ public class FdsInvoiceData
|
||||
{
|
||||
SQL_VarChar("@InvoiceType", Admin?.nz("type") ?? ""),
|
||||
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
|
||||
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
|
||||
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
|
||||
AmountParam("@InvoiceBalance", NewValues is null ? 0m : NewValues.getItem("total_gross"), scale: 3),
|
||||
AmountParam("@InvoiceBalance_net", NewValues is null ? 0m : NewValues.getItem("total_net"), scale: 3),
|
||||
AmountParam("@InvoiceVAT_net1", vatNet, scale: 3),
|
||||
SQL_VarChar("@InvoiceVAT_1", vatRate),
|
||||
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
|
||||
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
||||
@@ -185,12 +185,63 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
|
||||
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
|
||||
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
||||
AmountParam("@InvoiceService_net", Sms is null ? 0m : Sms.getItem("tscn"), scale: 2),
|
||||
AmountParam("@InvoiceService_VAT", Sms is null ? 0m : Sms.getItem("tscvat"), scale: 2),
|
||||
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Largest magnitude the <c>numeric(10,x)</c> amount parameters of the invoice procs accept.</summary>
|
||||
internal const decimal MaxAmount = 9_999_999.99m;
|
||||
|
||||
/// <summary>
|
||||
/// Invoice amounts go to SQL as exact <c>decimal</c> parameters rounded to cents — never through
|
||||
/// a culture-dependent string → float round trip. That round trip once parsed negative amounts
|
||||
/// with the server's de-DE culture ("-615.3758" → -6153758, with float noise beyond numeric(10,3)),
|
||||
/// so Stornorechnungen could not be saved. An amount outside the procs' range is refused here
|
||||
/// (<see cref="InvoiceAmountOutOfRangeException"/>) instead of being stored wrongly or failing in SQL.
|
||||
/// </summary>
|
||||
internal static SqlParameter AmountParam(string name, object? raw, byte scale)
|
||||
{
|
||||
decimal? value = ParseAmount(raw);
|
||||
if (value is { } v && Math.Abs(v) > MaxAmount)
|
||||
throw new InvoiceAmountOutOfRangeException(name, v);
|
||||
return new SqlParameter(name, System.Data.SqlDbType.Decimal)
|
||||
{
|
||||
Precision = 10,
|
||||
Scale = scale,
|
||||
Value = value.HasValue ? value.Value : DBNull.Value
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an amount the draft session or editor supplied: numbers are taken as-is; strings may be
|
||||
/// German ("-1234,56") or invariant ("-1234.56", "-1.5E-05") — a comma marks German formatting.
|
||||
/// The result is rounded to cents. Empty / "NaN" / unparseable → null (stored as NULL).
|
||||
/// </summary>
|
||||
internal static decimal? ParseAmount(object? raw)
|
||||
{
|
||||
if (raw is JValue jv) raw = jv.Value;
|
||||
decimal d;
|
||||
switch (raw)
|
||||
{
|
||||
case null: return null;
|
||||
case decimal m: d = m; break;
|
||||
case double dbl when double.IsFinite(dbl): d = (decimal)dbl; break;
|
||||
case float flt when float.IsFinite(flt): d = (decimal)flt; break;
|
||||
case int or long or short or byte: d = Convert.ToDecimal(raw, CultureInfo.InvariantCulture); break;
|
||||
case double or float: return null;
|
||||
default:
|
||||
string s = (Convert.ToString(raw, CultureInfo.InvariantCulture) ?? "").Trim();
|
||||
var culture = s.Contains(',') ? GermanCulture : CultureInfo.InvariantCulture;
|
||||
if (!decimal.TryParse(s, NumberStyles.Float, culture, out d)) return null;
|
||||
break;
|
||||
}
|
||||
return Math.Round(d, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static readonly CultureInfo GermanCulture = new("de-DE");
|
||||
|
||||
/// <summary>
|
||||
/// Builds the InvoiceOptions CSV from the posted admin flags.
|
||||
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:<mode></c>
|
||||
@@ -253,6 +304,14 @@ public class FdsInvoiceData
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An invoice amount exceeds what the invoice procs' <c>numeric(10,x)</c> parameters can store.</summary>
|
||||
public sealed class InvoiceAmountOutOfRangeException(string parameter, decimal value)
|
||||
: Exception($"Invoice amount {parameter} = {value.ToString(CultureInfo.InvariantCulture)} is outside ±{FdsInvoiceData.MaxAmount.ToString(CultureInfo.InvariantCulture)}.")
|
||||
{
|
||||
public string Parameter { get; } = parameter;
|
||||
public decimal Value { get; } = value;
|
||||
}
|
||||
|
||||
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
|
||||
public sealed class InvoiceBlock
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user