From 86fdd7771ddf7a1b277ffdc77c6113afa5df55cb Mon Sep 17 00:00:00 2001 From: "aleksei.ushakov" Date: Tue, 15 Sep 2026 14:19:35 +0300 Subject: [PATCH 1/2] Swiss ql bill update for 2.3 version --- .../SwissQRBill/ChecksumValidator.cs | 52 ++++ .../SwissQRBill/DataModels/Address.cs | 76 ++++-- .../DataModels/AlternativeProcedures.cs | 14 +- .../DataModels/CreditorAccountNumber.cs | 6 +- .../PaymentReferenceAccountNumber.cs | 18 +- .../SwissQRBill/DataModels/QRBillDataItem.cs | 42 +-- .../DataModels/SwicoBillingInformation.cs | 255 ++++++++++++++++++ .../SwissQRBill/Enums.cs | 1 + .../SwissQRBill/FieldValidation.cs | 40 +++ .../SwissQRBill/TestData.cs | 74 ++--- .../SwissQRBill/Validation.cs | 4 + 11 files changed, 497 insertions(+), 85 deletions(-) create mode 100644 CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs create mode 100644 CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs create mode 100644 CustomControls.SwissQRBill/SwissQRBill/FieldValidation.cs diff --git a/CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs b/CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs new file mode 100644 index 0000000..4e03efe --- /dev/null +++ b/CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs @@ -0,0 +1,52 @@ +using System.Linq; + +namespace CustomControls.SwissQRBill { + + public static class ChecksumValidator { + static readonly int[][] Mod10RecursiveTable = new int[][] { + new[] {0,9,4,6,8,2,7,1,3,5}, + new[] {9,4,6,8,2,7,1,3,5,0}, + new[] {4,6,8,2,7,1,3,5,0,9}, + new[] {6,8,2,7,1,3,5,0,9,4}, + new[] {8,2,7,1,3,5,0,9,4,6}, + new[] {2,7,1,3,5,0,9,4,6,8}, + new[] {7,1,3,5,0,9,4,6,8,2}, + new[] {1,3,5,0,9,4,6,8,2,7}, + new[] {3,5,0,9,4,6,8,2,7,1}, + new[] {5,0,9,4,6,8,2,7,1,3}, + }; + + public static bool IsValidMod10Recursive(string reference) { + if(reference == null || reference.Length != 27 || !reference.All(char.IsDigit)) + return false; + + int carry = 0; + for(int i = 0; i < 26; i++) + carry = Mod10RecursiveTable[carry][reference[i] - '0']; + + int checkDigit = (10 - carry) % 10; + return checkDigit == reference[26] - '0'; + } + + public static bool IsValidIso7064Mod97(string value) { + if(value == null || value.Length < 5) + return false; + + string rearranged = value.Substring(4) + value.Substring(0, 4); + int remainder = 0; + foreach(char c in rearranged) { + int numericValue; + if(char.IsDigit(c)) { + numericValue = c - '0'; + remainder = (remainder * 10 + numericValue) % 97; + } else if(char.IsLetter(c)) { + numericValue = char.ToUpperInvariant(c) - 'A' + 10; + remainder = (remainder * 100 + numericValue) % 97; + } else { + return false; + } + } + return remainder == 1; + } + } +} diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs index 0e45dbb..9d8ea9f 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.ComponentModel; +using System.Text.RegularExpressions; using DevExpress.Utils.Serializing; namespace CustomControls.SwissQRBill { @@ -10,15 +11,21 @@ static string ToChar(AddressType addressType) { switch(addressType) { case AddressType.Structured: return "S"; +#pragma warning disable CS0618 case AddressType.Combined: +#pragma warning restore CS0618 return "K"; } return null; } const string Switzerland = "CH"; - const int DefaultPostalCode = 0; - const int DefaultBuildingNumber = 0; + string countryCode = string.Empty; + string buildingNumber = string.Empty; + string name = string.Empty; + string postalCode = string.Empty; + string street = string.Empty; + string town = string.Empty; [DisplayName("Address Type")] [Description("Address Type desription")] @@ -31,13 +38,23 @@ static string ToChar(AddressType addressType) { [Description("Country Code description")] [DefaultValue("")] [XtraSerializableProperty] - public string CountryCode { get; set; } = string.Empty; + public string CountryCode { + get => countryCode; + set { + if(!string.IsNullOrEmpty(value) && !Regex.IsMatch(value, "^[A-Za-z]{2}$")) + throw ValidationError.FieldException("Country", "Must be a two-letter country code (ISO 3166-1)."); + countryCode = value ?? string.Empty; + } + } [DisplayName("Building Number")] [Description("Building Number description")] - [DefaultValue(DefaultBuildingNumber)] + [DefaultValue("")] [XtraSerializableProperty] - public int BuildingNumber { get; set; } = DefaultBuildingNumber; + public string BuildingNumber { + get => buildingNumber; + set { FieldValidation.Validate(value, 16, "BuildingNumber"); buildingNumber = value ?? string.Empty; } + } [DisplayName("Address Line 1")] [Description("Address Line 1 description")] @@ -55,36 +72,48 @@ static string ToChar(AddressType addressType) { [Description("Name description")] [DefaultValue("")] [XtraSerializableProperty] - public string Name { get; set; } = string.Empty; + public string Name { + get => name; + set { FieldValidation.Validate(value, 70, "Name"); name = value ?? string.Empty; } + } [DisplayName("Postal Code")] [Description("Postal Code description")] - [DefaultValue(DefaultPostalCode)] + [DefaultValue("")] [XtraSerializableProperty] - public int PostalCode { get; set; } = DefaultPostalCode; + public string PostalCode { + get => postalCode; + set { FieldValidation.Validate(value, 16, "PostalCode"); postalCode = value ?? string.Empty; } + } [DisplayName("Street")] [Description("Street description")] [DefaultValue("")] [XtraSerializableProperty] - public string Street { get; set; } = string.Empty; + public string Street { + get => street; + set { FieldValidation.Validate(value, 70, "Street"); street = value ?? string.Empty; } + } [DisplayName("Town")] [Description("Town description")] [DefaultValue("")] [XtraSerializableProperty] - public string Town { get; set; } = string.Empty; + public string Town { + get => town; + set { FieldValidation.Validate(value, 35, "Town"); town = value ?? string.Empty; } + } public override void ConvertFromQRCodeDataString(string[] rawString) { +#pragma warning disable CS0618 AddressType = rawString[0] == "K" ? AddressType.Combined - : AddressType.Structured; //real value is empty or structured + : AddressType.Structured; +#pragma warning restore CS0618 Name = rawString[1]; if(AddressType == AddressType.Structured) { Street = rawString[2]; - if(int.TryParse(rawString[3], out int result)) - BuildingNumber = result; - if(int.TryParse(rawString[4], out result)) - PostalCode = result; + BuildingNumber = rawString[3]; + PostalCode = rawString[4]; Town = rawString[5]; CountryCode = rawString[6]; } else { @@ -98,10 +127,11 @@ public override string ConvertToPresentationString() { if(IsEmpty()) return string.Empty; if(AddressType == AddressType.Structured) { + string streetLine = string.IsNullOrEmpty(BuildingNumber) ? Street : $"{Street} {BuildingNumber}"; if(CountryCode == Switzerland) { - return string.Join(Environment.NewLine, Name, $"{Street} {BuildingNumber}", $"{PostalCode} {Town}"); + return string.Join(Environment.NewLine, Name, streetLine, $"{PostalCode} {Town}"); } else { - return string.Join(Environment.NewLine, Name, $"{Street} {BuildingNumber}", $"{CountryCode} {PostalCode} {Town}"); + return string.Join(Environment.NewLine, Name, streetLine, $"{CountryCode} {PostalCode} {Town}"); } } else @@ -114,19 +144,21 @@ public override string ConvertToQRCodeDataString() { if(AddressType == AddressType.Structured) { return string.Join(Environment.NewLine, ToChar(AddressType), Name, Street, BuildingNumber, PostalCode, Town, CountryCode); - } else { - return string.Join(Environment.NewLine, ToChar(AddressType), Name, AddressLine1, AddressLine2, string.Empty, CountryCode); } + throw ValidationError.FieldException("AddressType", + "Combined address type ('K') is no longer part of the Swiss QR Code data structure since Implementation Guidelines v2.3 (valid from 21 November 2025, mandatory since 30 September 2026). Use AddressType.Structured instead."); } bool IsEmpty() { if(!string.IsNullOrEmpty(Name)) return false; +#pragma warning disable CS0618 return AddressType == AddressType.Combined ? string.IsNullOrEmpty(AddressLine1) && string.IsNullOrEmpty(AddressLine2) : string.IsNullOrEmpty(Street) && string.IsNullOrEmpty(Town) - && PostalCode == DefaultPostalCode && BuildingNumber == DefaultBuildingNumber; + && string.IsNullOrEmpty(PostalCode) && string.IsNullOrEmpty(BuildingNumber); +#pragma warning restore CS0618 } public override void Reset() { @@ -135,8 +167,8 @@ public override void Reset() { Street = string.Empty; Town = string.Empty; CountryCode = string.Empty; - PostalCode = DefaultPostalCode; - BuildingNumber = DefaultBuildingNumber; + PostalCode = string.Empty; + BuildingNumber = string.Empty; } } } diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/AlternativeProcedures.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/AlternativeProcedures.cs index c2eac92..50f8d2e 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/AlternativeProcedures.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/AlternativeProcedures.cs @@ -67,10 +67,16 @@ public override string ConvertToPresentationString() { public override string ConvertToQRCodeDataString() { List result = new List(); - if(!IsFirstPairEmpty) - result.Add($"{Name1}{SplitterString}{Instruction1}"); - if(!IsSecondPairEmpty) - result.Add($"{Name2}{SplitterString}{Instruction2}"); + if(!IsFirstPairEmpty) { + string procedure1 = $"{Name1}{SplitterString}{Instruction1}"; + FieldValidation.Validate(procedure1, 100, "AlternativeProcedures.Name1/Instruction1"); + result.Add(procedure1); + } + if(!IsSecondPairEmpty) { + string procedure2 = $"{Name2}{SplitterString}{Instruction2}"; + FieldValidation.Validate(procedure2, 100, "AlternativeProcedures.Name2/Instruction2"); + result.Add(procedure2); + } return string.Join(Environment.NewLine, result); } diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/CreditorAccountNumber.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/CreditorAccountNumber.cs index 3b1494e..69bd0a7 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/CreditorAccountNumber.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/CreditorAccountNumber.cs @@ -1,6 +1,6 @@ namespace CustomControls.SwissQRBill { public class CreditorAccountNumber : AccountNumber { - const string DefaultNumber = "CHXXXXXXXXXXXXXXXXXXX"; + const string DefaultNumber = "CH5800791123000889012"; public CreditorAccountNumber(string ibanStr) : base(ibanStr) { } public CreditorAccountNumber() : this(DefaultNumber) { } @@ -8,10 +8,10 @@ static bool IsIban(string str) { return str.Length == 21 && (str.StartsWith("CH") || str.StartsWith("LI")); } static bool IsQR_IBAN(string str) { - return IsIban(str) && str[4] == '3'; + return IsIban(str) && int.TryParse(str.Substring(4, 5), out int iid) && iid >= 30000 && iid <= 31999; } protected override bool IsValid(string str) { - return IsIban(str) || IsQR_IBAN(str); + return (IsIban(str) || IsQR_IBAN(str)) && ChecksumValidator.IsValidIso7064Mod97(str); } protected override void IdentifyFormat() { if(IsIban(Number)) diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs index 5827e18..d933674 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs @@ -1,17 +1,27 @@ +using System.Linq; +using System.Text.RegularExpressions; + namespace CustomControls.SwissQRBill { public class PaymentReferenceAccountNumber : AccountNumber { - const string DefaultNumber = "RFXXXXXXXXXXXXXX"; + const string DefaultNumber = ""; public PaymentReferenceAccountNumber() : this(DefaultNumber) { } public PaymentReferenceAccountNumber(string ibanStr) : base(ibanStr) { } static bool IsCreditorReference(string str) { - return str.Length == 16 && str.StartsWith("RF"); + return str.Length >= 5 && str.Length <= 25 + && Regex.IsMatch(str, "^RF[0-9]{2}[A-Za-z0-9]+$"); } static bool IsQR_Reference(string str) { - return str.Length == 27; + return str.Length == 27 && str.All(char.IsDigit); } protected override bool IsValid(string str) { - return string.IsNullOrEmpty(str) || IsQR_Reference(str) || IsCreditorReference(str); + if(string.IsNullOrEmpty(str)) + return true; + if(IsQR_Reference(str)) + return ChecksumValidator.IsValidMod10Recursive(str); + if(IsCreditorReference(str)) + return ChecksumValidator.IsValidIso7064Mod97(str); + return false; } protected override void IdentifyFormat() { if(IsCreditorReference(Number)) diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs index 34ad795..db62bdb 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -18,10 +19,18 @@ public class QRBillDataItem { public Address DebtorInformation { get; set; } = new Address(); - public string AdditionalInformation { get; set; } = string.Empty; + string additionalInformation = string.Empty; + public string AdditionalInformation { + get => additionalInformation; + set { FieldValidation.Validate(value, 140, "AdditionalInformation (Unstructured message)"); additionalInformation = value ?? string.Empty; } + } + + string structuredInformation = string.Empty; + public string StructuredInformation { + get => structuredInformation; + set { FieldValidation.Validate(value, 140, "StructuredInformation (Billing information)"); structuredInformation = value ?? string.Empty; } + } - public string StructuredInformation { get; set; } = string.Empty; - public AlternativeProcedures AlternativeProcedures { get; } = new AlternativeProcedures(); public string QRCodeData { @@ -44,13 +53,11 @@ void ResetQRCodeData() { public ReferenceType ReferenceType { get { - if(CreditorAccountNumber == null || Reference == null || Reference.NumberFormat == AccountNumberFormat.None) + if(CreditorAccountNumber == null || Reference == null) return ReferenceType.NON; - if(CreditorAccountNumber.NumberFormat == AccountNumberFormat.QR_IBAN && Reference.NumberFormat == AccountNumberFormat.QRReference) - return ReferenceType.QRR; - if(CreditorAccountNumber.NumberFormat == AccountNumberFormat.IBAN || Reference.NumberFormat == AccountNumberFormat.CreditorReference) - return ReferenceType.SCOR; - return ReferenceType.NON; + if(CreditorAccountNumber.NumberFormat == AccountNumberFormat.QR_IBAN) + return Reference.NumberFormat == AccountNumberFormat.QRReference ? ReferenceType.QRR : ReferenceType.NON; + return Reference.NumberFormat == AccountNumberFormat.CreditorReference ? ReferenceType.SCOR : ReferenceType.NON; } } @@ -68,7 +75,7 @@ void ConvertFromQRCodeDataString(string value) { CreditorInformation.ConvertFromQRCodeDataString(rawData.Skip(4).Take(7).ToArray()); if(rawData[18].Length > 0) { double amount; - if(double.TryParse(rawData[18], out amount)) { + if(double.TryParse(rawData[18], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out amount)) { Amount = amount; } else { ValidationError.ThrowValidationException(ValidationCode.InvalidAmount); @@ -97,11 +104,12 @@ string ConvertToQRCodeDataString() { stringBuilder.AppendLine(CreditorAccountNumber.ConvertToQRCodeDataString()); stringBuilder.AppendLine(CreditorInformation.ConvertToQRCodeDataString()); stringBuilder.AppendLine(new Address().ConvertToQRCodeDataString()); - stringBuilder.AppendLine(Amount == null ? string.Empty : Amount.Value.ToString("#0.00")); + stringBuilder.AppendLine(Amount == null ? string.Empty : Amount.Value.ToString("#0.00", CultureInfo.InvariantCulture)); stringBuilder.AppendLine(Currency.ToString()); stringBuilder.AppendLine(DebtorInformation.ConvertToQRCodeDataString()); stringBuilder.AppendLine(ReferenceType.ToString()); stringBuilder.AppendLine(Reference == null ? string.Empty : Reference.ConvertToQRCodeDataString()); + FieldValidation.ValidateCombinedLength(AdditionalInformation, StructuredInformation, 140, "AdditionalInformation + StructuredInformation (combined)"); stringBuilder.AppendLine(AdditionalInformation); stringBuilder.Append("EPD"); AddIfNotEmpty(stringBuilder, StructuredInformation); @@ -116,12 +124,16 @@ void AddIfNotEmpty(StringBuilder stringBuilder, string dataField) { } } + const double MinAmount = 0.01d; + const double MaxAmount = 999999999.99d; double? CoerceAmount(double? amount) { - if(amount == null) { + if(amount == null) return amount; - } else { - return amount.Value <= 0.01d ? 0.01d : amount; - } + if(amount.Value <= MinAmount) + return MinAmount; + if(amount.Value > MaxAmount) + return MaxAmount; + return amount; } } } diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs new file mode 100644 index 0000000..e532cc1 --- /dev/null +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace CustomControls.SwissQRBill { + public class SwicoVatRateAmount { + public decimal Rate { get; set; } + public decimal Amount { get; set; } + public SwicoVatRateAmount() { } + public SwicoVatRateAmount(decimal rate, decimal amount) { + Rate = rate; + Amount = amount; + } + } + + public class SwicoPaymentCondition { + public decimal DiscountRate { get; set; } + public int Days { get; set; } + public SwicoPaymentCondition() { } + public SwicoPaymentCondition(decimal discountRate, int days) { + DiscountRate = discountRate; + Days = days; + } + } + + public class SwicoBillingInformation { + const string Prefix = "//S1"; + + public string InvoiceNumber { get; set; } + public DateTime? InvoiceDate { get; set; } + public string CustomerReference { get; set; } + + public string VatNumber { get; set; } + + public DateTime? VatDate { get; set; } + public DateTime? VatDateRangeEnd { get; set; } + + public decimal? VatRate { get; set; } + public List VatDetails { get; } = new List(); + public List ImportVatDetails { get; } = new List(); + public List PaymentConditions { get; } = new List(); + + public bool IsEmpty { + get { + return string.IsNullOrEmpty(InvoiceNumber) && InvoiceDate == null && string.IsNullOrEmpty(CustomerReference) + && string.IsNullOrEmpty(VatNumber) && VatDate == null && VatRate == null + && VatDetails.Count == 0 && ImportVatDetails.Count == 0 && PaymentConditions.Count == 0; + } + } + + public string ConvertToQRCodeDataString() { + if(IsEmpty) + return string.Empty; + if(VatRate != null && VatDetails.Count > 0) + throw ValidationError.FieldException("SwicoBillingInformation.VatRate/VatDetails", + "VAT details must contain either a single overall rate (VatRate) or an itemized list (VatDetails), not both."); + if(!string.IsNullOrEmpty(VatNumber) && !Regex.IsMatch(VatNumber, "^[0-9]{9}$")) + throw ValidationError.FieldException("SwicoBillingInformation.VatNumber", + "Must be the 9-digit UID number, without the CHE prefix, separators or MWST/TVA/IVA/VAT suffix."); + + var tags = new List>(); + AddTag(tags, 10, Escape(InvoiceNumber)); + AddTag(tags, 11, FormatDate(InvoiceDate)); + AddTag(tags, 20, Escape(CustomerReference)); + AddTag(tags, 30, VatNumber); + AddTag(tags, 31, FormatDateOrRange(VatDate, VatDateRangeEnd)); + AddTag(tags, 32, VatDetails.Count > 0 ? FormatList(VatDetails) : FormatRate(VatRate)); + AddTag(tags, 33, FormatList(ImportVatDetails)); + AddTag(tags, 40, FormatConditions(PaymentConditions)); + + var builder = new StringBuilder(Prefix); + foreach(var tag in tags) + builder.Append($"/{tag.Item1}/{tag.Item2}"); + return builder.ToString(); + } + + static void AddTag(List> tags, int tag, string value) { + if(!string.IsNullOrEmpty(value)) + tags.Add(Tuple.Create(tag, value)); + } + + static string Escape(string value) { + return string.IsNullOrEmpty(value) ? value : value.Replace("\\", "\\\\").Replace("/", "\\/"); + } + static string Unescape(string value) { + return string.IsNullOrEmpty(value) ? value : value.Replace("\\/", "/").Replace("\\\\", "\\"); + } + + static string FormatDate(DateTime? date) { + return date == null ? null : date.Value.ToString("yyMMdd", CultureInfo.InvariantCulture); + } + static string FormatDateOrRange(DateTime? start, DateTime? end) { + if(start == null) + return null; + return end == null ? FormatDate(start) : FormatDate(start) + FormatDate(end); + } + static string FormatRate(decimal? rate) { + return rate == null ? null : rate.Value.ToString("0.###", CultureInfo.InvariantCulture); + } + static string FormatList(List items) { + if(items == null || items.Count == 0) + return null; + return string.Join(";", items.Select(i => + $"{i.Rate.ToString("0.###", CultureInfo.InvariantCulture)}:{i.Amount.ToString("0.##", CultureInfo.InvariantCulture)}")); + } + static string FormatConditions(List items) { + if(items == null || items.Count == 0) + return null; + return string.Join(";", items.Select(i => + $"{i.DiscountRate.ToString("0.###", CultureInfo.InvariantCulture)}:{i.Days.ToString(CultureInfo.InvariantCulture)}")); + } + + public static bool TryParse(string value, out SwicoBillingInformation result) { + result = null; + if(string.IsNullOrEmpty(value) || !value.StartsWith(Prefix, StringComparison.Ordinal)) + return false; + + var tags = SplitTags(value.Substring(Prefix.Length)); + if(tags == null) + return false; + + var info = new SwicoBillingInformation(); + int? lastTag = null; + foreach(var entry in tags) { + int tag = entry.Item1; + string raw = entry.Item2; + if(lastTag != null && tag <= lastTag) + return false; + lastTag = tag; + + DateTime parsedDate, rangeStart, rangeEnd; + decimal rate; + switch(tag) { + case 10: + info.InvoiceNumber = Unescape(raw); + break; + case 11: + if(!TryParseDate(raw, out parsedDate)) + return false; + info.InvoiceDate = parsedDate; + break; + case 20: + info.CustomerReference = Unescape(raw); + break; + case 30: + info.VatNumber = raw; + break; + case 31: + if(raw.Length == 6) { + if(!TryParseDate(raw, out parsedDate)) + return false; + info.VatDate = parsedDate; + } else if(raw.Length == 12) { + if(!TryParseDate(raw.Substring(0, 6), out rangeStart) || !TryParseDate(raw.Substring(6, 6), out rangeEnd)) + return false; + info.VatDate = rangeStart; + info.VatDateRangeEnd = rangeEnd; + } else { + return false; + } + break; + case 32: + if(raw.Contains(":")) { + if(!TryParseList(raw, info.VatDetails)) + return false; + } else if(decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out rate)) { + info.VatRate = rate; + } else { + return false; + } + break; + case 33: + if(!TryParseList(raw, info.ImportVatDetails)) + return false; + break; + case 40: + if(!TryParseConditions(raw, info.PaymentConditions)) + return false; + break; + default: + return false; + } + } + result = info; + return true; + } + + public static SwicoBillingInformation Parse(string value) { + SwicoBillingInformation result; + if(!TryParse(value, out result)) + throw ValidationError.FieldException("SwicoBillingInformation", "Value is not a valid Swico 'Billing information' syntax (Annex D)."); + return result; + } + + static bool TryParseDate(string raw, out DateTime date) { + return DateTime.TryParseExact(raw, "yyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); + } + static bool TryParseList(string raw, List target) { + foreach(string part in raw.Split(';')) { + string[] pieces = part.Split(':'); + decimal rate, amount; + if(pieces.Length != 2 + || !decimal.TryParse(pieces[0], NumberStyles.Number, CultureInfo.InvariantCulture, out rate) + || !decimal.TryParse(pieces[1], NumberStyles.Number, CultureInfo.InvariantCulture, out amount)) + return false; + target.Add(new SwicoVatRateAmount(rate, amount)); + } + return true; + } + static bool TryParseConditions(string raw, List target) { + foreach(string part in raw.Split(';')) { + string[] pieces = part.Split(':'); + decimal rate; + int days; + if(pieces.Length != 2 + || !decimal.TryParse(pieces[0], NumberStyles.Number, CultureInfo.InvariantCulture, out rate) + || !int.TryParse(pieces[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out days)) + return false; + target.Add(new SwicoPaymentCondition(rate, days)); + } + return true; + } + + static List> SplitTags(string remainder) { + var result = new List>(); + int i = 0; + while(i < remainder.Length) { + if(remainder[i] != '/') + return null; + int tagEnd = remainder.IndexOf('/', i + 1); + int tag; + if(tagEnd < 0 || !int.TryParse(remainder.Substring(i + 1, tagEnd - i - 1), out tag)) + return null; + + int valueStart = tagEnd + 1; + int valueEnd = valueStart; + while(valueEnd < remainder.Length) { + if(remainder[valueEnd] == '\\' && valueEnd + 1 < remainder.Length) { + valueEnd += 2; + continue; + } + if(remainder[valueEnd] == '/') + break; + valueEnd++; + } + result.Add(Tuple.Create(tag, remainder.Substring(valueStart, valueEnd - valueStart))); + i = valueEnd; + } + return result; + } + } +} diff --git a/CustomControls.SwissQRBill/SwissQRBill/Enums.cs b/CustomControls.SwissQRBill/SwissQRBill/Enums.cs index ffb90e1..bd34226 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/Enums.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/Enums.cs @@ -13,6 +13,7 @@ public enum Currency { } public enum AddressType { Structured, + [Obsolete("Combined address type ('K') was removed in SIX Implementation Guidelines for the QR-bill v2.3 (valid from 21 November 2025). Use AddressType.Structured instead.")] Combined, } public enum Language { diff --git a/CustomControls.SwissQRBill/SwissQRBill/FieldValidation.cs b/CustomControls.SwissQRBill/SwissQRBill/FieldValidation.cs new file mode 100644 index 0000000..a27644f --- /dev/null +++ b/CustomControls.SwissQRBill/SwissQRBill/FieldValidation.cs @@ -0,0 +1,40 @@ +namespace CustomControls.SwissQRBill { + + public static class FieldValidation { + public static void Validate(string value, int maxLength, string fieldName) { + if(string.IsNullOrEmpty(value)) + return; + if(value.Length > maxLength) + throw ValidationError.FieldException(fieldName, $"Maximum {maxLength} characters permitted."); + foreach(char c in value) { + if(!IsAllowedCharacter(c)) + throw ValidationError.FieldException(fieldName, $"Character '{c}' is not part of the character set allowed in the Swiss QR Code (chapter 4.1.1)."); + } + } + + public static void ValidateCombinedLength(string first, string second, int maxLength, string fieldName) { + int length = (first?.Length ?? 0) + (second?.Length ?? 0); + if(length > maxLength) + throw ValidationError.FieldException(fieldName, $"Maximum {maxLength} characters permitted in total."); + } + + static bool IsAllowedCharacter(char c) { + if(c >= ' ' && c <= '~') + return true; + if(c >= ' ' && c <= 'ÿ') + return true; + if(c >= 'Ā' && c <= 'ſ') + return true; + switch(c) { + case 'Ș': + case 'ș': + case 'Ț': + case 'ț': + case '€': + return true; + default: + return false; + } + } + } +} diff --git a/CustomControls.SwissQRBill/SwissQRBill/TestData.cs b/CustomControls.SwissQRBill/SwissQRBill/TestData.cs index a5c9adb..43a8959 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/TestData.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/TestData.cs @@ -2,14 +2,14 @@ namespace CustomControls.SwissQRBill { public static class TestData { - public const string BillWithoutAdditionalInfo = "SPC\r\n0200\r\n1\r\nCHXXXXXXXXXXXXXXXXXXA\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\nS\r\nSimon Glarner\r\nBachliwis\r\n55\r\n8184\r\nBachenbulach\r\nCH\r\nSCOR\r\nRFXXXXXXXXXXXXXX\r\n\r\nEPD\r\n"; - public const string BillWithAdditionalInfo = "SPC\r\n0200\r\n1\r\nCHXXXXXXXXXXXXXXXXXXA\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\nS\r\nSimon Glarner\r\nBachliwis\r\n55\r\n8184\r\nBachenbulach\r\nCH\r\nSCOR\r\nRFXXXXXXXXXXXXXX\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n"; - public const string BillWithEmptySenderInfo = "SPC\r\n0200\r\n1\r\nCHXXXXXXXXXXXXXXXXXXA\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRFXXXXXXXXXXXXXX\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information"; - public const string BillWithFullBillInfo = "SPC\r\n0200\r\n1\r\nCHXXXXXXXXXXXXXXXXXXA\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\nS\r\nSimon Glarner\r\nBachliwis\r\n55\r\n8184\r\nBachenbulach\r\nCH\r\nSCOR\r\nRFXXXXXXXXXXXXXX\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information"; - public const string BillWithFullSectionFieldsEmpty = "SPC\r\n0200\r\n1\r\nCHXXXXXXXXXXXXXXXXXXA\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRFXXXXXXXXXXXXXX\r\n\r\nEPD\r\n"; - public const string BillWithTwoProcedures = "SPC\r\n0200\r\n1\r\nCHXXXXXXXXXXXXXXXXXXA\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRFXXXXXXXXXXXXXX\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information\r\nName AV1: UV;UltraPay005;12345\r\nName AV2: XY;XYService;54321"; - public const string BillWithOneProcedure = "SPC\r\n0200\r\n1\r\nCHXXXXXXXXXXXXXXXXXXA\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRFXXXXXXXXXXXXXX\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information\r\nName AV1: XY;XYService;54321"; - public const string BillFromRealData = "SPC\r\n0200\r\n1\r\nCH4444995599000899901\r\nS\r\nHenri Schmid Service Switzerland AG\r\nMuseumstrasse\r\n258\r\n2501\r\nBiel\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n8690.00\r\nCHF\r\nS\r\nPia-Maria Rutschmann-Schnyder\r\nGrosse Marktgasse\r\n28\r\n9400\r\nRorschach\r\nCH\r\nSCOR\r\n210000000003139471430009017\r\nAuftrag vom 25.10.2019##S1/01/20170309/11/10201409/20/14000/22/36958/30/CH10646546/40/1020/41/3010\r\nEPD\r\n"; + public const string BillWithoutAdditionalInfo = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\nS\r\nSimon Glarner\r\nBachliwis\r\n55\r\n8184\r\nBachenbulach\r\nCH\r\nSCOR\r\nRF18539007547034\r\n\r\nEPD\r\n"; + public const string BillWithAdditionalInfo = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\nS\r\nSimon Glarner\r\nBachliwis\r\n55\r\n8184\r\nBachenbulach\r\nCH\r\nSCOR\r\nRF18539007547034\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n"; + public const string BillWithEmptySenderInfo = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRF18539007547034\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information"; + public const string BillWithFullBillInfo = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\nS\r\nSimon Glarner\r\nBachliwis\r\n55\r\n8184\r\nBachenbulach\r\nCH\r\nSCOR\r\nRF18539007547034\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information"; + public const string BillWithFullSectionFieldsEmpty = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRF18539007547034\r\n\r\nEPD\r\n"; + public const string BillWithTwoProcedures = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRF18539007547034\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information\r\nName AV1: UV;UltraPay005;12345\r\nName AV2: XY;XYService;54321"; + public const string BillWithOneProcedure = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRF18539007547034\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information\r\nName AV1: XY;XYService;54321"; + public const string BillFromRealData = "SPC\r\n0200\r\n1\r\nCH4944995599000899901\r\nS\r\nHenri Schmid Service Switzerland AG\r\nMuseumstrasse\r\n258\r\n2501\r\nBiel\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n8690.00\r\nCHF\r\nS\r\nPia-Maria Rutschmann-Schnyder\r\nGrosse Marktgasse\r\n28\r\n9400\r\nRorschach\r\nCH\r\nSCOR\r\n210000000003139471430009017\r\nAuftrag vom 25.10.2019##S1/01/20170309/11/10201409/20/14000/22/36958/30/CH10646546/40/1020/41/3010\r\nEPD\r\n"; public static List CreateData() { return new List() { @@ -25,78 +25,78 @@ public static List CreateData() { public static QRBillDataItem Create_Test_BillWithoutAdditionalInfo() { return new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Amount = 287.30d, Currency = Currency.EUR, - Reference = new PaymentReferenceAccountNumber("RFXXXXXXXXXXXXXX"), - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, - DebtorInformation = new Address() { CountryCode = "CH", Name = "Simon Glarner", BuildingNumber = 55, PostalCode = 8184, Street = "Bachliwis", Town = "Bachenbulach" }, + Reference = new PaymentReferenceAccountNumber("RF18539007547034"), + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, + DebtorInformation = new Address() { CountryCode = "CH", Name = "Simon Glarner", BuildingNumber = "55", PostalCode = "8184", Street = "Bachliwis", Town = "Bachenbulach" }, }; } public static QRBillDataItem Create_Test_Item_BillWithAdditionalInfo() { return new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Amount = 287.30d, Currency = Currency.EUR, - Reference = new PaymentReferenceAccountNumber("RFXXXXXXXXXXXXXX"), + Reference = new PaymentReferenceAccountNumber("RF18539007547034"), AdditionalInformation = "Rechnungsnr. 10978 / Auftragsrnr. 3987", - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, - DebtorInformation = new Address() { CountryCode = "CH", Name = "Simon Glarner", BuildingNumber = 55, PostalCode = 8184, Street = "Bachliwis", Town = "Bachenbulach" }, + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, + DebtorInformation = new Address() { CountryCode = "CH", Name = "Simon Glarner", BuildingNumber = "55", PostalCode = "8184", Street = "Bachliwis", Town = "Bachenbulach" }, }; } public static QRBillDataItem Create_Test_Item_WithBillInfo() { return new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Amount = 287.30d, Currency = Currency.EUR, - Reference = new PaymentReferenceAccountNumber("RFXXXXXXXXXXXXXX"), + Reference = new PaymentReferenceAccountNumber("RF18539007547034"), AdditionalInformation = "Rechnungsnr. 10978 / Auftragsrnr. 3987", StructuredInformation = "//bill information", - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, - DebtorInformation = new Address() { CountryCode = "CH", Name = "Simon Glarner", BuildingNumber = 55, PostalCode = 8184, Street = "Bachliwis", Town = "Bachenbulach" }, + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, + DebtorInformation = new Address() { CountryCode = "CH", Name = "Simon Glarner", BuildingNumber = "55", PostalCode = "8184", Street = "Bachliwis", Town = "Bachenbulach" }, }; } public static QRBillDataItem Create_Test_Item_WithEmptySenderInfo() { return new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Amount = 287.30d, Currency = Currency.EUR, - Reference = new PaymentReferenceAccountNumber("RFXXXXXXXXXXXXXX"), + Reference = new PaymentReferenceAccountNumber("RF18539007547034"), AdditionalInformation = "Rechnungsnr. 10978 / Auftragsrnr. 3987", StructuredInformation = "//bill information", - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, }; } public static QRBillDataItem Create_Test_Item_WithoutAdditionalInfo() { return new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Currency = Currency.EUR, - Reference = new PaymentReferenceAccountNumber("RFXXXXXXXXXXXXXX"), - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, + Reference = new PaymentReferenceAccountNumber("RF18539007547034"), + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, }; } public static QRBillDataItem Create_Test_EmptyItem() { return new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Currency = Currency.EUR, - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, }; } public static QRBillDataItem Create_Test_Item_WithTwoAlternativeProcedures() { var result = new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Amount = 287.30d, Currency = Currency.EUR, - Reference = new PaymentReferenceAccountNumber("RFXXXXXXXXXXXXXX"), + Reference = new PaymentReferenceAccountNumber("RF18539007547034"), AdditionalInformation = "Rechnungsnr. 10978 / Auftragsrnr. 3987", StructuredInformation = "//bill information", - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, }; result.AlternativeProcedures.Assign(new AlternativeProcedures() { Name1 = "Name AV1", Instruction1 = "UV;UltraPay005;12345", Name2 = "Name AV2", Instruction2 = "XY;XYService;54321" }); return result; @@ -104,13 +104,13 @@ public static QRBillDataItem Create_Test_Item_WithTwoAlternativeProcedures() { public static QRBillDataItem Create_Test_Item_WithOneAlternativeProcedures() { var result = new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CHXXXXXXXXXXXXXXXXXXA"), + CreditorAccountNumber = new CreditorAccountNumber("CH5800791123000889012"), Amount = 287.30d, Currency = Currency.EUR, - Reference = new PaymentReferenceAccountNumber("RFXXXXXXXXXXXXXX"), + Reference = new PaymentReferenceAccountNumber("RF18539007547034"), AdditionalInformation = "Rechnungsnr. 10978 / Auftragsrnr. 3987", StructuredInformation = "//bill information", - CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = 138, PostalCode = 8045, Street = "Uetlibergstrasse", Town = "Zuric" }, + CreditorInformation = new Address() { CountryCode = "CH", Name = "Schreinerei Habegger & Sohne", BuildingNumber = "138", PostalCode = "8045", Street = "Uetlibergstrasse", Town = "Zuric" }, }; result.AlternativeProcedures.Assign(new AlternativeProcedures() { Name1 = "Name AV1", Instruction1 = "XY;XYService;54321" }); return result; @@ -118,13 +118,13 @@ public static QRBillDataItem Create_Test_Item_WithOneAlternativeProcedures() { public static QRBillDataItem Create_Test_Item_FromRealData() { var result = new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CH4444995599000899901"), + CreditorAccountNumber = new CreditorAccountNumber("CH4944995599000899901"), Amount = 8690, Currency = Currency.CHF, Reference = new PaymentReferenceAccountNumber("210000000003139471430009017"), AdditionalInformation = "Auftrag vom 25.10.2019##S1/01/20170309/11/10201409/20/14000/22/36958/30/CH10646546/40/1020/41/3010", - DebtorInformation = new Address() { CountryCode = "CH", Name = "Pia-Maria Rutschmann-Schnyder", BuildingNumber = 28, PostalCode = 9400, Street = "Grosse Marktgasse", Town = "Rorschach" }, - CreditorInformation = new Address() { CountryCode = "CH", Name = "Henri Schmid Service Switzerland AG", BuildingNumber = 258, PostalCode = 2501, Street = "Museumstrasse", Town = "Biel" }, + DebtorInformation = new Address() { CountryCode = "CH", Name = "Pia-Maria Rutschmann-Schnyder", BuildingNumber = "28", PostalCode = "9400", Street = "Grosse Marktgasse", Town = "Rorschach" }, + CreditorInformation = new Address() { CountryCode = "CH", Name = "Henri Schmid Service Switzerland AG", BuildingNumber = "258", PostalCode = "2501", Street = "Museumstrasse", Town = "Biel" }, }; return result; } diff --git a/CustomControls.SwissQRBill/SwissQRBill/Validation.cs b/CustomControls.SwissQRBill/SwissQRBill/Validation.cs index c5e6ad4..0d1483a 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/Validation.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/Validation.cs @@ -63,5 +63,9 @@ public static class ValidationError { public static void ThrowValidationException(ValidationCode validationCode) { throw new Exception(validationStringConstants[validationCode] + " Section: " + validationCodeSections[validationCode]); } + + public static Exception FieldException(string fieldName, string reason) { + return new Exception(reason + " Field: " + fieldName); + } } } From b3d6abe7e9321d539f7e2f3b4e55c2d4d49ad189 Mon Sep 17 00:00:00 2001 From: "aleksei.ushakov" Date: Fri, 18 Sep 2026 15:25:54 +0300 Subject: [PATCH 2/2] copilot review fixes --- .../SwissQRBill/ChecksumValidator.cs | 4 ++-- .../SwissQRBill/DataModels/Address.cs | 2 +- .../PaymentReferenceAccountNumber.cs | 2 +- .../SwissQRBill/DataModels/QRBillDataItem.cs | 22 ++++++++++++++----- .../DataModels/SwicoBillingInformation.cs | 22 ++++++++++++++----- .../SwissQRBill/TestData.cs | 4 ++-- .../SwissQRBill/Validation.cs | 3 +++ 7 files changed, 43 insertions(+), 16 deletions(-) diff --git a/CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs b/CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs index 4e03efe..3604d57 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/ChecksumValidator.cs @@ -17,7 +17,7 @@ public static class ChecksumValidator { }; public static bool IsValidMod10Recursive(string reference) { - if(reference == null || reference.Length != 27 || !reference.All(char.IsDigit)) + if(reference == null || reference.Length != 27 || !reference.All(c => c >= '0' && c <= '9')) return false; int carry = 0; @@ -36,7 +36,7 @@ public static bool IsValidIso7064Mod97(string value) { int remainder = 0; foreach(char c in rearranged) { int numericValue; - if(char.IsDigit(c)) { + if(c >= '0' && c <= '9') { numericValue = c - '0'; remainder = (remainder * 10 + numericValue) % 97; } else if(char.IsLetter(c)) { diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs index 9d8ea9f..a8f28ea 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/Address.cs @@ -41,7 +41,7 @@ static string ToChar(AddressType addressType) { public string CountryCode { get => countryCode; set { - if(!string.IsNullOrEmpty(value) && !Regex.IsMatch(value, "^[A-Za-z]{2}$")) + if(!string.IsNullOrEmpty(value) && !Regex.IsMatch(value, "^[A-Z]{2}$")) throw ValidationError.FieldException("Country", "Must be a two-letter country code (ISO 3166-1)."); countryCode = value ?? string.Empty; } diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs index d933674..87c0df6 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/PaymentReferenceAccountNumber.cs @@ -12,7 +12,7 @@ static bool IsCreditorReference(string str) { && Regex.IsMatch(str, "^RF[0-9]{2}[A-Za-z0-9]+$"); } static bool IsQR_Reference(string str) { - return str.Length == 27 && str.All(char.IsDigit); + return str.Length == 27 && str.All(c => c >= '0' && c <= '9'); } protected override bool IsValid(string str) { if(string.IsNullOrEmpty(str)) diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs index db62bdb..3385aa9 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/QRBillDataItem.cs @@ -10,7 +10,7 @@ public class QRBillDataItem { private double? amount = null; public Currency Currency { get; set; } = Currency.CHF; - public double? Amount { get { return amount; } set { amount = CoerceAmount(value); } } + public double? Amount { get { return amount; } set { amount = ValidateAmount(value); } } public CreditorAccountNumber CreditorAccountNumber { get; set; } = new CreditorAccountNumber(); public Address CreditorInformation { get; set; } = new Address(); @@ -61,6 +61,16 @@ public ReferenceType ReferenceType { } } + void ValidateReferenceCombination() { + if(CreditorAccountNumber == null || Reference == null) + return; + bool isQRIban = CreditorAccountNumber.NumberFormat == AccountNumberFormat.QR_IBAN; + if(isQRIban && Reference.NumberFormat == AccountNumberFormat.CreditorReference) + ValidationError.ThrowValidationException(ValidationCode.InvalidReferenceType); + if(!isQRIban && Reference.NumberFormat == AccountNumberFormat.QRReference) + ValidationError.ThrowValidationException(ValidationCode.InvalidReferenceType); + } + void ConvertFromQRCodeDataString(string value) { var rawData = Regex.Split(value, Environment.NewLine); if((rawData.Length < 31 || rawData.Length > 34) && !(rawData.Length == 35 && string.IsNullOrEmpty(rawData[34]))) @@ -92,6 +102,7 @@ void ConvertFromQRCodeDataString(string value) { if(rawData[30] != "EPD") ValidationError.ThrowValidationException(ValidationCode.InvalidFieldTrailer); StructuredInformation = rawData.Length > 31 ? rawData[31] : string.Empty; + FieldValidation.ValidateCombinedLength(AdditionalInformation, StructuredInformation, 140, "AdditionalInformation + StructuredInformation (combined)"); if(rawData.Length - 32 > 0) AlternativeProcedures.ConvertFromQRCodeDataString(rawData.Skip(32).Take(2).ToArray()); } @@ -107,6 +118,7 @@ string ConvertToQRCodeDataString() { stringBuilder.AppendLine(Amount == null ? string.Empty : Amount.Value.ToString("#0.00", CultureInfo.InvariantCulture)); stringBuilder.AppendLine(Currency.ToString()); stringBuilder.AppendLine(DebtorInformation.ConvertToQRCodeDataString()); + ValidateReferenceCombination(); stringBuilder.AppendLine(ReferenceType.ToString()); stringBuilder.AppendLine(Reference == null ? string.Empty : Reference.ConvertToQRCodeDataString()); FieldValidation.ValidateCombinedLength(AdditionalInformation, StructuredInformation, 140, "AdditionalInformation + StructuredInformation (combined)"); @@ -126,13 +138,13 @@ void AddIfNotEmpty(StringBuilder stringBuilder, string dataField) { const double MinAmount = 0.01d; const double MaxAmount = 999999999.99d; - double? CoerceAmount(double? amount) { + double? ValidateAmount(double? amount) { if(amount == null) return amount; - if(amount.Value <= MinAmount) - return MinAmount; if(amount.Value > MaxAmount) - return MaxAmount; + throw ValidationError.FieldException("Amount", $"Maximum amount is {MaxAmount.ToString("#0.00", CultureInfo.InvariantCulture)}."); + if(amount.Value < MinAmount) + throw ValidationError.FieldException("Amount", $"Minimum amount is {MinAmount.ToString("#0.00", CultureInfo.InvariantCulture)}."); return amount; } } diff --git a/CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs b/CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs index e532cc1..f7da93c 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/DataModels/SwicoBillingInformation.cs @@ -46,7 +46,7 @@ public class SwicoBillingInformation { public bool IsEmpty { get { return string.IsNullOrEmpty(InvoiceNumber) && InvoiceDate == null && string.IsNullOrEmpty(CustomerReference) - && string.IsNullOrEmpty(VatNumber) && VatDate == null && VatRate == null + && string.IsNullOrEmpty(VatNumber) && VatDate == null && VatDateRangeEnd == null && VatRate == null && VatDetails.Count == 0 && ImportVatDetails.Count == 0 && PaymentConditions.Count == 0; } } @@ -60,6 +60,9 @@ public string ConvertToQRCodeDataString() { if(!string.IsNullOrEmpty(VatNumber) && !Regex.IsMatch(VatNumber, "^[0-9]{9}$")) throw ValidationError.FieldException("SwicoBillingInformation.VatNumber", "Must be the 9-digit UID number, without the CHE prefix, separators or MWST/TVA/IVA/VAT suffix."); + if(VatDate == null && VatDateRangeEnd != null) + throw ValidationError.FieldException("SwicoBillingInformation.VatDateRangeEnd", + "VatDateRangeEnd requires VatDate to be set as the start of the range."); var tags = new List>(); AddTag(tags, 10, Escape(InvoiceNumber)); @@ -146,6 +149,8 @@ public static bool TryParse(string value, out SwicoBillingInformation result) { info.CustomerReference = Unescape(raw); break; case 30: + if(!Regex.IsMatch(raw, "^[0-9]{9}$")) + return false; info.VatNumber = raw; break; case 31: @@ -166,7 +171,7 @@ public static bool TryParse(string value, out SwicoBillingInformation result) { if(raw.Contains(":")) { if(!TryParseList(raw, info.VatDetails)) return false; - } else if(decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out rate)) { + } else if(TryParseDecimal(raw, out rate)) { info.VatRate = rate; } else { return false; @@ -198,13 +203,20 @@ public static SwicoBillingInformation Parse(string value) { static bool TryParseDate(string raw, out DateTime date) { return DateTime.TryParseExact(raw, "yyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); } + static bool TryParseDecimal(string raw, out decimal value) { + if(!Regex.IsMatch(raw, @"^-?[0-9]+(\.[0-9]+)?$")) { + value = default; + return false; + } + return decimal.TryParse(raw, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out value); + } static bool TryParseList(string raw, List target) { foreach(string part in raw.Split(';')) { string[] pieces = part.Split(':'); decimal rate, amount; if(pieces.Length != 2 - || !decimal.TryParse(pieces[0], NumberStyles.Number, CultureInfo.InvariantCulture, out rate) - || !decimal.TryParse(pieces[1], NumberStyles.Number, CultureInfo.InvariantCulture, out amount)) + || !TryParseDecimal(pieces[0], out rate) + || !TryParseDecimal(pieces[1], out amount)) return false; target.Add(new SwicoVatRateAmount(rate, amount)); } @@ -216,7 +228,7 @@ static bool TryParseConditions(string raw, List target) { decimal rate; int days; if(pieces.Length != 2 - || !decimal.TryParse(pieces[0], NumberStyles.Number, CultureInfo.InvariantCulture, out rate) + || !TryParseDecimal(pieces[0], out rate) || !int.TryParse(pieces[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out days)) return false; target.Add(new SwicoPaymentCondition(rate, days)); diff --git a/CustomControls.SwissQRBill/SwissQRBill/TestData.cs b/CustomControls.SwissQRBill/SwissQRBill/TestData.cs index 43a8959..0034371 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/TestData.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/TestData.cs @@ -9,7 +9,7 @@ public static class TestData { public const string BillWithFullSectionFieldsEmpty = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRF18539007547034\r\n\r\nEPD\r\n"; public const string BillWithTwoProcedures = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRF18539007547034\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information\r\nName AV1: UV;UltraPay005;12345\r\nName AV2: XY;XYService;54321"; public const string BillWithOneProcedure = "SPC\r\n0200\r\n1\r\nCH5800791123000889012\r\nS\r\nSchreinerei Habegger & Sohne\r\nUetlibergstrasse\r\n138\r\n8045\r\nZuric\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n287.30\r\nEUR\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nSCOR\r\nRF18539007547034\r\nRechnungsnr. 10978 / Auftragsrnr. 3987\r\nEPD\r\n//bill information\r\nName AV1: XY;XYService;54321"; - public const string BillFromRealData = "SPC\r\n0200\r\n1\r\nCH4944995599000899901\r\nS\r\nHenri Schmid Service Switzerland AG\r\nMuseumstrasse\r\n258\r\n2501\r\nBiel\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n8690.00\r\nCHF\r\nS\r\nPia-Maria Rutschmann-Schnyder\r\nGrosse Marktgasse\r\n28\r\n9400\r\nRorschach\r\nCH\r\nSCOR\r\n210000000003139471430009017\r\nAuftrag vom 25.10.2019##S1/01/20170309/11/10201409/20/14000/22/36958/30/CH10646546/40/1020/41/3010\r\nEPD\r\n"; + public const string BillFromRealData = "SPC\r\n0200\r\n1\r\nCH6531999599000899901\r\nS\r\nHenri Schmid Service Switzerland AG\r\nMuseumstrasse\r\n258\r\n2501\r\nBiel\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n8690.00\r\nCHF\r\nS\r\nPia-Maria Rutschmann-Schnyder\r\nGrosse Marktgasse\r\n28\r\n9400\r\nRorschach\r\nCH\r\nQRR\r\n210000000003139471430009017\r\nAuftrag vom 25.10.2019##S1/01/20170309/11/10201409/20/14000/22/36958/30/CH10646546/40/1020/41/3010\r\nEPD\r\n"; public static List CreateData() { return new List() { @@ -118,7 +118,7 @@ public static QRBillDataItem Create_Test_Item_WithOneAlternativeProcedures() { public static QRBillDataItem Create_Test_Item_FromRealData() { var result = new QRBillDataItem() { - CreditorAccountNumber = new CreditorAccountNumber("CH4944995599000899901"), + CreditorAccountNumber = new CreditorAccountNumber("CH6531999599000899901"), Amount = 8690, Currency = Currency.CHF, Reference = new PaymentReferenceAccountNumber("210000000003139471430009017"), diff --git a/CustomControls.SwissQRBill/SwissQRBill/Validation.cs b/CustomControls.SwissQRBill/SwissQRBill/Validation.cs index 0d1483a..3547098 100644 --- a/CustomControls.SwissQRBill/SwissQRBill/Validation.cs +++ b/CustomControls.SwissQRBill/SwissQRBill/Validation.cs @@ -38,6 +38,7 @@ public enum ValidationCode { InvalidAmount, InvalidFieldTrailer, InvalidAccountNumber, + InvalidReferenceType, } public static class ValidationError { static Dictionary validationCodeSections = new Dictionary() { @@ -48,6 +49,7 @@ public static class ValidationError { { ValidationCode.InvalidAmount, QRCodeSections.Amount }, { ValidationCode.InvalidFieldTrailer, QRCodeSections.Trailer }, { ValidationCode.InvalidAccountNumber, QRCodeSections.IBAN }, + { ValidationCode.InvalidReferenceType, QRCodeSections.ReferenceType }, }; static Dictionary validationStringConstants = new Dictionary() { @@ -58,6 +60,7 @@ public static class ValidationError { { ValidationCode.InvalidAmount, "Valid number required EN culture and (#.##) format" }, { ValidationCode.InvalidFieldTrailer, "Valid data structure contains EPD field trailer" }, { ValidationCode.InvalidAccountNumber, "Account Number must have valid format" }, + { ValidationCode.InvalidReferenceType, "A QR-IBAN requires a QRR reference and a standard IBAN cannot have a QRR reference" }, }; public static void ThrowValidationException(ValidationCode validationCode) {