fix(invoices): send invoice amounts as exact cent-rounded decimals
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:
Stefan
2026-10-01 10:57:41 +02:00
co-authored by Claude Opus 5.5
parent 0a8384ab33
commit d0002ccdad
9 changed files with 267 additions and 56 deletions
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.Linq;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Invoice amounts reach the procs as exact, cent-rounded decimals, independent of the server
/// culture. Regression for the Stornorechnung save failure: the VAT amount was formatted
/// invariantly ("-615.3757999999999", unrounded double from a reloaded Storno line) and re-parsed
/// under de-DE as -6153757999999999 → "Error converting data type float to numeric".
/// </summary>
public class InvoiceAmountParamTests : IDisposable
{
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
public void Dispose() => CultureInfo.CurrentCulture = _culture;
private static readonly string[] AmountParams =
{ "@InvoiceBalance", "@InvoiceBalance_net", "@InvoiceVAT_net1", "@InvoiceService_net", "@InvoiceService_VAT" };
/// <summary>The exact shape InvoiceDraftEditService.BuildFdsData produces: decimal sums, VAT map by rate.</summary>
private static FdsInvoiceData StornoDraft(object vatAmount) => new(new JObject
{
["admin"] = new JObject { ["type"] = "c", ["customerid"] = "7" },
["new"] = new JObject { ["title"] = "Stornorechnung", ["total_net"] = -3238.82m, ["total_gross"] = -3854.20m },
["sms"] = new JObject
{
["tscn"] = -1234.56m, ["tscvat"] = -234.57m,
["vat"] = new JObject { ["19"] = JToken.FromObject(vatAmount) }
}
});
private static decimal Val(List<SqlParameter> pl, string name)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(SqlDbType.Decimal, p.SqlDbType);
return Assert.IsType<decimal>(p.Value);
}
public static TheoryData<string, object> CulturesAndVat => new()
{
{ "de-DE", -615.38m },
{ "en-US", -615.38m },
{ "de-DE", -615.3757999999999 }, // unrounded double from a reloaded Storno line (net * vat * 0.01)
{ "en-US", -615.3757999999999 },
{ "de-DE", -615.3800000000001 },
{ "", -615.38m }, // invariant
};
[Theory]
[MemberData(nameof(CulturesAndVat))]
public void BuildInvoiceParams_StornoDraft_AmountsExactInEveryCulture(string culture, object vatAmount)
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var pl = StornoDraft(vatAmount).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal(-3854.20m, Val(pl, "@InvoiceBalance"));
Assert.Equal(-3238.82m, Val(pl, "@InvoiceBalance_net"));
Assert.Equal(-615.38m, Val(pl, "@InvoiceVAT_net1"));
Assert.Equal(-1234.56m, Val(pl, "@InvoiceService_net"));
Assert.Equal(-234.57m, Val(pl, "@InvoiceService_VAT"));
}
[Fact]
public void BuildInvoiceParams_AmountParams_MatchProcPrecisionAndScale()
{
var pl = StornoDraft(-615.38m).BuildInvoiceParams(change: false, invId: "");
foreach (var name in AmountParams)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(10, p.Precision);
// fds__setInvoice/fds__createInvoice: numeric(10,3); fds__createInvoice_Details: numeric(10,2).
Assert.Equal(name.StartsWith("@InvoiceService_") ? 2 : 3, p.Scale);
}
}
[Theory]
[InlineData(-10_000_000.00)]
[InlineData(10_000_000.00)]
[InlineData(-6153757999999999.0)]
public void BuildInvoiceParams_AmountOutOfProcRange_Throws(double vat)
{
var ex = Assert.Throws<InvoiceAmountOutOfRangeException>(() =>
StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc"));
Assert.Equal("@InvoiceVAT_net1", ex.Parameter);
}
[Theory]
[InlineData(9_999_999.99)]
[InlineData(-9_999_999.99)]
[InlineData(0.0)]
public void BuildInvoiceParams_AmountAtProcRangeBoundary_Accepted(double vat)
{
var pl = StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal((decimal)vat, Val(pl, "@InvoiceVAT_net1"));
}
[Fact]
public void BuildInvoiceParams_MissingAmounts_KeepPreviousNullAndZeroSemantics()
{
var noSms = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["new"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, noSms.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // no sms block → 0
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceVAT_net1").Value); // HighestVat → "0"
var emptySms = new FdsInvoiceData(new JObject { ["new"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, emptySms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // key missing → NULL
// No "new" block at all: the legacy SQL_Float(nz(...) ?? "0") sent 0, not NULL.
var noNew = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance_net").Value);
}
[Theory]
[InlineData("-19.95", "-19.95")]
[InlineData("-19,95", "-19.95")]
[InlineData("-1234.5600000000002", "-1234.56")]
[InlineData("-615.3757999999999", "-615.38")]
[InlineData("1234,565", "1234.57")] // rounded half away from zero
[InlineData("-1234,565", "-1234.57")]
[InlineData("-1.5E-05", "0")]
[InlineData(" 42 ", "42")]
public void ParseAmount_Strings_ParsedCultureIndependentlyAndRoundedToCents(string raw, string expected)
{
foreach (var c in new[] { "de-DE", "en-US" })
{
CultureInfo.CurrentCulture = new CultureInfo(c);
Assert.Equal(decimal.Parse(expected, CultureInfo.InvariantCulture), FdsInvoiceData.ParseAmount(raw));
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ParseAmount_EmptyOrInvalid_IsNull(object? raw) =>
Assert.Null(FdsInvoiceData.ParseAmount(raw));
[Fact]
public void ParseAmount_NumbersAndJValues_TakenAsIs()
{
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82m));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82));
Assert.Equal(-12m, FdsInvoiceData.ParseAmount(-12));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(new JValue(-3238.82m)));
Assert.Null(FdsInvoiceData.ParseAmount(JValue.CreateNull()));
}
}
-21
View File
@@ -83,25 +83,4 @@ public class SqlNumberParameterTests : IDisposable
Assert.Equal(DBNull.Value, p.Value);
}
[Fact]
public void BuildInvoiceParams_StornoNegativeTotals_KeepsTheirValueAndFitTheProcNumerics()
{
// Shape of a Stornorechnung post: negated totals with JS float noise.
var jo = JObject.Parse(
"{ \"admin\": { \"type\": \"S\", \"customerid\": \"7\" }, " +
" \"new\": { \"title\": \"Stornorechnung\", \"total_gross\": \"-1469.1400000000003\", \"total_net\": \"-1234.5600000000002\" }, " +
" \"sms\": { \"vat\": { \"19,0%\": -234.58000000000004 }, \"tscn\": \"-1234.56\", \"tscvat\": \"-234.58\" } }");
var pl = new FdsInvoiceData(jo).BuildInvoiceParams(change: true, invId: "abc");
double Val(string n) => Convert.ToDouble(Assert.IsType<float>(pl.Single(p => p.ParameterName == n).Value));
Assert.Equal(-1469.14, Val("@InvoiceBalance"), 0.01);
Assert.Equal(-1234.56, Val("@InvoiceBalance_net"), 0.01);
Assert.Equal(-234.58, Val("@InvoiceVAT_net1"), 0.01);
Assert.Equal(-1234.56, Val("@InvoiceService_net"), 0.01);
Assert.Equal(-234.58, Val("@InvoiceService_VAT"), 0.01);
// numeric(10,3) / numeric(10,2) in fds__setInvoice / fds__createInvoice_Details.
Assert.All(new[] { "@InvoiceBalance", "@InvoiceBalance_net", "@InvoiceVAT_net1", "@InvoiceService_net", "@InvoiceService_VAT" },
n => Assert.InRange(Math.Abs(Val(n)), 0, 9_999_999.99));
}
}