Created
January 22, 2026 14:39
-
-
Save swaters86/b69fa18d2d2ba96712698a7196b8b0fc to your computer and use it in GitHub Desktop.
Rules
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Program.cs | |
| // Single-file .NET console demo: | |
| // - Input is an "envelope" containing JSONL lines (transactions from any source) | |
| // - No DTOs: each line is parsed into Dictionary<string, object?> | |
| // - RulesEngine runs rules against the dictionary using safe helper functions (no missing-property crashes) | |
| // - Classification + post-classification field modifications are driven by the rules JSON (Properties.Patches) | |
| // (set/copy/rename/remove/mul/add/setIfNull) | |
| // | |
| // Setup: | |
| // dotnet new console -n TxnRulesAgnosticDemo | |
| // cd TxnRulesAgnosticDemo | |
| // dotnet add package RulesEngine | |
| // replace Program.cs with this file | |
| // dotnet run | |
| using System; | |
| using System.Collections.Generic; | |
| using System.Globalization; | |
| using System.Linq; | |
| using System.Text.Json; | |
| using System.Text.Json.Serialization; | |
| using System.Threading.Tasks; | |
| using RulesEngine; | |
| using RulesEngine.Models; | |
| public static class Program | |
| { | |
| public static async Task Main() | |
| { | |
| // 1) Hard-coded rules JSON (this is what you'd externalize to a file/db later). | |
| // - Expressions call Fns.* for safe access across heterogeneous payloads. | |
| // - Each rule has Properties.Category and Properties.Patches for controlled modifications. | |
| // | |
| // Notes on expressions: | |
| // - Use Fns.Has(t,"field") to check existence | |
| // - Use Fns.GetDecimal/GetString/GetBool to safely read/convert | |
| // - Use Fns.EqString / Fns.Contains to match strings safely | |
| // | |
| // Patches (Properties.Patches) supported ops: | |
| // - set: { op:"set", path:"field", value: <any> } | |
| // - setIfNull: { op:"setIfNull", path:"field", value: <any> } | |
| // - copy: { op:"copy", from:"fieldA", path:"fieldB" } | |
| // - rename: { op:"rename", from:"old", path:"new" } | |
| // - remove: { op:"remove", path:"field" } | |
| // - mul: { op:"mul", path:"amount", factor: 0.01 } | |
| // - add: { op:"add", path:"amount", value: 5 } | |
| var workflowsJson = """ | |
| [ | |
| { | |
| "WorkflowName": "TxnClassify", | |
| "Rules": [ | |
| { | |
| "RuleName": "BillingTransaction_ByXmlG", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "Fns.Has(t,\"xml_g\") && Fns.GetDecimal(t,\"xml_g\") > 10 && !Fns.EqString(t,\"kind\",\"RETURN\")", | |
| "Priority": 10, | |
| "Properties": { | |
| "Category": "BILLING_TRANSACTION", | |
| "Patches": [ | |
| { "op": "set", "path": "classification", "value": "BILLING_TRANSACTION" }, | |
| { "op": "copy", "from": "xml_g", "path": "amount_raw" }, | |
| { "op": "set", "path": "currency", "value": "USD" }, | |
| { "op": "mul", "path": "amount_raw", "factor": 0.01 }, | |
| { "op": "rename", "from": "day", "path": "service_date" } | |
| ] | |
| } | |
| }, | |
| { | |
| "RuleName": "Return_ByKindOrTransId", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "Fns.EqString(t,\"kind\",\"RETURN\") || Fns.Contains(t,\"transid\",\"-R\")", | |
| "Priority": 20, | |
| "Properties": { | |
| "Category": "RETURN", | |
| "Patches": [ | |
| { "op": "set", "path": "classification", "value": "RETURN" }, | |
| { "op": "copy", "from": "xml_g", "path": "amount_raw" }, | |
| { "op": "mul", "path": "amount_raw", "factor": -0.01 } | |
| ] | |
| } | |
| }, | |
| { | |
| "RuleName": "Adjustment_ByTypeOrFlags", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "Fns.EqString(t,\"kind\",\"ADJ\") || Fns.EqInt(t,\"type\",2) || Fns.GetBool(t,\"isAdjustment\")==true", | |
| "Priority": 30, | |
| "Properties": { | |
| "Category": "ADJUSTMENT", | |
| "Patches": [ | |
| { "op": "set", "path": "classification", "value": "ADJUSTMENT" }, | |
| { "op": "setIfNull", "path": "reason", "value": "Unspecified adjustment" }, | |
| { "op": "copy", "from": "xml_g", "path": "amount_raw" } | |
| ] | |
| } | |
| }, | |
| { | |
| "RuleName": "Fallback_Unclassified", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "true", | |
| "Priority": 999, | |
| "Properties": { | |
| "Category": "UNCLASSIFIED", | |
| "Patches": [ | |
| { "op": "set", "path": "classification", "value": "UNCLASSIFIED" } | |
| ] | |
| } | |
| } | |
| ] | |
| } | |
| ] | |
| """; | |
| var workflows = JsonSerializer.Deserialize<Workflow[]>(workflowsJson, JsonOptions()) | |
| ?? throw new InvalidOperationException("Failed to deserialize workflows JSON."); | |
| // Allow calling helper functions in expressions. | |
| var settings = new ReSettings | |
| { | |
| CustomTypes = new[] { typeof(Fns) } | |
| }; | |
| var engine = new RulesEngine.RulesEngine(workflows, settings); | |
| // 2) Simulate an incoming envelope with JSONL lines (source-agnostic). | |
| // In practice, you'd receive the envelope via HTTP and read lines from the payload. | |
| var envelopeJson = """ | |
| { | |
| "source": "EHR_ANY", | |
| "batchId": "batch-001", | |
| "payloadJsonl": "{ | |
| \\"transid\\":\\"12345-1-138847\\",\\"invoiceid\\":138847,\\"day\\":\\"2019-01-02\\",\\"xml_g\\":\\"11000\\",\\"kind\\":\\"CHARGE\\" | |
| }\\n{ | |
| \\"transid\\":\\"12345-2-138847-R\\",\\"invoiceid\\":138847,\\"day\\":\\"2019-01-02\\",\\"xml_g\\":\\"2500\\" | |
| }\\n{ | |
| \\"transid\\":\\"12345-3-138847\\",\\"invoiceid\\":138847,\\"day\\":\\"2019-01-02\\",\\"xml_g\\":\\"500\\",\\"kind\\":\\"ADJ\\",\\"isAdjustment\\":true | |
| }" | |
| } | |
| """; | |
| var envelope = JsonDocument.Parse(envelopeJson).RootElement; | |
| var source = envelope.TryGetProperty("source", out var sEl) ? sEl.GetString() : null; | |
| var batchId = envelope.TryGetProperty("batchId", out var bEl) ? bEl.GetString() : null; | |
| var payloadJsonl = envelope.GetProperty("payloadJsonl").GetString() ?? ""; | |
| // 3) Parse JSONL lines -> dictionaries. | |
| var lines = payloadJsonl | |
| .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | |
| var outputRecords = new List<Dictionary<string, object?>>(); | |
| foreach (var line in lines) | |
| { | |
| var txn = JsonToDictionary(line); | |
| // Add envelope metadata (optional but common). | |
| txn["source"] = source; | |
| txn["batchId"] = batchId; | |
| // Run rules for this transaction. | |
| var results = await engine.ExecuteAllRulesAsync( | |
| "TxnClassify", | |
| new RuleParameter("t", txn) | |
| ); | |
| // Choose the "best" rule: lowest Priority number among successes (i.e., highest precedence). | |
| var matched = results | |
| .Where(r => r.IsSuccess) | |
| .OrderBy(r => r.Rule?.Priority ?? int.MaxValue) | |
| .FirstOrDefault(); | |
| if (matched is null || matched.Rule is null) | |
| { | |
| // Should not happen because fallback rule returns true, but keep defensive. | |
| txn["classification"] = "UNCLASSIFIED"; | |
| txn["_matchedRule"] = null; | |
| outputRecords.Add(txn); | |
| continue; | |
| } | |
| // Create a new record for downstream storage (clone + modifications). | |
| var newRecord = new Dictionary<string, object?>(txn, StringComparer.OrdinalIgnoreCase) | |
| { | |
| ["_matchedRule"] = matched.Rule.RuleName | |
| }; | |
| // Apply patch-driven modifications from rule JSON. | |
| ApplyPatchesFromRuleProperties(matched.Rule.Properties, newRecord); | |
| outputRecords.Add(newRecord); | |
| } | |
| // 4) Print results (what you'd post to your API / write to transfer tables). | |
| Console.WriteLine("=== Output Records ==="); | |
| foreach (var rec in outputRecords) | |
| { | |
| Console.WriteLine(JsonSerializer.Serialize(rec, JsonOptions())); | |
| Console.WriteLine("----"); | |
| } | |
| } | |
| private static JsonSerializerOptions JsonOptions() | |
| => new() | |
| { | |
| WriteIndented = true, | |
| PropertyNameCaseInsensitive = true | |
| }; | |
| // Parse a JSON object string into a Dictionary<string, object?> (recursive). | |
| private static Dictionary<string, object?> JsonToDictionary(string json) | |
| { | |
| using var doc = JsonDocument.Parse(json); | |
| if (doc.RootElement.ValueKind != JsonValueKind.Object) | |
| throw new InvalidOperationException("Each JSONL line must be a JSON object."); | |
| return (Dictionary<string, object?>)FromJsonElement(doc.RootElement)!; | |
| } | |
| private static object? FromJsonElement(JsonElement el) | |
| { | |
| switch (el.ValueKind) | |
| { | |
| case JsonValueKind.Object: | |
| { | |
| var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); | |
| foreach (var p in el.EnumerateObject()) | |
| dict[p.Name] = FromJsonElement(p.Value); | |
| return dict; | |
| } | |
| case JsonValueKind.Array: | |
| { | |
| var list = new List<object?>(); | |
| foreach (var i in el.EnumerateArray()) | |
| list.Add(FromJsonElement(i)); | |
| return list; | |
| } | |
| case JsonValueKind.String: | |
| return el.GetString(); | |
| case JsonValueKind.Number: | |
| if (el.TryGetInt64(out var l)) return l; | |
| if (el.TryGetDecimal(out var d)) return d; | |
| return el.ToString(); | |
| case JsonValueKind.True: | |
| return true; | |
| case JsonValueKind.False: | |
| return false; | |
| case JsonValueKind.Null: | |
| case JsonValueKind.Undefined: | |
| return null; | |
| default: | |
| return el.ToString(); | |
| } | |
| } | |
| // Applies patches stored under rule.Properties["Patches"]. | |
| // Because RulesEngine deserialization often yields JsonElement for nested objects, we handle JsonElement and CLR types. | |
| private static void ApplyPatchesFromRuleProperties(IDictionary<string, object>? properties, Dictionary<string, object?> record) | |
| { | |
| if (properties is null) return; | |
| // Also apply a simple "Category" property as a standard field if present. | |
| if (properties.TryGetValue("Category", out var categoryObj)) | |
| { | |
| var category = UnwrapToString(categoryObj); | |
| if (!string.IsNullOrWhiteSpace(category)) | |
| record["category"] = category; | |
| } | |
| if (!properties.TryGetValue("Patches", out var patchesObj) || patchesObj is null) | |
| return; | |
| // Patches can come through as JsonElement or as List<object>. | |
| if (patchesObj is JsonElement je && je.ValueKind == JsonValueKind.Array) | |
| { | |
| foreach (var patchEl in je.EnumerateArray()) | |
| ApplySinglePatch(patchEl, record); | |
| return; | |
| } | |
| if (patchesObj is IEnumerable<object> list) | |
| { | |
| foreach (var item in list) | |
| { | |
| if (item is JsonElement jel) | |
| ApplySinglePatch(jel, record); | |
| else if (item is IDictionary<string, object?> patchDict) | |
| ApplySinglePatch(patchDict, record); | |
| } | |
| } | |
| } | |
| private static void ApplySinglePatch(JsonElement patchEl, Dictionary<string, object?> record) | |
| { | |
| if (patchEl.ValueKind != JsonValueKind.Object) return; | |
| string op = patchEl.TryGetProperty("op", out var opEl) ? (opEl.GetString() ?? "") : ""; | |
| string path = patchEl.TryGetProperty("path", out var pathEl) ? (pathEl.GetString() ?? "") : ""; | |
| string from = patchEl.TryGetProperty("from", out var fromEl) ? (fromEl.GetString() ?? "") : ""; | |
| op = op.Trim(); | |
| path = path.Trim(); | |
| from = from.Trim(); | |
| switch (op) | |
| { | |
| case "set": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| var value = patchEl.TryGetProperty("value", out var vEl) ? FromJsonElement(vEl) : null; | |
| record[path] = value; | |
| break; | |
| } | |
| case "setIfNull": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| if (!record.TryGetValue(path, out var existing) || existing is null || (existing is string s && string.IsNullOrWhiteSpace(s))) | |
| { | |
| var value = patchEl.TryGetProperty("value", out var vEl) ? FromJsonElement(vEl) : null; | |
| record[path] = value; | |
| } | |
| break; | |
| } | |
| case "copy": | |
| { | |
| if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(path)) return; | |
| record.TryGetValue(from, out var val); | |
| record[path] = val; | |
| break; | |
| } | |
| case "rename": | |
| { | |
| if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(path)) return; | |
| if (record.TryGetValue(from, out var val)) | |
| { | |
| record.Remove(from); | |
| record[path] = val; | |
| } | |
| break; | |
| } | |
| case "remove": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| record.Remove(path); | |
| break; | |
| } | |
| case "mul": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| if (!record.TryGetValue(path, out var val) || val is null) return; | |
| var factor = patchEl.TryGetProperty("factor", out var fEl) ? ToDecimal(FromJsonElement(fEl)) : null; | |
| var number = ToDecimal(val); | |
| if (factor is null || number is null) return; | |
| record[path] = number.Value * factor.Value; | |
| break; | |
| } | |
| case "add": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| if (!record.TryGetValue(path, out var val) || val is null) return; | |
| var addend = patchEl.TryGetProperty("value", out var aEl) ? ToDecimal(FromJsonElement(aEl)) : null; | |
| var number = ToDecimal(val); | |
| if (addend is null || number is null) return; | |
| record[path] = number.Value + addend.Value; | |
| break; | |
| } | |
| } | |
| } | |
| private static void ApplySinglePatch(IDictionary<string, object?> patch, Dictionary<string, object?> record) | |
| { | |
| patch.TryGetValue("op", out var opObj); | |
| patch.TryGetValue("path", out var pathObj); | |
| patch.TryGetValue("from", out var fromObj); | |
| var op = (opObj?.ToString() ?? "").Trim(); | |
| var path = (pathObj?.ToString() ?? "").Trim(); | |
| var from = (fromObj?.ToString() ?? "").Trim(); | |
| switch (op) | |
| { | |
| case "set": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| patch.TryGetValue("value", out var value); | |
| record[path] = value; | |
| break; | |
| } | |
| case "setIfNull": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| if (!record.TryGetValue(path, out var existing) || existing is null || (existing is string s && string.IsNullOrWhiteSpace(s))) | |
| { | |
| patch.TryGetValue("value", out var value); | |
| record[path] = value; | |
| } | |
| break; | |
| } | |
| case "copy": | |
| { | |
| if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(path)) return; | |
| record.TryGetValue(from, out var val); | |
| record[path] = val; | |
| break; | |
| } | |
| case "rename": | |
| { | |
| if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(path)) return; | |
| if (record.TryGetValue(from, out var val)) | |
| { | |
| record.Remove(from); | |
| record[path] = val; | |
| } | |
| break; | |
| } | |
| case "remove": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| record.Remove(path); | |
| break; | |
| } | |
| case "mul": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| if (!record.TryGetValue(path, out var val) || val is null) return; | |
| patch.TryGetValue("factor", out var factorObj); | |
| var factor = ToDecimal(factorObj); | |
| var number = ToDecimal(val); | |
| if (factor is null || number is null) return; | |
| record[path] = number.Value * factor.Value; | |
| break; | |
| } | |
| case "add": | |
| { | |
| if (string.IsNullOrWhiteSpace(path)) return; | |
| if (!record.TryGetValue(path, out var val) || val is null) return; | |
| patch.TryGetValue("value", out var addObj); | |
| var addend = ToDecimal(addObj); | |
| var number = ToDecimal(val); | |
| if (addend is null || number is null) return; | |
| record[path] = number.Value + addend.Value; | |
| break; | |
| } | |
| } | |
| } | |
| private static string? UnwrapToString(object obj) | |
| { | |
| if (obj is null) return null; | |
| if (obj is JsonElement je) | |
| { | |
| return je.ValueKind switch | |
| { | |
| JsonValueKind.String => je.GetString(), | |
| JsonValueKind.Number => je.ToString(), | |
| JsonValueKind.True => "true", | |
| JsonValueKind.False => "false", | |
| _ => je.ToString() | |
| }; | |
| } | |
| return obj.ToString(); | |
| } | |
| private static decimal? ToDecimal(object? obj) | |
| { | |
| if (obj is null) return null; | |
| if (obj is decimal d) return d; | |
| if (obj is long l) return l; | |
| if (obj is int i) return i; | |
| if (obj is double db) return (decimal)db; | |
| if (obj is float f) return (decimal)f; | |
| if (obj is JsonElement je) | |
| { | |
| if (je.ValueKind == JsonValueKind.Number && je.TryGetDecimal(out var jd)) return jd; | |
| if (je.ValueKind == JsonValueKind.String) | |
| { | |
| var s = je.GetString(); | |
| if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) return parsed; | |
| } | |
| return null; | |
| } | |
| if (obj is string s2) | |
| { | |
| if (decimal.TryParse(s2, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) return parsed; | |
| return null; | |
| } | |
| return null; | |
| } | |
| } | |
| // Helper functions intended for rule expressions. | |
| // IMPORTANT: Expressions run against IDictionary<string, object?> (source-agnostic) and must be safe on missing fields. | |
| public static class Fns | |
| { | |
| public static bool Has(IDictionary<string, object?> t, string key) | |
| => t is not null && t.ContainsKey(key); | |
| public static string? GetString(IDictionary<string, object?> t, string key) | |
| { | |
| if (t is null || !t.TryGetValue(key, out var v) || v is null) return null; | |
| if (v is JsonElement je) | |
| { | |
| if (je.ValueKind == JsonValueKind.String) return je.GetString(); | |
| return je.ToString(); | |
| } | |
| return v.ToString(); | |
| } | |
| public static bool? GetBool(IDictionary<string, object?> t, string key) | |
| { | |
| if (t is null || !t.TryGetValue(key, out var v) || v is null) return null; | |
| if (v is bool b) return b; | |
| if (v is JsonElement je) | |
| { | |
| if (je.ValueKind == JsonValueKind.True) return true; | |
| if (je.ValueKind == JsonValueKind.False) return false; | |
| if (je.ValueKind == JsonValueKind.String && bool.TryParse(je.GetString(), out var pb)) return pb; | |
| } | |
| if (v is string s && bool.TryParse(s, out var b2)) return b2; | |
| return null; | |
| } | |
| public static decimal GetDecimal(IDictionary<string, object?> t, string key) | |
| { | |
| // Returns 0 if missing/unparseable; use Has(...) to guard when needed. | |
| if (t is null || !t.TryGetValue(key, out var v) || v is null) return 0m; | |
| if (v is decimal d) return d; | |
| if (v is long l) return l; | |
| if (v is int i) return i; | |
| if (v is double db) return (decimal)db; | |
| if (v is float f) return (decimal)f; | |
| if (v is JsonElement je) | |
| { | |
| if (je.ValueKind == JsonValueKind.Number && je.TryGetDecimal(out var jd)) return jd; | |
| if (je.ValueKind == JsonValueKind.String) | |
| { | |
| var s = je.GetString(); | |
| if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) return parsed; | |
| return 0m; | |
| } | |
| return 0m; | |
| } | |
| if (v is string s2) | |
| { | |
| if (decimal.TryParse(s2, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) return parsed; | |
| return 0m; | |
| } | |
| return 0m; | |
| } | |
| public static int GetInt(IDictionary<string, object?> t, string key) | |
| { | |
| if (t is null || !t.TryGetValue(key, out var v) || v is null) return 0; | |
| if (v is int i) return i; | |
| if (v is long l) return (int)l; | |
| if (v is JsonElement je) | |
| { | |
| if (je.ValueKind == JsonValueKind.Number && je.TryGetInt32(out var ji)) return ji; | |
| if (je.ValueKind == JsonValueKind.String && int.TryParse(je.GetString(), out var pi)) return pi; | |
| } | |
| if (v is string s && int.TryParse(s, out var pi2)) return pi2; | |
| return 0; | |
| } | |
| public static bool EqString(IDictionary<string, object?> t, string key, string expected) | |
| { | |
| var s = GetString(t, key); | |
| if (s is null) return false; | |
| return string.Equals(s, expected, StringComparison.OrdinalIgnoreCase); | |
| } | |
| public static bool Contains(IDictionary<string, object?> t, string key, string fragment) | |
| { | |
| var s = GetString(t, key); | |
| if (s is null) return false; | |
| return s.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0; | |
| } | |
| public static bool EqInt(IDictionary<string, object?> t, string key, int expected) | |
| => GetInt(t, key) == expected; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment