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
@@ -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