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
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user