Skip to content

Commit 9526e88

Browse files
author
Jani Giannoudis
committed
Fixed JsonElement null handling in Function.ChangeValueType without unwrapping Nullable types
Fixed deep copy tags and attributes in `CaseValue` copy constructor Changed `Date.Tomorrow` and `Date.Yesterday` from static readonly to computed properties Fixed off-by-one in `HasOverlapping` skipping first element in `DatePeriod` and `HourPeriod` extensions Fixed period creation for open-ended date ranges in `PeriodValue` constructor Fixed period-matching in `CasePayrollValue` modulo operator to prevent `IndexOutOfRangeException` Updated `IPayrunRuntime` for async payrun job support Payrun function: added preview job Updated readme
1 parent 4b5be3f commit 9526e88

24 files changed

Lines changed: 547 additions & 461 deletions

Client.Scripting.Tests/PayrollEngine.Client.Scripting.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
</PropertyGroup>
99

1010
<ItemGroup>
11-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
1212
<PackageReference Include="xunit.v3" Version="3.2.2" />
1313
</ItemGroup>
1414

Client.Scripting/ActionReflector.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public static (Assembly Assembly, List<ActionInfo> Actions) LoadFrom(string asse
4040
/// <returns>List of action infos.</returns>
4141
public static List<ActionInfo> LoadFrom(Assembly assembly)
4242
{
43+
ArgumentNullException.ThrowIfNull(assembly);
4344
if (assembly == null)
4445
{
4546
throw new ArgumentNullException(nameof(assembly));

Client.Scripting/Cache.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ protected List<DateTime> GetConsolidatedPeriodStarts(PayrunFunction function, Co
8181
return null;
8282
}
8383

84+
// noRetro queries bypass the cache (cache contains retro entries)
85+
if (query.NoRetro)
86+
{
87+
return null;
88+
}
89+
8490
// mismatching job status
8591
if (query.JobStatus != JobStatus)
8692
{

Client.Scripting/CasePayrollValue.cs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ public CasePayrollValue(string caseFieldName, IEnumerable<PeriodValue> periodVal
143143
public bool HasPeriods => HasValue;
144144

145145
/// <summary>Total payroll value, summary of period values</summary>
146+
/// <remarks>The reference comparison (value != PeriodValues.First()) is intentional:
147+
/// it uses the first element as the seed and only accumulates distinct subsequent entries.
148+
/// This is required for cancellation mode "Previous" where split periods produce
149+
/// multiple entries with equal values that must not be double-counted.</remarks>
146150
public PayrollValue TotalValue()
147151
{
148152
// no values
@@ -205,14 +209,25 @@ public static implicit operator DateTime(CasePayrollValue payrollValue) =>
205209

206210
#region Binary operators
207211

212+
/// <summary>Find the best matching period value for a given left period.
213+
/// First tries exact match; falls back to containment (right period contains left period).
214+
/// Required when TimeType differs, e.g. CalendarPeriod (trimmed) vs. Period (open-ended, End=MaxValue).</summary>
215+
private static PeriodValue FindMatchingPeriodValue(ReadOnlyCollection<PeriodValue> periodValues, DatePeriod leftPeriod)
216+
{
217+
var match = periodValues.FirstOrDefault(x => Equals(x.Period, leftPeriod));
218+
if (match != null) return match;
219+
return periodValues.FirstOrDefault(x =>
220+
x.Period.Start <= leftPeriod.Start && x.Period.End >= leftPeriod.End);
221+
}
222+
208223
/// <summary>Addition of two case values</summary>
209224
public static PayrollValue operator +(CasePayrollValue left, CasePayrollValue right)
210225
{
211226
var result = Empty;
212227
foreach (var leftValue in left.PeriodValues)
213228
{
214229
// find matching period
215-
var rightValue = right.PeriodValues.FirstOrDefault(x => Equals(x.Period, leftValue.Period));
230+
var rightValue = FindMatchingPeriodValue(right.PeriodValues, leftValue.Period);
216231
if (rightValue == null)
217232
{
218233
continue;
@@ -254,7 +269,7 @@ public static implicit operator DateTime(CasePayrollValue payrollValue) =>
254269
// find matching period
255270
foreach (var leftValue in left.PeriodValues)
256271
{
257-
var rightValue = right.PeriodValues.FirstOrDefault(x => Equals(x.Period, leftValue.Period));
272+
var rightValue = FindMatchingPeriodValue(right.PeriodValues, leftValue.Period);
258273
if (rightValue == null)
259274
{
260275
continue;
@@ -296,7 +311,7 @@ public static implicit operator DateTime(CasePayrollValue payrollValue) =>
296311
// find matching period
297312
foreach (var leftValue in left.PeriodValues)
298313
{
299-
var rightValue = right.PeriodValues.FirstOrDefault(x => Equals(x.Period, leftValue.Period));
314+
var rightValue = FindMatchingPeriodValue(right.PeriodValues, leftValue.Period);
300315
if (rightValue == null)
301316
{
302317
continue;
@@ -338,7 +353,7 @@ public static implicit operator DateTime(CasePayrollValue payrollValue) =>
338353
// find matching period
339354
foreach (var leftValue in left.PeriodValues)
340355
{
341-
var rightValue = right.PeriodValues.FirstOrDefault(x => Equals(x.Period, leftValue.Period));
356+
var rightValue = FindMatchingPeriodValue(right.PeriodValues, leftValue.Period);
342357
if (rightValue == null)
343358
{
344359
continue;
@@ -374,12 +389,19 @@ public static implicit operator DateTime(CasePayrollValue payrollValue) =>
374389
}
375390

376391
/// <summary>Remainder of two case values</summary>
392+
/// <remarks>Fixed: uses period-matching like other binary operators
393+
/// to prevent IndexOutOfRangeException when collections differ in size.</remarks>
377394
public static PayrollValue operator %(CasePayrollValue left, CasePayrollValue right)
378395
{
379396
var result = Empty;
380-
for (var i = 0; i < left.PeriodValues.Count; i++)
397+
foreach (var leftValue in left.PeriodValues)
381398
{
382-
var periodResult = left.PeriodValues[i] % right.PeriodValues[i];
399+
var rightValue = FindMatchingPeriodValue(right.PeriodValues, leftValue.Period);
400+
if (rightValue == null)
401+
{
402+
continue;
403+
}
404+
var periodResult = leftValue % rightValue;
383405
result = AddToResult(result, periodResult);
384406
}
385407
return result;

Client.Scripting/CaseValue.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public CaseValue()
4040

4141
/// <summary>Initializes a new instance from a copy</summary>
4242
/// <param name="copySource">The copy source</param>
43+
/// <remarks>Deep copies Tags and Attributes to prevent shared-reference mutations.</remarks>
4344
public CaseValue(CaseValue copySource)
4445
{
4546
CaseFieldName = copySource.CaseFieldName;
@@ -48,8 +49,9 @@ public CaseValue(CaseValue copySource)
4849
End = copySource.End;
4950
Value = copySource.Value;
5051
CancellationDate = copySource.CancellationDate;
51-
Tags = copySource.Tags;
52-
Attributes = copySource.Attributes;
52+
// deep copy mutable collections
53+
Tags = copySource.Tags != null ? [..copySource.Tags] : null;
54+
Attributes = copySource.Attributes != null ? new Dictionary<string, object>(copySource.Attributes) : null;
5355
}
5456

5557
/// <summary>Initializes a new instance</summary>
@@ -101,9 +103,10 @@ public bool Equals(CaseValue compare)
101103
End == compare.End &&
102104
Value == compare.Value &&
103105
CancellationDate == compare.CancellationDate &&
104-
(Tags?.SequenceEqual(compare.Tags) ?? compare.Tags != null) &&
106+
// null-safe: both null → equal, one null → not equal
107+
(Tags?.SequenceEqual(compare.Tags) ?? compare.Tags == null) &&
105108
// ReSharper disable once UsageOfDefaultStructEquality
106-
(Attributes?.SequenceEqual(compare.Attributes) ?? compare.Attributes != null);
109+
(Attributes?.SequenceEqual(compare.Attributes) ?? compare.Attributes == null);
107110
}
108111

109112
/// <summary>Returns a <see cref="string" /> that represents this instance</summary>

Client.Scripting/Date.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ public static class Date
3636
public static DateTime Today => Now.Date;
3737

3838
/// <summary>Gets a time instant that is set to the next day</summary>
39-
public static readonly DateTime Tomorrow = Today.AddDays(1);
39+
/// <remarks>Computed property (not static readonly) so the value updates with Today.</remarks>
40+
public static DateTime Tomorrow => Today.AddDays(1);
4041

4142
/// <summary>Gets a time instant that is set to the previous day</summary>
42-
public static readonly DateTime Yesterday = Today.AddDays(-1);
43+
/// <remarks>Computed property (not static readonly) so the value updates with Today.</remarks>
44+
public static DateTime Yesterday => Today.AddDays(-1);
4345

4446
/// <summary>Get the year start date in UTC</summary>
4547
public static DateTime YearStart(int year) =>

Client.Scripting/Extensions.cs

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -587,8 +587,8 @@ public DateTime SubtractYears(int years) =>
587587

588588
/// <summary>Format a compact date (removes empty time parts)</summary>
589589
/// <returns>The formatted period start date</returns>
590-
public string ToCompactString() => moment.IsMidnight() ?
591-
moment.ToShortDateString() :
590+
public string ToCompactString() => moment.IsMidnight() ?
591+
moment.ToShortDateString() :
592592
$"{moment.ToShortDateString()} {moment.ToShortTimeString()}";
593593

594594
/// <summary>Format a period start date (removes empty time parts), using the current culture</summary>
@@ -597,8 +597,8 @@ public string ToCompactString() => moment.IsMidnight() ?
597597

598598
/// <summary>Format a period end date (removed empty time parts, and round last moment values), using the current culture</summary>
599599
/// <returns>The formatted period end date</returns>
600-
public string ToPeriodEndString() => moment.IsMidnight() || moment.IsLastMomentOfDay() ?
601-
moment.ToShortDateString() :
600+
public string ToPeriodEndString() => moment.IsMidnight() || moment.IsLastMomentOfDay() ?
601+
moment.ToShortDateString() :
602602
$"{moment.ToShortDateString()} {moment.ToShortTimeString()}";
603603

604604
/// <summary>Test if the date is in UTC</summary>
@@ -775,7 +775,7 @@ public bool IsFirstDayOfMonth(Month month) =>
775775
/// <summary>Test if a specific day is the last day of the month</summary>
776776
/// <param name="ignoreLeapYear">Ignore the leap year day</param>
777777
/// <returns>True if the test day is the last day of the month</returns>
778-
public bool IsLastDayOfMonth(bool ignoreLeapYear = false) =>
778+
public bool IsLastDayOfMonth(bool ignoreLeapYear = false) =>
779779
moment.IsLastDayOfMonth(moment.Month(), ignoreLeapYear);
780780

781781
/// <summary>Test if a specific day is the last day of the month</summary>
@@ -1152,8 +1152,15 @@ public object GetValue(string key, object defaultValue = null)
11521152
/// <param name="key">The value key</param>
11531153
/// <param name="defaultValue">The default value</param>
11541154
/// <returns>The dictionary value</returns>
1155-
public T GetValue<T>(string key, T defaultValue = default) =>
1156-
(T)Convert.ChangeType(dictionary.GetValue(key, (object)defaultValue), typeof(T));
1155+
public T GetValue<T>(string key, T defaultValue = default)
1156+
{
1157+
var value = dictionary.GetValue(key);
1158+
if (value == null)
1159+
{
1160+
return defaultValue;
1161+
}
1162+
return (T)value;
1163+
}
11571164
}
11581165

11591166
/// <summary>Get value from a string/JSON-string dictionary</summary>
@@ -1319,7 +1326,7 @@ public List<DatePeriod> Split(List<DateTime> splitMoments)
13191326
splitPeriods.Add(new(last.End.NextTick(), period.End));
13201327
}
13211328
}
1322-
return splitPeriods;
1329+
return splitPeriods.Any() ? splitPeriods : [period];
13231330
}
13241331

13251332
/// <summary>Get period days</summary>
@@ -1460,10 +1467,11 @@ public TimeSpan TotalDuration()
14601467

14611468
/// <summary>Test if any period is overlapping another period</summary>
14621469
/// <returns>True, if the period is overlapping this period</returns>
1470+
/// <remarks>Fixed: outer loop starts at 0 to include the first element in comparisons.</remarks>
14631471
public bool HasOverlapping()
14641472
{
14651473
var periodList = datePeriods.ToList();
1466-
for (var current = 1; current < periodList.Count; current++)
1474+
for (var current = 0; current < periodList.Count; current++)
14671475
{
14681476
for (var remain = current + 1; remain < periodList.Count; remain++)
14691477
{
@@ -1568,7 +1576,7 @@ public bool IsWithin(decimal test) =>
15681576
/// <summary>Test if a specific time period is within the period, including open periods</summary>
15691577
/// <param name="testPeriod">The period to test</param>
15701578
/// <returns>True, if the test period is within this period</returns>
1571-
public bool IsWithin(HourPeriod testPeriod) =>
1579+
public bool IsWithin(HourPeriod testPeriod) =>
15721580
period.IsWithin(testPeriod.Start) && period.IsWithin(testPeriod.End);
15731581

15741582
/// <summary>Test if a specific time moment is within or before the period, including open periods</summary>
@@ -1626,10 +1634,11 @@ public TimeSpan TotalDuration()
16261634

16271635
/// <summary>Test if any period is overlapping another period</summary>
16281636
/// <returns>True, if the period is overlapping this period</returns>
1637+
/// <remarks>Fixed: outer loop starts at 0 to include the first element in comparisons.</remarks>
16291638
public bool HasOverlapping()
16301639
{
16311640
var periodList = timePeriods.ToList();
1632-
for (var current = 1; current < periodList.Count; current++)
1641+
for (var current = 0; current < periodList.Count; current++)
16331642
{
16341643
for (var remain = current + 1; remain < periodList.Count; remain++)
16351644
{
@@ -2203,7 +2212,7 @@ valueType is ValueType.Date or
22032212

22042213
/// <summary>Test if value type is a number</summary>
22052214
/// <returns>True for number value types</returns>
2206-
public bool IsNumber() =>
2215+
public bool IsNumber() =>
22072216
valueType.IsInteger() || valueType.IsDecimal();
22082217

22092218
/// <summary>Test if value type is an integer</summary>
@@ -2376,7 +2385,7 @@ public static CaseValue TupleToCaseValue(this Tuple<string, DateTime, Tuple<Date
23762385
/// <summary>Convert tuple values to case values</summary>
23772386
/// <param name="values">The tuple values</param>
23782387
/// <returns>The case period values</returns>
2379-
public static List<CaseValue> TupleToCaseValues(this List<Tuple<string, DateTime,
2388+
public static List<CaseValue> TupleToCaseValues(this List<Tuple<string, DateTime,
23802389
Tuple<DateTime?, DateTime?>, object, DateTime?, List<string>, Dictionary<string, object>>> values)
23812390
{
23822391
var caseValues = new List<CaseValue>();
@@ -2386,7 +2395,7 @@ public static List<CaseValue> TupleToCaseValues(this List<Tuple<string, DateTime
23862395
{
23872396
if (value != null)
23882397
{
2389-
caseValues.Add(new(value.Item1, value.Item2, value.Item3.Item1,
2398+
caseValues.Add(new(value.Item1, value.Item2, value.Item3.Item1,
23902399
value.Item3.Item2, new(value.Item4), value.Item5, value.Item6, value.Item7));
23912400
}
23922401
}
@@ -2401,19 +2410,23 @@ public static CasePayrollValueDictionary TupleToCaseValuesDictionary(this Dictio
24012410
List<Tuple<DateTime, DateTime?, DateTime?, object>>> values)
24022411
{
24032412
var caseValues = new Dictionary<string, CasePayrollValue>();
2404-
foreach (var value in values)
2413+
if (values != null)
24052414
{
2406-
var periodValues = value.Value.Select(x => new PeriodValue(x.Item2, x.Item3, x.Item4));
2407-
caseValues.Add(value.Key, new(value.Key, periodValues));
2415+
foreach (var value in values)
2416+
{
2417+
var periodValues = value.Value.Select(x => new PeriodValue(x.Item2, x.Item3, x.Item4));
2418+
caseValues.Add(value.Key, new(value.Key, periodValues));
2419+
}
24082420
}
24092421
return new(caseValues);
24102422
}
24112423

24122424
/// <summary>Convert tuple values to a collector result</summary>
24132425
/// <param name="values">The tuple values</param>
24142426
/// <returns>The collector results</returns>
2415-
public static List<CollectorResult> TupleToCollectorResults(this List<Tuple<string,
2427+
public static List<CollectorResult> TupleToCollectorResults(this List<Tuple<string,
24162428
Tuple<DateTime, DateTime>, decimal, List<string>, Dictionary<string, object>>> values) =>
2429+
values == null ? new() :
24172430
[
24182431
..values.Select(x => new CollectorResult
24192432
{
@@ -2431,6 +2444,7 @@ public static List<CollectorResult> TupleToCollectorResults(this List<Tuple<stri
24312444
/// <returns>The collector custom results</returns>
24322445
public static List<CollectorCustomResult> TupleToCollectorCustomResults(
24332446
this List<Tuple<string, string, Tuple<DateTime, DateTime>, decimal, List<string>, Dictionary<string, object>>> values) =>
2447+
values == null ? new() :
24342448
[
24352449
..values.Select(x => new CollectorCustomResult
24362450
{
@@ -2449,6 +2463,7 @@ public static List<CollectorCustomResult> TupleToCollectorCustomResults(
24492463
/// <returns>The wage type results</returns>
24502464
public static List<WageTypeResult> TupleToWageTypeResults
24512465
(this List<Tuple<decimal, string, Tuple<DateTime, DateTime>, decimal, List<string>, Dictionary<string, object>>> values) =>
2466+
values == null ? new() :
24522467
[
24532468
..values.Select(x => new WageTypeResult
24542469
{
@@ -2467,6 +2482,7 @@ public static List<WageTypeResult> TupleToWageTypeResults
24672482
/// <returns>The wage type custom results</returns>
24682483
public static List<WageTypeCustomResult> TupleToWageTypeCustomResults(
24692484
this List<Tuple<decimal, string, string, Tuple<DateTime, DateTime>, decimal, List<string>, Dictionary<string, object>>> values) =>
2485+
values == null ? new() :
24702486
[
24712487
..values.Select(x => new WageTypeCustomResult
24722488
{
@@ -2486,6 +2502,7 @@ public static List<WageTypeCustomResult> TupleToWageTypeCustomResults(
24862502
/// <returns>The lookup brackets</returns>
24872503
public static List<LookupRangeBracket> TupleToLookupRangeBracketList(
24882504
List<Tuple<string, string, decimal, decimal, decimal?>> brackets) =>
2505+
brackets == null ? new() :
24892506
[
24902507
..brackets.Select(x => new LookupRangeBracket
24912508
{

0 commit comments

Comments
 (0)