Skip to content

Instantly share code, notes, and snippets.

@swaters86
Created January 22, 2026 14:52
Show Gist options
  • Select an option

  • Save swaters86/5bdbaec8802f03320e57d14dcfeea886 to your computer and use it in GitHub Desktop.

Select an option

Save swaters86/5bdbaec8802f03320e57d14dcfeea886 to your computer and use it in GitHub Desktop.
Rules :
// 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