Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3643b30
refactor(billing): extract doc-unit math to :proprietary.billing (sha…
ConnorYoh Jun 26, 2026
9097cce
feat(billing): 3-way category classifier in :proprietary.billing; gat…
ConnorYoh Jun 29, 2026
8be64ab
feat(account-link): entitlement sync carries unit-calc policy + billi…
ConnorYoh Jun 29, 2026
797781c
feat(account-link): metering flag + durable cumulative usage counter …
ConnorYoh Jun 29, 2026
7eea0b1
feat(account-link): meter successful billable ops into the cumulative…
ConnorYoh Jun 29, 2026
ddf1708
feat(billing): SaaS daily-sync ingest — bill linked-instance usage de…
ConnorYoh Jun 29, 2026
1a3cb71
feat(billing): instance daily-sync sender — report cumulative usage t…
ConnorYoh Jun 29, 2026
912377f
feat(billing): portal current-usage = SaaS-synced + instance-local un…
ConnorYoh Jun 29, 2026
abaa907
feat(billing): slice 4 hardening — PDF page-counting + per-sync anoma…
ConnorYoh Jun 30, 2026
d13b493
fix(billing): address multi-agent review of the daily-sync metering PR
ConnorYoh Jun 30, 2026
8a36e0f
fix(billing): second-review round 1 — grace window, minCharge test, O…
ConnorYoh Jun 30, 2026
fd05cef
fix(billing): second-review round 2 — jpdfium page parity + instance …
ConnorYoh Jun 30, 2026
90b6476
feat(billing): drop synced/unsynced UI split, add manual sync trigger…
ConnorYoh Jun 30, 2026
d1e34df
fix(billing): allow LINKED_INSTANCE in payg_shadow_charge.job_source …
ConnorYoh Jun 30, 2026
0d8ff76
feat(billing): instant subscription reflection + real-time free-grant…
ConnorYoh Jul 2, 2026
9980cd9
Merge remote-tracking branch 'origin/main' into feat/account-link-met…
ConnorYoh Jul 2, 2026
7199526
style(portal): prettier-format merge-resolved billing files
ConnorYoh Jul 2, 2026
26d3168
fix(portal): drop orphaned usage.finalizing i18n keys
ConnorYoh Jul 2, 2026
f5603fb
style(portal): use ASCII in checkout copy (review: @jbrunton96)
ConnorYoh Jul 3, 2026
06d9e0e
Merge remote-tracking branch 'origin/main' into feat/account-link-met…
jbrunton96 Jul 3, 2026
bf6d6ba
Fix translations
jbrunton96 Jul 3, 2026
95cef9c
Address PR review: trim comments, split checkout components, metering…
ConnorYoh Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
Expand All @@ -14,25 +15,31 @@

import lombok.extern.slf4j.Slf4j;

import stirling.software.proprietary.billing.UnitCalcPolicy;

import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;

/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Two calls:
* <p>Calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
* endpoint.
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
Expand Down Expand Up @@ -86,11 +93,9 @@ public int status() {
}

/**
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
* try/catch.
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
*/
public static final class RevokedException extends RuntimeException {
private final int status;
Expand Down Expand Up @@ -142,11 +147,9 @@ public RegisterResult register(String supabaseJwt, String instanceName) throws I
}

/**
* Revokes this instance's own credential on the SaaS side ({@code POST
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
Expand Down Expand Up @@ -218,6 +221,63 @@ public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret
}
}

/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
* not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
String deviceSecret,
long syncSeq,
LocalDateTime periodStart,
long apiUnits,
long aiUnits,
long automationUnits) {
HttpResponse<String> response;
try {
ObjectNode root = mapper.createObjectNode();
root.put("syncSeq", syncSeq);
// Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
root.put("periodStart", periodStart.toString());
ObjectNode units = root.putObject("cumulativeUnits");
units.put("api", apiUnits);
units.put("ai", aiUnits);
units.put("automation", automationUnits);
String body = mapper.writeValueAsString(root);
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/sync"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
response = send(request);
} catch (Exception e) {
log.debug("Usage sync failed: {}", e.getMessage());
return null;
}
int status = response.statusCode();
if (status == 401 || status == 403) {
throw new RevokedException(status);
}
if (status / 100 != 2) {
log.debug("Usage sync returned HTTP {}", status);
return null;
}
try {
return parseEntitlement(response.body());
} catch (IOException e) {
log.debug("Usage sync parse failed: {}", e.getMessage());
return null;
}
}

private InstanceEntitlement parseEntitlement(String body) throws IOException {
JsonNode root = mapper.readTree(body);
boolean subscribed = root.path("subscribed").asBoolean(false);
Expand All @@ -226,7 +286,45 @@ private InstanceEntitlement parseEntitlement(String body) throws IOException {
Long periodCap =
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
EntitlementState state = mapState(root.path("state").asText(null));
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
return new InstanceEntitlement(
subscribed,
freeRemaining,
periodSpend,
periodCap,
state,
parseUnitCalcPolicy(root),
parseDateTime(root, "periodStart"),
parseDateTime(root, "periodEnd"));
}

/** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
if (!root.hasNonNull("unitCalcPolicy")) {
return null;
}
JsonNode node = root.get("unitCalcPolicy");
try {
return new UnitCalcPolicy(
node.path("docPagesPerUnit").asInt(),
node.path("docBytesPerUnit").asLong(),
node.path("minChargeUnits").asInt(),
node.path("fileUnitCap").asInt());
} catch (RuntimeException e) {
// Malformed policy → degrade to "none" rather than fail the whole entitlement parse.
return null;
}
}

/** ISO date-time field → LocalDateTime; null if absent or unparseable. */
private static LocalDateTime parseDateTime(JsonNode root, String field) {
if (!root.hasNonNull(field)) {
return null;
}
try {
return LocalDateTime.parse(root.get(field).asText(null));
} catch (RuntimeException e) {
return null;
}
}

/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.io.IOException;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
Expand All @@ -23,7 +24,9 @@
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card.
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
* / test aid).
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
Expand All @@ -38,9 +41,17 @@
public class AccountLinkController {

private final AccountLinkService service;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
private final ObjectProvider<UsageSyncService> syncServiceProvider;

public AccountLinkController(AccountLinkService service) {
public AccountLinkController(
AccountLinkService service,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> syncServiceProvider) {
this.service = service;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}

/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
Expand Down Expand Up @@ -85,4 +96,29 @@ public ResponseEntity<Void> unlink() {
service.unlink();
return ResponseEntity.noContent().build();
}

/**
* Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend
* so "current usage" includes work done since the last daily sync.
*/
@GetMapping("/usage")
public ResponseEntity<LocalUsageService.LocalUsage> usage() {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}

/**
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
* {@code 409} when metering is off (the sync bean is absent).
*/
@PostMapping("/sync-now")
public ResponseEntity<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
if (sync == null) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
sync.syncNow();
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package stirling.software.proprietary.accountlink;

import java.time.Duration;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

Expand Down Expand Up @@ -36,4 +38,39 @@ public class AccountLinkProperties {

/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;

/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
private final Metering metering = new Metering();

/**
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
* enforcement. Both default off; metering requires the master flag too. This is the production
* safety key — flipping it on is what actually bills linked instances.
*/
@Getter
@Setter
public static class Metering {

/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
private boolean enabled = false;

/**
* How often the instance syncs usage + refreshes entitlement (matches the licence sync).
*/
private int syncIntervalHours = 24;

/**
* Block billable work after this many days with no successful sync (fail-open → closed).
*/
private int graceDays = 3;

/**
* Dedup window for identical input sets. A re-run of the same inputs within this window is
* treated as workflow chaining and not re-charged; the same inputs run again after it are
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
* costs the same on the instance and in the cloud.
*/
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package stirling.software.proprietary.accountlink;

import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
* so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
* sync SaaS accepted and drives the fail-open→closed grace window.
*
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
*/
@Entity
@Table(name = "account_link_sync_state")
@Getter
@Setter
@NoArgsConstructor
public class AccountLinkSyncState {

/** One instance links to one team → one bookkeeping row. */
public static final long SINGLETON_ID = 1L;

@Id private Long id;

// columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
@Column(
name = "last_sync_seq",
nullable = false,
columnDefinition = "bigint not null default 0")
private long lastSyncSeq;

/** Null until the first sync SaaS accepts. */
@Column(name = "last_success_at")
private LocalDateTime lastSuccessAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package stirling.software.proprietary.accountlink;

import org.springframework.data.jpa.repository.JpaRepository;

/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
Loading
Loading