Skip to content

Instantly share code, notes, and snippets.

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

  • Save swaters86/320825d89134a9438bb78fe3f149fb25 to your computer and use it in GitHub Desktop.

Select an option

Save swaters86/320825d89134a9438bb78fe3f149fb25 to your computer and use it in GitHub Desktop.
Rules 2
// 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