Created
January 22, 2026 14:52
-
-
Save swaters86/5bdbaec8802f03320e57d14dcfeea886 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 (DTO-free, source-agnostic): | |
| // - Envelope contains JSONL (one JSON object per line) from any source/EHR | |
| // - Each line is parsed into Dictionary<string, object?> (no DTOs) | |
| // - RulesEngine runs classification rules using safe helper functions (Fns.*) so missing fields don’t explode | |
| // - The FIRST matching rule wins, based on rule ORDER in the workflow JSON (no Rule.Priority in this library) | |
| // - After classification, rule-driven “patches” modify the record (set/copy/rename/remove/mul/add/setIfNull) | |
| // - Output is a list of new “classified records” you could insert into transfer tables | |
| // | |
| // 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.Threading.Tasks; | |
| using RulesEngine; | |
| using RulesEngine.Models; | |
| public static class Program | |
| { | |
| public static async Task Main() | |
| { | |
| // NOTE: Classification precedence is determined by the ORDER of rules in this JSON. | |
| // First rule that matches = selected classification. | |
| var workflowsJson = """ | |
| [ | |
| { | |
| "WorkflowName": "TxnClassify", | |
| "Rules": [ | |
| { | |
| "RuleName": "Return_ByKindOrTransId", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "Fns.EqString(t,\"kind\",\"RETURN\") || Fns.Contains(t,\"transid\",\"-R\")", | |
| "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 }, | |
| { "op": "setIfNull", "path": "currency", "value": "USD" } | |
| ] | |
| } | |
| }, | |
| { | |
| "RuleName": "Adjustment_ByTypeOrFlags", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "Fns.EqString(t,\"kind\",\"ADJ\") || Fns.EqInt(t,\"type\",2) || Fns.GetBool(t,\"isAdjustment\")==true", | |
| "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" }, | |
| { "op": "mul", "path": "amount_raw", "factor": 0.01 } | |
| ] | |
| } | |
| }, | |
| { | |
| "RuleName": "BillingTransaction_ByXmlG", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "Fns.Has(t,\"xml_g\") && Fns.GetDecimal(t,\"xml_g\") > 10 && !Fns.EqString(t,\"kind\",\"RETURN\")", | |
| "Properties": { | |
| "Category": "BILLING_TRANSACTION", | |
| "Patches": [ | |
| { "op": "set", "path": "classification", "value": "BILLING_TRANSACTION" }, | |
| { "op": "copy", "from": "xml_g", "path": "amount_raw" }, | |
| { "op": "mul", "path": "amount_raw", "factor": 0.01 }, | |
| { "op": "rename", "from": "day", "path": "service_date" }, | |
| { "op": "setIfNull", "path": "currency", "value": "USD" } | |
| ] | |
| } | |
| }, | |
| { | |
| "RuleName": "Fallback_Unclassified", | |
| "RuleExpressionType": "LambdaExpression", | |
| "Expression": "true", | |
| "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."); | |
| // Build a deterministic “rule order” map so we can pick the first matching rule. | |
| var wf = workflows.Single(w => w.WorkflowName == "TxnClassify"); | |
| var ruleOrder = wf.Rules | |
| .Select((r, idx) => new { r.RuleName, idx }) | |
| .ToDictionary(x => x.RuleName, x => x.idx, StringComparer.OrdinalIgnoreCase); | |
| var settings = new ReSettings | |
| { | |
| // Allow calling Fns.* from expressions. | |
| CustomTypes = new[] { typeof(Fns) }, | |
| // Optional: safer behavior if some rule throws due to unexpected data. | |
| IgnoreException = true, | |
| EnableExceptionAsErrorMessage = true | |
| }; | |
| var engine = new RulesEngine.RulesEngine(workflows, settings); | |
| // Example “envelope” containing JSONL payload (as a string). | |
| // Replace this with your actual HTTP payload structure. | |
| 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() ?? ""; | |
| var lines = payloadJsonl | |
| .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | |
| var outputRecords = new List<Dictionary<string, object?>>(); | |
| foreach (var line in lines) | |
| { | |
| // 1) Parse one JSONL line into a dictionary (source-agnostic record). | |
| var txn = JsonToDictionary(line); | |
| // 2) Add envelope metadata (optional). | |
| txn["source"] = source; | |
| txn["batchId"] = batchId; | |
| txn["_receivedUtc"] = DateTime.UtcNow.ToString("o"); | |
| // 3) Run all rules in the workflow against this transaction. | |
| var results = await engine.ExecuteAllRulesAsync( | |
| "TxnClassify", | |
| new RuleParameter("t", txn) | |
| ); | |
| // 4) Select the FIRST matching rule based on workflow order (no Rule.Priority in this library). | |
| var matched = results | |
| .Where(r => r.IsSuccess && r.Rule != null) | |
| .OrderBy(r => ruleOrder.TryGetValue(r.Rule!.RuleName, out var idx) ? idx : int.MaxValue) | |
| .FirstOrDefault(); | |
| // Defensive fallback (shouldn’t happen because we have a "true" fallback rule). | |
| if (matched?.Rule == null) | |
| { | |
| var rec = new Dictionary<string, object?>(txn, StringComparer.OrdinalIgnoreCase); | |
| rec["classification"] = "UNCLASSIFIED"; | |
| rec["_matchedRule"] = null; | |
| outputRecords.Add(rec); | |
| continue; | |
| } | |
| // 5) Create the output record and apply rule-driven modifications (“patches”). | |
| var newRecord = new Dictionary<string, object?>(txn, StringComparer.OrdinalIgnoreCase) | |
| { | |
| ["_matchedRule"] = matched.Rule.RuleName | |
| }; | |
| ApplyRuleProperties(matched.Rule.Properties, newRecord); | |
| outputRecords.Add(newRecord); | |
| } | |
| 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 | |
| }; | |
| // --------------------------- | |
| // JSON -> Dictionary utilities | |
| // --------------------------- | |
| 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) | |
| { | |
| return el.ValueKind switch | |
| { | |
| JsonValueKind.Object => el.EnumerateObject() | |
| .ToDictionary(p => p.Name, p => FromJsonElement(p.Value), StringComparer.OrdinalIgnoreCase), | |
| JsonValueKind.Array => el.EnumerateArray().Select(FromJsonElement).ToList(), | |
| JsonValueKind.String => el.GetString(), | |
| JsonValueKind.Number => el.TryGetInt64(out var l) ? l | |
| : el.TryGetDecimal(out var d) ? d | |
| : el.ToString(), | |
| JsonValueKind.True => true, | |
| JsonValueKind.False => false, | |
| JsonValueKind.Null => null, | |
| JsonValueKind.Undefined => null, | |
| _ => el.ToString() | |
| }; | |
| } | |
| // --------------------------- | |
| // Rule “Properties” processing | |
| // --------------------------- | |
| private static void ApplyRuleProperties(IDictionary<string, object>? properties, Dictionary<string, object?> record) | |
| { | |
| if (properties is null) return; | |
| // Optional: expose Category as a normalized field | |
| if (properties.TryGetValue("Category", out var catObj)) | |
| { | |
| var cat = UnwrapToString(catObj); | |
| if (!string.IsNullOrWhiteSpace(cat)) | |
| record["category"] = cat; | |
| } | |
| // Apply patches | |
| if (!properties.TryGetValue("Patches", out var patchesObj) || patchesObj is null) | |
| return; | |
| if (patchesObj is JsonElement je && je.ValueKind == JsonValueKind.Array) | |
| { | |
| foreach (var patch in je.EnumerateArray()) | |
| ApplyPatch(patch, record); | |
| return; | |
| } | |
| if (patchesObj is IEnumerable<object> list) | |
| { | |
| foreach (var item in list) | |
| { | |
| if (item is JsonElement jel) | |
| ApplyPatch(jel, record); | |
| else if (item is IDictionary<string, object?> dict) | |
| ApplyPatch(dict, record); | |
| } | |
| } | |
| } | |
| private static void ApplyPatch(JsonElement patch, Dictionary<string, object?> record) | |
| { | |
| if (patch.ValueKind != JsonValueKind.Object) return; | |
| string op = patch.TryGetProperty("op", out var opEl) ? (opEl.GetString() ?? "") : ""; | |
| string path = patch.TryGetProperty("path", out var pathEl) ? (pathEl.GetString() ?? "") : ""; | |
| string from = patch.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; | |
| record[path] = patch.TryGetProperty("value", out var vEl) ? FromJsonElement(vEl) : null; | |
| 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))) | |
| record[path] = patch.TryGetProperty("value", out var vEl) ? FromJsonElement(vEl) : null; | |
| 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 = patch.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 = patch.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 ApplyPatch(IDictionary<string, object?> patch, Dictionary<string, object?> record) | |
| { | |
| var op = (patch.TryGetValue("op", out var o) ? o?.ToString() : null)?.Trim() ?? ""; | |
| var path = (patch.TryGetValue("path", out var p) ? p?.ToString() : null)?.Trim() ?? ""; | |
| var from = (patch.TryGetValue("from", out var f) ? f?.ToString() : null)?.Trim() ?? ""; | |
| switch (op) | |
| { | |
| case "set": | |
| if (!string.IsNullOrWhiteSpace(path)) | |
| record[path] = patch.TryGetValue("value", out var v) ? v : null; | |
| break; | |
| case "setIfNull": | |
| if (!string.IsNullOrWhiteSpace(path) && | |
| (!record.TryGetValue(path, out var existing) || existing is null || (existing is string s && string.IsNullOrWhiteSpace(s)))) | |
| record[path] = patch.TryGetValue("value", out var v2) ? v2 : null; | |
| break; | |
| case "copy": | |
| if (!string.IsNullOrWhiteSpace(from) && !string.IsNullOrWhiteSpace(path)) | |
| { | |
| record.TryGetValue(from, out var val); | |
| record[path] = val; | |
| } | |
| break; | |
| case "rename": | |
| if (!string.IsNullOrWhiteSpace(from) && !string.IsNullOrWhiteSpace(path) && record.TryGetValue(from, out var rv)) | |
| { | |
| record.Remove(from); | |
| record[path] = rv; | |
| } | |
| break; | |
| case "remove": | |
| if (!string.IsNullOrWhiteSpace(path)) record.Remove(path); | |
| break; | |
| case "mul": | |
| if (!string.IsNullOrWhiteSpace(path) && record.TryGetValue(path, out var mv) && mv is not null) | |
| { | |
| var factor = patch.TryGetValue("factor", out var fo) ? ToDecimal(fo) : null; | |
| var number = ToDecimal(mv); | |
| if (factor is not null && number is not null) | |
| record[path] = number.Value * factor.Value; | |
| } | |
| break; | |
| case "add": | |
| if (!string.IsNullOrWhiteSpace(path) && record.TryGetValue(path, out var av) && av is not null) | |
| { | |
| var addend = patch.TryGetValue("value", out var ao) ? ToDecimal(ao) : null; | |
| var number = ToDecimal(av); | |
| if (addend is not null && number is not null) | |
| 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 used by rules expressions. | |
| // Designed for heterogeneous dictionaries where fields might be missing, null, strings, numbers, etc. | |
| 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; guard with Has(...) 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); | |
| return s is not null && string.Equals(s, expected, StringComparison.OrdinalIgnoreCase); | |
| } | |
| public static bool Contains(IDictionary<string, object?> t, string key, string fragment) | |
| { | |
| var s = GetString(t, key); | |
| return s is not null && 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