diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 1f27896b87..f92267da33 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -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 snapshot(Authentication auth) { - Long teamId = resolveTeam(auth); - if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + Optional 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 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) + .>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 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 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 primaryMembership(Authentication auth) { User user; try { user = AuthenticationUtils.getCurrentUser(auth, userRepository); } catch (SecurityException e) { - return null; + return Optional.empty(); } - List 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 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())); } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/config/KeygenConfigurationProperties.java b/app/saas/src/main/java/stirling/software/saas/procurement/config/KeygenConfigurationProperties.java new file mode 100644 index 0000000000..40ee2ba2c3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/config/KeygenConfigurationProperties.java @@ -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. + * + *

{@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(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java index 0f0c242601..0c3d547d4c 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java @@ -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); } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java new file mode 100644 index 0000000000..a11ad05755 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -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. + * + *

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 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 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 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 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 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 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 body = + jsonApi("users", Map.of("email", email), null); // no relationships + HttpResponse 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 metadata) { + Map attributes = new LinkedHashMap<>(); + attributes.put("expiry", iso(expiresAt)); + attributes.put("metadata", metadata); + Map 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 body = jsonApi("licenses", attributes, relationships); + HttpResponse 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 attributes) { + Map body = jsonApi("licenses", attributes, null); + HttpResponse 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 trialMetadata(Long teamId) { + Map 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 annualMetadata(Long teamId, LicenseEntitlements ent) { + Map 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 baseMetadata(Long teamId, boolean trial) { + Map 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 jsonApi( + String type, Map attributes, Map relationships) { + Map 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 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 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 res) { + try { + return mapper.readTree(res.body()); + } catch (Exception e) { + throw new IllegalStateException("Keygen returned unparseable JSON", e); + } + } + + private String write(Map 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(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/LicenseEntitlements.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/LicenseEntitlements.java new file mode 100644 index 0000000000..0b7a0be6c9 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/LicenseEntitlements.java @@ -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) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java index bde31a4119..511d67cd08 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java @@ -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. + * + *

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"; + } } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java index d8f4c3edb7..dd635a2cf6 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java index 19a3ab759d..d10f216e8a 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java @@ -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. * - *

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. + *

The meter is denominated in runs (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 1M–5M/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}, // 1–5 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; - } } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java index 62d5baed9d..4707c94197 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java @@ -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}: * *

- *   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
  * 
* - * 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). + *

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 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"; } } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java index 30917a73a6..cc6612c530 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java @@ -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"; } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index 52e19f751e..57f530cc9a 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -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 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 accepted 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. diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 1984974fef..49798d64cd 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -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 diff --git a/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql b/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql new file mode 100644 index 0000000000..e7ca0233a7 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql b/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql new file mode 100644 index 0000000000..4920d52d26 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql @@ -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; diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java index 1f903a1ee2..1676541db2 100644 --- a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java @@ -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(); } diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 7e0ca0116b..e81f5eaebb 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index ac48cccf08..d0398470eb 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -217,6 +217,31 @@ async function saasJson( return unwrap(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 { + 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, diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 9327179c03..05a903a8af 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -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 { return apiClient.saas.json("/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 { + return apiClient.saas.text("/api/v1/procurement/license/file"); +} + export function startTrial(): Promise { return apiClient.saas.json( "/api/v1/procurement/trial/start", diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index 2192d3b2ec..5035ec49b1 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -9,6 +9,7 @@ const base: ProcurementSnapshot = { trialEndsAt: "2026-07-09T00:00:00Z", trialExtensionsUsed: 0, licensed: false, + licenseKey: null, latestQuote: null, }; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx index 9ba3ebeef2..94ee979b86 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -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" && } + {data?.licenseKey && ( + + )} +

+ {offlineAvailable && ( + + )} +
+

+ {t("portal.procurement.license.hint")} +

+ + ); +} diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index d13aaaff06..1ba6016a4a 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -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)} /> + set("offlineLicense", !cfg.offlineLicense)} + /> @@ -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) + ); } diff --git a/frontend/editor/src/portal/components/procurement/useProcurement.ts b/frontend/editor/src/portal/components/procurement/useProcurement.ts index 70f64f8f30..e2fdb922bf 100644 --- a/frontend/editor/src/portal/components/procurement/useProcurement.ts +++ b/frontend/editor/src/portal/components/procurement/useProcurement.ts @@ -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; + onDownloadOfflineLicense: () => Promise; } 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(null); const [error, setError] = useState(null); const [extra, setExtra] = useState(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, }; } diff --git a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts index 4a4cc4bcb8..0a0c2b5ca3 100644 --- a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts +++ b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts @@ -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); diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index 0f827f9146..adc21fd79a 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -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;