Portal procurement: enterprise licence-key mechanism (generate at trial, upgrade on subscription, view/download) (#6902)

Builds on the procurement vertical slice (#6861). Adds the **enterprise
licence-key mechanism**: a Keygen licence is generated at trial,
upgraded in place when the committed subscription is created, and is
viewable/downloadable in the portal. Flag-gated — ships with the mock as
default until Keygen env vars are wired.

### What it does
- **Offline / air-gapped licence** is a new **priced add-on** on the
quote ($12k/yr, flat, alongside indemnification / training / QBR).
- **Licence key visible from the trial step** — the portal shows the key
with **Copy**, and (when the offline add-on is bought) a **Download
offline licence (.lic)** button.
- **Real Keygen client, called directly from Java**
(`KeygenEnterpriseLicenseService`), behind `stirling.keygen.enabled`;
`MockEnterpriseLicenseService` stays the default. All creds are env vars
(`STIRLING_KEYGEN_*`) — nothing committed.
- **Provisioning is driven by the Stripe `customer.subscription.created`
event** (source of truth), not a UI action — so a sales-led deal entered
manually in Stripe provisions a licence too. The webhook calls a new
admin `POST /api/v1/procurement/provision`, which upgrades the trial
licence **in place** to the committed annual term, **valid immediately**
(no wait for payment). The deal stays in the payment step so the
outstanding invoice remains visible.
- Offline `.lic` is checked out `base64+ed25519` (signed, unencrypted)
so the self-hosted `KeygenLicenseVerifier` validates it fully offline.

### Not in scope (deliberate, follow-ups)
- Cloud entitlement flip — a **cloud** customer sees/downloads the key
but the running cloud product doesn't unlock yet (self-hosted/air-gap
**are** unlocked by the key). Immediate next PR.
- `invoice.paid → fully-live` / `payment_failed → suspend` webhook
safety-net.

### Companion change (separate repo)
- The Stripe-webhook wiring that calls `/provision` lives in the
**Stirling-PDF-SaaS** repo (committed on `v3`, not part of this PR):
`stripe-webhook` routes enterprise-committed `subscription.created` →
`provisionProcurement()` → the Java admin endpoint.

### Prod setup required
- Create the committed-enterprise **Keygen policy with
`scheme=ed25519`** under the existing account, set
`STIRLING_KEYGEN_ENABLED=true` + account/token/policy env vars.

### Verified
saas `:saas:test` (procurement) · portal typecheck / eslint / prettier ·
82 portal tests · `deno check` on the webhook handler.

### Review follow-ups (PR review, tracked)
Low-hardening fixes applied in `85369633ed`: keep Keygen response bodies
out of thrown/logged messages; fail-fast at startup when the flag is on
but creds are missing; gate the offline `.lic` on the *accepted* quote
(not the latest draft).

Deliberately deferred, tracked here:
- **Pre-flag verification.** Before `stirling.keygen.enabled=true`,
confirm the id-vs-key addressing against live Keygen. (The shipping
self-hosted edge addresses licences by URL-safe key in the path and
Keygen docs allow it, so the client mirrors that — but confirm
empirically with the real committed-enterprise policy.)
- **No auto-revoke on non-payment.** Provision issues an
immediately-valid annual licence before payment settles; `invoice.paid →
live` and `payment_failed → suspend` are out of scope here. Note the
offline `.lic`, once downloaded, verifies offline for the full term and
**can't be revoked** — so the real mitigation for the offline case is a
shorter bridge term until `invoice.paid`, not just wiring `suspend`.
Enterprise is sales-led/ADMIN-gated, so this is a collections concern,
not mass abuse.
This commit is contained in:
ConnorYoh
2026-07-08 19:31:26 +00:00
committed by GitHub
parent 3fa0f30d43
commit b53aaa7d03
25 changed files with 1121 additions and 184 deletions
@@ -2,9 +2,12 @@ package stirling.software.saas.procurement.api;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
@@ -12,6 +15,7 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -72,24 +76,28 @@ public class ProcurementController {
public record QuoteRequest(
long volume,
int users,
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {
QuoteConfig toConfig() {
return new QuoteConfig(
volume,
users,
intensity,
deployment,
termYears,
serviceLevel,
indemnification,
training,
qbr,
offlineLicense,
currency);
}
}
@@ -115,12 +123,14 @@ public class ProcurementController {
public record QuoteConfigEcho(
long volume,
int users,
int intensity,
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {}
@@ -131,6 +141,7 @@ public class ProcurementController {
String trialEndsAt,
int trialExtensionsUsed,
boolean licensed,
String licenseKey,
QuoteResponse latestQuote) {}
// ---- endpoints ----------------------------------------------------------
@@ -143,21 +154,49 @@ public class ProcurementController {
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
Long teamId = resolveTeam(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
Optional<TeamMembership> membership = primaryMembership(auth);
if (membership.isEmpty()) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
Long teamId = membership.get().getTeam().getId();
// The licence key is the team's secret entitlement — leader-only. Members still see the
// journey (stage, trial, quote) but the key is withheld; the .lic file is likewise gated.
boolean leader = membership.get().getRole() == TeamRole.LEADER;
return ResponseEntity.ok(
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
procurement.getDeal(teamId).map(d -> toSnapshot(d, leader)).orElse(EMPTY_SNAPSHOT));
}
private static final SnapshotResponse EMPTY_SNAPSHOT =
new SnapshotResponse(null, null, null, null, 0, false, null);
new SnapshotResponse(null, null, null, null, 0, false, null, null);
/**
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
* that a licence exists to a team without the add-on.
*/
@GetMapping("/license/file")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<String> licenseFile(Authentication auth) {
// Leader-only: the offline .lic is the team's portable entitlement, not a member artefact.
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.offlineLicenseFile(teamId)
.<ResponseEntity<String>>map(
cert ->
ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"stirling-enterprise.lic\"")
.contentType(MediaType.TEXT_PLAIN)
.body(cert))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping("/trial/start")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
}
@PostMapping("/trial/extend")
@@ -166,7 +205,7 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId), true));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
@@ -197,12 +236,30 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId), true));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/**
* Provision on accept: upgrade the team's licence to the committed annual term, valid
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
* the subscription + invoice exist, so the buyer is licensed the moment they accept — the deal
* stays in the payment step until the invoice settles. Idempotent.
*/
@PostMapping("/provision")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> provision(@RequestParam("teamId") long teamId) {
try {
procurement.provisionLicense(teamId);
return ResponseEntity.ok().build();
} catch (IllegalStateException e) {
log.warn("[procurement] provision rejected team={}: {}", teamId, e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/**
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
@@ -214,7 +271,7 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId), true));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
@@ -233,34 +290,31 @@ public class ProcurementController {
// ---- helpers ------------------------------------------------------------
/**
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
*/
private Long resolveTeam(Authentication auth) {
/** The caller's primary team membership; empty when unauthenticated/teamless. */
private Optional<TeamMembership> primaryMembership(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return null;
return Optional.empty();
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
}
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
private Long requireLeader(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return null;
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
return rows.get(0).getTeam().getId();
return primaryMembership(auth)
.filter(m -> m.getRole() == TeamRole.LEADER)
.map(m -> m.getTeam().getId())
.orElse(null);
}
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
/**
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
* endpoints are leader-gated, so they always pass true.
*/
private SnapshotResponse toSnapshot(ProcurementDeal deal, boolean includeLicenseKey) {
QuoteResponse latest =
procurement.quotesForDeal(deal.getDealId()).stream()
.findFirst()
@@ -273,6 +327,7 @@ public class ProcurementController {
str(deal.getTrialEndsAt()),
deal.getTrialExtensionsUsed(),
deal.getLicenseRef() != null,
includeLicenseKey ? deal.getLicenseRef() : null,
latest);
}
@@ -291,12 +346,14 @@ public class ProcurementController {
new QuoteConfigEcho(
q.getVolume(),
0,
q.getIntensity(),
q.getDeployment(),
q.getTermYears(),
q.getServiceLevel(),
q.isIndemnification(),
q.isTraining(),
q.isQbr(),
q.isOfflineLicense(),
q.getCurrency(),
q.getBusinessName()));
}
@@ -0,0 +1,55 @@
package stirling.software.saas.procurement.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Keygen credentials + policy for issuing enterprise procurement licences directly from Java.
* Prefix {@code stirling.keygen}. All secrets come from the environment (relaxed binding: {@code
* STIRLING_KEYGEN_ACCOUNT_ID}, {@code STIRLING_KEYGEN_API_TOKEN}, …) — never committed.
*
* <p>{@code enabled} is the switch between {@code MockEnterpriseLicenseService} (default) and the
* real {@code KeygenEnterpriseLicenseService}; the mock stays in place until the env vars are
* wired.
*/
@Getter
@Setter
@Component
@Profile("saas")
@ConfigurationProperties(prefix = "stirling.keygen")
public class KeygenConfigurationProperties {
/** Master switch: when true, the real Keygen client replaces the mock licence service. */
private boolean enabled = false;
/** Keygen account id (UUID or slug). From {@code STIRLING_KEYGEN_ACCOUNT_ID}. */
private String accountId;
/** Keygen admin API token. From {@code STIRLING_KEYGEN_API_TOKEN}. Never log this. */
private String apiToken;
/** Policy the committed-enterprise licences are created under. From {@code ..._POLICY_ID}. */
private String policyId;
/** API base; overridable for self-hosted Keygen, defaults to the hosted service. */
private String apiBase = "https://api.keygen.sh/v1";
/**
* License-file check-out algorithm. Must stay {@code base64+ed25519} — the self-hosted {@code
* KeygenLicenseVerifier} only verifies that scheme (signed, unencrypted) offline.
*/
private String licenseFileAlgorithm = "base64+ed25519";
/** True when the credentials needed to talk to Keygen are all present. */
public boolean isConfigured() {
return notBlank(accountId) && notBlank(apiToken) && notBlank(policyId);
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}
@@ -11,15 +11,37 @@ import java.time.LocalDateTime;
*/
public interface EnterpriseLicenseService {
/** Issue a time-boxed trial licence for the team; returns the licence reference. */
String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
/**
* Issue a time-boxed trial licence for the team, owned by {@code ownerEmail} (the team leader);
* returns the licence reference (the Keygen key, stored on the deal).
*/
String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt);
/** Move a licence's expiry out (trial extension). */
void extendLicense(String licenseRef, LocalDateTime newExpiry);
/** Issue/upgrade to a committed annual licence with the quote's entitlements. */
String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
/**
* Issue/upgrade to a committed annual licence carrying the deal's {@link LicenseEntitlements}
* ({@code seats} = 0 means unlimited). When {@code existingRef} is non-null (the team already
* has a trial licence), that licence is upgraded in place so the key the buyer already holds
* keeps working; otherwise a new licence is created. Owned by {@code ownerEmail}; returns the
* licence reference.
*/
String issueAnnualLicense(
Long teamId,
String ownerEmail,
LocalDateTime expiresAt,
String existingRef,
LicenseEntitlements entitlements);
/** Suspend a licence (e.g. payment failed, deal lost). */
void suspendLicense(String licenseRef);
/**
* Check out a signed, offline-verifiable licence file (a {@code -----BEGIN LICENSE FILE-----}
* certificate) for the given licence, for an air-gapped self-hosted instance. The paid offline
* add-on gates whether this is offered; the certificate itself is generated on demand and never
* stored.
*/
String checkOutLicenseFile(String licenseRef);
}
@@ -0,0 +1,304 @@
package stirling.software.saas.procurement.license;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.procurement.config.KeygenConfigurationProperties;
/**
* Real {@link EnterpriseLicenseService}: manages the team's enterprise licence directly against the
* Keygen API (the "call Keygen from Java" direction), rather than via the Supabase edge functions
* the self-hosted checkout uses. Active only when {@code stirling.keygen.enabled=true}; otherwise
* {@link MockEnterpriseLicenseService} is the bean.
*
* <p>Licences are owned by the team leader (a Keygen user, found or created by email) and created
* under the committed-enterprise policy. Metadata carries {@code isEnterprise} + {@code users} so a
* checked-out offline licence file satisfies the self-hosted {@code KeygenLicenseVerifier}. The
* licence key returned is stored on the deal as its {@code license_ref}.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.keygen.enabled", havingValue = "true")
public class KeygenEnterpriseLicenseService implements EnterpriseLicenseService {
private static final String VND_JSON = "application/vnd.api+json";
private final ObjectMapper mapper = new ObjectMapper();
private final HttpClient http =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
private final KeygenConfigurationProperties config;
public KeygenEnterpriseLicenseService(KeygenConfigurationProperties config) {
this.config = config;
// Fail fast: if the flag is on but creds are missing, a misconfigured prod deploy should be
// caught at startup, not at the first trial/provision. (Flag off → Mock bean, never here.)
if (!config.isConfigured()) {
throw new IllegalStateException(
"stirling.keygen.enabled=true but Keygen is not fully configured — set "
+ "STIRLING_KEYGEN_ACCOUNT_ID / _API_TOKEN / _POLICY_ID, or turn the flag off");
}
}
@Override
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
String ownerId = findOrCreateUser(ownerEmail);
// Trial: enterprise entitlement, unlimited users, expiring at the trial end. No committed
// volume/add-ons yet — those are stamped on the annual licence at provision.
return createLicense(ownerId, expiresAt, trialMetadata(teamId));
}
@Override
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
Map<String, Object> attrs = new LinkedHashMap<>();
attrs.put("expiry", iso(newExpiry));
patchLicense(licenseRef, attrs);
}
@Override
public String issueAnnualLicense(
Long teamId,
String ownerEmail,
LocalDateTime expiresAt,
String existingRef,
LicenseEntitlements ent) {
Map<String, Object> metadata = annualMetadata(teamId, ent);
// Upgrade the trial licence in place so the key the buyer already holds keeps working.
if (existingRef != null && !existingRef.isBlank()) {
Map<String, Object> attrs = new LinkedHashMap<>();
attrs.put("expiry", iso(expiresAt));
attrs.put("suspended", false);
attrs.put("metadata", metadata);
patchLicense(existingRef, attrs);
return existingRef;
}
String ownerId = findOrCreateUser(ownerEmail);
return createLicense(ownerId, expiresAt, metadata);
}
@Override
public void suspendLicense(String licenseRef) {
HttpResponse<String> res =
send(
authed(licenseUrl(licenseRef) + "/actions/suspend")
.POST(HttpRequest.BodyPublishers.noBody())
.build());
expect(res, 200, "suspend licence");
}
@Override
public String checkOutLicenseFile(String licenseRef) {
// Signed, unencrypted (base64+ed25519) so the self-hosted verifier can validate it offline;
// include entitlements in the snapshot. Never encrypt — the verifier only reads
// base64+ed25519.
String url =
licenseUrl(licenseRef)
+ "/actions/check-out?include=entitlements&algorithm="
+ enc(config.getLicenseFileAlgorithm());
HttpResponse<String> res =
send(authed(url).POST(HttpRequest.BodyPublishers.noBody()).build());
expect(res, 200, "check-out licence file");
JsonNode cert = readJson(res).at("/data/attributes/certificate");
if (cert.isMissingNode() || cert.asText().isBlank()) {
throw new IllegalStateException("Keygen check-out returned no certificate");
}
return cert.asText();
}
// ---- Keygen primitives --------------------------------------------------
/** Find the Keygen user by email, creating it if absent; returns the user id. */
private String findOrCreateUser(String email) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("Cannot own a licence without an owner email");
}
HttpResponse<String> find =
send(authed(accountUrl() + "/users/" + enc(email)).GET().build());
if (find.statusCode() == 200) {
return readJson(find).at("/data/id").asText();
}
if (find.statusCode() != 404) {
expect(find, 200, "find Keygen user"); // throws with the real status
}
Map<String, Object> body =
jsonApi("users", Map.of("email", email), null); // no relationships
HttpResponse<String> create =
send(
authed(accountUrl() + "/users")
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
.build());
expect(create, 201, "create Keygen user");
return readJson(create).at("/data/id").asText();
}
private String createLicense(
String ownerId, LocalDateTime expiresAt, Map<String, Object> metadata) {
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("expiry", iso(expiresAt));
attributes.put("metadata", metadata);
Map<String, Object> relationships =
Map.of(
"policy",
Map.of("data", Map.of("type", "policies", "id", config.getPolicyId())),
"owner",
Map.of("data", Map.of("type", "users", "id", ownerId)));
Map<String, Object> body = jsonApi("licenses", attributes, relationships);
HttpResponse<String> res =
send(
authed(accountUrl() + "/licenses")
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
.build());
expect(res, 201, "create licence");
return readJson(res).at("/data/attributes/key").asText();
}
private void patchLicense(String licenseRef, Map<String, Object> attributes) {
Map<String, Object> body = jsonApi("licenses", attributes, null);
HttpResponse<String> res =
send(
authed(licenseUrl(licenseRef))
.method("PATCH", HttpRequest.BodyPublishers.ofString(write(body)))
.build());
expect(res, 200, "update licence");
}
// ---- helpers ------------------------------------------------------------
// The self-hosted verifier only reads isEnterprise + users; everything else is informational
// (dashboard / reconciliation) but kept so the licence is a self-describing record of the deal.
/** Trial licence: enterprise, unlimited users, no committed volume/add-ons yet. */
private Map<String, Object> trialMetadata(Long teamId) {
Map<String, Object> m = baseMetadata(teamId, true);
m.put("users", 0); // 0 = unlimited during the trial
m.put("seat_count", 0);
return m;
}
/** Committed annual licence: the full entitlement snapshot from the accepted quote + deal. */
private Map<String, Object> annualMetadata(Long teamId, LicenseEntitlements ent) {
Map<String, Object> m = baseMetadata(teamId, false);
int seats = Math.max(0, ent.seats());
m.put("users", seats); // 0 = unlimited
m.put("seat_count", seats); // parity with the self-hosted edge's metadata
m.put("volume", ent.volume()); // committed PDFs / year
m.put("term_years", ent.termYears());
if (ent.serviceLevel() != null && !ent.serviceLevel().isBlank()) {
m.put("service_level", ent.serviceLevel());
}
m.put("indemnification", ent.indemnification());
m.put("training", ent.training());
m.put("qbr", ent.qbr());
m.put("offline_license", ent.offlineLicense());
if (ent.deployment() != null && !ent.deployment().isBlank()) {
m.put("deployment", ent.deployment());
}
if (ent.dealId() != null) m.put("deal_id", ent.dealId());
if (ent.subscriptionId() != null && !ent.subscriptionId().isBlank()) {
m.put("subscription_id", ent.subscriptionId());
}
return m;
}
private Map<String, Object> baseMetadata(Long teamId, boolean trial) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("team_id", teamId);
m.put("plan_type", "enterprise");
m.put("isEnterprise", true);
m.put("trial", trial);
return m;
}
private Map<String, Object> jsonApi(
String type, Map<String, Object> attributes, Map<String, Object> relationships) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("type", type);
data.put("attributes", attributes);
if (relationships != null) data.put("relationships", relationships);
return Map.of("data", data);
}
private HttpRequest.Builder authed(String url) {
return HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + config.getApiToken())
.header("Content-Type", VND_JSON)
.header("Accept", VND_JSON)
.timeout(Duration.ofSeconds(30));
}
private HttpResponse<String> send(HttpRequest request) {
try {
return http.send(request, HttpResponse.BodyHandlers.ofString());
} catch (java.io.IOException e) {
throw new IllegalStateException("Keygen request failed: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Keygen request interrupted", e);
}
}
private void expect(HttpResponse<String> res, int status, String action) {
if (res.statusCode() != status) {
// Keep the response body out of the thrown message: it never carries the token, but can
// echo owner emails / metadata, and the message reaches warn-level logs. Body at debug.
log.debug(
"[procurement][keygen] {} failed: HTTP {} body={}",
action,
res.statusCode(),
res.body());
throw new IllegalStateException(
"Keygen " + action + " failed: HTTP " + res.statusCode());
}
}
private JsonNode readJson(HttpResponse<String> res) {
try {
return mapper.readTree(res.body());
} catch (Exception e) {
throw new IllegalStateException("Keygen returned unparseable JSON", e);
}
}
private String write(Map<String, Object> body) {
try {
return mapper.writeValueAsString(body);
} catch (Exception e) {
throw new IllegalStateException("Failed to serialise Keygen request", e);
}
}
private String accountUrl() {
return config.getApiBase() + "/accounts/" + config.getAccountId();
}
private String licenseUrl(String licenseRef) {
return accountUrl() + "/licenses/" + enc(licenseRef);
}
private static String enc(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
private static String iso(LocalDateTime dt) {
return dt.toInstant(ZoneOffset.UTC).toString();
}
}
@@ -0,0 +1,21 @@
package stirling.software.saas.procurement.license;
/**
* The committed deal's entitlements, stamped onto the annual Keygen licence's metadata so the
* licence is a self-describing record of what was bought. Only {@code seats} (as {@code users}) and
* the enterprise flag are read by the self-hosted verifier; the rest is informational — kept "for
* good measure" so the Keygen dashboard and any downstream reconciliation can see the full picture.
* Built by {@code ProcurementService} from the accepted quote + deal.
*/
public record LicenseEntitlements(
long volume, // committed PDFs / year
int seats, // 0 = unlimited
String deployment, // cloud | selfhost | airgap
int termYears,
String serviceLevel, // standard | priority | dedicated
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
Long dealId,
String subscriptionId) {}
@@ -3,6 +3,7 @@ package stirling.software.saas.procurement.license;
import java.time.LocalDateTime;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
@@ -13,15 +14,23 @@ import lombok.extern.slf4j.Slf4j;
* returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
* end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
* {@code license_ref} on the deal stay identical.
*
* <p>This is the default; it steps aside for {@code KeygenEnterpriseLicenseService} when {@code
* stirling.keygen.enabled=true} (real Keygen secrets are wired).
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(
name = "stirling.keygen.enabled",
havingValue = "false",
matchIfMissing = true)
public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
@Override
public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
String ref = "mock-trial-" + UUID.randomUUID();
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
log.info(
"[procurement][mock-license] issue trial team={} expires={} ref={}",
teamId,
@@ -36,14 +45,24 @@ public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
}
@Override
public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
String ref = "mock-annual-" + UUID.randomUUID();
public String issueAnnualLicense(
Long teamId,
String ownerEmail,
LocalDateTime expiresAt,
String existingRef,
LicenseEntitlements ent) {
// Upgrade in place when a trial licence already exists, so the key stays stable.
String ref = existingRef != null ? existingRef : "mock-annual-" + UUID.randomUUID();
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
log.info(
"[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
"[procurement][mock-license] issue annual team={} seats={} volume={} deployment={} expires={} ref={} upgrade={}",
teamId,
deployment,
ent.seats(),
ent.volume(),
ent.deployment(),
expiresAt,
ref);
ref,
existingRef != null);
return ref;
}
@@ -51,4 +70,14 @@ public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
public void suspendLicense(String licenseRef) {
log.info("[procurement][mock-license] suspend ref={}", licenseRef);
}
@Override
public String checkOutLicenseFile(String licenseRef) {
log.info("[procurement][mock-license] check-out licence file ref={}", licenseRef);
// A syntactically shaped stand-in so the portal download path is exercisable without
// Keygen; not a valid certificate.
return "-----BEGIN LICENSE FILE-----\nmock-offline-license-for-"
+ licenseRef
+ "\n-----END LICENSE FILE-----\n";
}
}
@@ -62,6 +62,10 @@ public class ProcurementQuote implements Serializable {
@Column(name = "seats")
private Integer seats;
/** Policy posture as runs-per-PDF: Essentials 2, Governed 4, Regulated 7. Defaults Governed. */
@Column(name = "intensity", nullable = false)
private int intensity = 4;
@Column(name = "deployment", length = 24)
private String deployment;
@@ -80,6 +84,9 @@ public class ProcurementQuote implements Serializable {
@Column(name = "qbr", nullable = false)
private boolean qbr;
@Column(name = "offline_license", nullable = false)
private boolean offlineLicense;
@Column(name = "annual_net_minor", nullable = false)
private long annualNetMinor;
@@ -1,53 +1,44 @@
package stirling.software.saas.procurement.pricing;
/**
* The enterprise rate card: the inputs pricing multiplies against. In production these are read
* from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the
* fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source
* of the numbers the marketing prototype encodes.
* The enterprise rate card (D71): the inputs pricing derives a quote from. In production these are
* read from the Stripe price mirror; {@link #defaults()} is the fallback used when the {@code
* stripe} schema isn't synced (dev / tests) and is the single source of the numbers the marketing
* prototype ({@code quotePricing}) encodes.
*
* <p>Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 =
* +15%). Flat/one-time fees are in minor units.
* <p>The meter is denominated in <b>runs</b> (a PDF running N policies is N runs). The per-run rate
* is a fraction of a dollar, so it lives here as a {@code double} in dollars; flat and one-time
* fees are whole-dollar amounts held in minor units (cents). See {@link ProcurementPricingService}
* for how they combine.
*/
public record PricingRates(
long perPdfMinorUnder1M,
long perPdfMinorUnder5M,
long perPdfMinor5MPlus,
double priorityUplift,
double dedicatedUplift,
double indemnificationUplift,
double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr
long qbrAnnualMinor,
long trainingOneTimeMinor) {
double listRatePerRun, // $0.01 list per run
double floorRatePerRun, // $0.005 asymptotic floor (cost + margin)
double discountPerDoubling, // 0.06 = 6% off the per-run rate per doubling past 1M runs/yr
double[] termDiscountByYear, // meter-only discount; index 0 = 1yr … index 4 = 5yr
double indemnificationRate, // fraction of the net meter (legal exposure scales with usage)
long dedicatedSupportMinor, // flat: dedicated SE/CSM (standard + priority are included)
long selfHostDeployMinor, // flat: self-hosted deployment
long airgapDeployMinor, // flat: air-gapped deployment
long qbrAnnualMinor, // flat: quarterly business reviews
long trainingOneTimeMinor) { // one-time: onboarding & training
public static PricingRates defaults() {
return new PricingRates(
5, // $0.05 / PDF under 1M/yr
4, // $0.04 / PDF at 1M5M/yr
3, // $0.03 / PDF at 5M+/yr
0.15, // priority +15%
0.30, // dedicated +30%
0.05, // IP indemnification +5%
new double[] {0.0, 0.05, 0.10, 0.12, 0.15},
0.01, // $0.01 / run list
0.005, // $0.005 / run floor
0.06, // 6% off per doubling of committed runs past 1M/yr
new double[] {0.0, 0.03, 0.05, 0.06, 0.07}, // 15 year term, meter only
0.05, // IP indemnification = 5% of the net meter
3_000_000, // dedicated SE/CSM $30,000 / yr
1_200_000, // self-hosted $12,000 / yr
3_600_000, // air-gapped $36,000 / yr
800_000, // QBRs $8,000 / yr
750_000); // onboarding & training $7,500 one-time
}
/** Volume-banded per-PDF rate for an annual volume, in minor units. */
public long perPdfMinor(long annualVolume) {
if (annualVolume >= 5_000_000) return perPdfMinor5MPlus;
if (annualVolume >= 1_000_000) return perPdfMinorUnder5M;
return perPdfMinorUnder1M;
}
public double termDiscount(int termYears) {
int idx = Math.max(1, Math.min(termYears, 5)) - 1;
return termDiscountByYear[idx];
}
public double serviceLevelUplift(String serviceLevel) {
if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift;
if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift;
return 0.0;
}
}
@@ -2,34 +2,39 @@ package stirling.software.saas.procurement.pricing;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Service;
/**
* The canonical enterprise pricing engine — the single server-side definition the quote builder,
* the order form, and the Stripe checkout all derive from. A faithful port of the marketing
* The canonical enterprise pricing engine (D71) — the single server-side definition the quote
* builder, the order form, and Stripe checkout all derive from. A faithful port of the marketing
* prototype's {@code quotePricing}:
*
* <pre>
* annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
* annualNet = round(annual x (1 - termDiscount)) + qbr
* tcv = annualNet x termYears + training
* runVol = volume x intensity // posture: Essentials x2, Governed x4, Regulated x7
* volDisc = min(0.5, 0.06 x log2(runVol / 1M)) // 6% off the per-run rate per doubling past 1M
* rate = max($0.005, $0.01 x (1 - volDisc)) // continuous curve, floors at half a cent
* meterNet = round(runVol x rate x (1 - termDisc)) // whole dollars; term discounts the meter only
* annualNet = meterNet + dedicated + deployment + indemnification + qbr // needs priced flat
* tcv = annualNet x termYears + training
* </pre>
*
* The multi-year discount applies to usage + service level + indemnification, but NOT to the flat
* QBR fee (added after), and one-time training sits outside the recurring total. All money is in
* minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
* <p>No volume tiers, no service-level multipliers, no ACV floor — those were the retired model.
* Deployment (self-hosted / air-gapped) and dedicated support are flat line items at cost basis;
* SSO / SCIM / RBAC / audit are always included. Money is USD only; amounts are in minor units
* (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
*/
@Service
public class ProcurementPricingService {
/** Bespoke/committed deals don't price below this ACV; the builder floors against it. */
public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000
private static final double LOG2 = Math.log(2.0);
private static final long RUN_CURVE_KNEE = 1_000_000L; // discount starts past 1M committed runs
/**
* Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
* days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's
* {@code users x 5 x 230 x 1.75}.
* days x 1.75), used to prefill the builder's volume step. Matches the prototype's {@code users
* x 5 x 230 x 1.75}.
*/
public long estimateAnnualVolume(int users) {
return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
@@ -40,55 +45,95 @@ public class ProcurementPricingService {
}
public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
// Never trust the client's volume: clamp to non-negative so a tampered request can't drive
// a
// negative amount. The rate card and formula are server-side, so the browser can't lower
// the
// price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed
// floor,
// a policy decision that is intentionally not force-applied here — see the review notes.)
// Never trust the client's volume/intensity: clamp so a tampered request can't drive a
// negative amount. The curve and flat fees are server-side, so the browser can only pick a
// smaller legitimate config, never a cheaper rate.
long volume = Math.max(0, cfg.volume());
long perPdf = rates.perPdfMinor(volume);
long usage = Math.round(volume * (double) perPdf); // base, pre-service-level
double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel());
long withSla = Math.round(usage * (1.0 + slaUplift));
long withIndemnity =
cfg.indemnification()
? Math.round(withSla * (1.0 + rates.indemnificationUplift()))
: withSla;
int intensity = Math.max(1, cfg.intensity());
long runVol = volume * (long) intensity;
double termDiscount = rates.termDiscount(cfg.termYears());
long discount = Math.round(withIndemnity * termDiscount);
// Committed-volume curve: continuous, no cliffs. Floors at the per-run cost + margin.
double volDisc =
runVol > RUN_CURVE_KNEE
? Math.min(
0.5,
rates.discountPerDoubling()
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
double termDisc = rates.termDiscount(cfg.termYears());
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
long annualBaseMinor = Math.round((double) runVol * rate) * 100L;
long meterNetMinor = Math.round((double) runVol * rate * (1.0 - termDisc)) * 100L;
long termDiscountMinor = meterNetMinor - annualBaseMinor; // <= 0
long support =
"dedicated".equalsIgnoreCase(cfg.serviceLevel())
? rates.dedicatedSupportMinor()
: 0L;
long deploy = deployFeeMinor(cfg.deployment(), rates);
long indemnity =
cfg.indemnification()
? Math.round(meterNetMinor * rates.indemnificationRate())
: 0L;
long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
long annualNet = (withIndemnity - discount) + qbr;
long annualNet = meterNetMinor + support + deploy + indemnity + qbr;
long tcv = annualNet * cfg.termYears() + training;
double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run
List<QuoteLineItem> lines = new ArrayList<>();
lines.add(
new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
new QuoteLineItem(
"usage",
String.format(
Locale.ROOT,
"PDF processing — %,d PDFs/yr at $%.4f/PDF (%s posture)",
volume,
effectivePerPdf,
postureLabel(intensity)),
QuoteLineItem.Kind.RECURRING,
annualBaseMinor));
lines.add(
new QuoteLineItem(
"seats",
"Unlimited users + SSO / SCIM / RBAC",
"Unlimited users + SSO / SCIM / RBAC / audit",
QuoteLineItem.Kind.INCLUDED,
0L));
if (withSla != usage) {
if (termDiscountMinor < 0) {
lines.add(
new QuoteLineItem(
"service-level",
serviceLevelLabel(cfg.serviceLevel()),
QuoteLineItem.Kind.RECURRING,
withSla - usage));
"multi-year",
cfg.termYears() + "-year commitment",
QuoteLineItem.Kind.DISCOUNT,
termDiscountMinor));
}
if (withIndemnity != withSla) {
if (support > 0) {
lines.add(
new QuoteLineItem(
"support",
"Dedicated SE / CSM",
QuoteLineItem.Kind.RECURRING,
support));
}
if (deploy > 0) {
lines.add(
new QuoteLineItem(
"deployment",
deploymentLabel(cfg.deployment()) + " deployment",
QuoteLineItem.Kind.RECURRING,
deploy));
}
if (indemnity > 0) {
lines.add(
new QuoteLineItem(
"indemnification",
"IP indemnification",
QuoteLineItem.Kind.RECURRING,
withIndemnity - withSla));
indemnity));
}
if (qbr > 0) {
lines.add(
@@ -98,14 +143,6 @@ public class ProcurementPricingService {
QuoteLineItem.Kind.RECURRING,
qbr));
}
if (discount > 0) {
lines.add(
new QuoteLineItem(
"multi-year",
cfg.termYears() + "-year commitment",
QuoteLineItem.Kind.DISCOUNT,
-discount));
}
if (training > 0) {
lines.add(
new QuoteLineItem(
@@ -117,9 +154,25 @@ public class ProcurementPricingService {
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
}
private static String serviceLevelLabel(String serviceLevel) {
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level";
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level";
return "Standard service level";
private static long deployFeeMinor(String deployment, PricingRates rates) {
if ("airgap".equalsIgnoreCase(deployment)) return rates.airgapDeployMinor();
if ("selfhost".equalsIgnoreCase(deployment)) return rates.selfHostDeployMinor();
return 0L; // cloud (managed) has no deployment fee
}
/** Buyer-facing posture name for the intensity (policy count); the demo's POLICY_POSTURES. */
private static String postureLabel(int intensity) {
return switch (intensity) {
case 2 -> "Essentials";
case 4 -> "Governed";
case 7 -> "Regulated";
default -> intensity + "-policy";
};
}
private static String deploymentLabel(String deployment) {
if ("airgap".equalsIgnoreCase(deployment)) return "Air-gapped";
if ("selfhost".equalsIgnoreCase(deployment)) return "Self-hosted";
return "Stirling Cloud";
}
}
@@ -9,17 +9,23 @@ package stirling.software.saas.procurement.pricing;
public record QuoteConfig(
long volume, // committed PDFs per year
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced)
int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7
String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial)
int termYears, // 1..5
String serviceLevel, // standard | priority | dedicated
String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee)
boolean indemnification,
boolean training,
boolean qbr,
String currency) { // USD | EUR | GBP
boolean offlineLicense, // offline .lic availability (gates download; no longer priced here)
String currency) { // USD only for now
/** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */
public static final int DEFAULT_INTENSITY = 4;
public QuoteConfig {
if (termYears < 1) termYears = 1;
if (termYears > 5) termYears = 5;
if (intensity < 1) intensity = DEFAULT_INTENSITY;
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
if (currency == null || currency.isBlank()) currency = "USD";
}
@@ -16,8 +16,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
import stirling.software.saas.procurement.license.LicenseEntitlements;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
@@ -25,6 +28,7 @@ import stirling.software.saas.procurement.pricing.QuoteBreakdown;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
/**
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
@@ -46,18 +50,37 @@ public class ProcurementService {
private final ProcurementPricingService pricing;
private final EnterpriseLicenseService licenses;
private final ProcurementConfigurationProperties config;
private final TeamMembershipRepository memberRepo;
public ProcurementService(
ProcurementDealRepository dealRepo,
ProcurementQuoteRepository quoteRepo,
ProcurementPricingService pricing,
EnterpriseLicenseService licenses,
ProcurementConfigurationProperties config) {
ProcurementConfigurationProperties config,
TeamMembershipRepository memberRepo) {
this.dealRepo = dealRepo;
this.quoteRepo = quoteRepo;
this.pricing = pricing;
this.licenses = licenses;
this.config = config;
this.memberRepo = memberRepo;
}
/**
* The team leader's email — the natural owner of the team's Keygen licence. Falls back to the
* username when no email is set; null when the team has no leader.
*/
private String leaderEmail(Long teamId) {
return memberRepo.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream()
.findFirst()
.map(TeamMembership::getUser)
.map(
u ->
u.getEmail() != null && !u.getEmail().isBlank()
? u.getEmail()
: u.getUsername())
.orElse(null);
}
@Transactional(readOnly = true)
@@ -85,7 +108,7 @@ public class ProcurementService {
deal.setTrialStartedAt(now);
deal.setTrialEndsAt(ends);
deal.setTrialExtensionsUsed(0);
deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
deal = dealRepo.save(deal);
log.info(
"[procurement] trial started team={} deal={} ends={}",
@@ -144,12 +167,14 @@ public class ProcurementService {
quote.setCurrency(cfg.currency());
quote.setVolume(cfg.volume());
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
quote.setIntensity(cfg.intensity());
quote.setDeployment(cfg.deployment());
quote.setTermYears(cfg.termYears());
quote.setServiceLevel(cfg.serviceLevel());
quote.setIndemnification(cfg.indemnification());
quote.setTraining(cfg.training());
quote.setQbr(cfg.qbr());
quote.setOfflineLicense(cfg.offlineLicense());
quote.setBusinessName(businessName);
quote.setAnnualNetMinor(breakdown.annualNetMinor());
quote.setTcvMinor(breakdown.tcvMinor());
@@ -188,35 +213,104 @@ public class ProcurementService {
}
/**
* Mark the deal live: issue the annual licence and advance to the active stage. In production
* this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this
* method is the demo/manual stand-in until that webhook lands.
* Provision on accept: upgrade the team's licence to the committed annual term (valid
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
* Driven by the accept edge function once the subscription + invoice are created. Idempotent
* (upgrades the existing licence in place); deliberately does NOT change the stage — the deal
* stays in the payment step so the outstanding invoice remains visible until it settles.
*/
@Transactional
public ProcurementDeal provisionLicense(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
deal = dealRepo.save(deal);
log.info("[procurement] licence provisioned team={} deal={}", teamId, deal.getDealId());
return deal;
}
/**
* Mark the deal fully live (advance to the active stage) once payment settles. In production
* this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the
* annual licence in case provisioning didn't run at accept.
*/
@Transactional
public ProcurementDeal markLive(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
int term = 1;
String deployment = "cloud";
if (deal.getAcceptedQuoteId() != null) {
ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
if (q != null) {
term = Math.max(1, q.getTermYears());
if (q.getDeployment() != null && !q.getDeployment().isBlank()) {
deployment = q.getDeployment();
}
}
}
deal.setLicenseRef(
licenses.issueAnnualLicense(
teamId, deployment, LocalDateTime.now().plusYears(term)));
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
deal.setStage(ProcurementDeal.STAGE_LIVE);
deal = dealRepo.save(deal);
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
return deal;
}
/**
* Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote,
* stamping the full entitlement snapshot onto it and upgrading the trial licence in place when
* one exists.
*/
private String issueOrUpgradeAnnual(ProcurementDeal deal) {
ProcurementQuote q =
deal.getAcceptedQuoteId() != null
? quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null)
: quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
.findFirst()
.orElse(null);
int term = q != null ? Math.max(1, q.getTermYears()) : 1;
String deployment =
q != null && q.getDeployment() != null && !q.getDeployment().isBlank()
? q.getDeployment()
: "cloud";
int seats = q != null && q.getSeats() != null ? q.getSeats() : 0; // 0 = unlimited
LicenseEntitlements entitlements =
new LicenseEntitlements(
q != null ? q.getVolume() : 0,
seats,
deployment,
term,
q != null ? q.getServiceLevel() : null,
q != null && q.isIndemnification(),
q != null && q.isTraining(),
q != null && q.isQbr(),
q != null && q.isOfflineLicense(),
deal.getDealId(),
deal.getSubscriptionId());
return licenses.issueAnnualLicense(
deal.getTeamId(),
leaderEmail(deal.getTeamId()),
LocalDateTime.now().plusYears(term),
deal.getLicenseRef(),
entitlements);
}
/**
* Check out the offline/air-gapped licence file for a team, when the offline add-on was
* purchased. Requires an issued licence on the deal and the accepted quote to carry the offline
* add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence
* exists). The certificate is generated on demand by Keygen and never stored.
*/
@Transactional(readOnly = true)
public Optional<String> offlineLicenseFile(Long teamId) {
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
if (!hasOfflineAddOn(deal)) return Optional.empty();
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
}
/**
* Whether the deal's <b>accepted</b> quote carries the paid offline-licence add-on. Gated on
* the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft
* can't unlock the offline file — it's only available once the add-on has actually been bought.
*/
private boolean hasOfflineAddOn(ProcurementDeal deal) {
if (deal.getAcceptedQuoteId() == null) return false;
ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
return quote != null && quote.isOfflineLicense();
}
/**
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
* re-demos/testing.
@@ -35,6 +35,15 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
# ---------- Enterprise procurement licences (Keygen) ----------
# Off by default → MockEnterpriseLicenseService (no real Keygen calls). Set enabled=true + the
# three creds to switch to the real KeygenEnterpriseLicenseService. Same account/policy as the
# self-hosted products; env names match the edge functions' KEYGEN_* for consistency.
stirling.keygen.enabled=${STIRLING_KEYGEN_ENABLED:false}
stirling.keygen.account-id=${KEYGEN_ACCOUNT_ID:}
stirling.keygen.api-token=${KEYGEN_API_TOKEN:}
stirling.keygen.policy-id=${KEYGEN_POLICY_ID:}
# ---------- PAYG meter reporting ----------
# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService
# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set
@@ -0,0 +1,6 @@
-- Offline / air-gapped licence add-on flag on the quote (a paid add-on; priced like QBR). Written
-- and read by the Java backend via JPA. Twin of Supabase migration
-- 20260710000000_procurement_offline_license.sql.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS offline_license BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,6 @@
-- Policy posture (runs per PDF) on the quote: the D71 meter is denominated in runs, so a quote
-- must remember the posture it was priced at (Essentials 2, Governed 4, Regulated 7). Defaults to
-- Governed (4). Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS intensity INTEGER NOT NULL DEFAULT 4;
@@ -7,72 +7,138 @@ import org.junit.jupiter.api.Test;
import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind;
/**
* Locks the pricing engine to the numbers the marketing prototype encodes — most importantly the
* canonical quote QT-AC9F-0001 (1M PDFs, priority, 3-year) = $41,400/yr, $124,200 TCV.
* Locks the D71 pricing engine to the numbers marketing publishes. The two anchor fixtures are the
* ones the demo memo foots against: acme (90M PDFs · Governed · self-hosted · dedicated · 3-yr =
* $1,752,000/yr, $5,256,000 TCV) and Northwind (6M · Governed · cloud · standard · 3-yr =
* $165,278/yr). If either moves, the engine has drifted from marketing.
*/
class ProcurementPricingServiceTest {
private final ProcurementPricingService pricing = new ProcurementPricingService();
private static QuoteConfig cfg(long volume, String sla, int term) {
return new QuoteConfig(volume, 0, "cloud", term, sla, false, false, false, "USD");
private static QuoteConfig cfg(
long volume, int intensity, String deployment, int term, String sla) {
return new QuoteConfig(
volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD");
}
@Test
void canonicalQuoteMatchesPrototype() {
QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3));
void acmeFixtureFootsExactly() {
// 90M PDFs × Governed(×4) = 360M runs → curve floors at $0.005/run → $0.0200/PDF effective.
// meter $1.8M 5% (3-yr) = $1,710,000 + self-hosted $12K + dedicated SE/CSM $30K.
QuoteBreakdown q = pricing.price(cfg(90_000_000, 4, "selfhost", 3, "dedicated"));
assertThat(q.annualNetMinor()).isEqualTo(4_140_000L); // $41,400
assertThat(q.tcvMinor()).isEqualTo(12_420_000L); // $124,200
assertThat(lineAmount(q, "usage")).isEqualTo(4_000_000L); // $40,000 @ $0.04
assertThat(lineAmount(q, "service-level")).isEqualTo(600_000L); // +15%
assertThat(lineAmount(q, "multi-year")).isEqualTo(-460_000L); // -10%
assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000
assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000
assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K
assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K
}
@Test
void volumeBandsPickTheRightPerPdfRate() {
assertThat(lineAmount(pricing.price(cfg(500_000, "standard", 1)), "usage"))
.isEqualTo(2_500_000L); // 500k @ $0.05
assertThat(lineAmount(pricing.price(cfg(1_000_000, "standard", 1)), "usage"))
.isEqualTo(4_000_000L); // 1M @ $0.04
assertThat(lineAmount(pricing.price(cfg(5_000_000, "standard", 1)), "usage"))
.isEqualTo(15_000_000L); // 5M @ $0.03
void northwindFixtureFootsExactly() {
// 6M × Governed(×4) = 24M runs → $0.0290/PDF effective → $165,278/yr at 3-yr.
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
assertThat(q.annualNetMinor()).isEqualTo(16_527_800L); // $165,278
assertThat(q.tcvMinor()).isEqualTo(49_583_400L); // × 3 years
// Cloud + standard: no deployment or support line.
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("deployment"));
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support"));
}
@Test
void addOnsAndTermStack() {
void rateFloorsAtHalfACent() {
// 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to
// the $0.005 floor: base meter = 700M × $0.005 = $3,500,000 (1-yr, no term discount).
QuoteBreakdown q = pricing.price(cfg(100_000_000, 7, "cloud", 1, "standard"));
assertThat(lineAmount(q, "usage")).isEqualTo(350_000_000L); // $3,500,000
assertThat(q.annualNetMinor()).isEqualTo(350_000_000L);
}
@Test
void postureDrivesThePrice() {
// Same PDFs, three postures — the meter scales with runs, so Regulated > Governed >
// Essentials. (This is what the retired flat-per-PDF model could not express.)
long essentials = pricing.price(cfg(6_000_000, 2, "cloud", 3, "standard")).annualNetMinor();
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
long regulated = pricing.price(cfg(6_000_000, 7, "cloud", 3, "standard")).annualNetMinor();
assertThat(essentials).isLessThan(governed);
assertThat(governed).isLessThan(regulated);
assertThat(governed).isEqualTo(16_527_800L); // Governed is the Northwind anchor
}
@Test
void deploymentIsAFlatFeeNotAMultiplier() {
long cloud = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
long selfhost =
pricing.price(cfg(6_000_000, 4, "selfhost", 3, "standard")).annualNetMinor();
long airgap = pricing.price(cfg(6_000_000, 4, "airgap", 3, "standard")).annualNetMinor();
assertThat(selfhost - cloud).isEqualTo(1_200_000L); // +$12,000 flat
assertThat(airgap - cloud).isEqualTo(3_600_000L); // +$36,000 flat
}
@Test
void standardAndPriorityIncludedDedicatedIsFlat() {
long standard = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
long priority = pricing.price(cfg(6_000_000, 4, "cloud", 3, "priority")).annualNetMinor();
long dedicated = pricing.price(cfg(6_000_000, 4, "cloud", 3, "dedicated")).annualNetMinor();
assertThat(priority).isEqualTo(standard); // both included, no uplift
assertThat(dedicated - standard).isEqualTo(3_000_000L); // dedicated SE/CSM +$30,000 flat
}
@Test
void termDiscountsTheMeterOnly() {
long oneYear = pricing.price(cfg(6_000_000, 4, "cloud", 1, "standard")).annualNetMinor();
long twoYear = pricing.price(cfg(6_000_000, 4, "cloud", 2, "standard")).annualNetMinor();
// 2-yr is 3% off the meter (discounted on the raw meter, then rounded to whole dollars).
assertThat(oneYear).isEqualTo(17_397_700L); // no discount
assertThat(twoYear).isEqualTo(16_875_700L); // 3% on the meter
assertThat(twoYear).isLessThan(oneYear);
}
@Test
void indemnificationIsFivePercentOfTheMeter() {
long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
QuoteConfig c =
new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD");
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD");
QuoteBreakdown q = pricing.price(c);
assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05));
}
long usage = 4_000_000L;
long withSla = Math.round(usage * 1.30); // 5,200,000
long withIndemnity = Math.round(withSla * 1.05); // 5,460,000
long discount = Math.round(withIndemnity * 0.15); // 819,000
long qbr = 800_000L;
long expectedAnnual = (withIndemnity - discount) + qbr;
long expectedTcv = expectedAnnual * 5 + 750_000L; // + training one-time
@Test
void trainingIsOneTimeOutsideTheAnnual() {
QuoteConfig withTraining =
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD");
QuoteBreakdown q = pricing.price(withTraining);
long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual);
assertThat(q.tcvMinor()).isEqualTo(expectedTcv);
assertThat(q.annualNetMinor()).isEqualTo(baseAnnual); // one-time never touches the annual
assertThat(q.tcvMinor()).isEqualTo(baseAnnual * 3 + 750_000L); // + $7,500 once
assertThat(q.lineItems())
.anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME);
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("qbr"));
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("indemnification"));
}
@Test
void standardSingleYearHasNoUpliftOrDiscountLines() {
QuoteBreakdown q = pricing.price(cfg(1_000_000, "standard", 1));
assertThat(q.annualNetMinor()).isEqualTo(4_000_000L);
assertThat(q.tcvMinor()).isEqualTo(4_000_000L);
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("service-level"));
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("multi-year"));
void unsetPostureDefaultsToGoverned() {
long defaulted = pricing.price(cfg(6_000_000, 0, "cloud", 3, "standard")).annualNetMinor();
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
assertThat(defaulted).isEqualTo(governed);
}
@Test
void ssoIsAlwaysAnIncludedZeroLine() {
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
assertThat(q.lineItems())
.anyMatch(l -> l.key().equals("seats") && l.kind() == Kind.INCLUDED);
}
@Test
void volumeEstimateFromSeats() {
// ~2,013 PDFs/user/yr
assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
assertThat(pricing.estimateAnnualVolume(0)).isZero();
}
@@ -7551,6 +7551,8 @@ generate = "Generate quote"
included = "Included"
indemnification = "IP indemnification"
indemnificationSub = "We defend qualifying IP claims, per the EULA"
offlineLicense = "Offline / air-gapped licence"
offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance"
qbr = "Quarterly business reviews"
qbrSub = "Your SE reviews usage and roadmap each quarter"
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
@@ -7636,6 +7638,14 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
title = "From trial to live, one guided path"
trialTitle = "Enterprise trial"
[portal.procurement.license]
copied = "Copied"
copy = "Copy key"
downloadError = "Could not generate the offline licence file just yet — please try again in a moment."
downloadOffline = "Download offline licence (.lic)"
hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe."
label = "Your licence key"
[portal.procurement.link]
cta = "Link account"
description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start."
@@ -7688,7 +7698,7 @@ uploadCta = "Upload purchase order"
uploadTitle = "Upload your purchase order"
[portal.procurement.payment]
description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed."
description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed."
downloadInvoice = "Download invoice"
simulate = "Simulate payment received (demo)"
title = "Subscription created"
+26
View File
@@ -217,6 +217,31 @@ async function saasJson<T>(
return unwrap<T>(res);
}
/** Fetch a plain-text SaaS response (e.g. a downloadable licence file). Throws on a non-2xx. */
async function saasText(
path: string,
options: HttpRequestOptions = {},
): Promise<string> {
const base = saasBaseUrl();
// null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
if (base === null) throw new SaasUnconfiguredError();
const token = await getPortalSaasToken();
if (!token) throw new SaasNotLinkedError();
const res = await fetch(`${base}${path}`, {
method: options.method ?? "GET",
headers: {
Accept: "text/plain",
Authorization: `Bearer ${token}`,
...options.headers,
},
signal: options.signal,
});
if (!res.ok) {
throw new Error(`SaaS request failed (${res.status})`);
}
return res.text();
}
/** SaaS GET returning a binary Blob, with the Supabase JWT attached. */
async function saasBlob(
path: string,
@@ -249,6 +274,7 @@ export const apiClient = {
/** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */
saas: {
json: saasJson,
text: saasText,
blob: saasBlob,
/** True when a SaaS base URL is resolvable. Doesn't check session liveness. */
isConfigured: (): boolean => saasBaseUrl() !== null,
@@ -149,6 +149,8 @@ export interface ProcurementSnapshot {
trialEndsAt: string | null;
trialExtensionsUsed: number;
licensed: boolean;
/** The team's Keygen licence key (present once licensed); shown in the portal to copy/install. */
licenseKey: string | null;
latestQuote: QuoteResult | null;
}
@@ -161,6 +163,8 @@ export interface QuoteConfigInput {
indemnification: boolean;
training: boolean;
qbr: boolean;
/** Offline / air-gapped licence file — a paid add-on. */
offlineLicense: boolean;
currency: string;
/** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */
businessName: string;
@@ -170,6 +174,14 @@ export function fetchSnapshot(): Promise<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>("/api/v1/procurement");
}
/**
* Download the offline / air-gapped licence file (.lic) as text. Only available when the paid
* offline add-on was purchased; the server 404s (→ throws) otherwise.
*/
export function fetchLicenseFile(): Promise<string> {
return apiClient.saas.text("/api/v1/procurement/license/file");
}
export function startTrial(): Promise<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>(
"/api/v1/procurement/trial/start",
@@ -9,6 +9,7 @@ const base: ProcurementSnapshot = {
trialEndsAt: "2026-07-09T00:00:00Z",
trialExtensionsUsed: 0,
licensed: false,
licenseKey: null,
latestQuote: null,
};
@@ -11,6 +11,7 @@ import {
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LicensePanel,
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
@@ -45,6 +46,7 @@ export function ProcurementFlow({
isDraft,
busy,
downloading,
downloadingLicense,
error,
setError,
open,
@@ -61,6 +63,7 @@ export function ProcurementFlow({
onAgree,
onGoLive,
onDownloadPdf,
onDownloadOfflineLicense,
} = controller;
return (
@@ -145,6 +148,15 @@ export function ProcurementFlow({
{!editing && stage === "active" && <LiveStageCard />}
{data?.licenseKey && (
<LicensePanel
licenseKey={data.licenseKey}
offlineAvailable={!!latest?.config.offlineLicense}
downloadingLicense={downloadingLicense}
onDownloadOffline={onDownloadOfflineLicense}
/>
)}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import type { QuoteResult } from "@portal/api/procurement";
@@ -158,3 +159,57 @@ export function LiveStageCard() {
</Card>
);
}
/**
* The team's licence key with Copy — and, when the paid offline add-on was taken, a download for
* the air-gapped licence file. Shown from the trial step onward (the key exists from the trial and
* is upgraded in place at accept), so it lives outside any single stage card.
*/
export function LicensePanel({
licenseKey,
offlineAvailable,
downloadingLicense,
onDownloadOffline,
}: {
licenseKey: string;
offlineAvailable: boolean;
downloadingLicense: boolean;
onDownloadOffline: () => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyKey = () => {
void navigator.clipboard?.writeText(licenseKey);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="portal-proc__license">
<span className="portal-proc__license-label">
{t("portal.procurement.license.label")}
</span>
<code className="portal-proc__license-key">{licenseKey}</code>
<div className="portal-proc__payment-actions">
<Button variant="secondary" onClick={copyKey}>
{copied
? t("portal.procurement.license.copied")
: t("portal.procurement.license.copy")}
</Button>
{offlineAvailable && (
<Button
variant="tertiary"
loading={downloadingLicense}
onClick={onDownloadOffline}
>
{t("portal.procurement.license.downloadOffline")}
</Button>
)}
</div>
<p className="portal-proc__license-hint">
{t("portal.procurement.license.hint")}
</p>
</div>
);
}
@@ -51,6 +51,7 @@ export function QuoteBuilder({
indemnification: false,
training: false,
qbr: false,
offlineLicense: false,
currency: "USD",
businessName: "",
},
@@ -223,6 +224,12 @@ export function QuoteBuilder({
sub={t("portal.procurement.builder.qbrSub")}
onClick={() => set("qbr", !cfg.qbr)}
/>
<AddOn
on={cfg.offlineLicense}
title={t("portal.procurement.builder.offlineLicense")}
sub={t("portal.procurement.builder.offlineLicenseSub")}
onClick={() => set("offlineLicense", !cfg.offlineLicense)}
/>
</div>
</Field>
</Step>
@@ -432,5 +439,12 @@ function previewAnnualMinor(cfg: QuoteConfigInput): number {
const disc = Math.round(
withInd * TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
);
return withInd - disc + (cfg.qbr ? 800_000 : 0);
// Flat annual add-ons (QBR, offline licence) sit outside the multi-year discount, mirroring the
// server (PricingRates: qbr 800_000, offline licence 1_200_000). TCV preview derives from this.
return (
withInd -
disc +
(cfg.qbr ? 800_000 : 0) +
(cfg.offlineLicense ? 1_200_000 : 0)
);
}
@@ -5,6 +5,7 @@ import { useAsync } from "@portal/hooks/useAsync";
import {
acceptQuote,
extendTrial,
fetchLicenseFile,
fetchQuotePdf,
fetchSnapshot,
goLive,
@@ -35,6 +36,7 @@ export interface ProcurementController {
isDraft: boolean;
busy: boolean;
downloading: boolean;
downloadingLicense: boolean;
error: string | null;
setError: (e: string | null) => void;
open: boolean;
@@ -52,6 +54,7 @@ export interface ProcurementController {
onAgree: () => void;
onGoLive: () => void;
onDownloadPdf: () => Promise<void>;
onDownloadOfflineLicense: () => Promise<void>;
}
export function useProcurement(autoOpen = false): ProcurementController {
@@ -67,6 +70,7 @@ export function useProcurement(autoOpen = false): ProcurementController {
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [downloadingLicense, setDownloadingLicense] = useState(false);
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [extra, setExtra] = useState<ProcurementExtra>(null);
@@ -141,6 +145,27 @@ export function useProcurement(autoOpen = false): ProcurementController {
}
}
async function onDownloadOfflineLicense() {
setDownloadingLicense(true);
try {
const cert = await fetchLicenseFile();
const blob = new Blob([cert], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "stirling-enterprise.lic";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e) {
console.error("[procurement] offline licence download failed", e);
setError(t("portal.procurement.license.downloadError"));
} finally {
setDownloadingLicense(false);
}
}
// A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
// yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
useEffect(() => {
@@ -158,6 +183,7 @@ export function useProcurement(autoOpen = false): ProcurementController {
isDraft,
busy,
downloading,
downloadingLicense,
error,
setError,
open,
@@ -175,5 +201,6 @@ export function useProcurement(autoOpen = false): ProcurementController {
onAgree,
onGoLive,
onDownloadPdf,
onDownloadOfflineLicense,
};
}
@@ -14,6 +14,7 @@ const EMPTY = {
trialEndsAt: null,
trialExtensionsUsed: 0,
licensed: false,
licenseKey: null,
latestQuote: null,
};
@@ -24,6 +25,7 @@ interface Cfg {
indemnification: boolean;
training: boolean;
qbr: boolean;
offlineLicense: boolean;
currency: string;
businessName?: string;
}
@@ -48,8 +50,9 @@ function priceQuote(cfg: Cfg) {
withInd * TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
);
const qbr = cfg.qbr ? 800_000 : 0;
const offline = cfg.offlineLicense ? 1_200_000 : 0;
const training = cfg.training ? 750_000 : 0;
const annualNetMinor = withInd - disc + qbr;
const annualNetMinor = withInd - disc + qbr + offline;
const tcvMinor = annualNetMinor * cfg.termYears + training;
type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED";
@@ -96,6 +99,13 @@ function priceQuote(cfg: Cfg) {
kind: "RECURRING",
amountMinor: qbr,
});
if (offline > 0)
lines.push({
key: "offline-license",
label: "Offline / air-gapped licence",
kind: "RECURRING",
amountMinor: offline,
});
if (disc > 0)
lines.push({
key: "multi-year",
@@ -132,6 +142,7 @@ function priceQuote(cfg: Cfg) {
indemnification: cfg.indemnification,
training: cfg.training,
qbr: cfg.qbr,
offlineLicense: cfg.offlineLicense,
currency: cfg.currency || "USD",
businessName: cfg.businessName ?? "",
},
@@ -154,6 +165,7 @@ export const procurementSaasHandlers = [
trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(),
trialExtensionsUsed: 0,
licensed: true,
licenseKey: "MOCK-TRIAL-KEY-0001",
latestQuote: null,
};
return HttpResponse.json(deal);
@@ -192,9 +204,20 @@ export const procurementSaasHandlers = [
if (d.dealId) {
d.stage = "active";
d.licensed = true;
d.licenseKey = "MOCK-ENTERPRISE-KEY-0001";
}
return HttpResponse.json(deal);
}),
http.get(`${SAAS}/api/v1/procurement/license/file`, () => {
const q = (deal as { latestQuote: { config?: Cfg } | null }).latestQuote;
if (!q?.config?.offlineLicense) {
return new HttpResponse(null, { status: 404 });
}
return new HttpResponse(
"-----BEGIN LICENSE FILE-----\nmock-offline-license\n-----END LICENSE FILE-----\n",
{ headers: { "Content-Type": "text/plain" } },
);
}),
http.post(`${SAAS}/api/v1/procurement/reset`, () => {
resetProcurementSaasStore();
return HttpResponse.json(EMPTY);
@@ -1119,6 +1119,37 @@
flex-wrap: wrap;
margin-top: 1rem;
}
.portal-proc__license {
margin-top: 1.25rem;
padding: 1rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.6rem;
background: var(--color-surface-2, rgba(0, 0, 0, 0.02));
}
.portal-proc__license-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-2);
}
.portal-proc__license-key {
display: block;
margin-top: 0.4rem;
padding: 0.55rem 0.7rem;
border-radius: 0.4rem;
background: var(--color-surface-3, rgba(0, 0, 0, 0.05));
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
word-break: break-all;
color: var(--color-text-1);
}
.portal-proc__license-hint {
margin: 0.6rem 0 0;
font-size: 0.75rem;
color: var(--color-text-3, var(--color-text-2));
}
.portal-proc__milestone-for {
margin: 0.15rem 0 0;
font-size: 0.8125rem;