diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
index 5e90df6bf5..53a5f34c15 100644
--- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
+++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
@@ -18,13 +18,15 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
- "stirling.software.saas.payg.repository"
+ "stirling.software.saas.payg.repository",
+ "stirling.software.saas.procurement.repository"
})
@EntityScan({
"stirling.software.saas.accountlink",
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
- "stirling.software.saas.payg"
+ "stirling.software.saas.payg",
+ "stirling.software.saas.procurement.model"
})
public class SaasJpaConfig {}
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
new file mode 100644
index 0000000000..1f27896b87
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java
@@ -0,0 +1,321 @@
+package stirling.software.saas.procurement.api;
+
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.swagger.v3.oas.annotations.Hidden;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.enumeration.TeamRole;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
+import stirling.software.saas.procurement.model.ProcurementDeal;
+import stirling.software.saas.procurement.model.ProcurementQuote;
+import stirling.software.saas.procurement.pricing.QuoteConfig;
+import stirling.software.saas.procurement.pricing.QuoteLineItem;
+import stirling.software.saas.procurement.service.ProcurementService;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.util.AuthenticationUtils;
+
+/**
+ * The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
+ * (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
+ * Supabase edge function the portal calls with the accepted quote — this controller never touches
+ * Stripe. The caller's team is resolved from the authenticated principal; a team id is never
+ * trusted from the request. Mutations require the team leader.
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/procurement")
+@Profile("saas")
+public class ProcurementController {
+
+ // Local mapper to parse the stored line-items JSON; the saas context exposes no injectable
+ // ObjectMapper bean.
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final ProcurementService procurement;
+ private final TeamMembershipRepository memberRepo;
+ private final UserRepository userRepository;
+ private final ProcurementConfigurationProperties config;
+
+ public ProcurementController(
+ ProcurementService procurement,
+ TeamMembershipRepository memberRepo,
+ UserRepository userRepository,
+ ProcurementConfigurationProperties config) {
+ this.procurement = Objects.requireNonNull(procurement);
+ this.memberRepo = Objects.requireNonNull(memberRepo);
+ this.userRepository = Objects.requireNonNull(userRepository);
+ this.config = Objects.requireNonNull(config);
+ }
+
+ // ---- request / response DTOs -------------------------------------------
+
+ public record QuoteRequest(
+ long volume,
+ int users,
+ String deployment,
+ int termYears,
+ String serviceLevel,
+ boolean indemnification,
+ boolean training,
+ boolean qbr,
+ String currency,
+ String businessName) {
+ QuoteConfig toConfig() {
+ return new QuoteConfig(
+ volume,
+ users,
+ deployment,
+ termYears,
+ serviceLevel,
+ indemnification,
+ training,
+ qbr,
+ currency);
+ }
+ }
+
+ public record QuoteResponse(
+ Long quoteId,
+ String quoteNumber,
+ String status,
+ String currency,
+ long annualNetMinor,
+ long tcvMinor,
+ List lineItems,
+ String validUntil,
+ String stripeQuoteId,
+ String invoiceUrl,
+ QuoteConfigEcho config) {}
+
+ /**
+ * The inputs the quote was priced from, echoed back so the builder can seed itself when the
+ * buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume
+ * is), so it is always 0 here; the builder treats the seeded volume as manually set.
+ */
+ public record QuoteConfigEcho(
+ long volume,
+ int users,
+ String deployment,
+ int termYears,
+ String serviceLevel,
+ boolean indemnification,
+ boolean training,
+ boolean qbr,
+ String currency,
+ String businessName) {}
+
+ public record SnapshotResponse(
+ Long dealId,
+ String stage,
+ String trialStartedAt,
+ String trialEndsAt,
+ int trialExtensionsUsed,
+ boolean licensed,
+ QuoteResponse latestQuote) {}
+
+ // ---- endpoints ----------------------------------------------------------
+
+ /**
+ * The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an
+ * empty snapshot ({@code dealId == null}) so the portal can render the "start" state without
+ * special-casing an empty body.
+ */
+ @GetMapping
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity snapshot(Authentication auth) {
+ Long teamId = resolveTeam(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ return ResponseEntity.ok(
+ procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
+ }
+
+ private static final SnapshotResponse EMPTY_SNAPSHOT =
+ new SnapshotResponse(null, null, null, null, 0, false, null);
+
+ @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)));
+ }
+
+ @PostMapping("/trial/extend")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity extendTrial(Authentication auth) {
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ try {
+ return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
+ } catch (IllegalStateException e) {
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ }
+
+ @PostMapping("/quote")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity buildQuote(
+ @RequestBody QuoteRequest request, Authentication auth) {
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ return ResponseEntity.ok(
+ toQuote(
+ procurement.buildQuote(
+ teamId, request.toConfig(), request.businessName())));
+ }
+
+ // Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
+ // draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription.
+ // Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them.
+
+ /**
+ * Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees.
+ */
+ @PostMapping("/agreement")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity startAgreement(Authentication auth) {
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ try {
+ return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
+ } catch (IllegalStateException e) {
+ 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.
+ */
+ @PostMapping("/go-live")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity goLive(Authentication auth) {
+ if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ try {
+ return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
+ } catch (IllegalStateException e) {
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ }
+
+ /** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */
+ @PostMapping("/reset")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity reset(Authentication auth) {
+ if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ procurement.resetDeal(teamId);
+ return ResponseEntity.ok(EMPTY_SNAPSHOT);
+ }
+
+ // ---- helpers ------------------------------------------------------------
+
+ /**
+ * Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
+ */
+ private Long resolveTeam(Authentication auth) {
+ User user;
+ try {
+ user = AuthenticationUtils.getCurrentUser(auth, userRepository);
+ } catch (SecurityException e) {
+ return null;
+ }
+ List rows = memberRepo.findPrimaryMembership(user.getId());
+ return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
+ }
+
+ /** 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();
+ }
+
+ private SnapshotResponse toSnapshot(ProcurementDeal deal) {
+ QuoteResponse latest =
+ procurement.quotesForDeal(deal.getDealId()).stream()
+ .findFirst()
+ .map(this::toQuote)
+ .orElse(null);
+ return new SnapshotResponse(
+ deal.getDealId(),
+ deal.getStage(),
+ str(deal.getTrialStartedAt()),
+ str(deal.getTrialEndsAt()),
+ deal.getTrialExtensionsUsed(),
+ deal.getLicenseRef() != null,
+ latest);
+ }
+
+ private QuoteResponse toQuote(ProcurementQuote q) {
+ return new QuoteResponse(
+ q.getQuoteId(),
+ q.getQuoteNumber(),
+ q.getStatus(),
+ q.getCurrency(),
+ q.getAnnualNetMinor(),
+ q.getTcvMinor(),
+ parseLineItems(q.getLineItemsJson()),
+ q.getValidUntil() == null ? null : q.getValidUntil().toString(),
+ q.getStripeQuoteId(),
+ q.getStripeInvoiceUrl(),
+ new QuoteConfigEcho(
+ q.getVolume(),
+ 0,
+ q.getDeployment(),
+ q.getTermYears(),
+ q.getServiceLevel(),
+ q.isIndemnification(),
+ q.isTraining(),
+ q.isQbr(),
+ q.getCurrency(),
+ q.getBusinessName()));
+ }
+
+ private List parseLineItems(String json) {
+ if (json == null || json.isBlank()) return List.of();
+ try {
+ return OBJECT_MAPPER.readValue(
+ json,
+ OBJECT_MAPPER
+ .getTypeFactory()
+ .constructCollectionType(List.class, QuoteLineItem.class));
+ } catch (Exception e) {
+ log.warn("[procurement] failed to parse line items", e);
+ return List.of();
+ }
+ }
+
+ private static String str(Object o) {
+ return o == null ? null : o.toString();
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java
new file mode 100644
index 0000000000..98ec0cd573
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java
@@ -0,0 +1,33 @@
+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;
+
+/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */
+@Getter
+@Setter
+@Component
+@Profile("saas")
+@ConfigurationProperties(prefix = "stirling.procurement")
+public class ProcurementConfigurationProperties {
+
+ /** Free trial length, in days (no card). */
+ private int trialDurationDays = 14;
+
+ /** Days added per trial extension. */
+ private int trialExtensionDays = 7;
+
+ /** Maximum number of trial extensions a buyer may take. */
+ private int maxTrialExtensions = 2;
+
+ /**
+ * Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement
+ * or mark it live without payment. Off by default; turn on ONLY in demo/dev environments —
+ * /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
+ */
+ private boolean demoControlsEnabled = false;
+}
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
new file mode 100644
index 0000000000..0f0c242601
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java
@@ -0,0 +1,25 @@
+package stirling.software.saas.procurement.license;
+
+import java.time.LocalDateTime;
+
+/**
+ * Issues and modifies the customer-facing entitlement that actually unlocks the product for an
+ * enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam
+ * the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records
+ * intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only
+ * verifies this instance's own licence.
+ */
+public interface EnterpriseLicenseService {
+
+ /** Issue a time-boxed trial licence for the team; returns the licence reference. */
+ String issueTrialLicense(Long teamId, 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);
+
+ /** Suspend a licence (e.g. payment failed, deal lost). */
+ void suspendLicense(String licenseRef);
+}
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
new file mode 100644
index 0000000000..bde31a4119
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java
@@ -0,0 +1,54 @@
+package stirling.software.saas.procurement.license;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and
+ * 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.
+ */
+@Slf4j
+@Service
+@Profile("saas")
+public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
+
+ @Override
+ public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
+ String ref = "mock-trial-" + UUID.randomUUID();
+ log.info(
+ "[procurement][mock-license] issue trial team={} expires={} ref={}",
+ teamId,
+ expiresAt,
+ ref);
+ return ref;
+ }
+
+ @Override
+ public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
+ log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry);
+ }
+
+ @Override
+ public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
+ String ref = "mock-annual-" + UUID.randomUUID();
+ log.info(
+ "[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
+ teamId,
+ deployment,
+ expiresAt,
+ ref);
+ return ref;
+ }
+
+ @Override
+ public void suspendLicense(String licenseRef) {
+ log.info("[procurement][mock-license] suspend ref={}", licenseRef);
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java
new file mode 100644
index 0000000000..f97d66f365
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java
@@ -0,0 +1,87 @@
+package stirling.software.saas.procurement.model;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the
+ * portal renders (trial -> quote -> agreement -> payment -> live). The entitlement that
+ * actually unlocks the product is the Keygen licence in {@code licenseRef}; the paid subscription,
+ * once commercial, is mirrored in {@code billing_subscriptions} and referenced by {@code
+ * subscriptionId}.
+ */
+@Entity
+@Table(name = "procurement_deal")
+@NoArgsConstructor
+@Getter
+@Setter
+public class ProcurementDeal implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String STAGE_TRIAL = "trial";
+ public static final String STAGE_QUOTE = "quote";
+ public static final String STAGE_AGREEMENT = "security";
+ public static final String STAGE_PAYMENT = "procurement";
+ public static final String STAGE_LIVE = "active";
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "deal_id")
+ private Long dealId;
+
+ @Column(name = "team_id", nullable = false, unique = true)
+ private Long teamId;
+
+ @Column(name = "stage", nullable = false, length = 32)
+ private String stage = STAGE_TRIAL;
+
+ @Column(name = "trial_started_at")
+ private LocalDateTime trialStartedAt;
+
+ @Column(name = "trial_ends_at")
+ private LocalDateTime trialEndsAt;
+
+ @Column(name = "trial_extensions_used", nullable = false)
+ private int trialExtensionsUsed;
+
+ @Column(name = "license_ref", length = 128)
+ private String licenseRef;
+
+ @Column(name = "subscription_id", length = 255)
+ private String subscriptionId;
+
+ @Column(name = "accepted_quote_id")
+ private Long acceptedQuoteId;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @UpdateTimestamp
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @Version
+ @Column(name = "version", nullable = false)
+ private Long version;
+
+ public ProcurementDeal(Long teamId) {
+ this.teamId = teamId;
+ }
+}
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
new file mode 100644
index 0000000000..d8f4c3edb7
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java
@@ -0,0 +1,119 @@
+package stirling.software.saas.procurement.model;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the
+ * buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the
+ * server-computed result (never trusted from the client). Stripe fields are populated when the
+ * accepted quote is turned into a checkout.
+ */
+@Entity
+@Table(name = "procurement_quote")
+@NoArgsConstructor
+@Getter
+@Setter
+public class ProcurementQuote implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String STATUS_DRAFT = "draft";
+ public static final String STATUS_SENT = "sent";
+ public static final String STATUS_ACCEPTED = "accepted";
+ public static final String STATUS_EXPIRED = "expired";
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "quote_id")
+ private Long quoteId;
+
+ @Column(name = "deal_id", nullable = false)
+ private Long dealId;
+
+ @Column(name = "quote_number", nullable = false, length = 64)
+ private String quoteNumber;
+
+ @Column(name = "status", nullable = false, length = 24)
+ private String status = STATUS_DRAFT;
+
+ @Column(name = "currency", nullable = false, length = 8)
+ private String currency = "USD";
+
+ @Column(name = "volume", nullable = false)
+ private long volume;
+
+ @Column(name = "seats")
+ private Integer seats;
+
+ @Column(name = "deployment", length = 24)
+ private String deployment;
+
+ @Column(name = "term_years", nullable = false)
+ private int termYears;
+
+ @Column(name = "service_level", nullable = false, length = 24)
+ private String serviceLevel;
+
+ @Column(name = "indemnification", nullable = false)
+ private boolean indemnification;
+
+ @Column(name = "training", nullable = false)
+ private boolean training;
+
+ @Column(name = "qbr", nullable = false)
+ private boolean qbr;
+
+ @Column(name = "annual_net_minor", nullable = false)
+ private long annualNetMinor;
+
+ @Column(name = "tcv_minor", nullable = false)
+ private long tcvMinor;
+
+ @Column(name = "line_items", columnDefinition = "text")
+ private String lineItemsJson;
+
+ // The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn.
+ @Column(name = "stripe_quote_id", length = 128)
+ private String stripeQuoteId;
+
+ // Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is
+ // accepted.
+ @Column(name = "stripe_invoice_url", columnDefinition = "text")
+ private String stripeInvoiceUrl;
+
+ // Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
+ @Column(name = "business_name", length = 255)
+ private String businessName;
+
+ @Column(name = "valid_until")
+ private LocalDate validUntil;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @UpdateTimestamp
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @Version
+ @Column(name = "version", nullable = false)
+ private Long version;
+}
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
new file mode 100644
index 0000000000..19a3ab759d
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java
@@ -0,0 +1,53 @@
+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.
+ *
+ * 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.
+ */
+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) {
+
+ 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},
+ 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
new file mode 100644
index 0000000000..62d5baed9d
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java
@@ -0,0 +1,125 @@
+package stirling.software.saas.procurement.pricing;
+
+import java.util.ArrayList;
+import java.util.List;
+
+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
+ * 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
+ *
+ *
+ * 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).
+ */
+@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
+
+ /**
+ * 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}.
+ */
+ public long estimateAnnualVolume(int users) {
+ return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
+ }
+
+ public QuoteBreakdown price(QuoteConfig cfg) {
+ return price(cfg, PricingRates.defaults());
+ }
+
+ 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.)
+ 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;
+
+ double termDiscount = rates.termDiscount(cfg.termYears());
+ long discount = Math.round(withIndemnity * termDiscount);
+ long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
+ long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
+
+ long annualNet = (withIndemnity - discount) + qbr;
+ long tcv = annualNet * cfg.termYears() + training;
+
+ List lines = new ArrayList<>();
+ lines.add(
+ new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
+ lines.add(
+ new QuoteLineItem(
+ "seats",
+ "Unlimited users + SSO / SCIM / RBAC",
+ QuoteLineItem.Kind.INCLUDED,
+ 0L));
+ if (withSla != usage) {
+ lines.add(
+ new QuoteLineItem(
+ "service-level",
+ serviceLevelLabel(cfg.serviceLevel()),
+ QuoteLineItem.Kind.RECURRING,
+ withSla - usage));
+ }
+ if (withIndemnity != withSla) {
+ lines.add(
+ new QuoteLineItem(
+ "indemnification",
+ "IP indemnification",
+ QuoteLineItem.Kind.RECURRING,
+ withIndemnity - withSla));
+ }
+ if (qbr > 0) {
+ lines.add(
+ new QuoteLineItem(
+ "qbr",
+ "Quarterly business reviews",
+ 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(
+ "training",
+ "Onboarding & training",
+ QuoteLineItem.Kind.ONE_TIME,
+ training));
+ }
+ 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";
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java
new file mode 100644
index 0000000000..10ffde5271
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java
@@ -0,0 +1,12 @@
+package stirling.software.saas.procurement.pricing;
+
+import java.util.List;
+
+/**
+ * The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
+ * order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
+ * after the multi-year discount; {@code tcvMinor} is total contract value across the term including
+ * one-time fees. Minor units (cents).
+ */
+public record QuoteBreakdown(
+ List lineItems, long annualNetMinor, long tcvMinor, String currency) {}
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
new file mode 100644
index 0000000000..30917a73a6
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java
@@ -0,0 +1,26 @@
+package stirling.software.saas.procurement.pricing;
+
+/**
+ * The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps
+ * (volume, commitment & service, add-ons) and is the sole input to {@link
+ * ProcurementPricingService}. Amounts are never carried here — the service derives them from these
+ * choices and the rate card.
+ */
+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 termYears, // 1..5
+ String serviceLevel, // standard | priority | dedicated
+ boolean indemnification,
+ boolean training,
+ boolean qbr,
+ String currency) { // USD | EUR | GBP
+
+ public QuoteConfig {
+ if (termYears < 1) termYears = 1;
+ if (termYears > 5) termYears = 5;
+ 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/pricing/QuoteLineItem.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java
new file mode 100644
index 0000000000..a8b0d29352
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java
@@ -0,0 +1,16 @@
+package stirling.software.saas.procurement.pricing;
+
+/**
+ * One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents);
+ * discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a
+ * one-time fee vs a discount line).
+ */
+public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) {
+
+ public enum Kind {
+ RECURRING,
+ ONE_TIME,
+ DISCOUNT,
+ INCLUDED
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java
new file mode 100644
index 0000000000..14247ba1ba
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java
@@ -0,0 +1,17 @@
+package stirling.software.saas.procurement.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import stirling.software.saas.procurement.model.ProcurementDeal;
+
+public interface ProcurementDealRepository extends JpaRepository {
+
+ Optional findByTeamId(Long teamId);
+
+ boolean existsByTeamId(Long teamId);
+
+ /** Reset: drop the team's deal (quotes + activity cascade via FK). */
+ void deleteByTeamId(Long teamId);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java
new file mode 100644
index 0000000000..45f7af8225
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java
@@ -0,0 +1,12 @@
+package stirling.software.saas.procurement.repository;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import stirling.software.saas.procurement.model.ProcurementQuote;
+
+public interface ProcurementQuoteRepository extends JpaRepository {
+
+ List findByDealIdOrderByCreatedAtDesc(Long dealId);
+}
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
new file mode 100644
index 0000000000..52e19f751e
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java
@@ -0,0 +1,244 @@
+package stirling.software.saas.procurement.service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
+import stirling.software.saas.procurement.license.EnterpriseLicenseService;
+import stirling.software.saas.procurement.model.ProcurementDeal;
+import stirling.software.saas.procurement.model.ProcurementQuote;
+import stirling.software.saas.procurement.pricing.ProcurementPricingService;
+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;
+
+/**
+ * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
+ * server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the
+ * portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions}
+ * and this service issues the annual licence. All amounts are minor units (cents).
+ */
+@Slf4j
+@Service
+@Profile("saas")
+public class ProcurementService {
+
+ // Local mapper for the line-items JSON snapshot; the saas context exposes no injectable
+ // ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one.
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final ProcurementDealRepository dealRepo;
+ private final ProcurementQuoteRepository quoteRepo;
+ private final ProcurementPricingService pricing;
+ private final EnterpriseLicenseService licenses;
+ private final ProcurementConfigurationProperties config;
+
+ public ProcurementService(
+ ProcurementDealRepository dealRepo,
+ ProcurementQuoteRepository quoteRepo,
+ ProcurementPricingService pricing,
+ EnterpriseLicenseService licenses,
+ ProcurementConfigurationProperties config) {
+ this.dealRepo = dealRepo;
+ this.quoteRepo = quoteRepo;
+ this.pricing = pricing;
+ this.licenses = licenses;
+ this.config = config;
+ }
+
+ @Transactional(readOnly = true)
+ public Optional getDeal(Long teamId) {
+ return dealRepo.findByTeamId(teamId);
+ }
+
+ @Transactional(readOnly = true)
+ public List quotesForDeal(Long dealId) {
+ return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
+ }
+
+ /**
+ * Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
+ * window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
+ * Keygen licence, and the deal row is the journey state.
+ */
+ @Transactional
+ public ProcurementDeal startTrial(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
+ deal.setStage(ProcurementDeal.STAGE_TRIAL);
+ deal.setTrialStartedAt(now);
+ deal.setTrialEndsAt(ends);
+ deal.setTrialExtensionsUsed(0);
+ deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
+ deal = dealRepo.save(deal);
+ log.info(
+ "[procurement] trial started team={} deal={} ends={}",
+ teamId,
+ deal.getDealId(),
+ ends);
+ return deal;
+ }
+
+ /** Extend the current trial by the configured increment, up to the cap. */
+ @Transactional
+ public ProcurementDeal extendTrial(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ // Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at
+ // the committed annual licence — extending would rewind its expiry.
+ if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) {
+ throw new IllegalStateException("Trial extension only allowed during the trial stage");
+ }
+ if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) {
+ throw new IllegalStateException("Trial extension cap reached");
+ }
+ LocalDateTime base =
+ deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now();
+ LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays());
+ deal.setTrialEndsAt(newEnd);
+ deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1);
+ if (deal.getLicenseRef() != null) {
+ licenses.extendLicense(deal.getLicenseRef(), newEnd);
+ }
+ return dealRepo.save(deal);
+ }
+
+ /** Price a quote config server-side and persist it as a draft against the team's deal. */
+ @Transactional
+ public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
+ if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
+ throw new IllegalStateException("Cannot rebuild a quote on a live deal");
+ }
+ // (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
+ // so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
+ deal.setStage(ProcurementDeal.STAGE_QUOTE);
+ deal.setAcceptedQuoteId(null);
+ deal = dealRepo.save(deal);
+
+ QuoteBreakdown breakdown = pricing.price(cfg);
+
+ ProcurementQuote quote = new ProcurementQuote();
+ quote.setDealId(deal.getDealId());
+ quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
+ // Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
+ quote.setStatus(ProcurementQuote.STATUS_DRAFT);
+ quote.setCurrency(cfg.currency());
+ quote.setVolume(cfg.volume());
+ quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
+ 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.setBusinessName(businessName);
+ quote.setAnnualNetMinor(breakdown.annualNetMinor());
+ quote.setTcvMinor(breakdown.tcvMinor());
+ quote.setLineItemsJson(writeLineItems(breakdown));
+ quote.setValidUntil(LocalDate.now().plusDays(30));
+ quote = quoteRepo.save(quote);
+ log.info(
+ "[procurement] quote built team={} quote={} annualNet={} tcv={}",
+ teamId,
+ quote.getQuoteNumber(),
+ quote.getAnnualNetMinor(),
+ quote.getTcvMinor());
+ return quote;
+ }
+
+ /**
+ * Advance the deal to the agreement (security) stage: the buyer has an issued quote and is
+ * reviewing the enterprise agreement before it's accepted into a subscription. Requires an
+ * issued quote on the deal.
+ */
+ @Transactional
+ public ProcurementDeal startAgreement(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ boolean hasIssuedQuote =
+ quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
+ .anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus()));
+ if (!hasIssuedQuote) {
+ throw new IllegalStateException("No issued quote for team " + teamId);
+ }
+ deal.setStage(ProcurementDeal.STAGE_AGREEMENT);
+ deal = dealRepo.save(deal);
+ log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId());
+ return deal;
+ }
+
+ /**
+ * 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.
+ */
+ @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.setStage(ProcurementDeal.STAGE_LIVE);
+ deal = dealRepo.save(deal);
+ log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
+ return deal;
+ }
+
+ /**
+ * Reset a team's procurement: delete the deal (quotes + activity cascade). For
+ * re-demos/testing.
+ */
+ @Transactional
+ public void resetDeal(Long teamId) {
+ dealRepo.deleteByTeamId(teamId);
+ log.info("[procurement] deal reset team={}", teamId);
+ }
+
+ private String nextQuoteNumber(Long dealId) {
+ int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
+ String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
+ return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
+ }
+
+ private String writeLineItems(QuoteBreakdown breakdown) {
+ try {
+ return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
+ } catch (JsonProcessingException e) {
+ log.warn("[procurement] failed to serialise line items", e);
+ return "[]";
+ }
+ }
+}
diff --git a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql
new file mode 100644
index 0000000000..0048f999c1
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql
@@ -0,0 +1,72 @@
+-- Enterprise procurement: the tables that track a linked team's journey from trial to live.
+--
+-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the
+-- quotes built against it (the itemised, priced offers), and an append-only activity log for the
+-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions
+-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence
+-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService).
+--
+-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal (
+ deal_id BIGSERIAL PRIMARY KEY,
+ team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
+ -- one active deal per team; the journey lives on this row.
+ stage VARCHAR(32) NOT NULL DEFAULT 'trial',
+ -- trial | quote | security (agreement) | procurement (payment) | active (live)
+ trial_started_at TIMESTAMP,
+ trial_ends_at TIMESTAMP,
+ trial_extensions_used INT NOT NULL DEFAULT 0,
+ license_ref VARCHAR(128),
+ -- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands.
+ subscription_id VARCHAR(255),
+ -- Stripe subscription id, mirrored into billing_subscriptions once commercial.
+ accepted_quote_id BIGINT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote (
+ quote_id BIGSERIAL PRIMARY KEY,
+ deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
+ quote_number VARCHAR(64) NOT NULL,
+ status VARCHAR(24) NOT NULL DEFAULT 'draft',
+ -- draft | sent | accepted | expired
+ currency VARCHAR(8) NOT NULL DEFAULT 'USD',
+ volume BIGINT NOT NULL,
+ seats INT,
+ deployment VARCHAR(24),
+ term_years INT NOT NULL,
+ service_level VARCHAR(24) NOT NULL,
+ indemnification BOOLEAN NOT NULL DEFAULT FALSE,
+ training BOOLEAN NOT NULL DEFAULT FALSE,
+ qbr BOOLEAN NOT NULL DEFAULT FALSE,
+ annual_net_minor BIGINT NOT NULL,
+ -- recurring annual fee after the multi-year discount, in minor units (cents).
+ tcv_minor BIGINT NOT NULL,
+ -- total contract value across the term incl. one-time fees, minor units.
+ line_items TEXT,
+ -- JSON snapshot of the itemised lines the order form renders.
+ stripe_price_id VARCHAR(128),
+ checkout_session_id VARCHAR(255),
+ checkout_url TEXT,
+ valid_until DATE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity (
+ activity_id BIGSERIAL PRIMARY KEY,
+ deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
+ actor_user_id BIGINT,
+ -- the internal/portal user who took the action; informational (no FK).
+ action VARCHAR(48) NOT NULL,
+ -- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ...
+ detail TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id);
+CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id);
diff --git a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql
new file mode 100644
index 0000000000..5d83ee35f8
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql
@@ -0,0 +1,8 @@
+-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF +
+-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The
+-- Stripe operations live in Supabase edge functions; these columns hold the references they write
+-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql.
+
+ALTER TABLE stirling_pdf.procurement_quote
+ ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128),
+ ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT;
diff --git a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql
new file mode 100644
index 0000000000..ba9c74dde8
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql
@@ -0,0 +1,5 @@
+-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on
+-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql.
+
+ALTER TABLE stirling_pdf.procurement_quote
+ ADD COLUMN IF NOT EXISTS business_name VARCHAR(255);
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
new file mode 100644
index 0000000000..1f903a1ee2
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java
@@ -0,0 +1,87 @@
+package stirling.software.saas.procurement.pricing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+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.
+ */
+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");
+ }
+
+ @Test
+ void canonicalQuoteMatchesPrototype() {
+ QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3));
+
+ 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%
+ }
+
+ @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
+ }
+
+ @Test
+ void addOnsAndTermStack() {
+ QuoteConfig c =
+ new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD");
+ QuoteBreakdown q = pricing.price(c);
+
+ 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
+
+ assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual);
+ assertThat(q.tcvMinor()).isEqualTo(expectedTcv);
+ 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"));
+ }
+
+ @Test
+ void volumeEstimateFromSeats() {
+ // ~2,013 PDFs/user/yr
+ assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
+ assertThat(pricing.estimateAnnualVolume(0)).isZero();
+ }
+
+ private static long lineAmount(QuoteBreakdown q, String key) {
+ return q.lineItems().stream()
+ .filter(l -> l.key().equals(key))
+ .mapToLong(QuoteLineItem::amountMinor)
+ .findFirst()
+ .orElseThrow();
+ }
+}
diff --git a/frontend/.gitignore b/frontend/.gitignore
index e07dce196a..0605e6e79f 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -11,6 +11,7 @@
# production
/build
/dist
+/dist-portal
/storybook-static
/editor/build
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index d086613f44..dd8e379a8a 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -2,7 +2,7 @@
// the decorators below transpiles to React.createElement and needs React in
// scope. (The app + story files use the automatic runtime via the portal vite
// config; this import is specifically for the preview config file.)
-import React, { useEffect } from "react";
+import React, { Suspense, useEffect } from "react";
import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
@@ -109,7 +109,9 @@ const withProviders: Decorator = (Story, context) => {
-
+
+
+
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 346ece1523..241289d1db 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -7430,7 +7430,7 @@ upgrade = "Upgrade"
volumeSuffix = "PDFs processed · last 30 days"
[portal.procurement]
-enterpriseBadge = "Enterprise"
+reset = "Reset procurement (demo)"
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -7441,6 +7441,63 @@ request = "Request"
sign = "Review & sign"
upload = "Upload"
+[portal.procurement.agreement]
+agreeCta = "Agree & subscribe"
+confirm = "I have read and agree to the Stirling Enterprise Agreement."
+eyebrow = "Agreement"
+intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription."
+title = "Review your enterprise agreement"
+
+[portal.procurement.builder]
+addons = "Add-ons"
+back = "Back"
+businessName = "Business name"
+businessNamePlaceholder = "Your company"
+continue = "Continue"
+country = "Country"
+countryEuro = "Eurozone (EUR €)"
+countryUK = "United Kingdom (GBP £)"
+countryUS = "United States (USD $)"
+eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
+generate = "Generate quote"
+included = "Included"
+indemnification = "IP indemnification"
+indemnificationSub = "We defend qualifying IP claims, per the EULA"
+qbr = "Quarterly business reviews"
+qbrSub = "Your SE reviews usage and roadmap each quarter"
+running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
+s1Sub = "Your team, and the PDFs you expect to run each year."
+# Step 1 — volume
+s1Title = "How much will you process?"
+s2Sub = "Longer terms discount the rate; your service level sets support."
+# Step 2 — commitment & service
+s2Title = "Commitment and service"
+s3Sub = "For the quote and the agreement it generates."
+# Step 3 — details
+s3Title = "Your details"
+serviceLevel = "Service level"
+slDedicated = "Dedicated"
+slDedicatedSub = "4 business hours · dedicated account manager · +30%"
+slPriority = "Priority"
+slPrioritySub = "Same business day · named CSM · +15%"
+slStandard = "Standard"
+slStandardSub = "Next business day · shared CSM · included"
+stepOf = "Step {{n}} of {{total}}"
+term = "Term"
+termDiscount = "{{pct}}% multi-year commitment discount applied"
+title = "Build your quote"
+training = "Onboarding & training"
+trainingSub = "Live sessions to get your team running"
+users = "Total users"
+usersPlaceholder = "e.g. 250"
+volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better."
+volManual = "Using your figure. Re-estimate from your team size any time."
+volNoUsers = "Not sure? Enter your team size and we'll estimate it."
+volume = "Annual PDF volume"
+volumePlaceholder = "e.g. 1,000,000"
+years_one = "{{count}} year"
+years_other = "{{count}} years"
+
[portal.procurement.docs]
count_one = "{{count}} doc"
count_other = "{{count}} docs"
@@ -7456,6 +7513,30 @@ supportingTitle = "Supporting your evaluation"
title = "Documents"
upcoming = "Upcoming"
+[portal.procurement.error]
+title = "Something went wrong"
+
+[portal.procurement.hero]
+company = "Your enterprise deal"
+ctaAgreement = "Review & sign agreement"
+ctaLive = "You're live"
+ctaPayment = "Add payment"
+ctaQuote = "Review your quote"
+ctaTrial = "Build your quote"
+eyebrow = "Enterprise procurement"
+inviteTeammates = "Invite teammates"
+keyDocs = "Key documents"
+nextStep = "Next step: {{action}}"
+notStarted = "Not started"
+open = "Open procurement"
+scheduleCall = "Schedule a call"
+setup1Sub = "Invite your teammates"
+setup1Title = "Deploy the PDF Editor"
+setup2Sub = "Turn on processing across editors and other sources"
+setup2Title = "Connect the PDF Processor"
+setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them"
+setup3Title = "Add recommended policies"
+
[portal.procurement.journey]
daysLeft_one = "{{count}} day left"
daysLeft_other = "{{count}} days left"
@@ -7467,15 +7548,39 @@ 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.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."
+eyebrow = "Enterprise"
+title = "Link your account to begin"
+
+[portal.procurement.live]
+description = "Your subscription is active and your licence is issued. Your team is provisioned and billing has started."
+eyebrow = "Live"
+title = "You're live on Stirling Enterprise"
+
[portal.procurement.locked]
description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
eyebrow = "Enterprise only"
talkToSales = "Talk to sales"
title = "The procurement track opens with Enterprise"
+[portal.procurement.milestone]
+accept = "Accept & continue"
+description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes."
+download = "Download PDF"
+downloadError = "Could not download the quote PDF just yet — please try again in a moment."
+edit = "Edit quote"
+eyebrow = "Quote {{number}}"
+perYear = " / yr"
+preparedFor = "Prepared for {{company}}"
+tcv = "{{value}} total contract value"
+title = "Your quote is ready"
+
[portal.procurement.modal]
cancel = "Cancel"
chooseFile = "Choose file"
+close = "Close"
downloadBody = "Your download will begin shortly."
downloadCta = "Download"
downloadTitle = "Download"
@@ -7494,6 +7599,13 @@ uploadBody = "Send us your PO and we invoice against it on your terms. Drag in t
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."
+downloadInvoice = "Download invoice"
+simulate = "Simulate payment received (demo)"
+title = "Subscription created"
+viewInvoice = "View & pay invoice"
+
[portal.procurement.status]
action = "Action needed"
available = "Available"
@@ -7501,6 +7613,21 @@ complete = "Complete"
pending = "Pending"
request = "On request"
+[portal.procurement.trial]
+body = "Extending adds 7 days and notifies your solutions engineer."
+bodyMaxed = "You have used all your extensions — talk to your solutions engineer if you need more time."
+cancel = "Cancel trial"
+extend = "Extend 7 days"
+maxed = "Maxed out"
+subtitle = "Your free trial runs through {{date}}. No card required."
+title = "Enterprise trial"
+
+[portal.procurement.upsell]
+homeBadge = "Enterprise"
+homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE."
+homeCta = "Start Trial →"
+homeHeadline = "Process millions of PDFs."
+
[portal.recentActivity]
title = "Recent activity"
viewAll = "View all"
diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts
index 3a2b9fe0f2..9327179c03 100644
--- a/frontend/editor/src/portal/api/procurement.ts
+++ b/frontend/editor/src/portal/api/procurement.ts
@@ -1,4 +1,5 @@
import { apiClient } from "@portal/api/http";
+import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import type { Tier } from "@portal/contexts/TierContext";
import type {
DealStage,
@@ -92,3 +93,165 @@ export async function requestDocument(
{ method: "POST", body: { action } },
);
}
+
+// ============================================================================
+// Enterprise procurement — real SaaS backend (/api/v1/procurement).
+//
+// The journey/ledger visuals above still ride the MSW mock; the commercial spine
+// below (trial, server-priced quote, accept -> Stripe checkout) is the real thing,
+// served by the saas Java backend and gated on a linked account.
+// ============================================================================
+
+export type QuoteLineItemKind =
+ | "RECURRING"
+ | "ONE_TIME"
+ | "DISCOUNT"
+ | "INCLUDED";
+
+export interface QuoteLineItem {
+ key: string;
+ label: string;
+ kind: QuoteLineItemKind;
+ amountMinor: number;
+}
+
+export interface QuoteResult {
+ quoteId: number;
+ quoteNumber: string;
+ /** draft (priced, editable) | sent (issued Stripe quote — PDF + shareable) | accepted | expired. */
+ status: string;
+ currency: string;
+ annualNetMinor: number;
+ tcvMinor: number;
+ lineItems: QuoteLineItem[];
+ validUntil: string | null;
+ /** The Stripe Quote id once issued; null while still a local draft. */
+ stripeQuoteId: string | null;
+ /** Hosted Stripe invoice URL, present once the quote is accepted and the subscription invoice exists. */
+ invoiceUrl: string | null;
+ /** The inputs this quote was priced from, so the builder can seed itself on re-edit. */
+ config: QuoteConfigInput;
+}
+
+/** Outcome of accepting an issued quote: Stripe creates the subscription + first invoice. */
+export interface AcceptResult {
+ status: string;
+ subscriptionId: string | null;
+ invoiceUrl: string | null;
+ invoicePdf: string | null;
+}
+
+/** One shape for every state; an unstarted procurement has {@link ProcurementSnapshot.dealId} null. */
+export interface ProcurementSnapshot {
+ dealId: number | null;
+ stage: DealStage | null;
+ trialStartedAt: string | null;
+ trialEndsAt: string | null;
+ trialExtensionsUsed: number;
+ licensed: boolean;
+ latestQuote: QuoteResult | null;
+}
+
+export interface QuoteConfigInput {
+ volume: number;
+ users: number;
+ deployment: string;
+ termYears: number;
+ serviceLevel: string;
+ indemnification: boolean;
+ training: boolean;
+ qbr: boolean;
+ currency: string;
+ /** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */
+ businessName: string;
+}
+
+export function fetchSnapshot(): Promise {
+ return apiClient.saas.json("/api/v1/procurement");
+}
+
+export function startTrial(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/trial/start",
+ { method: "POST" },
+ );
+}
+
+export function extendTrial(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/trial/extend",
+ { method: "POST" },
+ );
+}
+
+/** Advance an issued quote to the agreement (security) stage for review + agree. */
+export function startAgreement(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/agreement",
+ { method: "POST" },
+ );
+}
+
+/** Price a config server-side and persist it as a local DRAFT (no Stripe object yet). */
+export function buildQuote(cfg: QuoteConfigInput): Promise {
+ return apiClient.saas.json("/api/v1/procurement/quote", {
+ method: "POST",
+ body: cfg,
+ });
+}
+
+// ---- Stripe Quote operations (Supabase edge functions) ---------------------
+// Java has no Stripe SDK, so issuing/accepting the quote and fetching its PDF run in edge functions
+// that own Stripe; they persist results back through SECURITY DEFINER RPCs. The portal invokes them
+// directly (same pattern the PAYG checkout uses).
+
+async function invokeEdge(fn: string, quoteId: number): Promise {
+ const supabase = getSupabaseClient();
+ if (!supabase) throw new Error("No SaaS session");
+ const { data, error } = await supabase.functions.invoke(fn, {
+ body: { quote_id: quoteId },
+ });
+ if (error) throw error;
+ if (data == null) throw new Error(`${fn} returned no data`);
+ return data;
+}
+
+/** Turn a draft into an issued Stripe Quote (finalized → gets a number + PDF, shareable). */
+export function issueQuote(quoteId: number): Promise {
+ return invokeEdge("issue-procurement-quote", quoteId);
+}
+
+/** Accept an issued quote → Stripe creates the committed subscription + first invoice. */
+export function acceptQuote(quoteId: number): Promise {
+ return invokeEdge("accept-procurement-quote", quoteId);
+}
+
+/** Fetch the Stripe-generated quote PDF as a blob (for download / share). */
+export async function fetchQuotePdf(quoteId: number): Promise {
+ const supabase = getSupabaseClient();
+ if (!supabase) throw new Error("No SaaS session");
+ const { data, error } = await supabase.functions.invoke(
+ "get-procurement-quote-pdf",
+ { body: { quote_id: quoteId } },
+ );
+ if (error) throw error;
+ if (!data) throw new Error("No PDF returned");
+ return data;
+}
+
+/**
+ * Demo/manual stand-in for the invoice.paid webhook: mark the deal live (issue licence, go active).
+ */
+export function goLive(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/go-live",
+ { method: "POST" },
+ );
+}
+
+/** Reset the team's procurement (delete the deal) and get the fresh empty snapshot. */
+export function resetProcurement(): Promise {
+ return apiClient.saas.json("/api/v1/procurement/reset", {
+ method: "POST",
+ });
+}
diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx
index d9ced4ae65..0deb3c0dda 100644
--- a/frontend/editor/src/portal/components/Sidebar.tsx
+++ b/frontend/editor/src/portal/components/Sidebar.tsx
@@ -22,7 +22,6 @@ import {
UsageIcon,
LinkIcon,
DocsIcon,
- ProcurementIcon,
SettingsIcon,
ChevronDownIcon,
} from "@portal/components/icons";
@@ -136,15 +135,10 @@ export function Sidebar() {
const { activeView, setActiveView } = useView();
const { theme } = useTheme();
const { openSettings } = useUI();
- const { tier } = useTier();
const { t } = useTranslation();
- // Procurement is the enterprise buyer's commercial journey — surfaced only to
- // enterprise tenants (it has no free/pro equivalent).
- const platformGroup: NavEntry[] =
- tier === "enterprise"
- ? [{ id: "procurement", icon: }, ...GROUP_PLATFORM]
- : GROUP_PLATFORM;
+ // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into
+ // a takeover modal (matching the marketing prototype).
function renderGroup(entries: NavEntry[]) {
return entries.map((entry) => (
@@ -227,7 +221,7 @@ export function Sidebar() {
- {renderGroup(platformGroup)}
+ {renderGroup(GROUP_PLATFORM)}
diff --git a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
index b303c530ff..6525e66970 100644
--- a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
+++ b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
+import { useView } from "@portal/contexts/ViewContext";
interface Props {
/** Render without the Card wrapper, to embed inside another card's column. */
@@ -8,10 +9,12 @@ interface Props {
/**
* Volume-discount / Enterprise upsell, shared by the free and subscribed billing
- * views. The CTA is intentionally inert until the sales/quote URL is confirmed.
+ * views. The CTA opens the procurement journey (/procurement auto-opens the quote
+ * builder in the takeover modal).
*/
export function EnterpriseUpsell({ bare = false }: Props) {
const { t } = useTranslation();
+ const { setActiveView } = useView();
const body = (
<>
@@ -32,8 +35,11 @@ export function EnterpriseUpsell({ bare = false }: Props) {
)}
- {/* Destination wired when the enterprise/sales URL is confirmed. */}
-