diff --git a/.fernignore b/.fernignore
index de7d09c..8c55e60 100644
--- a/.fernignore
+++ b/.fernignore
@@ -12,7 +12,9 @@ sample-app/
src/main/java/com/schematic/api/BaseSchematic.java
src/main/java/com/schematic/api/EventBuffer.java
src/main/java/com/schematic/api/HttpEventSender.java
+src/main/java/com/schematic/api/IdentifyOptions.java
src/main/java/com/schematic/api/Schematic.java
+src/main/java/com/schematic/api/TrackOptions.java
src/main/java/com/schematic/api/cache/CacheProvider.java
src/main/java/com/schematic/api/cache/CachedItem.java
src/main/java/com/schematic/api/cache/LocalCache.java
@@ -20,9 +22,11 @@ src/main/java/com/schematic/api/cache/RedisCacheConfig.java
src/main/java/com/schematic/api/cache/RedisCacheProvider.java
src/main/java/com/schematic/api/core/NoOpHttpClient.java
src/main/java/com/schematic/api/logger/ConsoleLogger.java
+src/main/java/com/schematic/api/logger/LogLevel.java
src/main/java/com/schematic/api/datastream/
src/main/java/com/schematic/api/logger/SchematicLogger.java
src/main/java/com/schematic/webhook/
+src/test/java/com/schematic/api/HttpEventSenderTest.java
src/test/java/com/schematic/api/TestCache.java
src/test/java/com/schematic/api/TestEventBuffer.java
src/test/java/com/schematic/api/TestLogger.java
diff --git a/src/main/java/com/schematic/api/HttpEventSender.java b/src/main/java/com/schematic/api/HttpEventSender.java
index 57f6d96..20ab37e 100644
--- a/src/main/java/com/schematic/api/HttpEventSender.java
+++ b/src/main/java/com/schematic/api/HttpEventSender.java
@@ -20,8 +20,9 @@
* by posting to https://c.schematichq.com/batch.
*
*
Each event payload is built from the Fern-generated {@link CreateEventRequestBody} model
- * with {@code api_key} injected, so any fields added to the generated model are automatically
- * included in the capture service payload.
+ * with {@code api_key} injected. Optional metadata fields ({@code idempotency_key},
+ * {@code sent_at}, {@code trusted_client_clock}, {@code backfill}) are forwarded only when set,
+ * matching the {@code exclude_none} wire format used by the other SDKs.
*/
public class HttpEventSender implements Closeable {
private static final String DEFAULT_EVENT_CAPTURE_BASE_URL = "https://c.schematichq.com";
@@ -50,29 +51,9 @@ public void sendBatch(List events) throws IOException {
return;
}
- // Build batch matching the capture service format (same as Go SDK's EventPayload)
- ArrayNode eventsArray = ObjectMappers.JSON_MAPPER.createArrayNode();
- for (CreateEventRequestBody event : events) {
- ObjectNode eventNode = ObjectMappers.JSON_MAPPER.createObjectNode();
- eventNode.put("api_key", apiKey);
- eventNode.put("type", event.getEventType().toString());
- if (event.getBody().isPresent()) {
- eventNode.set(
- "body",
- ObjectMappers.JSON_MAPPER.valueToTree(event.getBody().get()));
- }
- if (event.getSentAt().isPresent()) {
- eventNode.put("sent_at", event.getSentAt().get().toString());
- }
- eventsArray.add(eventNode);
- }
-
- ObjectNode batchPayload = ObjectMappers.JSON_MAPPER.createObjectNode();
- batchPayload.set("events", eventsArray);
-
String json;
try {
- json = ObjectMappers.JSON_MAPPER.writeValueAsString(batchPayload);
+ json = serializeBatch(events);
} catch (JsonProcessingException e) {
throw new IOException("Failed to serialize event batch", e);
}
@@ -104,6 +85,45 @@ public void sendBatch(List events) throws IOException {
}
}
+ /**
+ * Serializes a batch of events into the capture service's wire format (same shape as the
+ * Go/Ruby/C#/Python SDKs): a {@code type} field, an embedded {@code api_key}, and the optional
+ * metadata fields forwarded only when present so we never send explicit nulls.
+ *
+ * Package-private for unit testing of the wire mapping.
+ */
+ String serializeBatch(List events) throws JsonProcessingException {
+ ArrayNode eventsArray = ObjectMappers.JSON_MAPPER.createArrayNode();
+ for (CreateEventRequestBody event : events) {
+ ObjectNode eventNode = ObjectMappers.JSON_MAPPER.createObjectNode();
+ eventNode.put("api_key", apiKey);
+ eventNode.put("type", event.getEventType().toString());
+ if (event.getBody().isPresent()) {
+ eventNode.set(
+ "body",
+ ObjectMappers.JSON_MAPPER.valueToTree(event.getBody().get()));
+ }
+ if (event.getSentAt().isPresent()) {
+ eventNode.put("sent_at", event.getSentAt().get().toString());
+ }
+ if (event.getIdempotencyKey().isPresent()) {
+ eventNode.put("idempotency_key", event.getIdempotencyKey().get());
+ }
+ if (event.getTrustedClientClock().isPresent()) {
+ eventNode.put(
+ "trusted_client_clock", event.getTrustedClientClock().get());
+ }
+ if (event.getBackfill().isPresent()) {
+ eventNode.put("backfill", event.getBackfill().get());
+ }
+ eventsArray.add(eventNode);
+ }
+
+ ObjectNode batchPayload = ObjectMappers.JSON_MAPPER.createObjectNode();
+ batchPayload.set("events", eventsArray);
+ return ObjectMappers.JSON_MAPPER.writeValueAsString(batchPayload);
+ }
+
@Override
public void close() {
httpClient.dispatcher().executorService().shutdownNow();
diff --git a/src/main/java/com/schematic/api/IdentifyOptions.java b/src/main/java/com/schematic/api/IdentifyOptions.java
new file mode 100644
index 0000000..176654c
--- /dev/null
+++ b/src/main/java/com/schematic/api/IdentifyOptions.java
@@ -0,0 +1,40 @@
+package com.schematic.api;
+
+/**
+ * Optional metadata for an {@link Schematic#identify} event.
+ *
+ * Omit any field you don't need; the SDK only sends fields that are explicitly set.
+ */
+public final class IdentifyOptions {
+
+ private final String idempotencyKey;
+
+ private IdentifyOptions(Builder builder) {
+ this.idempotencyKey = builder.idempotencyKey;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Client-supplied dedupe key. Duplicate events with the same key (scoped to the environment) are
+ * dropped server-side for 24 hours.
+ */
+ public String getIdempotencyKey() {
+ return idempotencyKey;
+ }
+
+ public static final class Builder {
+ private String idempotencyKey;
+
+ public Builder idempotencyKey(String idempotencyKey) {
+ this.idempotencyKey = idempotencyKey;
+ return this;
+ }
+
+ public IdentifyOptions build() {
+ return new IdentifyOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/com/schematic/api/Schematic.java b/src/main/java/com/schematic/api/Schematic.java
index cabf449..a7e6a99 100644
--- a/src/main/java/com/schematic/api/Schematic.java
+++ b/src/main/java/com/schematic/api/Schematic.java
@@ -12,6 +12,7 @@
import com.schematic.api.datastream.DatastreamOptions;
import com.schematic.api.datastream.WasmRulesEngine;
import com.schematic.api.logger.ConsoleLogger;
+import com.schematic.api.logger.LogLevel;
import com.schematic.api.logger.SchematicLogger;
import com.schematic.api.resources.features.types.CheckFlagResponse;
import com.schematic.api.resources.features.types.CheckFlagsResponse;
@@ -53,7 +54,9 @@ private Schematic(Builder builder) {
this.apiKey = builder.apiKey;
this.eventBufferInterval =
builder.eventBufferInterval != null ? builder.eventBufferInterval : Duration.ofMillis(5000);
- this.logger = builder.logger != null ? builder.logger : new ConsoleLogger();
+ // A consumer-provided logger is used as-is (its own level governs); logLevel only
+ // configures the default ConsoleLogger, which otherwise defaults to WARN.
+ this.logger = builder.logger != null ? builder.logger : new ConsoleLogger(builder.logLevel);
this.flagDefaults = builder.flagDefaults != null ? builder.flagDefaults : new HashMap<>();
this.offline = builder.offline;
this.flagCheckCacheProviders = builder.cacheProviders != null
@@ -150,6 +153,7 @@ public static Builder builder() {
public static class Builder {
private String apiKey;
private SchematicLogger logger;
+ private LogLevel logLevel;
private Map flagDefaults;
private List> cacheProviders;
private boolean offline;
@@ -170,6 +174,16 @@ public Builder logger(SchematicLogger logger) {
return this;
}
+ /**
+ * Sets the level for the default {@link ConsoleLogger} (defaults to {@link LogLevel#WARN}).
+ * Ignored when a custom {@link #logger(SchematicLogger)} is provided — that logger's own level
+ * configuration is the source of truth.
+ */
+ public Builder logLevel(LogLevel logLevel) {
+ this.logLevel = logLevel;
+ return this;
+ }
+
public Builder flagDefaults(Map flagDefaults) {
this.flagDefaults = flagDefaults;
return this;
@@ -556,6 +570,15 @@ private RulesengineCheckFlagResult checkFlagViaApi(
public void identify(
Map keys, EventBodyIdentifyCompany company, String name, Map traits) {
+ identify(keys, company, name, traits, null);
+ }
+
+ public void identify(
+ Map keys,
+ EventBodyIdentifyCompany company,
+ String name,
+ Map traits,
+ IdentifyOptions options) {
if (offline) return;
try {
@@ -566,13 +589,7 @@ public void identify(
.traits(objectMapToJsonNode(traits))
.build();
- CreateEventRequestBody event = CreateEventRequestBody.builder()
- .eventType(EventType.IDENTIFY)
- .body(EventBody.of(body))
- .sentAt(OffsetDateTime.now())
- .build();
-
- eventBuffer.push(event);
+ eventBuffer.push(buildIdentifyEvent(EventBody.of(body), options));
} catch (Exception e) {
logger.error("Error sending identify event: " + e.getMessage());
}
@@ -580,7 +597,7 @@ public void identify(
public void track(
String eventName, Map company, Map user, Map traits) {
- track(eventName, company, user, traits, 1);
+ track(eventName, company, user, traits, 1, null);
}
public void track(
@@ -589,6 +606,25 @@ public void track(
Map user,
Map traits,
Integer quantity) {
+ track(eventName, company, user, traits, quantity, null);
+ }
+
+ public void track(
+ String eventName,
+ Map company,
+ Map user,
+ Map traits,
+ TrackOptions options) {
+ track(eventName, company, user, traits, 1, options);
+ }
+
+ public void track(
+ String eventName,
+ Map company,
+ Map user,
+ Map traits,
+ Integer quantity,
+ TrackOptions options) {
if (offline) return;
try {
@@ -600,13 +636,7 @@ public void track(
.quantity(quantity)
.build();
- CreateEventRequestBody event = CreateEventRequestBody.builder()
- .eventType(EventType.TRACK)
- .body(EventBody.of(body))
- .sentAt(OffsetDateTime.now())
- .build();
-
- eventBuffer.push(event);
+ eventBuffer.push(buildTrackEvent(EventBody.of(body), options));
// Update cached company metrics if datastream is active
if (company != null && !company.isEmpty() && dataStreamClient != null && dataStreamClient.isConnected()) {
@@ -621,6 +651,41 @@ public void track(
}
}
+ /**
+ * Builds the identify event pushed to the buffer. Package-private for unit-testing the
+ * option-to-event mapping. {@code sent_at} is stamped with the local clock; a null option
+ * field passes through to {@code Optional.empty()} and is omitted from the wire.
+ */
+ static CreateEventRequestBody buildIdentifyEvent(EventBody body, IdentifyOptions options) {
+ CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder()
+ .eventType(EventType.IDENTIFY)
+ .body(body)
+ .sentAt(OffsetDateTime.now());
+ if (options != null) {
+ event.idempotencyKey(options.getIdempotencyKey());
+ }
+ return event.build();
+ }
+
+ /**
+ * Builds the track event pushed to the buffer. Package-private for unit-testing the
+ * option-to-event mapping. An explicit {@code sentAt} option overrides the local-clock default
+ * (required when {@code trustedClientClock} is set); other null option fields pass through to
+ * {@code Optional.empty()} and are omitted from the wire.
+ */
+ static CreateEventRequestBody buildTrackEvent(EventBody body, TrackOptions options) {
+ CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder()
+ .eventType(EventType.TRACK)
+ .body(body)
+ .sentAt(options != null && options.getSentAt() != null ? options.getSentAt() : OffsetDateTime.now());
+ if (options != null) {
+ event.idempotencyKey(options.getIdempotencyKey())
+ .trustedClientClock(options.getTrustedClientClock())
+ .backfill(options.getBackfill());
+ }
+ return event.build();
+ }
+
@Override
public void close() {
try {
diff --git a/src/main/java/com/schematic/api/TrackOptions.java b/src/main/java/com/schematic/api/TrackOptions.java
new file mode 100644
index 0000000..a9f9b5b
--- /dev/null
+++ b/src/main/java/com/schematic/api/TrackOptions.java
@@ -0,0 +1,88 @@
+package com.schematic.api;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Optional metadata for a {@link Schematic#track} event.
+ *
+ * Fields map directly to the corresponding {@code CreateEventRequestBody} properties. Omit any
+ * field you don't need; the SDK only sends fields that are explicitly set.
+ */
+public final class TrackOptions {
+
+ private final String idempotencyKey;
+ private final OffsetDateTime sentAt;
+ private final Boolean trustedClientClock;
+ private final Boolean backfill;
+
+ private TrackOptions(Builder builder) {
+ this.idempotencyKey = builder.idempotencyKey;
+ this.sentAt = builder.sentAt;
+ this.trustedClientClock = builder.trustedClientClock;
+ this.backfill = builder.backfill;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Client-supplied dedupe key. Duplicate events with the same key (scoped to the environment) are
+ * dropped server-side for 24 hours.
+ */
+ public String getIdempotencyKey() {
+ return idempotencyKey;
+ }
+
+ /** Timestamp the event was sent. Required when {@code trustedClientClock} is true. */
+ public OffsetDateTime getSentAt() {
+ return sentAt;
+ }
+
+ /**
+ * When true, use {@code sentAt} as the effective event timestamp instead of server receipt time.
+ * Requires a secret API key and {@code sentAt}.
+ */
+ public Boolean getTrustedClientClock() {
+ return trustedClientClock;
+ }
+
+ /**
+ * Import historical data without affecting billing. Requires a secret API key and
+ * {@code trustedClientClock}.
+ */
+ public Boolean getBackfill() {
+ return backfill;
+ }
+
+ public static final class Builder {
+ private String idempotencyKey;
+ private OffsetDateTime sentAt;
+ private Boolean trustedClientClock;
+ private Boolean backfill;
+
+ public Builder idempotencyKey(String idempotencyKey) {
+ this.idempotencyKey = idempotencyKey;
+ return this;
+ }
+
+ public Builder sentAt(OffsetDateTime sentAt) {
+ this.sentAt = sentAt;
+ return this;
+ }
+
+ public Builder trustedClientClock(Boolean trustedClientClock) {
+ this.trustedClientClock = trustedClientClock;
+ return this;
+ }
+
+ public Builder backfill(Boolean backfill) {
+ this.backfill = backfill;
+ return this;
+ }
+
+ public TrackOptions build() {
+ return new TrackOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/com/schematic/api/datastream/EntityMerge.java b/src/main/java/com/schematic/api/datastream/EntityMerge.java
index 76d8c7e..7b9cbc6 100644
--- a/src/main/java/com/schematic/api/datastream/EntityMerge.java
+++ b/src/main/java/com/schematic/api/datastream/EntityMerge.java
@@ -2,10 +2,13 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.IntNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.schematic.api.core.ObjectMappers;
import com.schematic.api.types.RulesengineCompany;
import com.schematic.api.types.RulesengineUser;
+import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
@@ -18,11 +21,23 @@
* upsert for {@code metrics}, replace for all other fields
*
User: additive merge for {@code keys}, replace for all other fields
*
+ *
+ * Partials don't carry refreshed entitlements, so when {@code credit_balances}
+ * or {@code metrics} change in another part of the company we sync the derived
+ * fields on existing entitlements here to match server behavior:
+ *
+ * - {@code credit_remaining} ← {@code credit_balances[credit_id]}
+ * - {@code usage} ← metric value matching {@code (event_name, metric_period, month_reset)}
+ *
+ * Both are skipped when the partial also sends {@code entitlements} wholesale.
*/
final class EntityMerge {
private static final ObjectMapper MAPPER = ObjectMappers.JSON_MAPPER;
+ /** Usage default for a matched metric that carries no value. */
+ private static final IntNode ZERO = IntNode.valueOf(0);
+
private EntityMerge() {}
/**
@@ -37,6 +52,9 @@ static RulesengineCompany partialCompany(RulesengineCompany existing, JsonNode p
// Serialize existing to a mutable JSON tree
ObjectNode base = (ObjectNode) MAPPER.valueToTree(existing);
+ JsonNode updatedBalances = null;
+ boolean metricsUpdated = false;
+
Iterator> fields = partial.fields();
while (fields.hasNext()) {
Map.Entry field = fields.next();
@@ -45,13 +63,17 @@ static RulesengineCompany partialCompany(RulesengineCompany existing, JsonNode p
switch (key) {
case "keys":
- case "credit_balances":
// Additive merge: overlay partial keys onto existing
mergeObject(base, key, value);
break;
+ case "credit_balances":
+ mergeObject(base, key, value);
+ updatedBalances = value;
+ break;
case "metrics":
// Upsert: match by (event_subtype, period, month_reset)
upsertMetrics(base, value);
+ metricsUpdated = true;
break;
default:
// Replace
@@ -60,9 +82,89 @@ static RulesengineCompany partialCompany(RulesengineCompany existing, JsonNode p
}
}
+ // Partials don't carry refreshed entitlements, so re-derive credit_remaining
+ // and usage from the merged credit_balances/metrics. Skipped when the partial
+ // sent entitlements wholesale — we trust those.
+ if ((updatedBalances != null || metricsUpdated) && !partial.has("entitlements")) {
+ syncEntitlements(base, updatedBalances, metricsUpdated);
+ }
+
return MAPPER.convertValue(base, RulesengineCompany.class);
}
+ /**
+ * Re-derives entitlement fields that a partial leaves stale:
+ * {@code credit_remaining} from the merged credit balances and {@code usage}
+ * from the merged metrics. Existing entitlements are rebuilt in place; the
+ * matching mirrors the server's effective-entitlement lookup.
+ *
+ * @param base the company JSON tree being built (with metrics/balances already merged)
+ * @param updatedBalances the partial's {@code credit_balances} node, or {@code null} if unchanged
+ * @param metricsUpdated whether the partial updated {@code metrics}
+ */
+ private static void syncEntitlements(ObjectNode base, JsonNode updatedBalances, boolean metricsUpdated) {
+ JsonNode entitlements = base.get("entitlements");
+ if (entitlements == null || !entitlements.isArray() || entitlements.isEmpty()) {
+ return;
+ }
+
+ // Index merged metric values by (event_subtype, period, month_reset).
+ Map metricsLookup = new HashMap<>();
+ if (metricsUpdated) {
+ JsonNode metrics = base.get("metrics");
+ if (metrics != null && metrics.isArray()) {
+ for (JsonNode metric : metrics) {
+ if (metric == null || !metric.isObject()) {
+ continue;
+ }
+ String key = metricKey(
+ textOrEmpty(metric, "event_subtype"),
+ textOrEmpty(metric, "period"),
+ textOrEmpty(metric, "month_reset"));
+ // Mirror Python/Ruby: a metric with no value counts as 0.
+ JsonNode value = metric.get("value");
+ metricsLookup.put(key, (value == null || value.isNull()) ? ZERO : value);
+ }
+ }
+ }
+
+ boolean balancesUsable = updatedBalances != null && updatedBalances.isObject();
+
+ ArrayNode result = MAPPER.createArrayNode();
+ for (JsonNode entNode : entitlements) {
+ if (entNode == null || !entNode.isObject()) {
+ result.add(entNode);
+ continue;
+ }
+ ObjectNode ent = ((ObjectNode) entNode).deepCopy();
+
+ if (balancesUsable) {
+ String creditId = textOrEmpty(ent, "credit_id");
+ if (!creditId.isEmpty() && updatedBalances.has(creditId)) {
+ ent.set("credit_remaining", updatedBalances.get(creditId));
+ }
+ }
+
+ if (metricsUpdated) {
+ String eventName = textOrEmpty(ent, "event_name");
+ if (!eventName.isEmpty()) {
+ // Server defaults when the entitlement omits these.
+ String period = textOrDefault(ent, "metric_period", "all_time");
+ String monthReset = textOrDefault(ent, "month_reset", "first_of_month");
+ // A matched key always sets usage; absent keys leave it unchanged.
+ JsonNode matched = metricsLookup.get(metricKey(eventName, period, monthReset));
+ if (matched != null) {
+ ent.set("usage", matched);
+ }
+ }
+ }
+
+ result.add(ent);
+ }
+
+ base.set("entitlements", result);
+ }
+
/**
* Merges a partial user update into an existing user.
* Only fields present in the partial are applied.
@@ -131,7 +233,7 @@ private static void upsertMetrics(ObjectNode base, JsonNode partialMetrics) {
}
// Build mutable list from existing
- com.fasterxml.jackson.databind.node.ArrayNode result = MAPPER.createArrayNode();
+ ArrayNode result = MAPPER.createArrayNode();
// Copy existing metrics, replacing any that match a partial metric
for (JsonNode existing : existingMetrics) {
boolean replaced = false;
@@ -175,4 +277,21 @@ private static boolean textEquals(JsonNode a, JsonNode b, String field) {
String bVal = b.has(field) ? b.get(field).asText("") : "";
return aVal.equals(bVal);
}
+
+ /** Composite key for matching a metric to an entitlement. */
+ private static String metricKey(String eventSubtype, String period, String monthReset) {
+ return eventSubtype + '\0' + period + '\0' + monthReset;
+ }
+
+ /** Returns the field's text value, or empty string if absent or null. */
+ private static String textOrEmpty(JsonNode node, String field) {
+ JsonNode value = node.get(field);
+ return (value == null || value.isNull()) ? "" : value.asText("");
+ }
+
+ /** Returns the field's text value, or {@code dflt} if absent, null, or empty. */
+ private static String textOrDefault(JsonNode node, String field, String dflt) {
+ String value = textOrEmpty(node, field);
+ return value.isEmpty() ? dflt : value;
+ }
}
diff --git a/src/main/java/com/schematic/api/logger/ConsoleLogger.java b/src/main/java/com/schematic/api/logger/ConsoleLogger.java
index e569538..267864a 100644
--- a/src/main/java/com/schematic/api/logger/ConsoleLogger.java
+++ b/src/main/java/com/schematic/api/logger/ConsoleLogger.java
@@ -1,23 +1,54 @@
package com.schematic.api.logger;
+/**
+ * Default {@link SchematicLogger} implementation that writes to standard out, filtering messages by
+ * a configured {@link LogLevel}.
+ *
+ * Defaults to {@link LogLevel#WARN}: {@code debug} and {@code info} are suppressed unless a more
+ * verbose level is requested, so production consumers aren't flooded with diagnostics they never
+ * asked for. Raise the level (e.g. {@link LogLevel#DEBUG}) for development.
+ */
public class ConsoleLogger implements SchematicLogger {
+
+ private final LogLevel level;
+
+ /** Creates a logger at the default {@link LogLevel#WARN} level. */
+ public ConsoleLogger() {
+ this(LogLevel.WARN);
+ }
+
+ /** Creates a logger that emits messages at {@code level} or more severe. Null defaults to WARN. */
+ public ConsoleLogger(LogLevel level) {
+ this.level = level != null ? level : LogLevel.WARN;
+ }
+
@Override
public void error(String message, Object... args) {
- System.out.println("[ERROR] " + String.format(message, args));
+ log(LogLevel.ERROR, message, args);
}
@Override
public void warn(String message, Object... args) {
- System.out.println("[WARN] " + String.format(message, args));
+ log(LogLevel.WARN, message, args);
}
@Override
public void info(String message, Object... args) {
- System.out.println("[INFO] " + String.format(message, args));
+ log(LogLevel.INFO, message, args);
}
@Override
public void debug(String message, Object... args) {
- System.out.println("[DEBUG] " + String.format(message, args));
+ log(LogLevel.DEBUG, message, args);
+ }
+
+ private void log(LogLevel messageLevel, String message, Object... args) {
+ if (!level.allows(messageLevel)) {
+ return;
+ }
+ // Only run through String.format when args are supplied, so a literal '%' in an
+ // arg-less message doesn't blow up with a format exception.
+ String formatted = (args == null || args.length == 0) ? message : String.format(message, args);
+ System.out.println("[" + messageLevel.name() + "] " + formatted);
}
}
diff --git a/src/main/java/com/schematic/api/logger/LogLevel.java b/src/main/java/com/schematic/api/logger/LogLevel.java
new file mode 100644
index 0000000..a456d26
--- /dev/null
+++ b/src/main/java/com/schematic/api/logger/LogLevel.java
@@ -0,0 +1,26 @@
+package com.schematic.api.logger;
+
+/**
+ * Severity levels for {@link SchematicLogger}, ordered from most to least verbose.
+ *
+ *
A logger configured at a given level emits messages at that level and every more severe level,
+ * suppressing the rest. For example {@link #WARN} emits {@code warn} and {@code error} but drops
+ * {@code info} and {@code debug}.
+ */
+public enum LogLevel {
+ DEBUG(0),
+ INFO(1),
+ WARN(2),
+ ERROR(3);
+
+ private final int severity;
+
+ LogLevel(int severity) {
+ this.severity = severity;
+ }
+
+ /** Whether a message logged at {@code messageLevel} should be emitted by a logger at this level. */
+ boolean allows(LogLevel messageLevel) {
+ return messageLevel.severity >= this.severity;
+ }
+}
diff --git a/src/test/java/com/schematic/api/HttpEventSenderTest.java b/src/test/java/com/schematic/api/HttpEventSenderTest.java
new file mode 100644
index 0000000..a7e1d38
--- /dev/null
+++ b/src/test/java/com/schematic/api/HttpEventSenderTest.java
@@ -0,0 +1,62 @@
+package com.schematic.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.schematic.api.core.ObjectMappers;
+import com.schematic.api.types.CreateEventRequestBody;
+import com.schematic.api.types.EventType;
+import java.time.OffsetDateTime;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+
+/** Pins the capture-service wire format produced by {@link HttpEventSender#serializeBatch}. */
+class HttpEventSenderTest {
+
+ private final HttpEventSender sender = new HttpEventSender(null, "test_api_key", null, null);
+
+ private JsonNode firstEvent(CreateEventRequestBody event) throws Exception {
+ String json = sender.serializeBatch(Collections.singletonList(event));
+ return ObjectMappers.JSON_MAPPER.readTree(json).get("events").get(0);
+ }
+
+ @Test
+ void serializeBatch_alwaysIncludesApiKeyAndType() throws Exception {
+ JsonNode wire = firstEvent(
+ CreateEventRequestBody.builder().eventType(EventType.TRACK).build());
+
+ assertEquals("test_api_key", wire.get("api_key").asText());
+ assertEquals("track", wire.get("type").asText());
+ }
+
+ @Test
+ void serializeBatch_excludesUnsetOptionalFields() throws Exception {
+ JsonNode wire = firstEvent(
+ CreateEventRequestBody.builder().eventType(EventType.TRACK).build());
+
+ // Unset optional fields must not appear as explicit nulls on the wire.
+ assertFalse(wire.has("idempotency_key"));
+ assertFalse(wire.has("sent_at"));
+ assertFalse(wire.has("trusted_client_clock"));
+ assertFalse(wire.has("backfill"));
+ }
+
+ @Test
+ void serializeBatch_includesSetOptionalFields() throws Exception {
+ OffsetDateTime sentAt = OffsetDateTime.parse("2026-01-01T00:00:00Z");
+ JsonNode wire = firstEvent(CreateEventRequestBody.builder()
+ .eventType(EventType.TRACK)
+ .idempotencyKey("evt_xyz")
+ .sentAt(sentAt)
+ .trustedClientClock(true)
+ // backfill=false is explicitly set, so it must still reach the wire.
+ .backfill(false)
+ .build());
+
+ assertEquals("evt_xyz", wire.get("idempotency_key").asText());
+ assertEquals(sentAt.toString(), wire.get("sent_at").asText());
+ assertTrue(wire.get("trusted_client_clock").asBoolean());
+ assertTrue(wire.has("backfill"));
+ assertFalse(wire.get("backfill").asBoolean());
+ }
+}
diff --git a/src/test/java/com/schematic/api/TestLogger.java b/src/test/java/com/schematic/api/TestLogger.java
index f8dcb23..3986509 100644
--- a/src/test/java/com/schematic/api/TestLogger.java
+++ b/src/test/java/com/schematic/api/TestLogger.java
@@ -3,6 +3,7 @@
import static org.junit.jupiter.api.Assertions.*;
import com.schematic.api.logger.ConsoleLogger;
+import com.schematic.api.logger.LogLevel;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import org.junit.jupiter.api.AfterEach;
@@ -12,14 +13,12 @@
class LoggerTest {
private ByteArrayOutputStream outputStream;
private PrintStream originalOut;
- private ConsoleLogger logger;
@BeforeEach
void setUp() {
outputStream = new ByteArrayOutputStream();
originalOut = System.out;
System.setOut(new PrintStream(outputStream));
- logger = new ConsoleLogger();
}
@AfterEach
@@ -27,79 +26,109 @@ void tearDown() {
System.setOut(originalOut);
}
+ private String output() {
+ return outputStream.toString().trim();
+ }
+
+ // --- Emission + formatting (verbose logger so every level is allowed) ---
+
@Test
void testErrorLogsMessage() {
- String message = "This is an error message";
- logger.error(message);
-
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[ERROR]"));
- assertTrue(output.contains(message));
+ new ConsoleLogger(LogLevel.DEBUG).error("This is an error message");
+ assertTrue(output().contains("[ERROR]"));
+ assertTrue(output().contains("This is an error message"));
}
@Test
void testWarnLogsMessage() {
- String message = "This is a warning message";
- logger.warn(message);
-
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[WARN]"));
- assertTrue(output.contains(message));
+ new ConsoleLogger(LogLevel.DEBUG).warn("This is a warning message");
+ assertTrue(output().contains("[WARN]"));
+ assertTrue(output().contains("This is a warning message"));
}
@Test
void testInfoLogsMessage() {
- String message = "This is an info message";
- logger.info(message);
-
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[INFO]"));
- assertTrue(output.contains(message));
+ new ConsoleLogger(LogLevel.DEBUG).info("This is an info message");
+ assertTrue(output().contains("[INFO]"));
+ assertTrue(output().contains("This is an info message"));
}
@Test
void testDebugLogsMessage() {
- String message = "This is a debug message";
- logger.debug(message);
-
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[DEBUG]"));
- assertTrue(output.contains(message));
+ new ConsoleLogger(LogLevel.DEBUG).debug("This is a debug message");
+ assertTrue(output().contains("[DEBUG]"));
+ assertTrue(output().contains("This is a debug message"));
}
@Test
- void testErrorFormatsMessageWithArgs() {
- logger.error("Error %s", "123");
-
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[ERROR]"));
- assertTrue(output.contains("Error 123"));
+ void testFormatsMessageWithArgs() {
+ new ConsoleLogger(LogLevel.DEBUG).debug("Debug %s", "123");
+ assertTrue(output().contains("[DEBUG]"));
+ assertTrue(output().contains("Debug 123"));
}
@Test
- void testWarnFormatsMessageWithArgs() {
- logger.warn("Warning %s", "123");
-
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[WARN]"));
- assertTrue(output.contains("Warning 123"));
+ void testArglessMessageWithPercentDoesNotThrow() {
+ // A literal '%' with no args must not trigger a format exception.
+ assertDoesNotThrow(() -> new ConsoleLogger(LogLevel.DEBUG).warn("100% complete"));
+ assertTrue(output().contains("100% complete"));
}
+ // --- Level filtering (the conformance requirement) ---
+
@Test
- void testInfoFormatsMessageWithArgs() {
- logger.info("Info %s", "123");
+ void testDefaultLevelIsWarnAndSuppressesInfoAndDebug() {
+ ConsoleLogger logger = new ConsoleLogger(); // default WARN
+ logger.debug("dbg");
+ logger.info("inf");
+ logger.warn("wrn");
+ logger.error("err");
+
+ String out = output();
+ assertFalse(out.contains("[DEBUG]"), "debug should be suppressed at WARN");
+ assertFalse(out.contains("[INFO]"), "info should be suppressed at WARN");
+ assertTrue(out.contains("[WARN]"));
+ assertTrue(out.contains("[ERROR]"));
+ }
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[INFO]"));
- assertTrue(output.contains("Info 123"));
+ @Test
+ void testNullLevelDefaultsToWarn() {
+ ConsoleLogger logger = new ConsoleLogger(null);
+ logger.info("inf");
+ logger.warn("wrn");
+
+ String out = output();
+ assertFalse(out.contains("[INFO]"));
+ assertTrue(out.contains("[WARN]"));
}
@Test
- void testDebugFormatsMessageWithArgs() {
- logger.debug("Debug %s", "123");
+ void testDebugLevelEmitsEveryLevel() {
+ ConsoleLogger logger = new ConsoleLogger(LogLevel.DEBUG);
+ logger.debug("dbg");
+ logger.info("inf");
+ logger.warn("wrn");
+ logger.error("err");
+
+ String out = output();
+ assertTrue(out.contains("[DEBUG]"));
+ assertTrue(out.contains("[INFO]"));
+ assertTrue(out.contains("[WARN]"));
+ assertTrue(out.contains("[ERROR]"));
+ }
- String output = outputStream.toString().trim();
- assertTrue(output.contains("[DEBUG]"));
- assertTrue(output.contains("Debug 123"));
+ @Test
+ void testErrorLevelSuppressesEverythingBelowError() {
+ ConsoleLogger logger = new ConsoleLogger(LogLevel.ERROR);
+ logger.debug("dbg");
+ logger.info("inf");
+ logger.warn("wrn");
+ logger.error("err");
+
+ String out = output();
+ assertFalse(out.contains("[DEBUG]"));
+ assertFalse(out.contains("[INFO]"));
+ assertFalse(out.contains("[WARN]"));
+ assertTrue(out.contains("[ERROR]"));
}
}
diff --git a/src/test/java/com/schematic/api/TestSchematic.java b/src/test/java/com/schematic/api/TestSchematic.java
index ca5978f..dab557d 100644
--- a/src/test/java/com/schematic/api/TestSchematic.java
+++ b/src/test/java/com/schematic/api/TestSchematic.java
@@ -15,9 +15,15 @@
import com.schematic.api.types.CheckFlagRequestBody;
import com.schematic.api.types.CheckFlagResponseData;
import com.schematic.api.types.CheckFlagsResponseData;
+import com.schematic.api.types.CreateEventRequestBody;
+import com.schematic.api.types.EventBody;
+import com.schematic.api.types.EventBodyIdentify;
import com.schematic.api.types.EventBodyIdentifyCompany;
+import com.schematic.api.types.EventBodyTrack;
+import com.schematic.api.types.EventType;
import com.schematic.api.types.RulesengineCheckFlagResult;
import java.time.Duration;
+import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -172,6 +178,82 @@ void track_EnqueuesEventNonBlocking() throws InterruptedException {
verify(logger, never()).error(any());
}
+ // --- Track/identify option mapping (buildTrackEvent / buildIdentifyEvent) ---
+
+ @Test
+ void buildTrackEvent_appliesAllOptions() {
+ OffsetDateTime sentAt = OffsetDateTime.parse("2026-01-01T00:00:00Z");
+ EventBody body = EventBody.of(EventBodyTrack.builder().event("e").build());
+
+ CreateEventRequestBody event = Schematic.buildTrackEvent(
+ body,
+ TrackOptions.builder()
+ .idempotencyKey("idem-1")
+ .sentAt(sentAt)
+ .trustedClientClock(true)
+ .backfill(false)
+ .build());
+
+ assertEquals(EventType.TRACK, event.getEventType());
+ assertEquals("idem-1", event.getIdempotencyKey().get());
+ assertEquals(sentAt, event.getSentAt().get());
+ assertTrue(event.getTrustedClientClock().get());
+ assertFalse(event.getBackfill().get());
+ }
+
+ @Test
+ void buildTrackEvent_nullOptionsLeavesMetadataUnsetAndStampsSentAt() {
+ EventBody body = EventBody.of(EventBodyTrack.builder().event("e").build());
+
+ CreateEventRequestBody event = Schematic.buildTrackEvent(body, null);
+
+ assertFalse(event.getIdempotencyKey().isPresent());
+ assertFalse(event.getTrustedClientClock().isPresent());
+ assertFalse(event.getBackfill().isPresent());
+ assertTrue(event.getSentAt().isPresent(), "sent_at should default to now()");
+ }
+
+ @Test
+ void buildTrackEvent_defaultsSentAtToNowWhenOptionOmitsIt() {
+ EventBody body = EventBody.of(EventBodyTrack.builder().event("e").build());
+ OffsetDateTime before = OffsetDateTime.now().minusSeconds(1);
+
+ CreateEventRequestBody event = Schematic.buildTrackEvent(
+ body, TrackOptions.builder().idempotencyKey("idem-1").build());
+
+ OffsetDateTime after = OffsetDateTime.now().plusSeconds(1);
+ assertTrue(event.getSentAt().isPresent());
+ OffsetDateTime sentAt = event.getSentAt().get();
+ assertTrue(sentAt.isAfter(before) && sentAt.isBefore(after));
+ assertEquals("idem-1", event.getIdempotencyKey().get());
+ }
+
+ @Test
+ void buildIdentifyEvent_appliesIdempotencyKey() {
+ EventBody body = EventBody.of(EventBodyIdentify.builder()
+ .keys(Collections.singletonMap("user_id", "u1"))
+ .build());
+
+ CreateEventRequestBody event = Schematic.buildIdentifyEvent(
+ body, IdentifyOptions.builder().idempotencyKey("idem-2").build());
+
+ assertEquals(EventType.IDENTIFY, event.getEventType());
+ assertEquals("idem-2", event.getIdempotencyKey().get());
+ assertTrue(event.getSentAt().isPresent());
+ }
+
+ @Test
+ void buildIdentifyEvent_nullOptionsLeavesIdempotencyUnset() {
+ EventBody body = EventBody.of(EventBodyIdentify.builder()
+ .keys(Collections.singletonMap("user_id", "u1"))
+ .build());
+
+ CreateEventRequestBody event = Schematic.buildIdentifyEvent(body, null);
+
+ assertFalse(event.getIdempotencyKey().isPresent());
+ assertTrue(event.getSentAt().isPresent());
+ }
+
@Test
void track_OfflineMode() {
Map company = Collections.singletonMap("company_id", "67890");
diff --git a/src/test/java/com/schematic/api/datastream/EntityMergeTest.java b/src/test/java/com/schematic/api/datastream/EntityMergeTest.java
index 7351129..3633556 100644
--- a/src/test/java/com/schematic/api/datastream/EntityMergeTest.java
+++ b/src/test/java/com/schematic/api/datastream/EntityMergeTest.java
@@ -9,6 +9,8 @@
import com.schematic.api.types.RulesengineCompany;
import com.schematic.api.types.RulesengineEntitlementValueType;
import com.schematic.api.types.RulesengineFeatureEntitlement;
+import com.schematic.api.types.RulesengineMetricPeriod;
+import com.schematic.api.types.RulesengineMetricPeriodMonthReset;
import com.schematic.api.types.RulesengineRule;
import com.schematic.api.types.RulesengineRuleType;
import com.schematic.api.types.RulesengineTrait;
@@ -541,6 +543,328 @@ void partialUser_fullEntityPartialMessage() {
assertEquals("rule-u1", merged.getRules().get(0).getId());
}
+ // --- Entitlement credit_remaining sync tests ---
+ // Credit-balance partials don't include refreshed entitlements, so the SDK syncs
+ // credit_remaining locally to mirror the server's partial-message handling.
+
+ @Test
+ void partialCompany_syncsCreditRemainingForMatchingCreditId() {
+ RulesengineCompany existing = companyWithEntitlements(
+ Collections.singletonMap("credit-1", 100.0),
+ List.of(
+ entitlement("feat-1", "f1", "credit-1", 100.0, null, null, null, null),
+ // no credit_id — must stay untouched
+ entitlement("feat-2", "f2", null, null, null, null, null, null)));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ partial.set("credit_balances", balances("credit-1", 25.0));
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(25.0, merged.getCreditBalances().get("credit-1"));
+ assertTrue(merged.getEntitlements().isPresent());
+ assertEquals(
+ 25.0, merged.getEntitlements().get().get(0).getCreditRemaining().get());
+ assertFalse(merged.getEntitlements().get().get(1).getCreditRemaining().isPresent());
+ }
+
+ @Test
+ void partialCompany_syncsCreditRemainingAcrossMultipleCreditIds() {
+ Map existingBalances = new HashMap<>();
+ existingBalances.put("credit-1", 100.0);
+ existingBalances.put("credit-2", 50.0);
+
+ RulesengineCompany existing = companyWithEntitlements(
+ existingBalances,
+ List.of(
+ entitlement("feat-1", "f1", "credit-1", 100.0, null, null, null, null),
+ entitlement("feat-2", "f2", "credit-2", 50.0, null, null, null, null)));
+
+ ObjectNode newBalances = objectMapper.createObjectNode();
+ newBalances.put("credit-1", 75.0);
+ newBalances.put("credit-2", 10.0);
+ ObjectNode partial = objectMapper.createObjectNode();
+ partial.set("credit_balances", newBalances);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(
+ 75.0, merged.getEntitlements().get().get(0).getCreditRemaining().get());
+ assertEquals(
+ 10.0, merged.getEntitlements().get().get(1).getCreditRemaining().get());
+ }
+
+ @Test
+ void partialCompany_leavesUnmatchedEntitlementCreditIdUntouched() {
+ Map existingBalances = new HashMap<>();
+ existingBalances.put("credit-1", 100.0);
+ existingBalances.put("credit-other", 999.0);
+
+ RulesengineCompany existing = companyWithEntitlements(
+ existingBalances, List.of(entitlement("feat-1", "f1", "credit-other", 999.0, null, null, null, null)));
+
+ // Partial only updates credit-1; entitlement points at credit-other.
+ ObjectNode partial = objectMapper.createObjectNode();
+ partial.set("credit_balances", balances("credit-1", 25.0));
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(
+ 999.0,
+ merged.getEntitlements().get().get(0).getCreditRemaining().get());
+ }
+
+ @Test
+ void partialCompany_singleCreditFansOutToMultipleEntitlements() {
+ // One credit type can fund multiple features; a balance update must sync
+ // credit_remaining on every entitlement pointing at that credit.
+ RulesengineCompany existing = companyWithEntitlements(
+ Collections.singletonMap("credit-shared", 500.0),
+ List.of(
+ entitlement("feat-a", "feature-a", "credit-shared", 500.0, null, null, null, null),
+ entitlement("feat-b", "feature-b", "credit-shared", 500.0, null, null, null, null),
+ entitlement("feat-c", "feature-c", "credit-shared", 500.0, null, null, null, null),
+ entitlement("feat-d", "feature-d", null, null, null, null, null, null)));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ partial.set("credit_balances", balances("credit-shared", 120.0));
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(
+ 120.0,
+ merged.getEntitlements().get().get(0).getCreditRemaining().get());
+ assertEquals(
+ 120.0,
+ merged.getEntitlements().get().get(1).getCreditRemaining().get());
+ assertEquals(
+ 120.0,
+ merged.getEntitlements().get().get(2).getCreditRemaining().get());
+ assertFalse(merged.getEntitlements().get().get(3).getCreditRemaining().isPresent());
+ }
+
+ @Test
+ void partialCompany_skipsSyncWhenPartialSendsEntitlements() {
+ // If the partial carries entitlements, trust those wholesale.
+ RulesengineCompany existing = companyWithEntitlements(
+ Collections.singletonMap("credit-1", 100.0),
+ List.of(entitlement("feat-1", "f1", "credit-1", 100.0, null, null, null, null)));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ partial.set("credit_balances", balances("credit-1", 25.0));
+ ArrayNode partialEnts = objectMapper.createArrayNode();
+ ObjectNode ent = objectMapper.createObjectNode();
+ ent.put("feature_id", "feat-1");
+ ent.put("feature_key", "f1");
+ ent.put("value_type", "boolean");
+ ent.put("credit_id", "credit-1");
+ ent.put("credit_remaining", 17.0);
+ partialEnts.add(ent);
+ partial.set("entitlements", partialEnts);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(
+ 17.0, merged.getEntitlements().get().get(0).getCreditRemaining().get());
+ }
+
+ @Test
+ void partialCompany_creditSyncNoOpWhenNoEntitlements() {
+ RulesengineCompany existing = buildCompany("comp-1", Collections.singletonMap("id", "comp-1"));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ partial.set("credit_balances", balances("credit-1", 25.0));
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(25.0, merged.getCreditBalances().get("credit-1"));
+ assertFalse(merged.getEntitlements().isPresent());
+ }
+
+ // --- Entitlement usage sync tests ---
+ // A metrics partial doesn't carry refreshed entitlements, so usage is re-derived
+ // from the matching metric (event_subtype + period + month_reset).
+
+ @Test
+ void partialCompany_syncsUsageForEventBasedEntitlement() {
+ RulesengineCompany existing = companyWithEntitlementsAndMetrics(
+ Collections.emptyMap(),
+ List.of(entitlement(
+ "feat-1",
+ "f1",
+ null,
+ null,
+ "credits_used",
+ RulesengineMetricPeriod.CURRENT_MONTH,
+ RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH,
+ 10L)),
+ metric("credits_used", "current_month", "first_of_month", 10));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ ArrayNode metrics = objectMapper.createArrayNode();
+ metrics.add(metric("credits_used", "current_month", "first_of_month", 42));
+ partial.set("metrics", metrics);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(42L, merged.getEntitlements().get().get(0).getUsage().get());
+ }
+
+ @Test
+ void partialCompany_usageMatchRequiresPeriodAndMonthReset() {
+ // Matching uses the full triple; a metric with a different period must not match.
+ RulesengineCompany existing = companyWithEntitlementsAndMetrics(
+ Collections.emptyMap(),
+ List.of(entitlement(
+ "feat-1",
+ "f1",
+ null,
+ null,
+ "api_calls",
+ RulesengineMetricPeriod.CURRENT_MONTH, // differs from metric's period
+ RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH,
+ 5L)),
+ metric("api_calls", "all_time", "first_of_month", 100));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ ArrayNode metrics = objectMapper.createArrayNode();
+ metrics.add(metric("api_calls", "all_time", "first_of_month", 999));
+ partial.set("metrics", metrics);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(5L, merged.getEntitlements().get().get(0).getUsage().get());
+ }
+
+ @Test
+ void partialCompany_usageMatchDefaultsToAllTimeFirstOfMonth() {
+ // When period/month_reset are absent, the lookup defaults to all_time/first_of_month.
+ RulesengineCompany existing = companyWithEntitlementsAndMetrics(
+ Collections.emptyMap(),
+ List.of(entitlement("feat-1", "f1", null, null, "api_calls", null, null, null)),
+ metric("api_calls", "all_time", "first_of_month", 0));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ ArrayNode metrics = objectMapper.createArrayNode();
+ metrics.add(metric("api_calls", "all_time", "first_of_month", 7));
+ partial.set("metrics", metrics);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(7L, merged.getEntitlements().get().get(0).getUsage().get());
+ }
+
+ @Test
+ void partialCompany_usageUnchangedWhenNoMatchingMetricInPartial() {
+ // Partial updates a different event; event-a stays in merged metrics at 50, so usage stays 50.
+ RulesengineCompany existing = companyWithEntitlementsAndMetrics(
+ Collections.emptyMap(),
+ List.of(entitlement(
+ "feat-1",
+ "f1",
+ null,
+ null,
+ "event-a",
+ RulesengineMetricPeriod.ALL_TIME,
+ RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH,
+ 50L)),
+ metric("event-a", "all_time", "first_of_month", 50));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ ArrayNode metrics = objectMapper.createArrayNode();
+ metrics.add(metric("event-b", "all_time", "first_of_month", 999));
+ partial.set("metrics", metrics);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(50L, merged.getEntitlements().get().get(0).getUsage().get());
+ }
+
+ @Test
+ void partialCompany_syncsUsageAndCreditRemainingInOnePartial() {
+ RulesengineCompany existing = companyWithEntitlementsAndMetrics(
+ Collections.singletonMap("credit-1", 100.0),
+ List.of(entitlement(
+ "feat-1",
+ "f1",
+ "credit-1",
+ 100.0,
+ "event-a",
+ RulesengineMetricPeriod.ALL_TIME,
+ RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH,
+ 5L)),
+ metric("event-a", "all_time", "first_of_month", 5));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ partial.set("credit_balances", balances("credit-1", 25.0));
+ ArrayNode metrics = objectMapper.createArrayNode();
+ metrics.add(metric("event-a", "all_time", "first_of_month", 80));
+ partial.set("metrics", metrics);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(
+ 25.0, merged.getEntitlements().get().get(0).getCreditRemaining().get());
+ assertEquals(80L, merged.getEntitlements().get().get(0).getUsage().get());
+ }
+
+ @Test
+ void partialCompany_usageDefaultsToZeroWhenMatchedMetricHasNoValue() {
+ // Mirrors Python/Ruby: a matching metric that carries no value counts as 0,
+ // so a previously non-zero usage is reset rather than left stale.
+ RulesengineCompany existing = companyWithEntitlementsAndMetrics(
+ Collections.emptyMap(),
+ List.of(entitlement(
+ "feat-1",
+ "f1",
+ null,
+ null,
+ "credits_used",
+ RulesengineMetricPeriod.ALL_TIME,
+ RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH,
+ 10L)),
+ metric("credits_used", "all_time", "first_of_month", 10));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ ArrayNode metrics = objectMapper.createArrayNode();
+ ObjectNode valueless = metric("credits_used", "all_time", "first_of_month", 0);
+ valueless.remove("value");
+ metrics.add(valueless);
+ partial.set("metrics", metrics);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(0L, merged.getEntitlements().get().get(0).getUsage().get());
+ }
+
+ @Test
+ void partialCompany_syncsUsageForCreditAttachedEntitlement() {
+ // Per spec, a credit-attached entitlement (credit_id set) still has usage synced to the
+ // matching metric value. For credit features this is the raw event count, not credits used
+ // — that divergence is expected; credit consumption comes from credit_used / REST.
+ RulesengineCompany existing = companyWithEntitlementsAndMetrics(
+ Collections.singletonMap("credit-1", 100.0),
+ List.of(entitlement(
+ "feat-1",
+ "f1",
+ "credit-1",
+ 100.0,
+ "credits_used",
+ RulesengineMetricPeriod.ALL_TIME,
+ RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH,
+ 10L)),
+ metric("credits_used", "all_time", "first_of_month", 10));
+
+ ObjectNode partial = objectMapper.createObjectNode();
+ ArrayNode metrics = objectMapper.createArrayNode();
+ metrics.add(metric("credits_used", "all_time", "first_of_month", 42));
+ partial.set("metrics", metrics);
+
+ RulesengineCompany merged = EntityMerge.partialCompany(existing, partial);
+
+ assertEquals(42L, merged.getEntitlements().get().get(0).getUsage().get());
+ }
+
// --- Helpers ---
private RulesengineCompany buildCompany(String id, Map keys) {
@@ -569,4 +893,85 @@ private RulesengineUser buildUser(String id, Map keys) {
.rules(Collections.emptyList())
.build();
}
+
+ private RulesengineCompany companyWithEntitlements(
+ Map creditBalances, List entitlements) {
+ return RulesengineCompany.builder()
+ .accountId("acc_1")
+ .environmentId("env_1")
+ .id("comp-1")
+ .keys(Collections.singletonMap("id", "comp-1"))
+ .traits(Collections.emptyList())
+ .metrics(Collections.emptyList())
+ .rules(Collections.emptyList())
+ .billingProductIds(Collections.emptyList())
+ .creditBalances(creditBalances)
+ .planIds(Collections.emptyList())
+ .planVersionIds(Collections.emptyList())
+ .entitlements(entitlements)
+ .build();
+ }
+
+ private RulesengineCompany companyWithEntitlementsAndMetrics(
+ Map creditBalances, List entitlements, ObjectNode metric) {
+ RulesengineCompany company = companyWithEntitlements(creditBalances, entitlements);
+ ObjectNode tree = (ObjectNode) objectMapper.valueToTree(company);
+ ArrayNode metrics = objectMapper.createArrayNode();
+ metrics.add(metric);
+ tree.set("metrics", metrics);
+ return objectMapper.convertValue(tree, RulesengineCompany.class);
+ }
+
+ private RulesengineFeatureEntitlement entitlement(
+ String featureId,
+ String featureKey,
+ String creditId,
+ Double creditRemaining,
+ String eventName,
+ RulesengineMetricPeriod metricPeriod,
+ RulesengineMetricPeriodMonthReset monthReset,
+ Long usage) {
+ RulesengineFeatureEntitlement._FinalStage builder = RulesengineFeatureEntitlement.builder()
+ .featureId(featureId)
+ .featureKey(featureKey)
+ .valueType(RulesengineEntitlementValueType.BOOLEAN);
+ if (creditId != null) {
+ builder.creditId(creditId);
+ }
+ if (creditRemaining != null) {
+ builder.creditRemaining(creditRemaining);
+ }
+ if (eventName != null) {
+ builder.eventName(eventName);
+ }
+ if (metricPeriod != null) {
+ builder.metricPeriod(metricPeriod);
+ }
+ if (monthReset != null) {
+ builder.monthReset(monthReset);
+ }
+ if (usage != null) {
+ builder.usage(usage);
+ }
+ return builder.build();
+ }
+
+ private ObjectNode metric(String eventSubtype, String period, String monthReset, int value) {
+ ObjectNode m = objectMapper.createObjectNode();
+ m.put("account_id", "acc_1");
+ m.put("company_id", "comp-1");
+ m.put("environment_id", "env_1");
+ m.put("created_at", "2026-01-01T00:00:00Z");
+ m.put("event_subtype", eventSubtype);
+ m.put("period", period);
+ m.put("month_reset", monthReset);
+ m.put("value", value);
+ return m;
+ }
+
+ private ObjectNode balances(String creditId, double amount) {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put(creditId, amount);
+ return node;
+ }
}