Merge branch 'main' into fix_win_desktop_task
This commit is contained in:
+113
-15
@@ -6,6 +6,7 @@ import java.net.http.HttpClient;
|
||||
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;
|
||||
@@ -14,25 +15,31 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
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
|
||||
@@ -86,11 +93,9 @@ public class AccountLinkClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -142,11 +147,9 @@ public class AccountLinkClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -218,6 +221,63 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -226,7 +286,45 @@ public class AccountLinkClient {
|
||||
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. */
|
||||
|
||||
+38
-2
@@ -2,6 +2,7 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
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;
|
||||
@@ -23,7 +24,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* <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.
|
||||
@@ -38,9 +41,17 @@ import lombok.extern.slf4j.Slf4j;
|
||||
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. */
|
||||
@@ -85,4 +96,29 @@ public class AccountLinkController {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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;
|
||||
}
|
||||
+6
@@ -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> {}
|
||||
+28
-10
@@ -3,14 +3,26 @@ package stirling.software.proprietary.accountlink;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.BillingCategoryClassifier;
|
||||
|
||||
/**
|
||||
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
|
||||
* Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
|
||||
* HTTP-level signals (no dependency on the saas module):
|
||||
*
|
||||
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
|
||||
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
|
||||
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
|
||||
* sub-steps). Everything else — interactive manual PDF tools — is always free.
|
||||
* <ul>
|
||||
* <li><b>AUTOMATION</b> — the automation marker header ({@link
|
||||
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
|
||||
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
|
||||
* <li><b>API</b> — an API-key authenticated tool call;
|
||||
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared
|
||||
* {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
|
||||
* saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
|
||||
* caller (resolved from the security context), so this class stays free of any security-type
|
||||
* dependency.
|
||||
*/
|
||||
public final class BillableOperationClassifier {
|
||||
|
||||
@@ -18,16 +30,22 @@ public final class BillableOperationClassifier {
|
||||
|
||||
private BillableOperationClassifier() {}
|
||||
|
||||
public static boolean isBillable(HttpServletRequest request) {
|
||||
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* @param apiKey whether the request authenticated via an API key (an {@code
|
||||
* ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
|
||||
*/
|
||||
public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
|
||||
boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
|
||||
return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
|
||||
}
|
||||
|
||||
private static boolean isAiSurface(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
|
||||
String ctx = request.getContextPath();
|
||||
String path =
|
||||
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
|
||||
|
||||
+27
-24
@@ -12,18 +12,13 @@ import org.springframework.stereotype.Service;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
|
||||
* every billable request. Single-slot (one instance = one linked team), TTL-based.
|
||||
* Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable
|
||||
* request. Single-slot (one instance = one linked team), TTL-based.
|
||||
*
|
||||
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
|
||||
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
|
||||
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
|
||||
* allow").
|
||||
*
|
||||
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
|
||||
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
|
||||
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
|
||||
* rather than serving a stale entitled snapshot.
|
||||
* <p>A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has
|
||||
* and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An
|
||||
* authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is
|
||||
* replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -63,9 +58,8 @@ public class EntitlementCache {
|
||||
* not linked or the SaaS side is unreachable and we have no prior snapshot.
|
||||
*/
|
||||
public Optional<InstanceEntitlement> current() {
|
||||
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
|
||||
// call) while concurrent callers serve the last snapshot — no thundering herd of
|
||||
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
|
||||
// Single-flight: when stale, exactly one thread refreshes while concurrent callers serve
|
||||
// the last snapshot — no thundering herd of round-trips on the billable hot path.
|
||||
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
|
||||
try {
|
||||
refresh();
|
||||
@@ -77,16 +71,15 @@ public class EntitlementCache {
|
||||
}
|
||||
|
||||
private boolean isStale(Snapshot snap) {
|
||||
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
|
||||
// fetch backs off for a full TTL instead of every billable request re-triggering a
|
||||
// blocking round-trip against a dead/slow SaaS endpoint.
|
||||
// fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch
|
||||
// backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS.
|
||||
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
|
||||
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
|
||||
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
|
||||
* Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the
|
||||
* attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a
|
||||
* blocked snapshot.
|
||||
*/
|
||||
void refresh() {
|
||||
Optional<DeviceCredential> cred = credentialStore.get();
|
||||
@@ -101,15 +94,15 @@ public class EntitlementCache {
|
||||
if (fresh != null) {
|
||||
snapshot = new Snapshot(fresh, Instant.now());
|
||||
} else {
|
||||
// Unreachable / server error: keep the last known entitlement (may be null) but
|
||||
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
|
||||
// Unreachable / server error: keep the last known entitlement but stamp the attempt
|
||||
// so we don't hammer SaaS; the gate fails open meanwhile.
|
||||
log.debug(
|
||||
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
|
||||
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
|
||||
}
|
||||
} catch (AccountLinkClient.RevokedException e) {
|
||||
// Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
|
||||
// rather than serving the stale entitled snapshot until the next unlink.
|
||||
// Authoritative deny — block immediately rather than serving the stale entitled
|
||||
// snapshot.
|
||||
log.info(
|
||||
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
|
||||
e.status());
|
||||
@@ -121,4 +114,14 @@ public class EntitlementCache {
|
||||
public void invalidate() {
|
||||
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh
|
||||
* one), saving a redundant fetch. No-op on null.
|
||||
*/
|
||||
public void accept(InstanceEntitlement fresh) {
|
||||
if (fresh != null) {
|
||||
snapshot = new Snapshot(fresh, Instant.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -16,6 +16,11 @@ public record GateDecision(boolean allowed, Reason reason) {
|
||||
ENTITLED,
|
||||
/** Entitlement source unreachable — fail open, allow. */
|
||||
FAIL_OPEN,
|
||||
/**
|
||||
* Linked + metering, but SaaS has been unreachable past the grace window — block (the
|
||||
* fail-open backstop expired) so unbounded free/unbilled billable work can't continue.
|
||||
*/
|
||||
GRACE_EXPIRED,
|
||||
/** Not linked — block billable work; FE should prompt to link. */
|
||||
NOT_LINKED,
|
||||
/** Linked but over the limit / no subscription — block billable work. */
|
||||
|
||||
+38
-4
@@ -1,19 +1,53 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
/**
|
||||
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response —
|
||||
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
|
||||
* saas types.
|
||||
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response.
|
||||
* Mirrors the saas {@code EntitlementResponse} shape but carries no saas types.
|
||||
*
|
||||
* <p>The first five fields are what the <b>gate</b> enforces against; the trailing three are the
|
||||
* metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its
|
||||
* per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the
|
||||
* revoked sentinel and unit tests that don't exercise metering.
|
||||
*
|
||||
* @param subscribed team has an active subscription
|
||||
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
|
||||
* @param periodSpendUnits paid units spent this period
|
||||
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
|
||||
* @param state coarse state classification (see {@link EntitlementState})
|
||||
* @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not
|
||||
* supplied (older SaaS / gate-only sentinel)
|
||||
* @param periodStart inclusive start of the current billing period; {@code null} if not supplied
|
||||
* @param periodEnd exclusive end of the current billing period; {@code null} if not supplied
|
||||
*/
|
||||
public record InstanceEntitlement(
|
||||
boolean subscribed,
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
EntitlementState state) {}
|
||||
EntitlementState state,
|
||||
UnitCalcPolicy unitCalcPolicy,
|
||||
LocalDateTime periodStart,
|
||||
LocalDateTime periodEnd) {
|
||||
|
||||
/** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */
|
||||
public InstanceEntitlement(
|
||||
boolean subscribed,
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
EntitlementState state) {
|
||||
this(
|
||||
subscribed,
|
||||
freeRemainingUnits,
|
||||
periodSpendUnits,
|
||||
periodCapUnits,
|
||||
state,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-16
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -15,14 +16,17 @@ import org.springframework.stereotype.Service;
|
||||
* <li>Flag off → always allow (feature inert).
|
||||
* <li>Manual tool → always allow (manual tools are free, never metered).
|
||||
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
|
||||
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
|
||||
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow — unless
|
||||
* metering is on and SaaS has been unreachable past the grace window, then block with {@code
|
||||
* GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever.
|
||||
* <li>Billable + linked + entitled → allow.
|
||||
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
|
||||
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
|
||||
* </ol>
|
||||
*
|
||||
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
|
||||
* live flag / linked-state / entitlement. This is the unit-tested core.
|
||||
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live
|
||||
* flag / linked-state / entitlement and computes whether the grace window has expired. This is the
|
||||
* unit-tested core.
|
||||
*/
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@@ -32,14 +36,20 @@ public class InstanceEntitlementGate {
|
||||
private final AccountLinkProperties properties;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final AccountLinkSyncStateRepository syncStateRepository;
|
||||
private final LocalUsageService localUsageService;
|
||||
|
||||
public InstanceEntitlementGate(
|
||||
AccountLinkProperties properties,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache) {
|
||||
EntitlementCache entitlementCache,
|
||||
AccountLinkSyncStateRepository syncStateRepository,
|
||||
LocalUsageService localUsageService) {
|
||||
this.properties = properties;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.syncStateRepository = syncStateRepository;
|
||||
this.localUsageService = localUsageService;
|
||||
}
|
||||
|
||||
/** Evaluates the gate for a request, resolving live state from the store + cache. */
|
||||
@@ -53,18 +63,39 @@ public class InstanceEntitlementGate {
|
||||
boolean linked = credentialStore.isLinked();
|
||||
Optional<InstanceEntitlement> entitlement =
|
||||
linked ? entitlementCache.current() : Optional.empty();
|
||||
return decide(true, true, linked, entitlement);
|
||||
boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired();
|
||||
// Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped
|
||||
// subscription) — by local usage not yet synced, so the gate stops in real time instead of
|
||||
// overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0.
|
||||
long pendingUnsynced =
|
||||
entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false)
|
||||
? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits()
|
||||
: 0L;
|
||||
return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced);
|
||||
}
|
||||
|
||||
/** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */
|
||||
private static boolean depletesCeiling(InstanceEntitlement e) {
|
||||
return !e.subscribed() || e.periodCapUnits() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
|
||||
* (unreachable): when linked, that fails open.
|
||||
* (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace
|
||||
* window elapsed with no authoritative contact), in which case it blocks.
|
||||
*
|
||||
* @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the
|
||||
* free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate
|
||||
* stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement
|
||||
* cases, where it has no effect).
|
||||
*/
|
||||
public static GateDecision decide(
|
||||
boolean flagEnabled,
|
||||
boolean billable,
|
||||
boolean linked,
|
||||
Optional<InstanceEntitlement> entitlement) {
|
||||
Optional<InstanceEntitlement> entitlement,
|
||||
boolean graceExpired,
|
||||
long pendingUnsyncedUnits) {
|
||||
if (!flagEnabled) {
|
||||
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
|
||||
}
|
||||
@@ -75,30 +106,69 @@ public class InstanceEntitlementGate {
|
||||
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
|
||||
}
|
||||
if (entitlement.isEmpty()) {
|
||||
// Linked but entitlement source unreachable — never hard-block billable work on our
|
||||
// inability to reach billing.
|
||||
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
|
||||
// Linked but entitlement unreachable: fail open, unless the grace window has expired
|
||||
// (so
|
||||
// the fail-open can't grant unbounded unbilled work forever).
|
||||
return graceExpired
|
||||
? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED)
|
||||
: GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
|
||||
}
|
||||
InstanceEntitlement e = entitlement.get();
|
||||
if (e.state() == EntitlementState.REVOKED) {
|
||||
// Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
|
||||
return GateDecision.block(GateDecision.Reason.REVOKED);
|
||||
}
|
||||
return entitled(e)
|
||||
return entitled(e, pendingUnsyncedUnits)
|
||||
? GateDecision.allow(GateDecision.Reason.ENTITLED)
|
||||
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when metering is on and it's been {@code graceDays} since the last authoritative contact
|
||||
* (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off
|
||||
* disables the backstop.
|
||||
*/
|
||||
private boolean isGraceExpired() {
|
||||
AccountLinkProperties.Metering metering = properties.getMetering();
|
||||
if (!metering.isEnabled() || metering.getGraceDays() <= 0) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime reference = lastAuthoritativeContact();
|
||||
if (reference == null) {
|
||||
return false; // can't determine elapsed time → fail open
|
||||
}
|
||||
return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private LocalDateTime lastAuthoritativeContact() {
|
||||
LocalDateTime lastSuccess =
|
||||
syncStateRepository
|
||||
.findById(AccountLinkSyncState.SINGLETON_ID)
|
||||
.map(AccountLinkSyncState::getLastSuccessAt)
|
||||
.orElse(null);
|
||||
if (lastSuccess != null) {
|
||||
return lastSuccess;
|
||||
}
|
||||
return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null);
|
||||
}
|
||||
|
||||
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
|
||||
private static boolean entitled(InstanceEntitlement e) {
|
||||
private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) {
|
||||
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
|
||||
return false;
|
||||
}
|
||||
if (e.subscribed()) {
|
||||
// Subscribed: allowed unless a period cap is set and exceeded.
|
||||
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
|
||||
if (e.periodCapUnits() == null) {
|
||||
return true; // uncapped subscription
|
||||
}
|
||||
// Project the cap the way the grant is projected: synced paid spend plus the paid part
|
||||
// of local usage not yet synced (free grant is consumed first, so only the excess
|
||||
// bills) — stops at the cap in real time instead of overshooting until the next sync.
|
||||
long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits());
|
||||
return e.periodSpendUnits() + pendingPaid < e.periodCapUnits();
|
||||
}
|
||||
// Unsubscribed: only the free pool covers billable work.
|
||||
return e.freeRemainingUnits() > 0;
|
||||
// Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local
|
||||
// usage not yet synced — deplete by the pending delta so we stop at the grant in real time.
|
||||
return e.freeRemainingUnits() - pendingUnsyncedUnits > 0;
|
||||
}
|
||||
}
|
||||
|
||||
+190
-12
@@ -1,27 +1,51 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.util.WebUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.ContentHasher;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
|
||||
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
|
||||
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
|
||||
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
|
||||
*
|
||||
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
|
||||
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
|
||||
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
|
||||
* responses). Fail-open and flag-off both let the request continue.
|
||||
*
|
||||
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
|
||||
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
|
||||
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
|
||||
* prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind
|
||||
* {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link
|
||||
* UsageMeterService} bean is absent and nothing accrues, while the gate still works.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -29,10 +53,23 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final InstanceEntitlementGate gate;
|
||||
private static final String ATTR_CATEGORY =
|
||||
InstanceEntitlementInterceptor.class.getName() + ".category";
|
||||
|
||||
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
|
||||
private final InstanceEntitlementGate gate;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final ObjectProvider<UsageMeterService> meterProvider;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public InstanceEntitlementInterceptor(
|
||||
InstanceEntitlementGate gate,
|
||||
EntitlementCache entitlementCache,
|
||||
ObjectProvider<UsageMeterService> meterProvider,
|
||||
TempFileManager tempFileManager) {
|
||||
this.gate = gate;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.meterProvider = meterProvider;
|
||||
this.tempFileManager = tempFileManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -41,7 +78,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
throws Exception {
|
||||
GateDecision decision;
|
||||
try {
|
||||
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
|
||||
// API-key tool calls are billable (category API); stash the category for the meter.
|
||||
boolean apiKey =
|
||||
SecurityContextHolder.getContext().getAuthentication()
|
||||
instanceof ApiKeyAuthenticationToken;
|
||||
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
|
||||
request.setAttribute(ATTR_CATEGORY, category);
|
||||
decision = gate.evaluate(category != BillingCategory.BYPASSED);
|
||||
} catch (RuntimeException e) {
|
||||
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
|
||||
// turn into a hard block on billable work.
|
||||
@@ -62,4 +105,139 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
+ "\"}");
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex) {
|
||||
// Meter successful billable ops only.
|
||||
if (ex != null || response.getStatus() >= 400) {
|
||||
return;
|
||||
}
|
||||
UsageMeterService meter = meterProvider.getIfAvailable();
|
||||
if (meter == null) {
|
||||
return; // metering switch off
|
||||
}
|
||||
if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category)
|
||||
|| category == BillingCategory.BYPASSED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
InstanceEntitlement ent = entitlementCache.current().orElse(null);
|
||||
if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
|
||||
// Not yet synced (no policy/period) — can't compute units; skip until next sync.
|
||||
return;
|
||||
}
|
||||
meterRequest(request, category, ent, meter);
|
||||
} catch (RuntimeException e) {
|
||||
// Metering must never affect the response that already completed.
|
||||
log.debug("Usage metering failed for {}", request.getRequestURI(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance
|
||||
* is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy
|
||||
* but small PDF must be page-counted or it under-bills. A fileless op has no input identity —
|
||||
* null signature (no dedup), billed the 1-unit floor each time.
|
||||
*/
|
||||
private void meterRequest(
|
||||
HttpServletRequest request,
|
||||
BillingCategory category,
|
||||
InstanceEntitlement ent,
|
||||
UsageMeterService meter) {
|
||||
UnitCalcPolicy policy = ent.unitCalcPolicy();
|
||||
MultipartHttpServletRequest mreq =
|
||||
WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
|
||||
if (mreq == null) {
|
||||
long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy);
|
||||
meter.accrue(ent.periodStart(), category, fileless, null);
|
||||
return;
|
||||
}
|
||||
List<TempFile> temps = new ArrayList<>();
|
||||
try {
|
||||
List<FileSize> sizes = new ArrayList<>();
|
||||
List<String> hashes = new ArrayList<>();
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> files : mreq.getMultiFileMap().values()) {
|
||||
for (MultipartFile f : files) {
|
||||
fileCount++;
|
||||
try {
|
||||
TempFile temp = tempFileManager.createManagedTempFile(".bin");
|
||||
temps.add(temp);
|
||||
// Hash in the same pass that writes the temp file — one read of the upload,
|
||||
// not a second full read just to fingerprint it.
|
||||
MessageDigest digest = ContentHasher.newSha256();
|
||||
try (InputStream in = f.getInputStream();
|
||||
DigestOutputStream out =
|
||||
new DigestOutputStream(
|
||||
Files.newOutputStream(temp.getPath()), digest)) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize()));
|
||||
hashes.add(ContentHasher.toHex(digest.digest()));
|
||||
} catch (IOException | RuntimeException perFile) {
|
||||
// Couldn't materialise/hash this input — bill on bytes only and, by leaving
|
||||
// it out of `hashes`, drop dedup for the whole op rather than risk a
|
||||
// mismatch.
|
||||
log.debug(
|
||||
"Metering materialise/hash failed for {}; bytes-only",
|
||||
f.getOriginalFilename());
|
||||
sizes.add(new FileSize(0, f.getSize()));
|
||||
}
|
||||
}
|
||||
}
|
||||
long units =
|
||||
sizes.isEmpty()
|
||||
? DocumentUnitCalculator.unitsForFile(0, 0, policy)
|
||||
: DocumentUnitCalculator.unitsForGroup(sizes, policy);
|
||||
// Only dedup when every input hashed; a partial signature could collide with a
|
||||
// different input set, so fall back to no-dedup (bill it) if any file failed.
|
||||
String opSignature =
|
||||
fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null;
|
||||
meter.accrue(ent.periodStart(), category, units, opSignature);
|
||||
} finally {
|
||||
for (TempFile temp : temps) {
|
||||
try {
|
||||
temp.close();
|
||||
} catch (RuntimeException cleanup) {
|
||||
log.debug("Temp file cleanup failed: {}", cleanup.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */
|
||||
private static int pageCount(Path path, MultipartFile file) {
|
||||
if (!isPdf(file)) {
|
||||
return 0;
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(path)) {
|
||||
return doc.pageCount();
|
||||
} catch (RuntimeException e) {
|
||||
// Malformed / encrypted → byte axis only, matching the SaaS classifier.
|
||||
log.debug(
|
||||
"Page count unavailable for {}; metering on bytes only",
|
||||
file.getOriginalFilename());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Order-independent signature of the input set: sorted per-file hashes, hashed together. */
|
||||
private static String opSignature(List<String> hashes) {
|
||||
List<String> sorted = new ArrayList<>(hashes);
|
||||
Collections.sort(sorted);
|
||||
return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static boolean isPdf(MultipartFile file) {
|
||||
String contentType = file.getContentType();
|
||||
if (contentType != null && contentType.toLowerCase().contains("pdf")) {
|
||||
return true;
|
||||
}
|
||||
String name = file.getOriginalFilename();
|
||||
return name != null && name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.EnumMap;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal
|
||||
* adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync.
|
||||
*
|
||||
* <p>Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0), scoped to
|
||||
* the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown
|
||||
* or metering is off.
|
||||
*/
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class LocalUsageService {
|
||||
|
||||
private final UsageCounterRepository counters;
|
||||
private final EntitlementCache entitlementCache;
|
||||
|
||||
public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) {
|
||||
this.counters = counters;
|
||||
this.entitlementCache = entitlementCache;
|
||||
}
|
||||
|
||||
/** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */
|
||||
public record LocalUsage(
|
||||
LocalDateTime periodStart,
|
||||
long apiUnsyncedUnits,
|
||||
long aiUnsyncedUnits,
|
||||
long automationUnsyncedUnits,
|
||||
long totalUnsyncedUnits) {}
|
||||
|
||||
public LocalUsage currentPeriodUnsynced() {
|
||||
LocalDateTime period =
|
||||
entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null);
|
||||
if (period == null) {
|
||||
return new LocalUsage(null, 0, 0, 0, 0);
|
||||
}
|
||||
EnumMap<BillingCategory, Long> unsynced = new EnumMap<>(BillingCategory.class);
|
||||
for (UsageCounter c : counters.findByPeriodStart(period)) {
|
||||
BillingCategory cat = c.billingCategory();
|
||||
if (cat != null && cat != BillingCategory.BYPASSED) {
|
||||
unsynced.merge(cat, c.unsyncedUnits(), Long::sum);
|
||||
}
|
||||
}
|
||||
long api = unsynced.getOrDefault(BillingCategory.API, 0L);
|
||||
long ai = unsynced.getOrDefault(BillingCategory.AI, 0L);
|
||||
long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L);
|
||||
return new LocalUsage(period, api, ai, automation, api + ai + automation);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* The last time the instance metered a given input set this period — the local equivalent of the
|
||||
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
|
||||
* window</b>: an identical input set re-submitted within the window (see {@link
|
||||
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
|
||||
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
|
||||
* window so the same operation costs the same on the instance and in the cloud.
|
||||
*
|
||||
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
|
||||
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
|
||||
* makes the first-sighting insert an atomic claim under concurrency.
|
||||
*
|
||||
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_metered_signature",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_account_link_metered_signature",
|
||||
columnNames = {"period_start", "signature"}))
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class MeteredInputSignature {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
/** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */
|
||||
@Column(name = "signature", nullable = false, length = 64)
|
||||
private String signature;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* When this input set was last metered — the anchor the workflow-window dedup compares against.
|
||||
*/
|
||||
@Column(name = "last_metered_at")
|
||||
private LocalDateTime lastMeteredAt;
|
||||
|
||||
public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) {
|
||||
this.periodStart = periodStart;
|
||||
this.signature = signature;
|
||||
this.createdAt = at;
|
||||
this.lastMeteredAt = at;
|
||||
}
|
||||
|
||||
/** Slides the window forward — the input set was seen again. */
|
||||
public void touch(LocalDateTime at) {
|
||||
this.lastMeteredAt = at;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
|
||||
public interface MeteredInputSignatureRepository
|
||||
extends JpaRepository<MeteredInputSignature, Long> {
|
||||
|
||||
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
|
||||
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
|
||||
LocalDateTime periodStart, String signature);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
|
||||
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
|
||||
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
|
||||
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_usage_counter",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_usage_counter_period_category",
|
||||
columnNames = {"period_start", "category"}))
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UsageCounter {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Inclusive start of the billing period this counter belongs to (from the entitlement sync).
|
||||
*/
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
/** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */
|
||||
@Column(name = "category", nullable = false, length = 32)
|
||||
private String category;
|
||||
|
||||
/** Running total of metered units in this period+category. */
|
||||
@Column(name = "cumulative_units", nullable = false)
|
||||
private long cumulativeUnits;
|
||||
|
||||
/**
|
||||
* {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported
|
||||
* usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default
|
||||
* keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already
|
||||
* populated (NOT NULL with no default would fail the ALTER).
|
||||
*/
|
||||
@Column(
|
||||
name = "last_synced_units",
|
||||
nullable = false,
|
||||
columnDefinition = "bigint not null default 0")
|
||||
private long lastSyncedUnits;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Fresh-accrual row: nothing synced yet. */
|
||||
public UsageCounter(
|
||||
LocalDateTime periodStart,
|
||||
String category,
|
||||
long cumulativeUnits,
|
||||
LocalDateTime updatedAt) {
|
||||
this(periodStart, category, cumulativeUnits, 0L, updatedAt);
|
||||
}
|
||||
|
||||
public UsageCounter(
|
||||
LocalDateTime periodStart,
|
||||
String category,
|
||||
long cumulativeUnits,
|
||||
long lastSyncedUnits,
|
||||
LocalDateTime updatedAt) {
|
||||
this.periodStart = periodStart;
|
||||
this.category = category;
|
||||
this.cumulativeUnits = cumulativeUnits;
|
||||
this.lastSyncedUnits = lastSyncedUnits;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
/** This row's category as the enum, or {@code null} for an unrecognised stored value. */
|
||||
public BillingCategory billingCategory() {
|
||||
try {
|
||||
return BillingCategory.valueOf(category);
|
||||
} catch (IllegalArgumentException unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Units accrued but not yet accepted by SaaS (floored at 0). */
|
||||
public long unsyncedUnits() {
|
||||
return Math.max(0, cumulativeUnits - lastSyncedUnits);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
|
||||
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
* Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated
|
||||
* (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a
|
||||
* read-modify-write race between concurrent billable requests.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta,"
|
||||
+ " c.updatedAt = :now"
|
||||
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
|
||||
int increment(
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category,
|
||||
@Param("delta") long delta,
|
||||
@Param("now") LocalDateTime now);
|
||||
|
||||
/** All counters for a period — the daily sync reads these to report cumulative totals. */
|
||||
List<UsageCounter> findByPeriodStart(LocalDateTime periodStart);
|
||||
|
||||
/**
|
||||
* Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each
|
||||
* so end-of-period usage isn't stranded when the billing period rolls over between syncs.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT DISTINCT c.periodStart FROM UsageCounter c"
|
||||
+ " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart")
|
||||
List<LocalDateTime> findPeriodsWithUnsyncedUsage();
|
||||
|
||||
/**
|
||||
* Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by
|
||||
* SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits"
|
||||
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
|
||||
int markSynced(
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category,
|
||||
@Param("syncedUnits") long syncedUnits);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily
|
||||
* sync later reports the cumulative totals to SaaS.
|
||||
*
|
||||
* <p>Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow-
|
||||
* window} is treated as chaining and not re-charged; the same inputs run again after the window are
|
||||
* billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the
|
||||
* instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue}
|
||||
* is best-effort: callers need not handle persistence errors.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.billing.account-link.metering.enabled",
|
||||
havingValue = "true")
|
||||
public class UsageMeterService {
|
||||
|
||||
private final UsageCounterRepository repo;
|
||||
private final MeteredInputSignatureRepository signatureRepo;
|
||||
private final Duration workflowWindow;
|
||||
|
||||
public UsageMeterService(
|
||||
UsageCounterRepository repo,
|
||||
MeteredInputSignatureRepository signatureRepo,
|
||||
AccountLinkProperties properties) {
|
||||
this.repo = repo;
|
||||
this.signatureRepo = signatureRepo;
|
||||
this.workflowWindow = properties.getMetering().getWorkflowWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first
|
||||
* use), unless {@code opSignature} was already metered this period. No-ops for non-billable
|
||||
* categories, non-positive units, or a missing period.
|
||||
*/
|
||||
public void accrue(
|
||||
LocalDateTime periodStart, BillingCategory category, long units, String opSignature) {
|
||||
if (periodStart == null
|
||||
|| category == null
|
||||
|| category == BillingCategory.BYPASSED
|
||||
|| units <= 0) {
|
||||
return;
|
||||
}
|
||||
if (opSignature != null && !shouldCharge(periodStart, opSignature)) {
|
||||
return; // identical inputs seen within the workflow window — chaining, already billed
|
||||
}
|
||||
incrementOrInsert(periodStart, category.name(), units);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this input set should be charged: unseen this period, or last seen outside the
|
||||
* workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and
|
||||
* slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge.
|
||||
*/
|
||||
private boolean shouldCharge(LocalDateTime periodStart, String opSignature) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
MeteredInputSignature seen =
|
||||
signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null);
|
||||
if (seen == null) {
|
||||
try {
|
||||
signatureRepo.saveAndFlush(
|
||||
new MeteredInputSignature(periodStart, opSignature, now));
|
||||
return true; // first sighting this period
|
||||
} catch (DataIntegrityViolationException raced) {
|
||||
return false; // a concurrent op just claimed it — within window → chaining
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now;
|
||||
boolean withinWindow = last.isAfter(now.minus(workflowWindow));
|
||||
try {
|
||||
seen.touch(now);
|
||||
signatureRepo.save(seen);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage());
|
||||
}
|
||||
return !withinWindow;
|
||||
}
|
||||
|
||||
private void incrementOrInsert(LocalDateTime periodStart, String category, long units) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
try {
|
||||
if (repo.increment(periodStart, category, units, now) > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
repo.saveAndFlush(new UsageCounter(periodStart, category, units, now));
|
||||
} catch (DataIntegrityViolationException raceLostInsert) {
|
||||
// A concurrent request inserted the row first — increment the now-existing row.
|
||||
repo.increment(periodStart, category, units, now);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Metering must never break the request it rode in on; a lost accrual self-heals on the
|
||||
// next increment and the daily sync reports the cumulative total either way.
|
||||
log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.SchedulingConfigurer;
|
||||
import org.springframework.scheduling.config.FixedDelayTask;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
|
||||
* usage to SaaS, which bills the delta against its own last-seen totals.
|
||||
*
|
||||
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
|
||||
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
|
||||
* usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All
|
||||
* periods with unsynced usage are reported so nothing is stranded when the period rolls over
|
||||
* between syncs.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.billing.account-link.metering.enabled",
|
||||
havingValue = "true")
|
||||
public class UsageSyncService implements SchedulingConfigurer {
|
||||
|
||||
// First run waits out startup churn; then every interval.
|
||||
private static final Duration INITIAL_DELAY = Duration.ofMinutes(5);
|
||||
|
||||
private final UsageCounterRepository counters;
|
||||
private final AccountLinkSyncStateRepository syncState;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final AccountLinkClient client;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final AccountLinkProperties properties;
|
||||
|
||||
public UsageSyncService(
|
||||
UsageCounterRepository counters,
|
||||
AccountLinkSyncStateRepository syncState,
|
||||
DeviceCredentialStore credentialStore,
|
||||
AccountLinkClient client,
|
||||
EntitlementCache entitlementCache,
|
||||
AccountLinkProperties properties) {
|
||||
this.counters = counters;
|
||||
this.syncState = syncState;
|
||||
this.credentialStore = credentialStore;
|
||||
this.client = client;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in
|
||||
* code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather
|
||||
* than only on a flags-on run.
|
||||
*/
|
||||
@Override
|
||||
public void configureTasks(ScheduledTaskRegistrar registrar) {
|
||||
Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours());
|
||||
registrar.addFixedDelayTask(
|
||||
new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY));
|
||||
}
|
||||
|
||||
public void scheduledSync() {
|
||||
try {
|
||||
syncNow();
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Scheduled usage sync failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports every period with unsynced usage and refreshes the cached entitlement from the reply.
|
||||
* Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when
|
||||
* unlinked or when nothing is pending.
|
||||
*/
|
||||
public void syncNow() {
|
||||
Optional<DeviceCredential> cred = credentialStore.get();
|
||||
if (cred.isEmpty()) {
|
||||
return; // not linked
|
||||
}
|
||||
List<LocalDateTime> periods = counters.findPeriodsWithUnsyncedUsage();
|
||||
if (periods.isEmpty()) {
|
||||
// Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement
|
||||
// change (e.g. the admin just subscribed) that otherwise wouldn't surface until the
|
||||
// cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now.
|
||||
entitlementCache.invalidate();
|
||||
entitlementCache.current();
|
||||
return;
|
||||
}
|
||||
InstanceEntitlement latest = null;
|
||||
try {
|
||||
for (LocalDateTime period : periods) {
|
||||
InstanceEntitlement fresh = syncPeriod(cred.get(), period);
|
||||
if (fresh != null) {
|
||||
latest = fresh;
|
||||
}
|
||||
}
|
||||
} catch (AccountLinkClient.RevokedException e) {
|
||||
// Authoritative deny — stop reporting; the entitlement cache blocks billable work on
|
||||
// its
|
||||
// own next refresh, so we don't synthesise the blocked state here.
|
||||
log.info(
|
||||
"Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next"
|
||||
+ " refresh",
|
||||
e.status());
|
||||
return;
|
||||
}
|
||||
// Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch.
|
||||
entitlementCache.accept(latest);
|
||||
}
|
||||
|
||||
/** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */
|
||||
private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) {
|
||||
EnumMap<BillingCategory, Long> cumulative = new EnumMap<>(BillingCategory.class);
|
||||
for (UsageCounter c : counters.findByPeriodStart(period)) {
|
||||
BillingCategory cat = c.billingCategory();
|
||||
if (cat != null && cat != BillingCategory.BYPASSED) {
|
||||
cumulative.merge(cat, c.getCumulativeUnits(), Long::sum);
|
||||
}
|
||||
}
|
||||
AccountLinkSyncState state = loadState();
|
||||
long seq = reserveNextSeq(state);
|
||||
InstanceEntitlement fresh =
|
||||
client.reportUsage(
|
||||
cred.getDeviceId(),
|
||||
cred.getDeviceSecret(),
|
||||
seq,
|
||||
period,
|
||||
cumulative.getOrDefault(BillingCategory.API, 0L),
|
||||
cumulative.getOrDefault(BillingCategory.AI, 0L),
|
||||
cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L));
|
||||
if (fresh == null) {
|
||||
// Transport/server failure: leave the synced markers untouched. The burned seq is
|
||||
// harmless (seqs need only be monotonic) and the delta bills on the next successful
|
||||
// sync.
|
||||
return null;
|
||||
}
|
||||
recordSuccess(period, cumulative, state);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Reserves and persists the next strictly-increasing sequence before the report goes out. */
|
||||
private long reserveNextSeq(AccountLinkSyncState state) {
|
||||
long next = state.getLastSyncSeq() + 1;
|
||||
state.setLastSyncSeq(next);
|
||||
syncState.save(state);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the per-category synced markers to the reported totals + stamps the success time.
|
||||
*/
|
||||
private void recordSuccess(
|
||||
LocalDateTime period,
|
||||
EnumMap<BillingCategory, Long> cumulative,
|
||||
AccountLinkSyncState state) {
|
||||
cumulative.forEach(
|
||||
(category, units) -> {
|
||||
if (units > 0) {
|
||||
counters.markSynced(period, category.name(), units);
|
||||
}
|
||||
});
|
||||
state.setLastSuccessAt(LocalDateTime.now());
|
||||
syncState.save(state);
|
||||
}
|
||||
|
||||
private AccountLinkSyncState loadState() {
|
||||
return syncState
|
||||
.findById(AccountLinkSyncState.SINGLETON_ID)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
AccountLinkSyncState s = new AccountLinkSyncState();
|
||||
s.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
return s;
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so
|
||||
* category is metadata only and never affects price.
|
||||
*
|
||||
* <p>Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
|
||||
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
|
||||
* billed.
|
||||
*
|
||||
* <p>Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted
|
||||
* instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} /
|
||||
* {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the
|
||||
* same names. (We deliberately do not share one enum across the modules: that would drag the SaaS
|
||||
* billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.)
|
||||
*/
|
||||
public enum BillingCategory {
|
||||
BYPASSED,
|
||||
API,
|
||||
AI,
|
||||
AUTOMATION
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a
|
||||
* linked self-hosted instance classify identically. Each backend resolves the three signals from
|
||||
* its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature}
|
||||
* annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this
|
||||
* applies the order {@code AUTOMATION → AI → API → BYPASSED}.
|
||||
*
|
||||
* <p>An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the
|
||||
* automation header dominates), while a direct call to it bills as {@code AI}.
|
||||
*/
|
||||
public final class BillingCategoryClassifier {
|
||||
|
||||
private BillingCategoryClassifier() {}
|
||||
|
||||
public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) {
|
||||
if (automation) {
|
||||
return BillingCategory.AUTOMATION;
|
||||
}
|
||||
if (ai) {
|
||||
return BillingCategory.AI;
|
||||
}
|
||||
if (apiKey) {
|
||||
return BillingCategory.API;
|
||||
}
|
||||
return BillingCategory.BYPASSED;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
|
||||
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
|
||||
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
|
||||
* of file size), hardware-accelerated by the JVM where available.
|
||||
*
|
||||
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
|
||||
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
|
||||
*/
|
||||
public final class ContentHasher {
|
||||
|
||||
private static final String ALGORITHM = "SHA-256";
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private ContentHasher() {}
|
||||
|
||||
/** Lower-case hex SHA-256 of the file's bytes. */
|
||||
public static String sha256(Path file) throws IOException {
|
||||
MessageDigest digest = newDigest();
|
||||
try (InputStream raw = Files.newInputStream(file);
|
||||
DigestInputStream in = new DigestInputStream(raw, digest)) {
|
||||
byte[] buf = new byte[BUFFER_SIZE];
|
||||
while (in.read(buf) != -1) {
|
||||
// drain through the digest; we only want the side effect
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
/** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */
|
||||
public static String sha256(byte[] bytes) {
|
||||
return HexFormat.of().formatHex(newDigest().digest(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh SHA-256 digest, for callers that stream bytes through a {@link
|
||||
* java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a
|
||||
* second full read just to fingerprint it. Pair with {@link #toHex(byte[])}.
|
||||
*/
|
||||
public static MessageDigest newSha256() {
|
||||
return newDigest();
|
||||
}
|
||||
|
||||
/** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */
|
||||
public static String toHex(byte[] digest) {
|
||||
return HexFormat.of().formatHex(digest);
|
||||
}
|
||||
|
||||
private static MessageDigest newDigest() {
|
||||
try {
|
||||
return MessageDigest.getInstance(ALGORITHM);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// SHA-256 is mandated by every JDK; unreachable in practice.
|
||||
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both
|
||||
* cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however
|
||||
* their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}.
|
||||
*
|
||||
* <p>Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code
|
||||
* ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis
|
||||
* contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the
|
||||
* <em>raw</em> per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not
|
||||
* per-file-clamped, units so the group cap can actually bind).
|
||||
*
|
||||
* <p>{@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this
|
||||
* enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on
|
||||
* "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code
|
||||
* DefaultDocumentClassifier} to preserve behaviour.
|
||||
*/
|
||||
public final class DocumentUnitCalculator {
|
||||
|
||||
/** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */
|
||||
public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
|
||||
|
||||
private DocumentUnitCalculator() {}
|
||||
|
||||
/** One file's page count (0 for non-PDF / unreadable) and byte size. */
|
||||
public record FileSize(int pages, long bytes) {}
|
||||
|
||||
/** Raw (unclamped) units for one file. */
|
||||
public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) {
|
||||
long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L;
|
||||
long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit());
|
||||
return Math.max(pageUnits, byteUnits);
|
||||
}
|
||||
|
||||
/** Units for a single file, clamped to {@code [1, fileUnitCap]}. */
|
||||
public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) {
|
||||
long raw = rawUnits(pages, bytes, policy);
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
|
||||
return Math.toIntExact(
|
||||
Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}.
|
||||
*/
|
||||
public static int unitsForGroup(List<FileSize> files, UnitCalcPolicy policy) {
|
||||
if (files.isEmpty()) {
|
||||
throw new IllegalArgumentException("files must not be empty");
|
||||
}
|
||||
long rawSum = 0;
|
||||
for (FileSize f : files) {
|
||||
rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy));
|
||||
}
|
||||
long groupCap = (long) policy.fileUnitCap() * files.size();
|
||||
return Math.toIntExact(
|
||||
Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum)));
|
||||
}
|
||||
|
||||
private static long ceilDiv(long numerator, long divisor) {
|
||||
if (numerator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (numerator + divisor - 1) / divisor;
|
||||
}
|
||||
|
||||
private static long saturatedAdd(long a, long b) {
|
||||
try {
|
||||
return Math.addExact(a, b);
|
||||
} catch (ArithmeticException e) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA
|
||||
* entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and
|
||||
* be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an
|
||||
* operation identically.
|
||||
*
|
||||
* <p>The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance
|
||||
* receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for
|
||||
* the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs).
|
||||
*/
|
||||
public record UnitCalcPolicy(
|
||||
int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) {
|
||||
|
||||
public UnitCalcPolicy {
|
||||
if (docPagesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docPagesPerUnit must be > 0");
|
||||
}
|
||||
if (docBytesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docBytesPerUnit must be > 0");
|
||||
}
|
||||
if (minChargeUnits < 1) {
|
||||
throw new IllegalArgumentException("minChargeUnits must be >= 1");
|
||||
}
|
||||
if (fileUnitCap < 1) {
|
||||
throw new IllegalArgumentException("fileUnitCap must be >= 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -12,6 +12,7 @@ import java.net.ConnectException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -189,4 +190,73 @@ class AccountLinkClientTest {
|
||||
.thenThrow(new ConnectException("refused"));
|
||||
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(
|
||||
200,
|
||||
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
|
||||
InstanceEntitlement e =
|
||||
client.reportUsage(
|
||||
"dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8);
|
||||
|
||||
assertNotNull(e);
|
||||
assertEquals(42, e.periodSpendUnits());
|
||||
assertEquals(EntitlementState.OK, e.state());
|
||||
|
||||
HttpRequest sent = captor.getValue();
|
||||
assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString());
|
||||
assertEquals("POST", sent.method());
|
||||
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
|
||||
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsageThrowsRevokedOnDeny() throws Exception {
|
||||
for (int status : new int[] {401, 403}) {
|
||||
HttpResponse<String> resp = response(status, "{}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
AccountLinkClient.RevokedException ex =
|
||||
assertThrows(
|
||||
AccountLinkClient.RevokedException.class,
|
||||
() ->
|
||||
client.reportUsage(
|
||||
"dev-1",
|
||||
"sec-1",
|
||||
1L,
|
||||
LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||
1,
|
||||
0,
|
||||
0));
|
||||
assertEquals(status, ex.status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsageReturnsNullWhenUnreachable() throws Exception {
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenThrow(new ConnectException("refused"));
|
||||
// Null = don't advance synced markers; the usage retries on the next sync.
|
||||
assertNull(
|
||||
client.reportUsage(
|
||||
"dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsageReturnsNullOnServerError() throws Exception {
|
||||
HttpResponse<String> resp = response(503, "{}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
assertNull(
|
||||
client.reportUsage(
|
||||
"dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -2,12 +2,15 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
@@ -21,12 +24,18 @@ import stirling.software.proprietary.accountlink.AccountLinkController.LinkReque
|
||||
class AccountLinkControllerTest {
|
||||
|
||||
private AccountLinkService service;
|
||||
private UsageSyncService syncService;
|
||||
private ObjectProvider<UsageSyncService> syncProvider;
|
||||
private AccountLinkController controller;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
service = mock(AccountLinkService.class);
|
||||
controller = new AccountLinkController(service);
|
||||
syncService = mock(UsageSyncService.class);
|
||||
syncProvider = mock(ObjectProvider.class);
|
||||
controller =
|
||||
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,4 +74,24 @@ class AccountLinkControllerTest {
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncNow_triggersSyncWhenMeteringOn() {
|
||||
when(syncProvider.getIfAvailable()).thenReturn(syncService);
|
||||
|
||||
ResponseEntity<Void> resp = controller.syncNow();
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
verify(syncService).syncNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncNow_returns409WhenMeteringOff() {
|
||||
when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent
|
||||
|
||||
ResponseEntity<Void> resp = controller.syncNow();
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
verify(syncService, never()).syncNow();
|
||||
}
|
||||
}
|
||||
|
||||
+52
-23
@@ -1,49 +1,78 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
class BillableOperationClassifierTest {
|
||||
|
||||
@Test
|
||||
void aiPathIsBillable() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
|
||||
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||
private static MockHttpServletRequest req(String uri) {
|
||||
return new MockHttpServletRequest("POST", uri);
|
||||
}
|
||||
|
||||
@Test
|
||||
void automationHeaderIsBillable() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||
void aiPathIsAi() {
|
||||
assertEquals(
|
||||
BillingCategory.AI,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void automationHeaderIsAutomation() {
|
||||
MockHttpServletRequest req = req("/api/v1/general/merge");
|
||||
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
|
||||
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||
assertEquals(
|
||||
BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainManualToolIsFree() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||
assertFalse(BillableOperationClassifier.isBillable(req));
|
||||
void apiKeyToolCallIsApi() {
|
||||
assertEquals(
|
||||
BillingCategory.API,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiSegmentNotAtPathStartIsFree() {
|
||||
// Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
|
||||
// proxy prefix) must NOT classify a manual tool as billable.
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
|
||||
assertFalse(BillableOperationClassifier.isBillable(req));
|
||||
void plainManualToolIsBypassed() {
|
||||
assertEquals(
|
||||
BillingCategory.BYPASSED,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiPathUnderContextPathIsBillable() {
|
||||
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
|
||||
void automationDominatesAiAndApiKey() {
|
||||
// An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION.
|
||||
MockHttpServletRequest req = req("/api/v1/ai/tools/foo");
|
||||
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
|
||||
assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiDominatesApiKey() {
|
||||
// A direct API-key call to an AI tool bills as AI, not API.
|
||||
assertEquals(
|
||||
BillingCategory.AI,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiSegmentNotAtPathStartIsBypassed() {
|
||||
// Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix)
|
||||
// must NOT classify a manual tool as AI.
|
||||
assertEquals(
|
||||
BillingCategory.BYPASSED,
|
||||
BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiPathUnderContextPathIsAi() {
|
||||
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is AI.
|
||||
MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo");
|
||||
req.setContextPath("/stirling");
|
||||
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||
assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false));
|
||||
}
|
||||
}
|
||||
|
||||
+198
-14
@@ -3,20 +3,32 @@ package stirling.software.proprietary.accountlink;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.accountlink.GateDecision.Reason;
|
||||
|
||||
/**
|
||||
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
|
||||
* over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
|
||||
* needed.
|
||||
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired,
|
||||
* linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no
|
||||
* Spring; the grace-window reference computation is exercised through {@link
|
||||
* InstanceEntitlementGate#evaluate} with mocked collaborators.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InstanceEntitlementGateTest {
|
||||
|
||||
@Mock private DeviceCredentialStore credentialStore;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
@Mock private AccountLinkSyncStateRepository syncStateRepository;
|
||||
@Mock private LocalUsageService localUsageService;
|
||||
|
||||
private static InstanceEntitlement free() {
|
||||
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
|
||||
}
|
||||
@@ -35,35 +47,67 @@ class InstanceEntitlementGateTest {
|
||||
|
||||
@Test
|
||||
void flagOff_allowsEverything_evenBillableUnlinked() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FLAG_OFF, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualTool_alwaysFree_evenUnlinked() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.MANUAL_FREE, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_notLinked_blocksWithLinkSignal() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.NOT_LINKED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_entitlementUnreachable_failsOpen() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
|
||||
void billable_linked_entitlementUnreachable_withinGrace_failsOpen() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FAIL_OPEN, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_entitlementUnreachable_graceExpired_blocks() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.GRACE_EXPIRED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_freePoolAvailable_allows() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() {
|
||||
// free() has 100 free units left per the last sync; 100 accrued locally since would exhaust
|
||||
// it once charged, so the gate stops here in real time rather than waiting for the sync.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() {
|
||||
// 99 pending against 100 remaining → one unit of grant still projected free → allow.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
@@ -72,7 +116,7 @@ class InstanceEntitlementGateTest {
|
||||
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(exhaustedUnsubscribed()));
|
||||
true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
@@ -81,7 +125,7 @@ class InstanceEntitlementGateTest {
|
||||
void billable_linked_subscribedWithinCap_allows() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithinCap()));
|
||||
true, true, true, Optional.of(subscribedWithinCap()), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
@@ -89,18 +133,67 @@ class InstanceEntitlementGateTest {
|
||||
@Test
|
||||
void billable_linked_subscribedOverCap_blocks() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedOverCap()), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() {
|
||||
// Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would
|
||||
// push
|
||||
// projected spend to 105 → the gate stops now, not after the next sync reconciles.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithinCap()), false, 95L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() {
|
||||
// 10 synced + 80 pending = 90 < 100 cap → still room.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithinCap()), false, 80L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() {
|
||||
// 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap →
|
||||
// allow.
|
||||
InstanceEntitlement subscribedWithGrant =
|
||||
new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK);
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithGrant), false, 40L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedUncapped_pendingIgnored_allows() {
|
||||
// No cap → local pending has no ceiling to hit → always allowed.
|
||||
InstanceEntitlement uncapped =
|
||||
new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK);
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(uncapped), false, 500L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_revoked_blocksWithRevokedSignal() {
|
||||
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED —
|
||||
// blocks distinctly from over-limit, even though the snapshot is "present".
|
||||
InstanceEntitlement revoked =
|
||||
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.REVOKED, d.reason());
|
||||
}
|
||||
@@ -110,7 +203,98 @@ class InstanceEntitlementGateTest {
|
||||
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
|
||||
InstanceEntitlement conflicting =
|
||||
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(conflicting), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
// --- grace window (evaluate()) ---------------------------------------------------------------
|
||||
|
||||
private InstanceEntitlementGate gate(AccountLinkProperties props) {
|
||||
return new InstanceEntitlementGate(
|
||||
props, credentialStore, entitlementCache, syncStateRepository, localUsageService);
|
||||
}
|
||||
|
||||
private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) {
|
||||
AccountLinkProperties p = new AccountLinkProperties();
|
||||
p.setEnabled(true);
|
||||
p.getMetering().setEnabled(meteringEnabled);
|
||||
p.getMetering().setGraceDays(graceDays);
|
||||
return p;
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
|
||||
GateDecision d = gate(props(false, 3)).evaluate(true);
|
||||
|
||||
// Metering off → grace never applies, even if a sync is ancient.
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FAIL_OPEN, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_neverSynced_pastGraceSinceLink_blocks() {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
DeviceCredential cred = new DeviceCredential();
|
||||
cred.setLinkedAt(LocalDateTime.now().minusDays(5));
|
||||
when(credentialStore.get()).thenReturn(Optional.of(cred));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.GRACE_EXPIRED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_recentSync_withinGrace_failsOpen() {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setLastSuccessAt(LocalDateTime.now().minusDays(1));
|
||||
when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(state));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FAIL_OPEN, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() {
|
||||
// 100 free units remaining per the last sync, but 100 already accrued locally since — the
|
||||
// gate subtracts the pending delta and blocks now, not after the next sync reconciles.
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(free()));
|
||||
when(localUsageService.currentPeriodUnsynced())
|
||||
.thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() {
|
||||
// Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally
|
||||
// since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so
|
||||
// the gate stops now instead of overshooting the cap until the next sync.
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap()));
|
||||
when(localUsageService.currentPeriodUnsynced())
|
||||
.thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
+13
-1
@@ -19,6 +19,7 @@ class InstanceEntitlementGateWiringTest {
|
||||
private AccountLinkProperties properties;
|
||||
private DeviceCredentialStore store;
|
||||
private EntitlementCache cache;
|
||||
private LocalUsageService localUsage;
|
||||
private InstanceEntitlementGate gate;
|
||||
|
||||
@BeforeEach
|
||||
@@ -27,7 +28,14 @@ class InstanceEntitlementGateWiringTest {
|
||||
properties.setEnabled(true);
|
||||
store = mock(DeviceCredentialStore.class);
|
||||
cache = mock(EntitlementCache.class);
|
||||
gate = new InstanceEntitlementGate(properties, store, cache);
|
||||
localUsage = mock(LocalUsageService.class);
|
||||
gate =
|
||||
new InstanceEntitlementGate(
|
||||
properties,
|
||||
store,
|
||||
cache,
|
||||
mock(AccountLinkSyncStateRepository.class),
|
||||
localUsage);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,6 +63,10 @@ class InstanceEntitlementGateWiringTest {
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
|
||||
// Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time;
|
||||
// nothing pending here, so the 5 free units still allow the request.
|
||||
when(localUsage.currentPeriodUnsynced())
|
||||
.thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0));
|
||||
GateDecision d = gate.evaluate(true);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
|
||||
|
||||
+113
-1
@@ -3,24 +3,55 @@ package stirling.software.proprietary.accountlink;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.notNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.mock.web.MockMultipartHttpServletRequest;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InstanceEntitlementInterceptorTest {
|
||||
|
||||
@Mock private InstanceEntitlementGate gate;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
@Mock private ObjectProvider<UsageMeterService> meterProvider;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private InstanceEntitlementInterceptor interceptor() {
|
||||
return new InstanceEntitlementInterceptor(
|
||||
gate, entitlementCache, meterProvider, tempFileManager);
|
||||
}
|
||||
|
||||
private boolean preHandle(MockHttpServletResponse response) throws Exception {
|
||||
return new InstanceEntitlementInterceptor(gate)
|
||||
return interceptor()
|
||||
.preHandle(
|
||||
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
|
||||
}
|
||||
@@ -58,4 +89,85 @@ class InstanceEntitlementInterceptorTest {
|
||||
assertTrue(preHandle(response));
|
||||
assertEquals(200, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void metersSuccessfulBillableOp() throws Exception {
|
||||
when(gate.evaluate(anyBoolean()))
|
||||
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
|
||||
UsageMeterService meter = mock(UsageMeterService.class);
|
||||
when(meterProvider.getIfAvailable()).thenReturn(meter);
|
||||
UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
|
||||
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
|
||||
|
||||
InstanceEntitlementInterceptor interceptor = interceptor();
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
interceptor.preHandle(req, resp, new Object()); // stashes AI category
|
||||
interceptor.afterCompletion(req, resp, new Object(), null);
|
||||
|
||||
// No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature.
|
||||
verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception {
|
||||
when(gate.evaluate(anyBoolean()))
|
||||
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
|
||||
UsageMeterService meter = mock(UsageMeterService.class);
|
||||
when(meterProvider.getIfAvailable()).thenReturn(meter);
|
||||
// docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but
|
||||
// only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly.
|
||||
UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
|
||||
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
|
||||
// Materialise to a real path under @TempDir; the interceptor writes the upload there and
|
||||
// jpdfium + the hasher read it back.
|
||||
TempFile temp = mock(TempFile.class);
|
||||
when(temp.getPath()).thenReturn(tmp.resolve("input.bin"));
|
||||
when(tempFileManager.createManagedTempFile(any())).thenReturn(temp);
|
||||
|
||||
InstanceEntitlementInterceptor interceptor = interceptor();
|
||||
MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest();
|
||||
req.setRequestURI("/api/v1/ai/x");
|
||||
req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf()));
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
interceptor.preHandle(req, resp, new Object());
|
||||
interceptor.afterCompletion(req, resp, new Object(), null);
|
||||
|
||||
// 5 pages + a non-null input-set signature (file ops carry a dedup key).
|
||||
verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMeterWhenMeteringSwitchOff() throws Exception {
|
||||
when(gate.evaluate(anyBoolean()))
|
||||
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
|
||||
when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false
|
||||
|
||||
InstanceEntitlementInterceptor interceptor = interceptor();
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
interceptor.preHandle(req, resp, new Object());
|
||||
interceptor.afterCompletion(req, resp, new Object(), null);
|
||||
|
||||
// Meter absent → no entitlement lookup, no accrual.
|
||||
verifyNoInteractions(entitlementCache);
|
||||
}
|
||||
|
||||
private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) {
|
||||
return new InstanceEntitlement(
|
||||
true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1));
|
||||
}
|
||||
|
||||
private static byte[] fivePagePdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LocalUsageServiceTest {
|
||||
|
||||
@Mock private UsageCounterRepository counters;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
|
||||
private LocalUsageService service;
|
||||
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new LocalUsageService(counters, entitlementCache);
|
||||
}
|
||||
|
||||
private static UsageCounter counter(
|
||||
LocalDateTime period, String category, long cumulative, long synced) {
|
||||
return new UsageCounter(period, category, cumulative, synced, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private static InstanceEntitlement entitledFor(LocalDateTime periodStart) {
|
||||
return new InstanceEntitlement(
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
EntitlementState.OK,
|
||||
null,
|
||||
periodStart,
|
||||
periodStart.plusMonths(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPeriodReturnsZeros() {
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
|
||||
LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
|
||||
|
||||
assertThat(usage.periodStart()).isNull();
|
||||
assertThat(usage.totalUnsyncedUnits()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumsPerCategoryUnsyncedDeltaForCurrentPeriod() {
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(entitledFor(period)));
|
||||
when(counters.findByPeriodStart(period))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
counter(period, "API", 30L, 10L), // 20 unsynced
|
||||
counter(period, "AI", 4L, 4L), // 0 unsynced (all reported)
|
||||
counter(period, "AUTOMATION", 7L, 2L))); // 5 unsynced
|
||||
|
||||
LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
|
||||
|
||||
assertThat(usage.periodStart()).isEqualTo(period);
|
||||
assertThat(usage.apiUnsyncedUnits()).isEqualTo(20L);
|
||||
assertThat(usage.aiUnsyncedUnits()).isEqualTo(0L);
|
||||
assertThat(usage.automationUnsyncedUnits()).isEqualTo(5L);
|
||||
assertThat(usage.totalUnsyncedUnits()).isEqualTo(25L);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UsageMeterServiceTest {
|
||||
|
||||
@Mock private UsageCounterRepository repo;
|
||||
@Mock private MeteredInputSignatureRepository signatureRepo;
|
||||
|
||||
private UsageMeterService service;
|
||||
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new UsageMeterService(repo, signatureRepo, new AccountLinkProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
void incrementsExistingCounter() {
|
||||
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, null);
|
||||
|
||||
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
|
||||
verify(repo, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertsWhenNoRowExists() {
|
||||
when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0);
|
||||
|
||||
service.accrue(period, BillingCategory.API, 3, null);
|
||||
|
||||
verify(repo).saveAndFlush(any(UsageCounter.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesIncrementWhenInsertLosesRace() {
|
||||
// First increment misses (no row); insert loses the race to a concurrent thread; the
|
||||
// second increment then succeeds against the row that thread created.
|
||||
when(repo.increment(eq(period), eq("AUTOMATION"), eq(2L), any())).thenReturn(0, 1);
|
||||
when(repo.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup"));
|
||||
|
||||
service.accrue(period, BillingCategory.AUTOMATION, 2, null);
|
||||
|
||||
verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsBypassedNonPositiveAndNullPeriod() {
|
||||
service.accrue(period, BillingCategory.BYPASSED, 5, null);
|
||||
service.accrue(period, BillingCategory.AI, 0, null);
|
||||
service.accrue(null, BillingCategory.AI, 5, null);
|
||||
|
||||
verifyNoInteractions(repo, signatureRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chargesNewSignatureThenAccrues() {
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-new"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig-new");
|
||||
|
||||
verify(signatureRepo).saveAndFlush(any(MeteredInputSignature.class));
|
||||
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsConcurrentDuplicateClaim() {
|
||||
// Unseen this period, but a concurrent op wins the insert first → treated as within-window
|
||||
// chaining, not re-charged.
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-race"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(signatureRepo.saveAndFlush(any()))
|
||||
.thenThrow(new DataIntegrityViolationException("dup"));
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig-race");
|
||||
|
||||
verify(repo, never()).increment(any(), any(), anyLong(), any());
|
||||
verify(repo, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsRepeatWithinWorkflowWindow() {
|
||||
// Same input set seen moments ago → chaining → not re-charged; the window slides.
|
||||
MeteredInputSignature recent =
|
||||
new MeteredInputSignature(period, "op-sig", LocalDateTime.now());
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
|
||||
.thenReturn(Optional.of(recent));
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig");
|
||||
|
||||
verify(repo, never()).increment(any(), any(), anyLong(), any());
|
||||
verify(signatureRepo).save(recent); // window touched
|
||||
}
|
||||
|
||||
@Test
|
||||
void chargesRepeatOutsideWorkflowWindow() {
|
||||
// Same input set last seen well past the 5-minute window → an independent re-run → charged.
|
||||
MeteredInputSignature stale =
|
||||
new MeteredInputSignature(period, "op-sig", LocalDateTime.now().minusMinutes(10));
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
|
||||
.thenReturn(Optional.of(stale));
|
||||
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig");
|
||||
|
||||
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
|
||||
verify(signatureRepo).save(stale); // window touched
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UsageSyncServiceTest {
|
||||
|
||||
@Mock private UsageCounterRepository counters;
|
||||
@Mock private AccountLinkSyncStateRepository syncState;
|
||||
@Mock private DeviceCredentialStore credentialStore;
|
||||
@Mock private AccountLinkClient client;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
|
||||
private UsageSyncService service;
|
||||
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service =
|
||||
new UsageSyncService(
|
||||
counters,
|
||||
syncState,
|
||||
credentialStore,
|
||||
client,
|
||||
entitlementCache,
|
||||
new AccountLinkProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersFixedDelayTaskWithConfiguredInterval() {
|
||||
AccountLinkProperties props = new AccountLinkProperties();
|
||||
props.getMetering().setSyncIntervalHours(6);
|
||||
UsageSyncService svc =
|
||||
new UsageSyncService(
|
||||
counters, syncState, credentialStore, client, entitlementCache, props);
|
||||
|
||||
ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar();
|
||||
svc.configureTasks(registrar);
|
||||
|
||||
// Pins the interval binding in CI — the old @Scheduled SpEL only resolved at flags-on boot.
|
||||
assertThat(registrar.getFixedDelayTaskList()).hasSize(1);
|
||||
assertThat(registrar.getFixedDelayTaskList().get(0).getIntervalDuration())
|
||||
.isEqualTo(Duration.ofHours(6));
|
||||
}
|
||||
|
||||
private static DeviceCredential credential() {
|
||||
DeviceCredential c = new DeviceCredential();
|
||||
c.setDeviceId("dev-1");
|
||||
c.setDeviceSecret("sec-1");
|
||||
return c;
|
||||
}
|
||||
|
||||
private static UsageCounter counter(LocalDateTime period, String category, long cumulative) {
|
||||
return new UsageCounter(period, category, cumulative, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private static InstanceEntitlement entitled() {
|
||||
return new InstanceEntitlement(true, 0, 0, null, EntitlementState.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void notLinkedSkipsEntirely() {
|
||||
when(credentialStore.get()).thenReturn(Optional.empty());
|
||||
|
||||
service.syncNow();
|
||||
|
||||
verifyNoInteractions(client, entitlementCache);
|
||||
verify(counters, never()).findPeriodsWithUnsyncedUsage();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nothingPendingStillForcesEntitlementRefresh() {
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of());
|
||||
|
||||
service.syncNow();
|
||||
|
||||
// No usage to report, so nothing is sent and no markers advance — but the sync still forces
|
||||
// an entitlement refresh so an out-of-band plan change (e.g. a just-completed subscription)
|
||||
// surfaces on the gate immediately instead of waiting out the entitlement-cache TTL.
|
||||
verifyNoInteractions(client);
|
||||
verify(syncState, never()).save(any());
|
||||
verify(entitlementCache, never()).accept(any());
|
||||
verify(entitlementCache).invalidate();
|
||||
verify(entitlementCache).current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsCumulativePerCategoryAndAdvancesSyncedMarkers() {
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
state.setLastSyncSeq(5L);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
|
||||
when(counters.findByPeriodStart(period))
|
||||
.thenReturn(List.of(counter(period, "API", 12L), counter(period, "AI", 4L)));
|
||||
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
InstanceEntitlement fresh = entitled();
|
||||
when(client.reportUsage(
|
||||
eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L)))
|
||||
.thenReturn(fresh);
|
||||
|
||||
service.syncNow();
|
||||
|
||||
// Seq advanced from 5 → 6 and the report carried the per-category cumulative.
|
||||
verify(client)
|
||||
.reportUsage(eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L));
|
||||
// Only categories with usage are marked; AUTOMATION (0) is skipped.
|
||||
verify(counters).markSynced(period, "API", 12L);
|
||||
verify(counters).markSynced(period, "AI", 4L);
|
||||
verify(counters, never()).markSynced(eq(period), eq("AUTOMATION"), anyLong());
|
||||
// Two saves: the pre-report seq reservation + the post-success timestamp.
|
||||
verify(syncState, times(2)).save(state);
|
||||
verify(entitlementCache).accept(fresh);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportFailureReservesSeqButLeavesMarkersUntouched() {
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
|
||||
when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
|
||||
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
|
||||
.thenReturn(null);
|
||||
|
||||
service.syncNow();
|
||||
|
||||
verify(counters, never()).markSynced(any(), any(), anyLong());
|
||||
verify(syncState, times(1)).save(state); // seq reserved, success not recorded
|
||||
verify(entitlementCache).accept(null); // nothing fresh adopted
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokedAbortsWithoutMarkingOrAdoptingEntitlement() {
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
|
||||
when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
|
||||
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
|
||||
.thenThrow(new AccountLinkClient.RevokedException(403));
|
||||
|
||||
service.syncNow();
|
||||
|
||||
verify(counters, never()).markSynced(any(), any(), anyLong());
|
||||
verify(entitlementCache, never()).accept(any());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BillingCategoryClassifierTest {
|
||||
|
||||
@Test
|
||||
void automationWinsOverEverything() {
|
||||
assertEquals(
|
||||
BillingCategory.AUTOMATION, BillingCategoryClassifier.classify(true, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiWinsOverApiKey() {
|
||||
assertEquals(BillingCategory.AI, BillingCategoryClassifier.classify(false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyWhenNotAutomationOrAi() {
|
||||
assertEquals(BillingCategory.API, BillingCategoryClassifier.classify(false, false, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bypassedWhenNoSignal() {
|
||||
assertEquals(
|
||||
BillingCategory.BYPASSED, BillingCategoryClassifier.classify(false, false, false));
|
||||
}
|
||||
}
|
||||
+112
-12
@@ -1,5 +1,8 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -9,6 +12,7 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -16,11 +20,16 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
|
||||
import stirling.software.saas.payg.instance.InstanceUsageIngestService;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
|
||||
/**
|
||||
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
|
||||
@@ -46,14 +55,23 @@ public class InstanceController {
|
||||
private final EntitlementService entitlementService;
|
||||
private final TeamBillingService billingService;
|
||||
private final AccountLinkService accountLinkService;
|
||||
private final PricingPolicyService pricingPolicyService;
|
||||
private final InstanceUsageIngestService usageIngestService;
|
||||
private final LinkedInstanceRepository linkedInstanceRepository;
|
||||
|
||||
public InstanceController(
|
||||
EntitlementService entitlementService,
|
||||
TeamBillingService billingService,
|
||||
AccountLinkService accountLinkService) {
|
||||
AccountLinkService accountLinkService,
|
||||
PricingPolicyService pricingPolicyService,
|
||||
InstanceUsageIngestService usageIngestService,
|
||||
LinkedInstanceRepository linkedInstanceRepository) {
|
||||
this.entitlementService = entitlementService;
|
||||
this.billingService = billingService;
|
||||
this.accountLinkService = accountLinkService;
|
||||
this.pricingPolicyService = pricingPolicyService;
|
||||
this.usageIngestService = usageIngestService;
|
||||
this.linkedInstanceRepository = linkedInstanceRepository;
|
||||
}
|
||||
|
||||
public record WhoAmIResponse(Long instanceId, Long teamId) {}
|
||||
@@ -68,7 +86,13 @@ public class InstanceController {
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
String state) {}
|
||||
String state,
|
||||
// Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The
|
||||
// instance computes units locally with this policy and resets its per-period cumulative
|
||||
// counters on the [periodStart, periodEnd) boundary.
|
||||
UnitCalcPolicy unitCalcPolicy,
|
||||
LocalDateTime periodStart,
|
||||
LocalDateTime periodEnd) {}
|
||||
|
||||
@GetMapping("/whoami")
|
||||
@PreAuthorize("hasRole('LINKED_INSTANCE')")
|
||||
@@ -103,20 +127,96 @@ public class InstanceController {
|
||||
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
Long teamId = token.getTeamId();
|
||||
// Drop the cached snapshot first: this low-frequency read gates real-time billable work, so
|
||||
// it must reflect a just-changed subscription/cap at once (the flip is a DB-function write
|
||||
// with no Java event to invalidate on).
|
||||
entitlementService.invalidate(token.getTeamId());
|
||||
return ResponseEntity.ok(buildEntitlement(token.getTeamId()));
|
||||
}
|
||||
|
||||
/** Body for {@code POST /sync}: the instance's cumulative units per category this period. */
|
||||
public record UsageSyncRequest(
|
||||
long syncSeq, LocalDateTime periodStart, CategoryUnits cumulativeUnits) {
|
||||
public record CategoryUnits(long api, long ai, long automation) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily usage sync: the instance reports its cumulative per-category unit totals for the
|
||||
* period; SaaS bills the delta since the last sync (reusing the standard charge path) and
|
||||
* returns the fresh entitlement — so one round-trip both reports usage and refreshes the gate
|
||||
* state.
|
||||
*/
|
||||
@PostMapping("/sync")
|
||||
@PreAuthorize("hasRole('LINKED_INSTANCE')")
|
||||
@Transactional
|
||||
public ResponseEntity<EntitlementResponse> sync(
|
||||
Authentication auth, @RequestBody UsageSyncRequest req) {
|
||||
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
if (req == null || req.periodStart() == null || req.cumulativeUnits() == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Long teamId = token.getTeamId();
|
||||
// periodStart is the dedup/regression partition key, so bound a fabricated value to the
|
||||
// snapshot window (current or immediately-prior period, never future).
|
||||
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
|
||||
LocalDateTime reported = req.periodStart();
|
||||
if (!reported.isBefore(snap.periodEnd())
|
||||
|| reported.isBefore(snap.periodStart().minusMonths(1))) {
|
||||
log.warn(
|
||||
"Instance sync for team {} reported implausible periodStart {} (authoritative"
|
||||
+ " {}..{}); rejecting.",
|
||||
teamId,
|
||||
reported,
|
||||
snap.periodStart(),
|
||||
snap.periodEnd());
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
// Attribute the charge to the admin who linked the instance (the device credential carries
|
||||
// no user). Null is tolerated by the ingest service (it skips + retries next sync).
|
||||
Long actorUserId =
|
||||
linkedInstanceRepository
|
||||
.findById(token.getInstanceId())
|
||||
.map(LinkedInstance::getCreatedByUserId)
|
||||
.orElse(null);
|
||||
UsageSyncRequest.CategoryUnits c = req.cumulativeUnits();
|
||||
usageIngestService.ingest(
|
||||
teamId,
|
||||
actorUserId,
|
||||
req.syncSeq(),
|
||||
req.periodStart(),
|
||||
Map.of(
|
||||
BillingCategory.API, c.api(),
|
||||
BillingCategory.AI, c.ai(),
|
||||
BillingCategory.AUTOMATION, c.automation()));
|
||||
// Drop the cache so the buildEntitlement below (and the portal's next read) reflect the
|
||||
// just-charged delta + moved free-grant balance now, not after the TTL.
|
||||
entitlementService.invalidate(teamId);
|
||||
return ResponseEntity.ok(buildEntitlement(teamId));
|
||||
}
|
||||
|
||||
/** The entitlement view shared by {@code GET /entitlement} and the {@code /sync} response. */
|
||||
private EntitlementResponse buildEntitlement(Long teamId) {
|
||||
// Same composition the FE wallet uses: billing facts (subscription, free pool) from
|
||||
// TeamBillingService, period spend/cap + state from the entitlement snapshot.
|
||||
// TeamBillingService, period spend/cap + state from the entitlement snapshot, plus the
|
||||
// unit-calc policy + period the instance needs to meter locally.
|
||||
TeamBillingContext billing = billingService.forTeam(teamId);
|
||||
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new EntitlementResponse(
|
||||
billing.subscribed(),
|
||||
billing.freeRemainingUnits(),
|
||||
snap.periodSpendUnits(),
|
||||
snap.periodCapUnits(),
|
||||
coarseState(snap.state())));
|
||||
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
|
||||
return new EntitlementResponse(
|
||||
billing.subscribed(),
|
||||
billing.freeRemainingUnits(),
|
||||
snap.periodSpendUnits(),
|
||||
snap.periodCapUnits(),
|
||||
coarseState(snap.state()),
|
||||
new UnitCalcPolicy(
|
||||
policy.getDocPagesPerUnit(),
|
||||
policy.getDocBytesPerUnit(),
|
||||
policy.getMinChargeUnits(),
|
||||
policy.getFileUnitCap()),
|
||||
snap.periodStart(),
|
||||
snap.periodEnd());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -328,6 +329,32 @@ public class PaygWalletController {
|
||||
/** Request body for {@link #updateCap}. */
|
||||
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a
|
||||
* billing state that just changed out-of-band. The subscription flip is written by a Postgres
|
||||
* function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client
|
||||
* that knows a change just happened — the portal while finalizing a checkout — pokes the cache
|
||||
* here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only
|
||||
* refresh its own team, and a no-team caller is a cheap no-op.
|
||||
*/
|
||||
@PostMapping("/wallet/refresh")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> refreshWallet(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
primaryMembership(user.getId())
|
||||
.ifPresent(m -> entitlementService.invalidate(m.getTeam().getId()));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
+18
-35
@@ -5,6 +5,7 @@ import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -18,6 +19,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
/**
|
||||
@@ -42,9 +46,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
||||
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
||||
|
||||
/** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */
|
||||
private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@Override
|
||||
@@ -59,13 +60,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
|
||||
FileFacts facts = inspect(file, materialisedPath);
|
||||
long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy);
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
|
||||
int units =
|
||||
Math.toIntExact(
|
||||
Math.max(
|
||||
MIN_UNITS_PER_NONEMPTY_FILE,
|
||||
Math.min(policy.getFileUnitCap(), rawUnits)));
|
||||
int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy));
|
||||
return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units);
|
||||
}
|
||||
|
||||
@@ -93,17 +88,16 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
|
||||
int totalPages = 0;
|
||||
long totalBytes = 0;
|
||||
long rawUnitsSum = 0;
|
||||
String firstContentType = null;
|
||||
List<FileSize> sizes = new ArrayList<>(files.size());
|
||||
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
MultipartFile file = files.get(i);
|
||||
Path path = materialisedPaths == null ? null : materialisedPaths.get(i);
|
||||
FileFacts facts = inspect(file, path);
|
||||
// Sum the *raw* (unclamped) per-file units so the group cap below can actually bind.
|
||||
// Per-file clamping in this loop would make the group cap a no-op.
|
||||
rawUnitsSum =
|
||||
saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy));
|
||||
// Collect raw page/byte facts; the group cap is applied over the raw sum in the
|
||||
// calculator (per-file clamping here would make the group cap a no-op).
|
||||
sizes.add(new FileSize(facts.pages, facts.bytes));
|
||||
totalPages = saturatedAdd(totalPages, facts.pages);
|
||||
totalBytes = saturatedAdd(totalBytes, facts.bytes);
|
||||
if (firstContentType == null) {
|
||||
@@ -111,13 +105,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
}
|
||||
}
|
||||
|
||||
long groupCap = (long) policy.getFileUnitCap() * files.size();
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping.
|
||||
int totalUnits =
|
||||
Math.toIntExact(
|
||||
Math.max(
|
||||
(long) MIN_UNITS_PER_NONEMPTY_FILE,
|
||||
Math.min(groupCap, rawUnitsSum)));
|
||||
int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy));
|
||||
|
||||
return new DocumentMetrics(
|
||||
totalPages,
|
||||
@@ -126,6 +114,14 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
totalUnits);
|
||||
}
|
||||
|
||||
private static UnitCalcPolicy unitCalc(PricingPolicy policy) {
|
||||
return new UnitCalcPolicy(
|
||||
policy.getDocPagesPerUnit(),
|
||||
policy.getDocBytesPerUnit(),
|
||||
policy.getMinChargeUnits(),
|
||||
policy.getFileUnitCap());
|
||||
}
|
||||
|
||||
private FileFacts inspect(MultipartFile file, Path materialisedPath) {
|
||||
long bytes = file.getSize();
|
||||
String contentType =
|
||||
@@ -140,19 +136,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
return new FileFacts(pages, bytes, contentType);
|
||||
}
|
||||
|
||||
private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) {
|
||||
long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L;
|
||||
long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit());
|
||||
return Math.max(pageUnits, byteUnits);
|
||||
}
|
||||
|
||||
private static long ceilDiv(long numerator, long divisor) {
|
||||
if (numerator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (numerator + divisor - 1) / divisor;
|
||||
}
|
||||
|
||||
private static boolean isPdf(String contentType, String filename) {
|
||||
if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) {
|
||||
return true;
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package stirling.software.saas.payg.instance;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.charge.ChargeContext;
|
||||
import stirling.software.saas.payg.charge.JobChargeService;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.ProcessType;
|
||||
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
|
||||
|
||||
/**
|
||||
* Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a
|
||||
* monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the
|
||||
* last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split,
|
||||
* ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and
|
||||
* tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The
|
||||
* cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class InstanceUsageIngestService {
|
||||
|
||||
private final PaygInstanceUsageRepository usageRepository;
|
||||
private final JobChargeService chargeService;
|
||||
|
||||
public InstanceUsageIngestService(
|
||||
PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) {
|
||||
this.usageRepository = usageRepository;
|
||||
this.chargeService = chargeService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bills the delta for each category and advances the last-seen cumulative + sync sequence. The
|
||||
* delta-advance and the charge share this transaction, so a crash before commit re-bills
|
||||
* cleanly on retry (delta unchanged) and a commit means the cumulative moved with the charge.
|
||||
*
|
||||
* @param actorUserId the linking admin ({@code linked_instance.created_by_user_id}); required
|
||||
* to attribute the charge. If {@code null} we skip entirely (don't advance) so a later
|
||||
* sync, once the actor is resolvable, still bills the usage.
|
||||
*/
|
||||
@Transactional
|
||||
public void ingest(
|
||||
Long teamId,
|
||||
Long actorUserId,
|
||||
long syncSeq,
|
||||
LocalDateTime periodStart,
|
||||
Map<BillingCategory, Long> cumulativeByCategory) {
|
||||
if (teamId == null || periodStart == null || cumulativeByCategory == null) {
|
||||
return;
|
||||
}
|
||||
if (actorUserId == null) {
|
||||
log.warn(
|
||||
"Instance usage sync for team {} has no actor (created_by_user_id null); not"
|
||||
+ " billing — a later sync will pick it up.",
|
||||
teamId);
|
||||
return;
|
||||
}
|
||||
cumulativeByCategory.forEach(
|
||||
(category, cumulative) -> {
|
||||
if (category == null
|
||||
|| category == BillingCategory.BYPASSED
|
||||
|| cumulative == null
|
||||
|| cumulative < 0) {
|
||||
return;
|
||||
}
|
||||
applyCategory(teamId, actorUserId, syncSeq, periodStart, category, cumulative);
|
||||
});
|
||||
}
|
||||
|
||||
private void applyCategory(
|
||||
Long teamId,
|
||||
Long actorUserId,
|
||||
long syncSeq,
|
||||
LocalDateTime periodStart,
|
||||
BillingCategory category,
|
||||
long cumulative) {
|
||||
// Pessimistic row lock so a duplicate delivery can't have two txns read the same baseline
|
||||
// and both charge: the second waits, then sees the advanced seq and replay-skips.
|
||||
PaygInstanceUsage row =
|
||||
usageRepository
|
||||
.findByTeamIdAndPeriodStartAndCategoryForUpdate(
|
||||
teamId, periodStart, category.name())
|
||||
.orElse(null);
|
||||
if (row != null && syncSeq <= row.getLastSyncSeq()) {
|
||||
return; // replay / out-of-order — already applied this or a later sync
|
||||
}
|
||||
long lastCumulative = row == null ? 0L : row.getLastCumulativeUnits();
|
||||
long delta = cumulative - lastCumulative;
|
||||
if (delta < 0) {
|
||||
// The cumulative counter went backwards — a reset or tampering. Refuse to credit; don't
|
||||
// advance, so the discrepancy stays visible and a corrected resend can reconcile.
|
||||
log.warn(
|
||||
"Instance usage regression team={} category={} reported {} < last {}; ignoring.",
|
||||
teamId,
|
||||
category,
|
||||
cumulative,
|
||||
lastCumulative);
|
||||
return;
|
||||
}
|
||||
if (delta > 0) {
|
||||
int units = (int) Math.min(delta, Integer.MAX_VALUE);
|
||||
chargeService.chargeStandalone(
|
||||
new ChargeContext(
|
||||
actorUserId,
|
||||
teamId,
|
||||
JobSource.LINKED_INSTANCE,
|
||||
ProcessType.SINGLE_TOOL,
|
||||
category),
|
||||
units);
|
||||
}
|
||||
if (row == null) {
|
||||
row = new PaygInstanceUsage(teamId, periodStart, category.name(), cumulative, syncSeq);
|
||||
} else {
|
||||
row.setLastCumulativeUnits(cumulative);
|
||||
row.setLastSyncSeq(syncSeq);
|
||||
}
|
||||
usageRepository.save(row);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package stirling.software.saas.payg.instance;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team,
|
||||
* billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative
|
||||
* unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via
|
||||
* the standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "payg_instance_usage",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_payg_instance_usage",
|
||||
columnNames = {"team_id", "period_start", "category"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class PaygInstanceUsage {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
/** {@code BillingCategory} name — API / AI / AUTOMATION. */
|
||||
@Column(name = "category", nullable = false, length = 32)
|
||||
private String category;
|
||||
|
||||
@Column(name = "last_cumulative_units", nullable = false)
|
||||
private long lastCumulativeUnits;
|
||||
|
||||
@Column(name = "last_sync_seq", nullable = false)
|
||||
private long lastSyncSeq;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public PaygInstanceUsage(
|
||||
Long teamId,
|
||||
LocalDateTime periodStart,
|
||||
String category,
|
||||
long lastCumulativeUnits,
|
||||
long lastSyncSeq) {
|
||||
this.teamId = teamId;
|
||||
this.periodStart = periodStart;
|
||||
this.category = category;
|
||||
this.lastCumulativeUnits = lastCumulativeUnits;
|
||||
this.lastSyncSeq = lastSyncSeq;
|
||||
}
|
||||
}
|
||||
+5
-30
@@ -1,22 +1,18 @@
|
||||
package stirling.software.saas.payg.lineage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.billing.ContentHasher;
|
||||
|
||||
/**
|
||||
* SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every
|
||||
* content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read
|
||||
* buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions).
|
||||
* content type, doesn't parse. Delegates to the shared {@link ContentHasher} so the cloud charge
|
||||
* path and a linked self-hosted instance's meter compute byte-identical signatures.
|
||||
*
|
||||
* <p>Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code
|
||||
* PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed
|
||||
@@ -26,36 +22,15 @@ import org.springframework.stereotype.Component;
|
||||
@Profile("saas")
|
||||
public class ByteHashSignatureExtractor implements LineageSignatureExtractor {
|
||||
|
||||
private static final String ALGORITHM = "SHA-256";
|
||||
private static final String SIGNATURE_TYPE = "sha256";
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
@Override
|
||||
public Set<LineageSignature> extract(Path file) throws IOException {
|
||||
MessageDigest digest = newDigest();
|
||||
try (InputStream raw = Files.newInputStream(file);
|
||||
DigestInputStream in = new DigestInputStream(raw, digest)) {
|
||||
byte[] buf = new byte[BUFFER_SIZE];
|
||||
// Drain through the digest stream; we only care about side effects on the digest.
|
||||
while (in.read(buf) != -1) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
String hex = HexFormat.of().formatHex(digest.digest());
|
||||
return Set.of(new LineageSignature(SIGNATURE_TYPE, hex));
|
||||
return Set.of(new LineageSignature(SIGNATURE_TYPE, ContentHasher.sha256(file)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return SIGNATURE_TYPE;
|
||||
}
|
||||
|
||||
private static MessageDigest newDigest() {
|
||||
try {
|
||||
return MessageDigest.getInstance(ALGORITHM);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// SHA-256 is mandated by every JDK; unreachable in practice.
|
||||
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,12 @@ public enum JobSource {
|
||||
/**
|
||||
* The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend.
|
||||
*/
|
||||
DESKTOP_APP
|
||||
DESKTOP_APP,
|
||||
/**
|
||||
* Usage reported by a linked self-hosted instance via the daily sync (combined-billing "Mode
|
||||
* A"). The per-request surface is lost in the aggregate — the instance reports cumulative units
|
||||
* per {@code BillingCategory} — so this just marks the charge as instance-synced. No per-source
|
||||
* step limit is seeded for it; the charge path's fallback applies.
|
||||
*/
|
||||
LINKED_INSTANCE
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import stirling.software.saas.payg.instance.PaygInstanceUsage;
|
||||
|
||||
/** Last-seen cumulative usage per (team, period, category) for linked-instance daily syncs. */
|
||||
public interface PaygInstanceUsageRepository extends JpaRepository<PaygInstanceUsage, Long> {
|
||||
|
||||
Optional<PaygInstanceUsage> findByTeamIdAndPeriodStartAndCategory(
|
||||
Long teamId, LocalDateTime periodStart, String category);
|
||||
|
||||
/**
|
||||
* Pessimistic-write variant the ingest uses so two concurrent deliveries of the same sync (e.g.
|
||||
* a proxy retry) can't both read the same baseline and double-charge the delta. Must run inside
|
||||
* a transaction.
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query(
|
||||
"SELECT u FROM PaygInstanceUsage u"
|
||||
+ " WHERE u.teamId = :teamId AND u.periodStart = :periodStart"
|
||||
+ " AND u.category = :category")
|
||||
Optional<PaygInstanceUsage> findByTeamIdAndPeriodStartAndCategoryForUpdate(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Twin of supabase/migrations/<ts>_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table
|
||||
-- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS
|
||||
-- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS,
|
||||
-- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry).
|
||||
--
|
||||
-- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted
|
||||
-- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on
|
||||
-- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and
|
||||
-- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path
|
||||
-- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow.
|
||||
--
|
||||
-- Inert until release: written only by the InstanceController /sync endpoint, gated behind
|
||||
-- stirling.billing.account-link.enabled (default off). Additive, idempotent table.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
period_start TIMESTAMP NOT NULL,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
-- Highest cumulative unit total seen for this (team, period, category); the next sync bills
|
||||
-- (reported cumulative - this).
|
||||
last_cumulative_units BIGINT NOT NULL DEFAULT 0,
|
||||
-- Highest sync sequence applied; a sync at or below this is a replay and is ignored.
|
||||
last_sync_seq BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team
|
||||
ON stirling_pdf.payg_instance_usage (team_id);
|
||||
|
||||
COMMENT ON TABLE stirling_pdf.payg_instance_usage IS
|
||||
'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted '
|
||||
'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the '
|
||||
'standard charge path; last_sync_seq dedups replays.';
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
-- Twin of supabase/migrations/<ts>_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS).
|
||||
-- Keep byte-identical to the Supabase twin.
|
||||
--
|
||||
-- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode
|
||||
-- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which
|
||||
-- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after
|
||||
-- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync.
|
||||
--
|
||||
-- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase
|
||||
-- migration sets against the same schema) and additive (the new set is a superset of the JobSource
|
||||
-- enum; the app only ever writes enum values, so no existing row can violate it).
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_shadow_charge
|
||||
DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check;
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_shadow_charge
|
||||
ADD CONSTRAINT payg_shadow_charge_job_source_check
|
||||
CHECK (job_source IS NULL
|
||||
OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE'));
|
||||
+99
-1
@@ -1,6 +1,7 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -8,9 +9,12 @@ import static org.mockito.Mockito.when;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -19,14 +23,19 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.saas.accountlink.InstanceController.EntitlementResponse;
|
||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
|
||||
import stirling.software.saas.payg.instance.InstanceUsageIngestService;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
|
||||
/**
|
||||
* Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read.
|
||||
@@ -39,9 +48,22 @@ class InstanceControllerTest {
|
||||
@Mock private EntitlementService entitlementService;
|
||||
@Mock private TeamBillingService billingService;
|
||||
@Mock private AccountLinkService accountLinkService;
|
||||
@Mock private PricingPolicyService pricingPolicyService;
|
||||
@Mock private InstanceUsageIngestService usageIngestService;
|
||||
@Mock private LinkedInstanceRepository linkedInstanceRepository;
|
||||
|
||||
private InstanceController controller() {
|
||||
return new InstanceController(entitlementService, billingService, accountLinkService);
|
||||
return new InstanceController(
|
||||
entitlementService,
|
||||
billingService,
|
||||
accountLinkService,
|
||||
pricingPolicyService,
|
||||
usageIngestService,
|
||||
linkedInstanceRepository);
|
||||
}
|
||||
|
||||
private static PricingPolicy policy() {
|
||||
return new PricingPolicy(1, 1_048_576L, 1, 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -50,6 +72,7 @@ class InstanceControllerTest {
|
||||
when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L));
|
||||
when(entitlementService.getSnapshot(42L))
|
||||
.thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L));
|
||||
when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy());
|
||||
|
||||
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
|
||||
|
||||
@@ -62,6 +85,13 @@ class InstanceControllerTest {
|
||||
assertThat(body.periodCapUnits()).isEqualTo(1250L);
|
||||
// WARNED is still within budget for the gate's purposes → coarse OK.
|
||||
assertThat(body.state()).isEqualTo("OK");
|
||||
// Phase 2: the metering inputs the instance needs ride along.
|
||||
assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000));
|
||||
assertThat(body.periodStart()).isNotNull();
|
||||
assertThat(body.periodEnd()).isNotNull();
|
||||
// The instance-facing read drops the cached snapshot first so a just-subscribed team's
|
||||
// plan surfaces on the next poll instead of waiting out the cache TTL.
|
||||
verify(entitlementService).invalidate(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -70,6 +100,7 @@ class InstanceControllerTest {
|
||||
when(billingService.forTeam(7L)).thenReturn(freeBilling(500L));
|
||||
when(entitlementService.getSnapshot(7L))
|
||||
.thenReturn(snapshot(EntitlementState.FULL, 0L, null));
|
||||
when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy());
|
||||
|
||||
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
|
||||
|
||||
@@ -89,6 +120,7 @@ class InstanceControllerTest {
|
||||
when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L));
|
||||
when(entitlementService.getSnapshot(8L))
|
||||
.thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L));
|
||||
when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy());
|
||||
|
||||
EntitlementResponse body = controller().entitlement(token).getBody();
|
||||
|
||||
@@ -110,6 +142,59 @@ class InstanceControllerTest {
|
||||
verifyNoInteractions(entitlementService, billingService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() {
|
||||
Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L);
|
||||
LinkedInstance li = new LinkedInstance();
|
||||
li.setCreatedByUserId(7L);
|
||||
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li));
|
||||
when(billingService.forTeam(99L)).thenReturn(freeBilling(10L));
|
||||
// The reported periodStart is validated against the authoritative snapshot period.
|
||||
when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null));
|
||||
when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy());
|
||||
|
||||
InstanceController.UsageSyncRequest req =
|
||||
new InstanceController.UsageSyncRequest(
|
||||
3L,
|
||||
period,
|
||||
new InstanceController.UsageSyncRequest.CategoryUnits(12, 4, 8));
|
||||
|
||||
ResponseEntity<EntitlementResponse> resp = controller().sync(token, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<BillingCategory, Long>> cumulative = ArgumentCaptor.forClass(Map.class);
|
||||
verify(usageIngestService)
|
||||
.ingest(eq(99L), eq(7L), eq(3L), eq(period), cumulative.capture());
|
||||
assertThat(cumulative.getValue())
|
||||
.containsEntry(BillingCategory.API, 12L)
|
||||
.containsEntry(BillingCategory.AI, 4L)
|
||||
.containsEntry(BillingCategory.AUTOMATION, 8L);
|
||||
// The sync drops the team's cached snapshot so the just-charged delta (and the free-grant
|
||||
// balance it moved) show on the next wallet read instead of lagging out the 30s TTL.
|
||||
verify(entitlementService).invalidate(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sync_rejectsImplausiblePeriodStart() {
|
||||
Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L);
|
||||
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null));
|
||||
|
||||
// A fabricated far-future periodStart (would reset the dedup partition) → 400, no ingest.
|
||||
InstanceController.UsageSyncRequest req =
|
||||
new InstanceController.UsageSyncRequest(
|
||||
1L,
|
||||
period.plusYears(5),
|
||||
new InstanceController.UsageSyncRequest.CategoryUnits(99, 0, 0));
|
||||
|
||||
ResponseEntity<EntitlementResponse> resp = controller().sync(token, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verifyNoInteractions(usageIngestService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeSelf_callsServiceWithTokenIdentityAndReturns204() {
|
||||
Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L);
|
||||
@@ -187,4 +272,17 @@ class InstanceControllerTest {
|
||||
start.plusMonths(1),
|
||||
false);
|
||||
}
|
||||
|
||||
/** Snapshot with an explicit period — the sync tests need a deterministic period window. */
|
||||
private static EntitlementSnapshot snapshotForPeriod(LocalDateTime start, Long cap) {
|
||||
return new EntitlementSnapshot(
|
||||
EntitlementState.FULL,
|
||||
FeatureSet.FULL,
|
||||
List.of(FeatureGate.OFFSITE_PROCESSING),
|
||||
0L,
|
||||
cap,
|
||||
start,
|
||||
start.plusMonths(1),
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,52 @@ class PaygWalletControllerTest {
|
||||
verifyNoInteractions(policyRepo, entitlementService);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// POST /wallet/refresh
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void refreshWallet_dropsCallerTeamCache() {
|
||||
User user = userWithId(30L, UUID.randomUUID());
|
||||
Team team = teamWithId(70L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||
when(memberRepo.findPrimaryMembership(30L))
|
||||
.thenReturn(List.of(membership(team, user, TeamRole.MEMBER)));
|
||||
|
||||
ResponseEntity<Void> resp = controller.refreshWallet(jwtAuth(user.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
// Portal pokes this after checkout so the next /wallet read reflects the subscription
|
||||
// immediately rather than after the cache TTL.
|
||||
verify(entitlementService).invalidate(70L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshWallet_noTeam_isNoOpButOk() {
|
||||
User user = userWithId(31L, UUID.randomUUID());
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||
when(memberRepo.findPrimaryMembership(31L)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<Void> resp = controller.refreshWallet(jwtAuth(user.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
verify(entitlementService, never()).invalidate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshWallet_anonymousIs401() {
|
||||
Authentication anon =
|
||||
new AnonymousAuthenticationToken(
|
||||
"k",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
|
||||
|
||||
ResponseEntity<Void> resp = controller.refreshWallet(anon);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(entitlementService);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@@ -913,6 +913,52 @@ class JobChargeServiceTest {
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chargeStandalone_floorsUnitsAtMinChargeUnits() {
|
||||
// Pins the per-call minChargeUnits floor that the linked-instance sync path inherits: a
|
||||
// daily delta below the floor bills the floor (max(delta, minChargeUnits)) — applied per
|
||||
// sync-delta here, not per underlying op (documented divergence from the in-cloud per-op
|
||||
// floor; can only under-bill vs per-op, never over).
|
||||
long teamId = 100L;
|
||||
PricingPolicy policy = stubPolicy(/*minCharge*/ 5, Map.of(JobSource.WEB, 10));
|
||||
when(policyService.getEffectivePolicy(teamId)).thenReturn(policy);
|
||||
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(jobService.open(any(JobContext.class), eq(5))).thenReturn(openJob(jobId));
|
||||
when(jobService.close(jobId)).thenReturn(openJob(jobId));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(teamId);
|
||||
ext.setStripeCustomerId("cus_x");
|
||||
ext.setPaygSubscriptionId("sub_x");
|
||||
ext.setFreeUnitsRemaining(0L);
|
||||
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
|
||||
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
|
||||
.thenReturn(
|
||||
Optional.of(chargedShadowRow(jobId, teamId, 5, 0, BillingCategory.API)));
|
||||
|
||||
ChargeContext ctx =
|
||||
new ChargeContext(
|
||||
7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API);
|
||||
ArgumentCaptor<WalletLedgerEntry> ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class);
|
||||
|
||||
withTransactionSynchronization(() -> service.chargeStandalone(ctx, 2));
|
||||
|
||||
// Delta of 2 floored to minChargeUnits=5: the job, ledger debit, and meter all use 5.
|
||||
verify(jobService).open(any(JobContext.class), eq(5));
|
||||
verify(ledgerRepo).save(ledger.capture());
|
||||
assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-5);
|
||||
verify(meterReporter)
|
||||
.recordUsage(
|
||||
eq(teamId),
|
||||
eq("cus_x"),
|
||||
eq(5),
|
||||
eq(BillingCategory.API),
|
||||
eq("process:" + jobId + ":close"),
|
||||
eq(jobId));
|
||||
}
|
||||
|
||||
private static void withTransactionSynchronization(Runnable body) {
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package stirling.software.saas.payg.instance;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.saas.payg.charge.ChargeContext;
|
||||
import stirling.software.saas.payg.charge.JobChargeService;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InstanceUsageIngestServiceTest {
|
||||
|
||||
@Mock private PaygInstanceUsageRepository repo;
|
||||
@Mock private JobChargeService chargeService;
|
||||
|
||||
private InstanceUsageIngestService service;
|
||||
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InstanceUsageIngestService(repo, chargeService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstSyncChargesFullCumulativeAndSavesRow() {
|
||||
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "AI"))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.AI, 10L));
|
||||
|
||||
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
|
||||
verify(chargeService).chargeStandalone(ctx.capture(), eq(10));
|
||||
assertThat(ctx.getValue().ownerTeamId()).isEqualTo(1L);
|
||||
assertThat(ctx.getValue().ownerUserId()).isEqualTo(7L);
|
||||
assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI);
|
||||
assertThat(ctx.getValue().source()).isEqualTo(JobSource.LINKED_INSTANCE);
|
||||
|
||||
ArgumentCaptor<PaygInstanceUsage> row = ArgumentCaptor.forClass(PaygInstanceUsage.class);
|
||||
verify(repo).save(row.capture());
|
||||
assertThat(row.getValue().getLastCumulativeUnits()).isEqualTo(10L);
|
||||
assertThat(row.getValue().getLastSyncSeq()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondSyncChargesOnlyDelta() {
|
||||
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 10L, 1L);
|
||||
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L));
|
||||
|
||||
// One charge for the aggregated delta (15), not per underlying op — pins the per-delta
|
||||
// model.
|
||||
verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(15));
|
||||
verify(repo).save(existing);
|
||||
assertThat(existing.getLastCumulativeUnits()).isEqualTo(25L);
|
||||
assertThat(existing.getLastSyncSeq()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayIsIgnored() {
|
||||
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
|
||||
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L));
|
||||
|
||||
verify(chargeService, never()).chargeStandalone(any(), anyInt());
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void regressionIsRefusedAndNotAdvanced() {
|
||||
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
|
||||
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 5L));
|
||||
|
||||
verify(chargeService, never()).chargeStandalone(any(), anyInt());
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroDeltaAdvancesSeqWithoutCharging() {
|
||||
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
|
||||
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 25L));
|
||||
|
||||
verify(chargeService, never()).chargeStandalone(any(), anyInt());
|
||||
verify(repo).save(existing);
|
||||
assertThat(existing.getLastSyncSeq()).isEqualTo(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void billsAccruedDeltaWithoutConsultingCap() {
|
||||
// Intent pin: the ingest has no cap input and always bills the accrued delta — cap
|
||||
// enforcement is the request-time gate's job (the instance stops accruing at the cap), not
|
||||
// this aggregate charge path's. A large valid delta is billed in full.
|
||||
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, 5_000_000L));
|
||||
|
||||
verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(5_000_000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullActorSkipsEntirely() {
|
||||
service.ingest(1L, null, 1L, period, Map.of(BillingCategory.AI, 10L));
|
||||
|
||||
verifyNoInteractions(repo, chargeService);
|
||||
}
|
||||
}
|
||||
@@ -6229,9 +6229,19 @@ noClientSecret = "Edge function returned no client_secret."
|
||||
subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest."
|
||||
title = "Turn on the Processor plan"
|
||||
|
||||
[portal.billing.checkout.activationSlow]
|
||||
body = "Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly."
|
||||
close = "Close"
|
||||
title = "Almost there"
|
||||
|
||||
[portal.billing.checkout.error]
|
||||
title = "Couldn't start checkout"
|
||||
|
||||
[portal.billing.checkout.finalizing]
|
||||
body = "Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds."
|
||||
hint = "Please keep this window open."
|
||||
title = "Activating your Processor plan..."
|
||||
|
||||
[portal.billing.checkout.notConfigured]
|
||||
bodyAfter = "in the portal env to enable in-app checkout."
|
||||
bodyBefore = "Set"
|
||||
@@ -7746,10 +7756,6 @@ loadWallet = "Couldn't load wallet"
|
||||
openStripePortal = "Couldn't open Stripe portal"
|
||||
walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}"
|
||||
|
||||
[portal.usage.finalizing]
|
||||
body = "It can take a few seconds for your subscription to activate. This page updates automatically."
|
||||
title = "Finalizing your subscription…"
|
||||
|
||||
[portal.usage.sessionExpired]
|
||||
action = "Sign in again"
|
||||
body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked."
|
||||
|
||||
@@ -21,6 +21,18 @@ export async function fetchWallet(): Promise<Wallet> {
|
||||
return apiClient.saas.json<Wallet>("/api/v1/payg/wallet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the SaaS to drop this team's cached wallet snapshot so the next
|
||||
* {@link fetchWallet} reflects a just-changed billing state (e.g. a completed
|
||||
* checkout) without waiting out the ~30s server-side cache. Best-effort — the
|
||||
* caller polls regardless of whether this succeeds.
|
||||
*/
|
||||
export async function refreshWalletCache(): Promise<void> {
|
||||
await apiClient.saas.json<void>("/api/v1/payg/wallet/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Cap — leader-only PATCH (real endpoint).
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
|
||||
|
||||
import {
|
||||
fetchInstances,
|
||||
fetchLocalUsage,
|
||||
fetchStatus,
|
||||
linkInstance,
|
||||
revokeInstance,
|
||||
@@ -74,6 +75,16 @@ describe("api/link — local backend (this instance)", () => {
|
||||
expect((await fetchStatus()).linked).toBe(false);
|
||||
});
|
||||
|
||||
it("reads instance-local unsynced usage via the local endpoint", async () => {
|
||||
const usage = await fetchLocalUsage();
|
||||
expect(usage.totalUnsyncedUnits).toBe(
|
||||
usage.apiUnsyncedUnits +
|
||||
usage.aiUnsyncedUnits +
|
||||
usage.automationUnsyncedUnits,
|
||||
);
|
||||
expect(usage.totalUnsyncedUnits).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("forwards the SaaS JWT in the link body", async () => {
|
||||
let seenBody: unknown = null;
|
||||
server.events.on("request:start", async ({ request }) => {
|
||||
|
||||
@@ -3,12 +3,14 @@ import type {
|
||||
LinkInstanceRequest,
|
||||
LinkStatus,
|
||||
LinkedInstanceRow,
|
||||
LocalUsage,
|
||||
} from "@portal/mocks/link";
|
||||
|
||||
export type {
|
||||
LinkInstanceRequest,
|
||||
LinkStatus,
|
||||
LinkedInstanceRow,
|
||||
LocalUsage,
|
||||
} from "@portal/mocks/link";
|
||||
|
||||
/**
|
||||
@@ -57,6 +59,15 @@ export async function fetchStatus(): Promise<LinkStatus> {
|
||||
return apiClient.local.json<LinkStatus>(`${BASE}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally-accrued usage not yet reported to SaaS — the portal adds this on top
|
||||
* of the SaaS-synced spend so "current usage" includes work done since the last
|
||||
* daily sync. Local-backend call; returns zeros when metering is off.
|
||||
*/
|
||||
export async function fetchLocalUsage(): Promise<LocalUsage> {
|
||||
return apiClient.local.json<LocalUsage>(`${BASE}/usage`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this instance's link. The local backend best-effort tells SaaS to
|
||||
* revoke before clearing the credential locally, then returns 204 — there's no
|
||||
@@ -66,6 +77,17 @@ export async function unlinkInstance(): Promise<void> {
|
||||
await apiClient.local.json<void>(`${BASE}/unlink`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the local backend to sync + refresh its cached entitlement now. Called
|
||||
* right after a checkout completes so the instance's request-time gate reflects
|
||||
* the new subscription immediately instead of waiting out its entitlement-cache
|
||||
* TTL. Best-effort — the caller swallows failures (metering off → 409, or the
|
||||
* local backend unreachable); the scheduled sync / TTL refresh is the backstop.
|
||||
*/
|
||||
export async function triggerLocalSync(): Promise<void> {
|
||||
await apiClient.local.json<void>(`${BASE}/sync-now`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Every linked instance for the team — SaaS-direct call with the admin's
|
||||
* Supabase JWT (no longer takes an accessToken parameter; the saas client
|
||||
|
||||
@@ -116,6 +116,11 @@ export async function createCheckoutSession(
|
||||
currency: req.currency ?? "usd",
|
||||
success_url: req.successUrl,
|
||||
cancel_url: req.cancelUrl,
|
||||
// The portal drives an in-page onComplete handler (the checkout modal stays open to
|
||||
// finalise activation + nudge the linked instance), so tell the edge function not to
|
||||
// redirect on completion. A redirect would reload the page, skip that finalize step, and
|
||||
// make Stripe ignore onComplete entirely (console warns "redirect_on_completion: always").
|
||||
redirect_on_completion: "never",
|
||||
...(req.billingOwnerEmail
|
||||
? { billing_owner_email: req.billingOwnerEmail }
|
||||
: {}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, StatusBadge } from "@app/ui";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import type { LocalUsage } from "@portal/api/link";
|
||||
import type { SaasCurrency } from "@portal/billing/stripe";
|
||||
import { WalletMeter } from "@portal/components/billing/WalletMeter";
|
||||
import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard";
|
||||
@@ -10,8 +11,14 @@ import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutMo
|
||||
|
||||
interface Props {
|
||||
wallet: Wallet;
|
||||
/** Called after checkout completes so the parent refetches the wallet. */
|
||||
onSubscribed?: () => void;
|
||||
/** Instance-local usage not yet synced to SaaS; folded into the trial meter. */
|
||||
unsynced?: LocalUsage | null;
|
||||
/**
|
||||
* Runs the post-checkout activation poll and resolves true once the wallet
|
||||
* reads subscribed (false if it's lagging past the poll window). The checkout
|
||||
* modal awaits this to stay open through activation.
|
||||
*/
|
||||
onSubscribed?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function isSaasCurrency(c: string | null): c is SaasCurrency {
|
||||
@@ -23,7 +30,7 @@ function isSaasCurrency(c: string | null): c is SaasCurrency {
|
||||
* editor fleet, the Processor trial meter (with the inline "Switch on the
|
||||
* Processor" CTA → embedded Stripe Checkout), and the Enterprise upsell.
|
||||
*/
|
||||
export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [missingTeam, setMissingTeam] = useState<string | null>(null);
|
||||
@@ -86,7 +93,11 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
<FreePdfEditorsCard />
|
||||
|
||||
{/* Processor trial — meter with the inline upgrade CTA */}
|
||||
<WalletMeter wallet={wallet} action={switchOnAction} />
|
||||
<WalletMeter
|
||||
wallet={wallet}
|
||||
unsynced={unsynced}
|
||||
action={switchOnAction}
|
||||
/>
|
||||
|
||||
{missingTeam && (
|
||||
<Banner
|
||||
@@ -117,10 +128,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
onClose={() => setModalOpen(false)}
|
||||
teamId={wallet.teamId}
|
||||
currency={currency}
|
||||
onComplete={() => {
|
||||
setModalOpen(false);
|
||||
onSubscribed?.();
|
||||
}}
|
||||
onComplete={() => onSubscribed?.() ?? Promise.resolve(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,20 @@ type Story = StoryObj<typeof PdfsProcessedCard>;
|
||||
/** Metered PDFs split across API / Agents / Automation (real categoryBreakdown). */
|
||||
export const WithBreakdown: Story = { args: { wallet: subscribedWallet } };
|
||||
|
||||
/** Synced usage plus instance-local work not yet billed — headline + split combine, with a pending note. */
|
||||
export const WithUnsynced: Story = {
|
||||
args: {
|
||||
wallet: subscribedWallet,
|
||||
unsynced: {
|
||||
periodStart: subscribedWallet.billingPeriodStart,
|
||||
apiUnsyncedUnits: 12,
|
||||
aiUnsyncedUnits: 3,
|
||||
automationUnsyncedUnits: 0,
|
||||
totalUnsyncedUnits: 15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Nothing metered yet this period — the split hides. */
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@app/ui";
|
||||
import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing";
|
||||
import type { LocalUsage } from "@portal/api/link";
|
||||
|
||||
/**
|
||||
* "PDFs processed this period" headline + a stacked split of where the metered
|
||||
@@ -8,6 +9,11 @@ import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing";
|
||||
* (API / Agents / Automation — the same buckets the entitlement service tracks;
|
||||
* the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when
|
||||
* nothing metered has run yet.
|
||||
*
|
||||
* <p>When a linked instance has accrued usage SaaS hasn't billed yet ({@code
|
||||
* unsynced}), it's folded into the headline + split so "current usage" reflects
|
||||
* work done since the last daily sync. The synced-vs-pending split is an internal
|
||||
* detail the customer doesn't need, so it's not surfaced — just the combined total.
|
||||
*/
|
||||
const SEGMENTS: ReadonlyArray<{
|
||||
key: keyof WalletCategoryBreakdown;
|
||||
@@ -43,10 +49,25 @@ const SEGMENTS: ReadonlyArray<{
|
||||
},
|
||||
];
|
||||
|
||||
export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
|
||||
export function PdfsProcessedCard({
|
||||
wallet,
|
||||
unsynced,
|
||||
}: {
|
||||
wallet: Wallet;
|
||||
unsynced?: LocalUsage | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const b = wallet.categoryBreakdown;
|
||||
// Fold instance-local unsynced usage into both the headline and the split, so
|
||||
// the card shows synced + not-yet-billed work as a single current-usage figure.
|
||||
const pending = unsynced?.totalUnsyncedUnits ?? 0;
|
||||
const base = wallet.categoryBreakdown;
|
||||
const b: WalletCategoryBreakdown = {
|
||||
api: base.api + (unsynced?.apiUnsyncedUnits ?? 0),
|
||||
ai: base.ai + (unsynced?.aiUnsyncedUnits ?? 0),
|
||||
automation: base.automation + (unsynced?.automationUnsyncedUnits ?? 0),
|
||||
};
|
||||
const total = b.api + b.ai + b.automation;
|
||||
const headline = wallet.billableUsed + pending;
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
@@ -58,7 +79,7 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
|
||||
</span>
|
||||
<div className="portal-billing__bignum-row">
|
||||
<span className="portal-billing__bignum">
|
||||
{wallet.billableUsed.toLocaleString()}
|
||||
{headline.toLocaleString()}
|
||||
</span>
|
||||
<span className="portal-billing__bignum-unit">
|
||||
{t("portal.billing.pdfsProcessed.unit", "metered PDFs")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Modal, Skeleton } from "@app/ui";
|
||||
import { Banner, Button, Modal, Skeleton, Spinner } from "@app/ui";
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
@@ -22,10 +22,14 @@ interface Props {
|
||||
/** Optional billing email prefill (Stripe locks the field when set). */
|
||||
billingOwnerEmail?: string;
|
||||
/**
|
||||
* Fired when Stripe (or the mock continue button) signals success. Caller
|
||||
* refreshes the wallet so the linked-subscribed view takes over.
|
||||
* Fired when Stripe (or the mock continue button) signals payment success.
|
||||
* Runs the caller's activation flow (poll the wallet until the subscription
|
||||
* webhook lands) and resolves {@code true} once subscribed, {@code false} if
|
||||
* it's taking longer than the poll window. The modal stays open and
|
||||
* non-dismissable while this runs, so the admin watches activation through
|
||||
* instead of the modal vanishing and needing a manual refresh.
|
||||
*/
|
||||
onComplete: () => void;
|
||||
onComplete: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +49,55 @@ function loadStripeOnce(pk: string): Promise<Stripe | null> {
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
/** Payment done, waiting for the subscription webhook to activate the plan. */
|
||||
function CheckoutFinalizing() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-billing__checkout-finalizing" role="status">
|
||||
<Spinner size="lg" />
|
||||
<h3 className="portal-billing__checkout-status-title">
|
||||
{t(
|
||||
"portal.billing.checkout.finalizing.title",
|
||||
"Activating your Processor plan...",
|
||||
)}
|
||||
</h3>
|
||||
<p className="portal-billing__checkout-status-body">
|
||||
{t(
|
||||
"portal.billing.checkout.finalizing.body",
|
||||
"Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds.",
|
||||
)}
|
||||
</p>
|
||||
<p className="portal-billing__checkout-status-hint">
|
||||
{t(
|
||||
"portal.billing.checkout.finalizing.hint",
|
||||
"Please keep this window open.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Webhook lagging past the poll window — dismissable "it'll appear shortly" notice. */
|
||||
function CheckoutActivationSlow({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-billing__checkout-finalizing" role="status">
|
||||
<h3 className="portal-billing__checkout-status-title">
|
||||
{t("portal.billing.checkout.activationSlow.title", "Almost there")}
|
||||
</h3>
|
||||
<p className="portal-billing__checkout-status-body">
|
||||
{t(
|
||||
"portal.billing.checkout.activationSlow.body",
|
||||
"Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly.",
|
||||
)}
|
||||
</p>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("portal.billing.checkout.activationSlow.close", "Close")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StripeCheckoutModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -57,16 +110,29 @@ export function StripeCheckoutModal({
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// "checkout" = Stripe form; "finalizing" = payment done, waiting for the plan to activate
|
||||
// (non-dismissable); "activationSlow" = webhook lagging past the poll window (dismissable).
|
||||
const [phase, setPhase] = useState<
|
||||
"checkout" | "finalizing" | "activationSlow"
|
||||
>("checkout");
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const publishableKey = getStripePublishableKey();
|
||||
|
||||
// Mint the checkout session whenever the modal opens for a fresh team/currency.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset on close so re-opening fetches a fresh session.
|
||||
// Reset on close so re-opening fetches a fresh session + starts at checkout.
|
||||
setClientSecret(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setPhase("checkout");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -115,57 +181,91 @@ export function StripeCheckoutModal({
|
||||
const stripe = publishableKey ? loadStripeOnce(publishableKey) : null;
|
||||
const canRender = Boolean(stripe && clientSecret);
|
||||
|
||||
// Payment succeeded — hold the modal open and run the caller's activation poll instead of
|
||||
// closing. The parent swaps to the subscribed view on success (unmounting us); a lagging
|
||||
// webhook drops us into the dismissable "almost there" state.
|
||||
function handleStripeComplete() {
|
||||
setPhase("finalizing");
|
||||
onComplete()
|
||||
.then((activated) => {
|
||||
if (!activated && mounted.current) setPhase("activationSlow");
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted.current) setPhase("activationSlow");
|
||||
});
|
||||
}
|
||||
|
||||
// Block dismissal while activation is in flight so a half-finished flow can't be abandoned;
|
||||
// the X, backdrop, and Escape all route through this.
|
||||
const dismissable = phase !== "finalizing";
|
||||
const handleClose = () => {
|
||||
if (dismissable) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
onClose={handleClose}
|
||||
width="xl"
|
||||
className="portal-billing__checkout-modal"
|
||||
disableBackdropClose={!dismissable}
|
||||
disableEscapeClose={!dismissable}
|
||||
title={t("portal.billing.checkout.title", "Turn on the Processor plan")}
|
||||
subtitle={t(
|
||||
"portal.billing.checkout.subtitle",
|
||||
"Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.",
|
||||
)}
|
||||
>
|
||||
{!publishableKey && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t(
|
||||
"portal.billing.checkout.notConfigured.title",
|
||||
"Stripe not configured",
|
||||
)}
|
||||
>
|
||||
{t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "}
|
||||
<code>VITE_STRIPE_PUBLISHABLE_KEY</code>{" "}
|
||||
{t(
|
||||
"portal.billing.checkout.notConfigured.bodyAfter",
|
||||
"in the portal env to enable in-app checkout.",
|
||||
)}
|
||||
</Banner>
|
||||
{phase === "finalizing" && <CheckoutFinalizing />}
|
||||
|
||||
{phase === "activationSlow" && (
|
||||
<CheckoutActivationSlow onClose={onClose} />
|
||||
)}
|
||||
{publishableKey && error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t(
|
||||
"portal.billing.checkout.error.title",
|
||||
"Couldn't start checkout",
|
||||
|
||||
{phase === "checkout" && (
|
||||
<>
|
||||
{!publishableKey && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t(
|
||||
"portal.billing.checkout.notConfigured.title",
|
||||
"Stripe not configured",
|
||||
)}
|
||||
>
|
||||
{t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "}
|
||||
<code>VITE_STRIPE_PUBLISHABLE_KEY</code>{" "}
|
||||
{t(
|
||||
"portal.billing.checkout.notConfigured.bodyAfter",
|
||||
"in the portal env to enable in-app checkout.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
{publishableKey && loading && !error && (
|
||||
<div className="portal-billing__skeleton" aria-hidden>
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="18rem" />
|
||||
</div>
|
||||
)}
|
||||
{publishableKey && canRender && stripe && clientSecret && (
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripe}
|
||||
options={{ clientSecret, onComplete }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
{publishableKey && error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t(
|
||||
"portal.billing.checkout.error.title",
|
||||
"Couldn't start checkout",
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
{publishableKey && loading && !error && (
|
||||
<div className="portal-billing__skeleton" aria-hidden>
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="18rem" />
|
||||
</div>
|
||||
)}
|
||||
{publishableKey && canRender && stripe && clientSecret && (
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripe}
|
||||
options={{ clientSecret, onComplete: handleStripeComplete }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button } from "@app/ui";
|
||||
import { meterState } from "@app/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import type { LocalUsage } from "@portal/api/link";
|
||||
import { useStripePortal } from "@portal/hooks/useStripePortal";
|
||||
import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard";
|
||||
import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard";
|
||||
@@ -13,6 +14,8 @@ import { InvoicesList } from "@portal/components/billing/InvoicesList";
|
||||
|
||||
interface Props {
|
||||
wallet: Wallet;
|
||||
/** Instance-local usage not yet synced to SaaS; folded into the PDFs-processed card. */
|
||||
unsynced?: LocalUsage | null;
|
||||
onWalletChange?: () => void;
|
||||
}
|
||||
|
||||
@@ -30,7 +33,11 @@ interface Props {
|
||||
* page-header "Manage Payment" action and the payment card's "Update" button
|
||||
* deep-link there via {@link useStripePortal}.
|
||||
*/
|
||||
export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
|
||||
export function SubscribedPlanView({
|
||||
wallet,
|
||||
unsynced,
|
||||
onWalletChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [adjusting, setAdjusting] = useState(false);
|
||||
const portal = useStripePortal(wallet);
|
||||
@@ -93,7 +100,7 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
|
||||
|
||||
<FreePdfEditorsCard />
|
||||
|
||||
<PdfsProcessedCard wallet={wallet} />
|
||||
<PdfsProcessedCard wallet={wallet} unsynced={unsynced} />
|
||||
|
||||
<div className="portal-billing__spend-row">
|
||||
<SpendThisMonthCard wallet={wallet} />
|
||||
|
||||
@@ -3,10 +3,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@app/ui";
|
||||
import { formatMinor, MeterBar, meterState } from "@app/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import type { LocalUsage } from "@portal/api/link";
|
||||
|
||||
interface Props {
|
||||
/** A linked-free wallet. */
|
||||
wallet: Wallet;
|
||||
/** Instance-local usage not yet synced to SaaS; folded into "used" so the trial meter reflects work since the last sync. */
|
||||
unsynced?: LocalUsage | null;
|
||||
/** Optional top-right action (e.g. "Switch on the Processor"). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
@@ -16,10 +19,18 @@ interface Props {
|
||||
* grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
|
||||
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
|
||||
* ({@code SpendLimitCard}); this card is only the free face.
|
||||
*
|
||||
* <p>Locally-accrued usage SaaS hasn't billed yet ({@code unsynced}) is folded
|
||||
* into the used figure + remaining count so the trial depletes in step with the
|
||||
* gate — which now also blocks against the pending local delta — instead of only
|
||||
* moving after a daily sync.
|
||||
*/
|
||||
export function WalletMeter({ wallet, action }: Props) {
|
||||
export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { state, pct } = meterState(wallet.billableUsed, wallet.freeAllowance);
|
||||
const pending = unsynced?.totalUnsyncedUnits ?? 0;
|
||||
const used = wallet.billableUsed + pending;
|
||||
const remaining = Math.max(0, wallet.freeRemaining - pending);
|
||||
const { state, pct } = meterState(used, wallet.freeAllowance);
|
||||
const rate =
|
||||
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
|
||||
? wallet.pricePerDocMinor
|
||||
@@ -65,7 +76,7 @@ export function WalletMeter({ wallet, action }: Props) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
figure={wallet.billableUsed.toLocaleString()}
|
||||
figure={used.toLocaleString()}
|
||||
capSuffix={t(
|
||||
"portal.billing.walletMeter.capSuffix",
|
||||
"of {{allowance}} free PDFs used",
|
||||
@@ -78,8 +89,8 @@ export function WalletMeter({ wallet, action }: Props) {
|
||||
"portal.billing.walletMeter.statusLabel",
|
||||
"{{remaining}} left",
|
||||
{
|
||||
count: wallet.freeRemaining,
|
||||
remaining: wallet.freeRemaining.toLocaleString(),
|
||||
count: remaining,
|
||||
remaining: remaining.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -122,6 +122,47 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Widen the checkout modal past the ~1000px iframe threshold where Stripe
|
||||
Embedded Checkout flips from its single-column ("mobile") layout to the
|
||||
two-column desktop one — matching the SaaS Plan page's UpgradeModal (1100px
|
||||
cap → ~1056px iframe). Two-class selector so it beats .sui-modal--xl's
|
||||
max-width regardless of stylesheet order. width:100% still shrinks it on
|
||||
narrow viewports, where Stripe falls back to single column on its own. */
|
||||
.sui-modal.portal-billing__checkout-modal {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
/* Post-checkout activation state, shown inside the checkout modal while the
|
||||
subscription webhook lands (and the "almost there" fallback if it lags). */
|
||||
.portal-billing__checkout-finalizing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.portal-billing__checkout-status-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.portal-billing__checkout-status-body {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-2);
|
||||
margin: 0;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.portal-billing__checkout-status-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.portal-billing__error {
|
||||
color: var(--color-red, #b91c1c);
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import {
|
||||
getLocalStatus,
|
||||
getLocalUsage,
|
||||
linkLocal,
|
||||
listInstances,
|
||||
revokeInstance,
|
||||
@@ -37,6 +38,11 @@ export const linkHandlers = [
|
||||
return HttpResponse.json(linkLocal(name), { status: 201 });
|
||||
}),
|
||||
|
||||
http.get("/api/v1/account-link/usage", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(getLocalUsage());
|
||||
}),
|
||||
|
||||
http.post("/api/v1/account-link/unlink", async () => {
|
||||
await delay(120);
|
||||
// Clear local link state, then 204 (no body) to match the real backend.
|
||||
@@ -44,6 +50,14 @@ export const linkHandlers = [
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
// Manual sync trigger — the real backend runs a sync + entitlement refresh and
|
||||
// returns 204 (or 409 when metering is off). The portal fires it best-effort
|
||||
// after a checkout completes; the mock just acknowledges.
|
||||
http.post("/api/v1/account-link/sync-now", async () => {
|
||||
await delay(120);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
// Team-wide list/revoke are SaaS-direct now (apiClient.saas calls the
|
||||
// absolute VITE_SAAS_API_URL). Wildcard so the same handlers intercept both
|
||||
// the relative pattern (legacy / direct-MSW usage) and any absolute SaaS
|
||||
|
||||
@@ -36,6 +36,21 @@ export interface LinkStatus {
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage).
|
||||
* The portal adds this on top of the SaaS-synced spend so "current usage"
|
||||
* includes work done since the last daily sync. Per-category unsynced units for
|
||||
* the current period; all zero when metering is off or nothing is pending.
|
||||
*/
|
||||
export interface LocalUsage {
|
||||
/** ISO timestamp of the current period start; null when unknown (not yet synced). */
|
||||
periodStart: string | null;
|
||||
apiUnsyncedUnits: number;
|
||||
aiUnsyncedUnits: number;
|
||||
automationUnsyncedUnits: number;
|
||||
totalUnsyncedUnits: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* SaaS backend — team-wide instance management */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -85,15 +100,34 @@ function seedInstances(): LinkedInstanceRow[] {
|
||||
];
|
||||
}
|
||||
|
||||
function seedLocalUsage(): LocalUsage {
|
||||
// A little unsynced usage so the portal's "+ pending sync" combine is visible
|
||||
// in dev/Storybook.
|
||||
return {
|
||||
periodStart: daysAgo(6),
|
||||
apiUnsyncedUnits: 12,
|
||||
aiUnsyncedUnits: 3,
|
||||
automationUnsyncedUnits: 0,
|
||||
totalUnsyncedUnits: 15,
|
||||
};
|
||||
}
|
||||
|
||||
let store: LinkedInstanceRow[] = seedInstances();
|
||||
let nextId = 1004;
|
||||
let localStatus: LinkStatus = { linked: false, name: null };
|
||||
let localUsage: LocalUsage = seedLocalUsage();
|
||||
|
||||
/** Resets the mock store + local link status to seed state (Storybook / tests). */
|
||||
export function resetLinkStore(): void {
|
||||
store = seedInstances();
|
||||
nextId = 1004;
|
||||
localStatus = { linked: false, name: null };
|
||||
localUsage = seedLocalUsage();
|
||||
}
|
||||
|
||||
/** Current instance-local unsynced usage (GET /api/v1/account-link/usage). */
|
||||
export function getLocalUsage(): LocalUsage {
|
||||
return { ...localUsage };
|
||||
}
|
||||
|
||||
/** Current local link status for this instance. */
|
||||
|
||||
@@ -3,7 +3,16 @@ import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Skeleton } from "@app/ui";
|
||||
import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { fetchWallet, type Wallet } from "@portal/api/billing";
|
||||
import {
|
||||
fetchWallet,
|
||||
refreshWalletCache,
|
||||
type Wallet,
|
||||
} from "@portal/api/billing";
|
||||
import {
|
||||
fetchLocalUsage,
|
||||
triggerLocalSync,
|
||||
type LocalUsage,
|
||||
} from "@portal/api/link";
|
||||
import { useStripePortal } from "@portal/hooks/useStripePortal";
|
||||
import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt";
|
||||
import { FreePlanView } from "@portal/components/billing/FreePlanView";
|
||||
@@ -35,14 +44,14 @@ export function Usage() {
|
||||
const { isLinked, setLinkState, saasSessionNonce } = useLink();
|
||||
const { openLinkModal } = useUI();
|
||||
const [wallet, setWallet] = useState<Wallet | null>(null);
|
||||
// Locally-accrued usage SaaS hasn't billed yet; added to the synced figure so
|
||||
// "current usage" reflects work since the last daily sync. Best-effort.
|
||||
const [localUsage, setLocalUsage] = useState<LocalUsage | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(isLinked);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The instance is linked but the browser's SaaS session has lapsed — needs a
|
||||
// re-sign-in, NOT a re-link.
|
||||
const [needsReauth, setNeedsReauth] = useState(false);
|
||||
// Briefly polling the wallet after a successful checkout until the webhook flips
|
||||
// it to subscribed.
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// Stripe customer portal — the subscribed header's "Manage Payment" action.
|
||||
const portal = useStripePortal(wallet);
|
||||
@@ -60,6 +69,7 @@ export function Usage() {
|
||||
// link prompt; no SaaS call needed.
|
||||
if (!isLinked) {
|
||||
setWallet(null);
|
||||
setLocalUsage(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setNeedsReauth(false);
|
||||
@@ -69,6 +79,15 @@ export function Usage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNeedsReauth(false);
|
||||
// Independent of the wallet load — a local-usage failure must not break the
|
||||
// page; it just means no unsynced delta is shown.
|
||||
fetchLocalUsage()
|
||||
.then((u) => {
|
||||
if (!cancelled) setLocalUsage(u);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLocalUsage(null);
|
||||
});
|
||||
fetchWallet()
|
||||
.then((w) => {
|
||||
if (cancelled) return;
|
||||
@@ -112,31 +131,37 @@ export function Usage() {
|
||||
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
|
||||
const confirmSubscription = useCallback(async () => {
|
||||
const confirmSubscription = useCallback(async (): Promise<boolean> => {
|
||||
// Stripe's onComplete fires before the subscription webhook lands, so poll the
|
||||
// wallet briefly until it flips to subscribed rather than dropping the
|
||||
// just-paid admin back on the free CTA.
|
||||
setFinalizing(true);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// wallet until it flips to subscribed. Drop the server cache before each read
|
||||
// so we see the webhook the moment it lands rather than after the ~30s TTL.
|
||||
// ~60s of attempts — longer than the observed webhook + sync-engine latency —
|
||||
// so a slightly slow activation still completes inside the (open) checkout
|
||||
// modal instead of falling back to a manual refresh. Resolves true once
|
||||
// subscribed so the modal can close itself in.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await refreshWalletCache().catch(() => {});
|
||||
const w = await fetchWallet();
|
||||
if (!mounted.current) return;
|
||||
if (!mounted.current) return false;
|
||||
if (w.status === "subscribed") {
|
||||
setWallet(w);
|
||||
setLinkState("linked-subscribed");
|
||||
setFinalizing(false);
|
||||
return;
|
||||
// Nudge the local instance to refresh its gate now so billable work
|
||||
// unblocks immediately rather than on its next poll. Fire-and-forget.
|
||||
triggerLocalSync().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Transient read failure — keep polling.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
if (!mounted.current) return;
|
||||
if (!mounted.current) return false;
|
||||
}
|
||||
// Webhook still hasn't landed after ~20s: stop blocking and refresh. The page
|
||||
// self-heals on the next load once provisioning completes.
|
||||
setFinalizing(false);
|
||||
// Webhook still hasn't landed: re-fetch once more and report back so the modal
|
||||
// shows its "almost there" notice rather than the page silently self-healing.
|
||||
setRefreshKey((k) => k + 1);
|
||||
return false;
|
||||
}, [setLinkState]);
|
||||
|
||||
return (
|
||||
@@ -177,21 +202,6 @@ export function Usage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLinked && finalizing && (
|
||||
<Banner
|
||||
tone="info"
|
||||
title={t(
|
||||
"portal.usage.finalizing.title",
|
||||
"Finalizing your subscription…",
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"portal.usage.finalizing.body",
|
||||
"It can take a few seconds for your subscription to activate. This page updates automatically.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && needsReauth && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
@@ -230,12 +240,20 @@ export function Usage() {
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && !finalizing && wallet && wallet.status === "free" && (
|
||||
<FreePlanView wallet={wallet} onSubscribed={confirmSubscription} />
|
||||
{isLinked && wallet && wallet.status === "free" && (
|
||||
<FreePlanView
|
||||
wallet={wallet}
|
||||
unsynced={localUsage}
|
||||
onSubscribed={confirmSubscription}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLinked && wallet && wallet.status === "subscribed" && (
|
||||
<SubscribedPlanView wallet={wallet} onWalletChange={refresh} />
|
||||
<SubscribedPlanView
|
||||
wallet={wallet}
|
||||
unsynced={localUsage}
|
||||
onWalletChange={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user