mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
+82
-25
@@ -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()));
|
||||
}
|
||||
|
||||
+55
@@ -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();
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -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);
|
||||
}
|
||||
|
||||
+304
@@ -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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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) {}
|
||||
+35
-6
@@ -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;
|
||||
|
||||
|
||||
+26
-35
@@ -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 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;
|
||||
}
|
||||
}
|
||||
|
||||
+104
-51
@@ -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";
|
||||
}
|
||||
|
||||
+113
-19
@@ -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;
|
||||
+104
-38
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user