Portal procurement: real pricing/trial/quote spine + linked-gated checkout (vertical slice) (#6861)

## What this is

The enterprise procurement flow, built into the customer portal as a
**vertical slice** — one linked account can go the whole way from trial
to a paid, committed subscription, using real Stripe under the hood.

Procurement no longer lives as a nav tab. It sits on **Home** as a
deal-status hero and expands into a full-screen takeover, matching the
marketing prototype.

## The journey (what a customer does)

- **Start a trial** in one click — the deadline and next steps show on
the Home hero (no card, mock licence).
- **Build a quote** — a short form (volume → commitment & service →
details); pricing is computed server-side.
- **Generate the quote** — this creates a real **Stripe Quote** with a
proper **PDF** you can download and share, and it becomes a milestone
you can come back to.
- **Review & sign the agreement** — one combined agreement (MSA + Order
Form + EULA + DPA) with an itemised order form and an "I agree" (no
e-signature yet).
- **Accept** — Stripe creates the committed annual **subscription** and
its **first invoice**, which you can **pay or download right in the
app** (no waiting on email).
- Edit a quote any time — it remembers your inputs and company name; the
old Stripe quote is cancelled so it can't still be accepted.
- The hero also has quick actions: **key documents**, **invite
teammates**, **schedule a call**, and a **trial countdown** you can
extend.

## Architecture — Supabase vs Java

Pricing, deal/quote state, and the commercial journey live in **Java
(`:saas`)**. Everything that touches **Stripe** (writes + PDFs) lives in
**Supabase edge functions** — Java has no Stripe SDK and only reads
Stripe via the sync mirror. The portal calls both.

```mermaid
flowchart LR
  Portal["Portal (React · editor/src/portal)"]

  subgraph JAVA["Java :saas backend (trusted cloud)"]
    Pricing["Pricing engine (volume bands, SLA, term, add-ons)"]
    Deal["Deal + quote state, journey, snapshot"]
    Trial["Trial (mock Keygen licence seam)"]
    Authz["Auth: team resolve + leader gating"]
    Mirror["Reads Stripe via sync mirror (stripe.* tables)"]
  end

  subgraph SUPA["Supabase edge functions (own Stripe)"]
    Issue["issue-procurement-quote → create + finalize Stripe Quote"]
    Accept["accept-procurement-quote → subscription + finalize invoice"]
    Pdf["get-procurement-quote-pdf → proxy the quote PDF"]
    RPC["SECURITY DEFINER RPCs (read/write stirling_pdf, enforce team/leader)"]
  end

  Stripe["Stripe (Quotes · Subscription · Invoice)"]

  Portal -->|"price / build / trial / agreement / snapshot"| JAVA
  Portal -->|"issue / accept / download PDF"| SUPA
  SUPA --> Stripe
  SUPA --- RPC
  Mirror -. reads .-> Stripe
```

| Top-level feature | Handled in |
|---|---|
| Quote pricing (bands, SLA, term, add-ons) | **Java** |
| Deal + quote state, journey, snapshot | **Java** |
| Trial start / extend (mock licence) | **Java** |
| AuthN/Z (team resolve, leader gating) | **Java** |
| Issue quote → Stripe Quote + PDF | **Supabase edge fn** |
| Accept → subscription + invoice | **Supabase edge fn** |
| Quote PDF download | **Supabase edge fn** |
| Reading Stripe state | **Java** (sync mirror) |
| `stirling_pdf` writes from edge | **SECURITY DEFINER RPCs**
(service-role only) |

## Screenshots

<!-- Drag each PNG into the box below it before publishing. -->

**Home deal-status hero (trial)**
<img width="1920" height="1009" alt="hero-check"
src="https://github.com/user-attachments/assets/7ae21831-9578-4f4d-b91a-d3ab2cb171dc"
/>

**Issued quote milestone (with breakdown)**
<img width="1920" height="1009" alt="milestone-breakdown"
src="https://github.com/user-attachments/assets/3a463c67-3c6e-4f93-a1cc-59b250d54cc9"
/>


**Agreement step (itemised order form)**
<img width="1920" height="1009" alt="agreement-itemised"
src="https://github.com/user-attachments/assets/1a4efa16-d0a3-41d0-849c-a125b1492a34"
/>

**Key documents**
<img width="1920" height="1009" alt="keydocs-modal"
src="https://github.com/user-attachments/assets/5672d1d2-99d9-499e-9edf-d485df378e7f"
/>

**Subscription created (pay / download invoice)**
<img width="1920" height="1009" alt="accepted-check"
src="https://github.com/user-attachments/assets/3a8cbe9f-e72c-4389-b365-0c1749108b6f"
/>


## Mocked for now (scaffolding, not wired to real backends)

- **Key documents** ledger — static demo list.
- **Schedule a call** — static solutions-engineer + time slots.
- **Invite teammates** — routes to the existing Users view.
- **Simulate payment received** / **Reset procurement** — demo controls,
**off by default** in prod (flag-gated), 404 unless enabled.

## Deferred (separate follow-up PRs)

- **Real `invoice.paid` webhook** → go-live (today a demo button stands
in).
- **Keygen licence controller** — real licensing (currently a mock
seam).
- **Document sharing**.
- **Stirling admin / Deal Desk** view.
- **Minimum ACV floor** — pending a number from marketing (server-side
enforcement is a one-liner once decided).

## How to test

- **Frontend, no backend:** runs against MSW mocks (Storybook + mocks-on
dev) — the whole journey is clickable.
- **Real end-to-end:** apply the migrations (Flyway `V27–V29` / Supabase
`20260701–20260707`), deploy the three edge functions, ensure
**Invoicing Plus** is enabled on Stripe, and set
`STIRLING_PROCUREMENT_DEMO_CONTROLS_ENABLED=true` if you want the demo
controls.
- Paired SaaS PR: **Stirling-Tools/Stirling-PDF-SaaS#318**.

## Notes for reviewers

- Pricing is server-authoritative (client sends config, never amounts).
- Security review done: edge functions validate the JWT and enforce
**team membership** (and **leader** for issue/accept) via the RPC; demo
endpoints are flag-gated off. Only open item is the ACV floor (policy).
This commit is contained in:
ConnorYoh
2026-07-07 12:02:02 +00:00
committed by GitHub
parent 17aa71850c
commit 7bd3826178
42 changed files with 4603 additions and 118 deletions
@@ -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 {}
@@ -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<QuoteLineItem> 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<SnapshotResponse> 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<SnapshotResponse> 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<SnapshotResponse> 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<QuoteResponse> 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<SnapshotResponse> 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<SnapshotResponse> 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<SnapshotResponse> 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<TeamMembership> 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<TeamMembership> 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<QuoteLineItem> 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();
}
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
}
@@ -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 -&gt; quote -&gt; agreement -&gt; payment -&gt; 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;
}
}
@@ -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;
}
@@ -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.
*
* <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.
*/
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;
}
}
@@ -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}:
*
* <pre>
* annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
* annualNet = round(annual x (1 - termDiscount)) + qbr
* 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).
*/
@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<QuoteLineItem> 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";
}
}
@@ -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<QuoteLineItem> lineItems, long annualNetMinor, long tcvMinor, String currency) {}
@@ -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 &amp; 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";
}
}
@@ -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
}
}
@@ -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<ProcurementDeal, Long> {
Optional<ProcurementDeal> findByTeamId(Long teamId);
boolean existsByTeamId(Long teamId);
/** Reset: drop the team's deal (quotes + activity cascade via FK). */
void deleteByTeamId(Long teamId);
}
@@ -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<ProcurementQuote, Long> {
List<ProcurementQuote> findByDealIdOrderByCreatedAtDesc(Long dealId);
}
@@ -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<ProcurementDeal> getDeal(Long teamId) {
return dealRepo.findByTeamId(teamId);
}
@Transactional(readOnly = true)
public List<ProcurementQuote> 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 "[]";
}
}
}
@@ -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);
@@ -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;
@@ -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);
@@ -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();
}
}
+1
View File
@@ -11,6 +11,7 @@
# production
/build
/dist
/dist-portal
/storybook-static
/editor/build
+4 -2
View File
@@ -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) => {
<TierKey tier={tier}>
<UIProvider>
<ThemeWatcher />
<Story />
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
@@ -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"
@@ -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<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>("/api/v1/procurement");
}
export function startTrial(): Promise<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>(
"/api/v1/procurement/trial/start",
{ method: "POST" },
);
}
export function extendTrial(): Promise<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>(
"/api/v1/procurement/trial/extend",
{ method: "POST" },
);
}
/** Advance an issued quote to the agreement (security) stage for review + agree. */
export function startAgreement(): Promise<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>(
"/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<QuoteResult> {
return apiClient.saas.json<QuoteResult>("/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<T>(fn: string, quoteId: number): Promise<T> {
const supabase = getSupabaseClient();
if (!supabase) throw new Error("No SaaS session");
const { data, error } = await supabase.functions.invoke<T>(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<QuoteResult> {
return invokeEdge<QuoteResult>("issue-procurement-quote", quoteId);
}
/** Accept an issued quote → Stripe creates the committed subscription + first invoice. */
export function acceptQuote(quoteId: number): Promise<AcceptResult> {
return invokeEdge<AcceptResult>("accept-procurement-quote", quoteId);
}
/** Fetch the Stripe-generated quote PDF as a blob (for download / share). */
export async function fetchQuotePdf(quoteId: number): Promise<Blob> {
const supabase = getSupabaseClient();
if (!supabase) throw new Error("No SaaS session");
const { data, error } = await supabase.functions.invoke<Blob>(
"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<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>(
"/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<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>("/api/v1/procurement/reset", {
method: "POST",
});
}
@@ -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: <ProcurementIcon /> }, ...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() {
</div>
<div className="portal-sidebar__divider" aria-hidden />
<div className="portal-sidebar__group">
{renderGroup(platformGroup)}
{renderGroup(GROUP_PLATFORM)}
</div>
</nav>
@@ -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 = (
<>
<span className="portal-billing__eyebrow">
@@ -32,8 +35,11 @@ export function EnterpriseUpsell({ bare = false }: Props) {
)}
</p>
</div>
{/* Destination wired when the enterprise/sales URL is confirmed. */}
<Button variant="gradient" size="sm" disabled>
<Button
variant="gradient"
size="sm"
onClick={() => setActiveView("procurement")}
>
{t(
"portal.billing.enterpriseUpsell.cta",
"Build your Enterprise quote",
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
import type { ProcurementSnapshot } from "@portal/api/procurement";
const base: ProcurementSnapshot = {
dealId: 1,
stage: "trial",
trialStartedAt: "2026-06-25T00:00:00Z",
trialEndsAt: "2026-07-09T00:00:00Z",
trialExtensionsUsed: 0,
licensed: false,
latestQuote: null,
};
/** The Home deal-status hero across the commercial stages; the CTA expands the takeover modal. */
const meta: Meta<typeof DealStatusHero> = {
title: "Portal/Procurement/DealStatusHero",
component: DealStatusHero,
parameters: { layout: "padded" },
args: {
onExpand: () => {},
onKeyDocs: () => {},
onInvite: () => {},
onSchedule: () => {},
onManageTrial: () => {},
onNavigate: () => {},
},
};
export default meta;
type Story = StoryObj<typeof DealStatusHero>;
export const Trial: Story = { args: { snapshot: base } };
export const Quote: Story = {
args: { snapshot: { ...base, stage: "quote", trialEndsAt: null } },
};
export const Agreement: Story = {
args: { snapshot: { ...base, stage: "security", trialEndsAt: null } },
};
export const Payment: Story = {
args: { snapshot: { ...base, stage: "procurement", trialEndsAt: null } },
};
export const Live: Story = {
args: {
snapshot: { ...base, stage: "active", licensed: true, trialEndsAt: null },
},
};
@@ -0,0 +1,162 @@
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { ViewId } from "@portal/contexts/ViewContext";
import { JOURNEY, type ProcurementSnapshot } from "@portal/api/procurement";
import { StageStepper } from "@portal/components/procurement/StageStepper";
import "@portal/views/Procurement.css";
/**
* The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the
* deal stage: quick-action chips (trial countdown → manage, key documents, invite teammates,
* schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that
* expands the flow into the takeover modal. Matches the marketing prototype.
*/
export function DealStatusHero({
snapshot,
busy = false,
onExpand,
onKeyDocs,
onInvite,
onSchedule,
onManageTrial,
onNavigate,
}: {
snapshot: ProcurementSnapshot;
busy?: boolean;
onExpand: () => void;
onKeyDocs: () => void;
onInvite: () => void;
onSchedule: () => void;
onManageTrial: () => void;
onNavigate: (view: ViewId) => void;
}) {
const { t } = useTranslation();
const stage = snapshot.stage ?? "trial";
const inTrial = stage === "trial";
const cta =
stage === "trial"
? t("portal.procurement.hero.ctaTrial")
: stage === "quote"
? t("portal.procurement.hero.ctaQuote")
: stage === "security"
? t("portal.procurement.hero.ctaAgreement")
: stage === "procurement"
? t("portal.procurement.hero.ctaPayment")
: t("portal.procurement.hero.ctaLive");
const setupSteps: { title: string; sub: string; view: ViewId }[] = [
{
title: t("portal.procurement.hero.setup1Title"),
sub: t("portal.procurement.hero.setup1Sub"),
view: "users",
},
{
title: t("portal.procurement.hero.setup2Title"),
sub: t("portal.procurement.hero.setup2Sub"),
view: "sources",
},
{
title: t("portal.procurement.hero.setup3Title"),
sub: t("portal.procurement.hero.setup3Sub"),
view: "policies",
},
];
return (
<div className="portal-hero">
<div className="portal-hero__top">
<div>
<span className="portal-hero__eyebrow">
{t("portal.procurement.hero.eyebrow")}
</span>
<span className="portal-hero__company">
{t("portal.procurement.hero.company")}
</span>
</div>
<div className="portal-hero__chips">
{inTrial && snapshot.trialEndsAt && (
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onManageTrial}
>
{t("portal.procurement.journey.daysLeft", {
count: daysLeft(snapshot.trialEndsAt),
})}
</button>
)}
{stage !== "active" && (
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onKeyDocs}
>
{t("portal.procurement.hero.keyDocs")}
</button>
)}
{stage !== "active" && (
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onInvite}
>
{t("portal.procurement.hero.inviteTeammates")}
</button>
)}
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onSchedule}
>
{t("portal.procurement.hero.scheduleCall")}
</button>
</div>
</div>
<div className="portal-hero__stepper">
<StageStepper journey={JOURNEY} currentStage={stage} />
</div>
{inTrial && (
<ul className="portal-hero__checklist">
{setupSteps.map((s) => (
<li key={s.title}>
<button type="button" onClick={() => onNavigate(s.view)}>
<span className="portal-hero__check-dot" aria-hidden />
<span className="portal-hero__check-text">
<span className="portal-hero__check-title">{s.title}</span>
<span className="portal-hero__check-sub">{s.sub}</span>
</span>
<span className="portal-hero__check-pill">
{t("portal.procurement.hero.notStarted")}
</span>
</button>
</li>
))}
</ul>
)}
<div className="portal-hero__next">
<span className="portal-hero__next-label">
<span className="portal-hero__next-dot" />
{t("portal.procurement.hero.nextStep", { action: cta })}
</span>
<div className="portal-hero__next-actions">
<Button
variant="gradient"
accent="purple"
loading={busy}
onClick={onExpand}
>
{stage === "active" ? t("portal.procurement.hero.open") : cta}
</Button>
</div>
</div>
</div>
);
}
function daysLeft(iso: string): number {
const end = new Date(iso).getTime();
return Math.max(0, Math.ceil((end - Date.now()) / 86_400_000));
}
@@ -0,0 +1,126 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import type { QuoteResult } from "@portal/api/procurement";
import { money } from "@portal/components/procurement/format";
import "@portal/views/Procurement.css";
/**
* The agreement (security) step: a single combined Stirling Enterprise Agreement — Master Service
* Agreement + Order Form (from the issued quote) + EULA + Data Processing Agreement — that the buyer
* reviews and agrees to before it's accepted into a subscription. No e-signature for now: an explicit
* "I agree" click stands in (the terms reference the accepted quote). Document body is static legal
* copy; the surrounding UI is translated.
*/
export function ProcurementAgreement({
quote,
busy,
onAgree,
}: {
quote: QuoteResult;
busy: boolean;
onAgree: () => void;
}) {
const { t } = useTranslation();
const [checked, setChecked] = useState(false);
const annual = money(quote.annualNetMinor, quote.currency);
const tcv = money(quote.tcvMinor, quote.currency);
const years = quote.config.termYears;
return (
<Card padding="loose">
<span className="portal-proc__eyebrow">
{t("portal.procurement.agreement.eyebrow")}
</span>
<h3 className="portal-proc__builder-title">
{t("portal.procurement.agreement.title")}
</h3>
<p className="portal-proc__subtitle">
{t("portal.procurement.agreement.intro")}
</p>
<div className="portal-agreement__doc">
<h4>1. Master Service Agreement</h4>
<p>
This Stirling Enterprise Agreement ("Agreement") is entered into
between Stirling PDF Inc. ("Stirling") and the customer identified on
the Order Form ("Customer"). It governs Customer's access to and use
of the Stirling enterprise platform and related services (the
"Service"). Stirling will provide the Service with commercially
reasonable skill and care and in accordance with the service levels
set out in the Order Form.
</p>
<h4>2. Order Form</h4>
<p>
Quote <strong>{quote.quoteNumber}</strong> forms the Order Form for
this Agreement. Customer commits to a {years}-year term at{" "}
<strong>{annual}</strong> per year (total contract value{" "}
<strong>{tcv}</strong>), billed annually in advance by invoice. Fees
are exclusive of taxes. The committed volume, service level, and
add-ons are itemised below:
</p>
<ul className="portal-qb__lines portal-agreement__lines">
{quote.lineItems.map((li) => (
<li key={li.key} data-kind={li.kind}>
<span>{li.label}</span>
<span>
{li.kind === "INCLUDED"
? t("portal.procurement.builder.included")
: money(li.amountMinor, quote.currency)}
</span>
</li>
))}
</ul>
<h4>3. End-User License Agreement</h4>
<p>
Subject to the terms of this Agreement, Stirling grants Customer a
non-exclusive, non-transferable right to use the Service for its
internal business purposes during the term. Customer is responsible
for its users' compliance and for the content it processes. The
Service, and all intellectual property in it, remains Stirling's.
</p>
<h4>4. Data Processing Agreement</h4>
<p>
Where Stirling processes personal data on Customer's behalf, it does
so only on Customer's documented instructions and applies appropriate
technical and organisational measures. Sub-processors, international
transfers, and security commitments are as described in Stirling's
Data Processing Agreement and Trust Center, incorporated here by
reference.
</p>
<h4>5. Acceptance</h4>
<p>
By agreeing below, Customer accepts this Agreement and the Order Form.
On acceptance, Stirling will issue the committed annual subscription
and its first invoice. This preview stands in for e-signature during
the pilot.
</p>
</div>
<label className="portal-qb__eula portal-agreement__accept">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span>{t("portal.procurement.agreement.confirm")}</span>
</label>
<div className="portal-proc__payment-actions">
<Button
variant="gradient"
accent="purple"
loading={busy}
disabled={!checked}
onClick={onAgree}
>
{t("portal.procurement.agreement.agreeCta")}
</Button>
</div>
</Card>
);
}
@@ -0,0 +1,299 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { ProcurementSnapshot } from "@portal/api/procurement";
import { useFocusTrap } from "@portal/components/procurement/ProcurementModal";
import "@portal/views/Procurement.css";
/**
* Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule
* a call, and trial management. Content is mocked for the pilot (static demo data); the shells and
* wiring are real so the hero behaves like the marketing prototype.
*/
function SideModal({
open,
onClose,
title,
subtitle,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
subtitle?: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
const { t } = useTranslation();
const trapRef = useFocusTrap(open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="portal-sidemodal"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
ref={trapRef}
className="portal-sidemodal__panel"
role="dialog"
aria-modal="true"
tabIndex={-1}
>
<button
type="button"
className="portal-procmodal__close"
onClick={onClose}
aria-label={t("portal.procurement.modal.close")}
>
✕
</button>
<div className="portal-sidemodal__header">
<h3 className="portal-sidemodal__title">{title}</h3>
{subtitle && <p className="portal-sidemodal__sub">{subtitle}</p>}
</div>
<div className="portal-sidemodal__body">{children}</div>
{footer && <div className="portal-sidemodal__footer">{footer}</div>}
</div>
</div>,
document.body,
);
}
// ── Key documents ────────────────────────────────────────────────────────────
type DocStatus = "available" | "action" | "request";
interface DocRow {
name: string;
sub: string;
status: DocStatus;
fee?: number;
}
const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [
{
group: "Your deal",
docs: [
{
name: "Formal quote",
sub: "Built to your volume, term, and service level",
status: "available",
},
{
name: "Master Services Agreement",
sub: "One signature — MSA, order form, EULA, and DPA combined",
status: "action",
},
{
name: "Bank transfer instructions",
sub: "Wire details for your AP team",
status: "available",
},
{
name: "Purchase order",
sub: "Issuing a PO? Upload it and we invoice against it",
status: "request",
},
],
},
{
group: "Supporting your evaluation",
docs: [
{
name: "SOC 2 Type II report",
sub: "Audited · NDA-gated",
status: "available",
},
{
name: "Custom security review",
sub: "We complete your questionnaire and join your review call",
status: "request",
fee: 5000,
},
{
name: "Business Associate Agreement",
sub: "HIPAA · available on request",
status: "request",
fee: 2500,
},
{ name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" },
{
name: "Certificate of Insurance",
sub: "Cyber + E&O · current policy",
status: "available",
},
],
},
];
const STATUS_LABEL: Record<DocStatus, string> = {
available: "Download",
action: "Action needed",
request: "Request",
};
export function KeyDocumentsModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
return (
<SideModal
open={open}
onClose={onClose}
title="Key documents"
subtitle="Everything for each stage of your rollout, in one place."
>
{STAGE_DOCS.map((g) => (
<div key={g.group} className="portal-docs__group">
<div className="portal-docs__group-title">{g.group}</div>
<ul className="portal-docs__list">
{g.docs.map((d) => (
<li key={d.name} className="portal-docs__row">
<div className="portal-docs__row-text">
<span className="portal-docs__row-name">{d.name}</span>
<span className="portal-docs__row-sub">
{d.sub}
{d.fee ? ` · one-time $${d.fee.toLocaleString()}` : ""}
</span>
</div>
<span
className="portal-docs__row-action"
data-status={d.status}
>
{STATUS_LABEL[d.status]}
</span>
</li>
))}
</ul>
</div>
))}
</SideModal>
);
}
// ── Schedule a call ──────────────────────────────────────────────────────────
const SLOTS = [
"Tomorrow · 10:00",
"Tomorrow · 15:30",
"Thursday · 11:00",
"Friday · 09:30",
];
export function ScheduleCallModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
return (
<SideModal
open={open}
onClose={onClose}
title="Schedule a call"
subtitle="Your solutions engineer will walk your team through the rollout."
>
<div className="portal-se">
<span className="portal-se__avatar" aria-hidden>
SE
</span>
<div>
<div className="portal-se__name">Your solutions engineer</div>
<div className="portal-se__role">
Dedicated to your evaluation and rollout
</div>
</div>
</div>
<div className="portal-slots">
{SLOTS.map((s) => (
<button
key={s}
type="button"
className="portal-slots__slot"
onClick={onClose}
>
{s}
</button>
))}
</div>
</SideModal>
);
}
// ── Trial management ─────────────────────────────────────────────────────────
export function TrialManageModal({
open,
onClose,
snapshot,
busy,
onExtend,
onCancel,
}: {
open: boolean;
onClose: () => void;
snapshot: ProcurementSnapshot;
busy: boolean;
onExtend: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const ends = snapshot.trialEndsAt
? new Date(snapshot.trialEndsAt).toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
})
: "";
const maxed = snapshot.trialExtensionsUsed >= 2;
return (
<SideModal
open={open}
onClose={onClose}
title={t("portal.procurement.trial.title")}
subtitle={
ends
? t("portal.procurement.trial.subtitle", { date: ends })
: undefined
}
footer={
<>
<button
type="button"
className="portal-sidemodal__ghost"
onClick={onCancel}
disabled={busy}
>
{t("portal.procurement.trial.cancel")}
</button>
<Button
variant="gradient"
accent="purple"
loading={busy}
disabled={maxed}
onClick={onExtend}
>
{maxed
? t("portal.procurement.trial.maxed")
: t("portal.procurement.trial.extend")}
</Button>
</>
}
>
<p className="portal-sidemodal__text">
{maxed
? t("portal.procurement.trial.bodyMaxed")
: t("portal.procurement.trial.body")}
</p>
</SideModal>
);
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
/**
* The end-to-end procurement experience (Home hero + takeover modal), driven by the `procurementSaas`
* MSW handlers: start trial → build quote → generate (issue Stripe Quote) → milestone (download PDF /
* accept). `autoOpen` opens the modal so the flow is immediately clickable.
*/
const meta: Meta<typeof ProcurementHome> = {
title: "Portal/Procurement/ProcurementHome",
component: ProcurementHome,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof ProcurementHome>;
export const Default: Story = { args: { autoOpen: true } };
@@ -0,0 +1,296 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Card, EmptyState, Skeleton } from "@app/ui";
import { useLink } from "@portal/contexts/LinkContext";
import { useUI } from "@portal/contexts/UIContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
acceptQuote,
extendTrial,
fetchQuotePdf,
fetchSnapshot,
goLive,
issueQuote,
JOURNEY,
resetProcurement,
startAgreement,
startTrial,
type ProcurementSnapshot,
type QuoteResult,
} from "@portal/api/procurement";
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
KeyDocumentsModal,
ScheduleCallModal,
TrialManageModal,
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
} from "@portal/components/procurement/ProcurementStages";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import { StageStepper } from "@portal/components/procurement/StageStepper";
import "@portal/views/Procurement.css";
/**
* The procurement experience on Home: a compact deal-status hero once a trial is running (or the
* enterprise upsell on-ramp before it), both expanding into the full-screen takeover modal that
* holds the journey — build + issue a quote (a Stripe Quote with a real PDF, the milestone the buyer
* can share and return to), review + agree to the enterprise agreement, then accept into a committed
* subscription. Starting a trial is a single click (no "start a trial" prompt); the deadline + next
* steps then show on the hero. Rendered on Home and at /procurement (autoOpen). Gated on a link.
*/
export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) {
const { t } = useTranslation();
const { isLinked } = useLink();
const { openLinkModal } = useUI();
const { setActiveView } = useView();
const state = useAsync<ProcurementSnapshot | null>(
() => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
[isLinked],
);
const [snap, setSnap] = useState<ProcurementSnapshot | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [extra, setExtra] = useState<null | "docs" | "schedule" | "trial">(
null,
);
const data = snap ?? (state.loading ? null : state.data);
const started = data?.dealId != null;
const stage = data?.stage;
const latest = data?.latestQuote ?? null;
const isIssued = latest?.status === "sent" || latest?.status === "open";
// No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds.
const isDraft =
!latest ||
["draft", "expired", "canceled", "cancelled"].includes(latest.status);
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await fn();
setSnap(await fetchSnapshot());
} catch (e) {
console.error("[procurement] action failed", e);
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const onStartTrial = () => run(startTrial);
const onExtendTrial = () => run(extendTrial);
const onReset = () =>
run(async () => {
await resetProcurement();
setEditing(false);
setInvoicePdf(null);
});
const onGenerate = (draft: QuoteResult) =>
run(async () => {
await issueQuote(draft.quoteId);
setEditing(false);
});
// Milestone → agreement (security) stage; then agreeing accepts into a subscription.
const onAcceptQuote = () => run(startAgreement);
const onAgree = () =>
run(async () => {
if (!latest) return;
const res = await acceptQuote(latest.quoteId);
setInvoicePdf(res.invoicePdf);
});
async function onDownloadPdf() {
if (!latest) return;
setDownloading(true);
try {
const blob = await fetchQuotePdf(latest.quoteId);
// A same-gesture <a download> click is reliable; window.open after an await is often
// popup-blocked (which is what made this take "a few goes").
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${latest.quoteNumber || "quote"}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e) {
console.error("[procurement] quote PDF download failed", e);
setError(t("portal.procurement.milestone.downloadError"));
} finally {
setDownloading(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(() => {
if (autoOpen && started) setOpen(true);
}, [autoOpen, started]);
const banner =
isLinked && started && data ? (
<DealStatusHero
snapshot={data}
busy={busy}
onExpand={() => setOpen(true)}
onKeyDocs={() => setExtra("docs")}
onInvite={() => setActiveView("users")}
onSchedule={() => setExtra("schedule")}
onManageTrial={() => setExtra("trial")}
onNavigate={setActiveView}
/>
) : (
<Card className="portal-proc__upsell">
<div className="portal-proc__upsell-text">
<span className="portal-proc__upsell-badge">
{t("portal.procurement.upsell.homeBadge")}
</span>
<p className="portal-proc__upsell-copy">
<strong>{t("portal.procurement.upsell.homeHeadline")} </strong>
{t("portal.procurement.upsell.homeBody")}
</p>
</div>
<Button
variant="outline"
accent="blue"
loading={busy}
disabled={!isLinked}
onClick={onStartTrial}
>
{t("portal.procurement.upsell.homeCta")}
</Button>
</Card>
);
return (
<>
{banner}
<ProcurementModal
open={open}
onClose={() => setOpen(false)}
title={t("portal.procurement.title")}
subtitle={t("portal.procurement.subtitle")}
>
{error && (
<Banner
tone="danger"
title={t("portal.procurement.error.title")}
onDismiss={() => setError(null)}
>
{error}
</Banner>
)}
{!isLinked && (
<EmptyState
eyebrow={t("portal.procurement.link.eyebrow")}
title={t("portal.procurement.link.title")}
description={t("portal.procurement.link.description")}
actions={
<Button
variant="gradient"
accent="purple"
onClick={() => openLinkModal()}
>
{t("portal.procurement.link.cta")}
</Button>
}
/>
)}
{isLinked && (state.loading || !started) && <Skeleton height="10rem" />}
{isLinked && started && (
<>
<div className="portal-proc__modal-stepper">
<StageStepper journey={JOURNEY} currentStage={stage!} />
</div>
{(editing ||
(isDraft && (stage === "trial" || stage === "quote"))) && (
<QuoteBuilder
deployment="cloud"
initial={latest?.config}
onGenerate={onGenerate}
/>
)}
{!editing && isIssued && stage === "quote" && latest && (
<QuoteMilestoneCard
quote={latest}
busy={busy}
downloading={downloading}
onAccept={onAcceptQuote}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "security" && latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
onAgree={onAgree}
/>
)}
{!editing && stage === "procurement" && latest && (
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={invoicePdf}
busy={busy}
onSimulate={() => run(goLive)}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
</button>
</div>
</>
)}
</ProcurementModal>
<KeyDocumentsModal
open={extra === "docs"}
onClose={() => setExtra(null)}
/>
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
/>
{data && (
<TrialManageModal
open={extra === "trial"}
onClose={() => setExtra(null)}
snapshot={data}
busy={busy}
onExtend={async () => {
await onExtendTrial();
setExtra(null);
}}
onCancel={async () => {
await onReset();
setExtra(null);
}}
/>
)}
</>
);
}
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button, Card } from "@app/ui";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
/**
* The full-screen takeover modal shell. Rendered always-open here to verify the panel has a solid
* (not see-through) surface over the dimmed, blurred backdrop.
*/
const meta: Meta<typeof ProcurementModal> = {
title: "Portal/Procurement/ProcurementModal",
component: ProcurementModal,
parameters: { layout: "fullscreen" },
args: {
open: true,
onClose: () => {},
title: "Enterprise procurement",
subtitle: "Get your team evaluated, contracted, and onboarded.",
},
};
export default meta;
type Story = StoryObj<typeof ProcurementModal>;
export const Open: Story = {
args: {
children: (
<Card padding="loose">
<h3 style={{ margin: 0 }}>Ready for payment</h3>
<p style={{ color: "var(--color-text-3)" }}>
Your quote is accepted. Continue to checkout to pay your committed
contract and go live.
</p>
<div style={{ display: "flex", gap: "0.6rem" }}>
<Button variant="gradient" accent="purple">
Continue to checkout
</Button>
<Button variant="outline">Edit quote</Button>
</div>
</Card>
),
},
};
@@ -0,0 +1,102 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import "@portal/views/Procurement.css";
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
export function useFocusTrap(open: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const panel = ref.current;
if (!panel) return;
const prev = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(
panel.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute("disabled"));
(focusables()[0] ?? panel).focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
panel.addEventListener("keydown", onKey);
return () => {
panel.removeEventListener("keydown", onKey);
prev?.focus?.();
};
}, [open]);
return ref;
}
/**
* Full-screen takeover modal for the procurement flow, copying the prototype's modal design
* (portaled to body, dimmed + blurred backdrop, rounded panel, close button). The Home deal-status
* hero expands into this.
*/
export function ProcurementModal({
open,
onClose,
title,
subtitle,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
const trapRef = useFocusTrap(open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="portal-procmodal"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
ref={trapRef}
className="portal-procmodal__panel"
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
>
<button
type="button"
className="portal-procmodal__close"
onClick={onClose}
aria-label="Close"
>
✕
</button>
<div className="portal-procmodal__header">
<h2 className="portal-procmodal__title">{title}</h2>
{subtitle && <p className="portal-procmodal__sub">{subtitle}</p>}
</div>
<div className="portal-procmodal__body">{children}</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,160 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import type { QuoteResult } from "@portal/api/procurement";
import { money } from "@portal/components/procurement/format";
import "@portal/views/Procurement.css";
/**
* The stage-specific cards shown inside the procurement takeover modal once a quote exists: the
* issued-quote milestone, the subscription-created payment step, and the live confirmation. Each is
* a pure presentational view driven by props; ProcurementHome owns the state and the actions.
*/
/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */
export function QuoteMilestoneCard({
quote,
busy,
downloading,
onAccept,
onDownload,
onEdit,
}: {
quote: QuoteResult;
busy: boolean;
downloading: boolean;
onAccept: () => void;
onDownload: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation();
return (
<Card padding="loose">
<span className="portal-proc__eyebrow">
{t("portal.procurement.milestone.eyebrow", {
number: quote.quoteNumber,
})}
</span>
<h3 className="portal-proc__builder-title">
{t("portal.procurement.milestone.title")}
</h3>
{quote.config.businessName && (
<p className="portal-proc__milestone-for">
{t("portal.procurement.milestone.preparedFor", {
company: quote.config.businessName,
})}
</p>
)}
<p className="portal-proc__subtitle">
{t("portal.procurement.milestone.description")}
</p>
<ul className="portal-qb__lines portal-proc__milestone-lines">
{quote.lineItems.map((li) => (
<li key={li.key} data-kind={li.kind}>
<span>{li.label}</span>
<span>
{li.kind === "INCLUDED"
? t("portal.procurement.builder.included")
: money(li.amountMinor, quote.currency)}
</span>
</li>
))}
</ul>
<div className="portal-proc__milestone-totals">
<span className="portal-proc__milestone-annual">
{money(quote.annualNetMinor, quote.currency)}
<small>{t("portal.procurement.milestone.perYear")}</small>
</span>
<span className="portal-proc__milestone-tcv">
{t("portal.procurement.milestone.tcv", {
value: money(quote.tcvMinor, quote.currency),
})}
</span>
</div>
<div className="portal-proc__payment-actions">
<Button
variant="gradient"
accent="purple"
loading={busy}
onClick={onAccept}
>
{t("portal.procurement.milestone.accept")}
</Button>
<Button variant="outline" loading={downloading} onClick={onDownload}>
{t("portal.procurement.milestone.download")}
</Button>
<Button variant="ghost" onClick={onEdit}>
{t("portal.procurement.milestone.edit")}
</Button>
</div>
</Card>
);
}
/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */
export function PaymentStageCard({
invoiceUrl,
invoicePdf,
busy,
onSimulate,
}: {
invoiceUrl?: string | null;
invoicePdf?: string | null;
busy: boolean;
onSimulate: () => void;
}) {
const { t } = useTranslation();
return (
<Card padding="loose">
<h3 className="portal-proc__builder-title">
{t("portal.procurement.payment.title")}
</h3>
<p className="portal-proc__subtitle">
{t("portal.procurement.payment.description")}
</p>
{(invoiceUrl || invoicePdf) && (
<div className="portal-proc__payment-actions">
{invoiceUrl && (
<Button
variant="gradient"
accent="purple"
onClick={() => window.open(invoiceUrl, "_blank", "noopener")}
>
{t("portal.procurement.payment.viewInvoice")}
</Button>
)}
{invoicePdf && (
<Button
variant="outline"
onClick={() => window.open(invoicePdf, "_blank", "noopener")}
>
{t("portal.procurement.payment.downloadInvoice")}
</Button>
)}
</div>
)}
<div className="portal-proc__reset">
<button type="button" onClick={onSimulate} disabled={busy}>
{t("portal.procurement.payment.simulate")}
</button>
</div>
</Card>
);
}
/** The live confirmation once the deal is active. */
export function LiveStageCard() {
const { t } = useTranslation();
return (
<Card padding="loose">
<span className="portal-proc__eyebrow">
{t("portal.procurement.live.eyebrow")}
</span>
<h3 className="portal-proc__builder-title">
{t("portal.procurement.live.title")}
</h3>
<p className="portal-proc__subtitle">
{t("portal.procurement.live.description")}
</p>
</Card>
);
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import "@portal/views/Procurement.css";
/**
* The enterprise quote builder. The "Review quote" step calls the SaaS backend, answered here by
* the `procurementSaas` MSW handler, so all four steps (incl. the itemised quote paper) work in
* Storybook. Click through Volume → Commitment & service → Details → Quote.
*/
const meta: Meta<typeof QuoteBuilder> = {
title: "Portal/Procurement/QuoteBuilder",
component: QuoteBuilder,
parameters: { layout: "padded" },
args: { deployment: "cloud", onGenerate: () => {} },
};
export default meta;
type Story = StoryObj<typeof QuoteBuilder>;
export const Default: Story = {};
@@ -0,0 +1,436 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import {
DocumentsIcon,
PoliciesIcon,
UsersIcon,
} from "@portal/components/icons";
import { money } from "@portal/components/procurement/format";
import {
buildQuote,
type QuoteConfigInput,
type QuoteResult,
} from "@portal/api/procurement";
import "@portal/views/Procurement.css";
const STEPS = ["volume", "plan", "details"] as const;
const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years
const SLA_UPLIFT: Record<string, number> = {
standard: 0,
priority: 0.15,
dedicated: 0.3,
};
/**
* The enterprise quote builder — volume → commitment &amp; service → details. A client-side preview
* drives the live footer total; the backend is authoritative. Completing the form generates the
* quote directly (build + issue in one step) — the issued quote is then shown as the milestone, so
* there's no redundant in-builder preview.
*/
export function QuoteBuilder({
deployment,
initial,
onGenerate,
}: {
deployment: string;
/** Seed the builder from an existing quote's config (re-editing a quote). */
initial?: QuoteConfigInput;
/** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */
onGenerate: (quote: QuoteResult) => void;
}) {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [cfg, setCfg] = useState<QuoteConfigInput>(
initial ?? {
volume: 1_000_000,
users: 0,
deployment,
termYears: 3,
serviceLevel: "priority",
indemnification: false,
training: false,
qbr: false,
currency: "USD",
businessName: "",
},
);
// A seeded quote carries a volume but no user count, so treat it as manually set.
const [manualVolume, setManualVolume] = useState(initial != null);
const [eula, setEula] = useState(initial != null);
const [busy, setBusy] = useState(false);
function set<K extends keyof QuoteConfigInput>(k: K, v: QuoteConfigInput[K]) {
setCfg((c) => ({ ...c, [k]: v }));
}
// Re-editing an existing quote: everything is seeded, so jump to the last step (details) with the
// agreement pre-accepted — one click re-generates, or Back to change a field. No walking from step 1.
// Mount-only: seed the step from `initial` once (deliberately no deps).
useEffect(() => {
if (initial) setStep(STEPS.length - 1);
}, []);
const preview = previewAnnualMinor(cfg);
const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0);
// Fully filled → price + hand the draft to the parent to issue as a Stripe Quote (which then shows
// as the milestone). No separate in-builder preview step.
async function generate() {
setBusy(true);
try {
onGenerate(await buildQuote(cfg));
} finally {
setBusy(false);
}
}
return (
<div className="portal-qb">
<div className="portal-qb__head">
<h3 className="portal-qb__title">
{t("portal.procurement.builder.title")}
</h3>
<span className="portal-qb__stepchip">
{t("portal.procurement.builder.stepOf", {
n: step + 1,
total: STEPS.length,
})}
</span>
</div>
<div className="portal-qb__progress">
{STEPS.map((s, i) => (
<span key={s} data-on={i <= step || undefined} />
))}
</div>
<div className="portal-qb__body">
{step === 0 && (
<Step
icon={<DocumentsIcon size={22} />}
title={t("portal.procurement.builder.s1Title")}
sub={t("portal.procurement.builder.s1Sub")}
>
<div className="portal-qb__row">
<Field label={t("portal.procurement.builder.users")}>
<input
type="number"
min={0}
placeholder={t("portal.procurement.builder.usersPlaceholder")}
value={cfg.users || ""}
onChange={(e) => {
const users = Number(e.target.value);
set("users", users);
if (!manualVolume) set("volume", estimateVolume(users));
}}
/>
</Field>
<Field label={t("portal.procurement.builder.volume")}>
<input
type="number"
min={0}
placeholder={t(
"portal.procurement.builder.volumePlaceholder",
)}
value={cfg.volume || ""}
onChange={(e) => {
setManualVolume(true);
set("volume", Number(e.target.value));
}}
/>
</Field>
</div>
<p className="portal-qb__hint">
{cfg.users > 0 && !manualVolume
? t("portal.procurement.builder.volEstimated", {
count: cfg.users,
})
: cfg.users > 0
? t("portal.procurement.builder.volManual")
: t("portal.procurement.builder.volNoUsers")}
</p>
</Step>
)}
{step === 1 && (
<Step
icon={<PoliciesIcon size={22} />}
title={t("portal.procurement.builder.s2Title")}
sub={t("portal.procurement.builder.s2Sub")}
>
<Field label={t("portal.procurement.builder.term")}>
<div className="portal-qb__pills">
{[1, 2, 3, 4, 5].map((y) => (
<button
key={y}
type="button"
data-on={cfg.termYears === y || undefined}
onClick={() => set("termYears", y)}
>
{t("portal.procurement.builder.years", { count: y })}
</button>
))}
</div>
{TERM_DISCOUNT[cfg.termYears - 1] > 0 && (
<p className="portal-qb__discount">
{t("portal.procurement.builder.termDiscount", {
pct: Math.round(TERM_DISCOUNT[cfg.termYears - 1] * 100),
})}
</p>
)}
</Field>
<Field label={t("portal.procurement.builder.serviceLevel")}>
<div className="portal-qb__opts">
<OptCard
on={cfg.serviceLevel === "standard"}
title={t("portal.procurement.builder.slStandard")}
sub={t("portal.procurement.builder.slStandardSub")}
onClick={() => set("serviceLevel", "standard")}
/>
<OptCard
on={cfg.serviceLevel === "priority"}
title={t("portal.procurement.builder.slPriority")}
sub={t("portal.procurement.builder.slPrioritySub")}
onClick={() => set("serviceLevel", "priority")}
/>
<OptCard
on={cfg.serviceLevel === "dedicated"}
title={t("portal.procurement.builder.slDedicated")}
sub={t("portal.procurement.builder.slDedicatedSub")}
onClick={() => set("serviceLevel", "dedicated")}
/>
</div>
</Field>
<Field label={t("portal.procurement.builder.addons")}>
<div className="portal-qb__addons">
<AddOn
on={cfg.indemnification}
title={t("portal.procurement.builder.indemnification")}
sub={t("portal.procurement.builder.indemnificationSub")}
onClick={() => set("indemnification", !cfg.indemnification)}
/>
<AddOn
on={cfg.training}
title={t("portal.procurement.builder.training")}
sub={t("portal.procurement.builder.trainingSub")}
onClick={() => set("training", !cfg.training)}
/>
<AddOn
on={cfg.qbr}
title={t("portal.procurement.builder.qbr")}
sub={t("portal.procurement.builder.qbrSub")}
onClick={() => set("qbr", !cfg.qbr)}
/>
</div>
</Field>
</Step>
)}
{step === 2 && (
<Step
icon={<UsersIcon size={22} />}
title={t("portal.procurement.builder.s3Title")}
sub={t("portal.procurement.builder.s3Sub")}
>
<Field label={t("portal.procurement.builder.businessName")}>
<input
placeholder={t(
"portal.procurement.builder.businessNamePlaceholder",
)}
value={cfg.businessName}
onChange={(e) => set("businessName", e.target.value)}
/>
</Field>
<div className="portal-qb__row">
<Field label={t("portal.procurement.builder.country")}>
<select
value={cfg.currency}
onChange={(e) => set("currency", e.target.value)}
>
<option value="USD">
{t("portal.procurement.builder.countryUS")}
</option>
<option value="GBP">
{t("portal.procurement.builder.countryUK")}
</option>
<option value="EUR">
{t("portal.procurement.builder.countryEuro")}
</option>
</select>
</Field>
</div>
<label className="portal-qb__eula">
<input
type="checkbox"
checked={eula}
onChange={(e) => setEula(e.target.checked)}
/>
<span>{t("portal.procurement.builder.eula")}</span>
</label>
</Step>
)}
</div>
<div className="portal-qb__foot">
<span className="portal-qb__running">
{t("portal.procurement.builder.running", {
annual: money(preview, cfg.currency),
years: cfg.termYears,
tcv: money(tcvPreview, cfg.currency),
})}
</span>
<div className="portal-qb__foot-btns">
{step > 0 && (
<Button variant="outline" onClick={() => setStep(step - 1)}>
{t("portal.procurement.builder.back")}
</Button>
)}
{step === 0 && (
<Button
variant="gradient"
accent="purple"
disabled={cfg.volume <= 0}
onClick={() => setStep(1)}
>
{t("portal.procurement.builder.continue")}
</Button>
)}
{step === 1 && (
<Button
variant="gradient"
accent="purple"
onClick={() => setStep(2)}
>
{t("portal.procurement.builder.continue")}
</Button>
)}
{step === 2 && (
<Button
variant="gradient"
accent="purple"
loading={busy}
disabled={!eula}
onClick={generate}
>
{t("portal.procurement.builder.generate")}
</Button>
)}
</div>
</div>
</div>
);
}
function Step({
icon,
title,
sub,
children,
}: {
icon: ReactNode;
title: string;
sub: string;
children: React.ReactNode;
}) {
return (
<>
<div className="portal-qb__intro">
<span className="portal-qb__intro-icon" aria-hidden>
{icon}
</span>
<div>
<div className="portal-qb__intro-title">{title}</div>
<div className="portal-qb__intro-sub">{sub}</div>
</div>
</div>
{children}
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="portal-qb__field">
<span className="portal-qb__field-label">{label}</span>
{children}
</label>
);
}
function OptCard({
on,
title,
sub,
onClick,
}: {
on: boolean;
title: string;
sub: string;
onClick: () => void;
}) {
return (
<button
type="button"
className="portal-qb__opt"
data-on={on || undefined}
onClick={onClick}
>
<span className="portal-qb__opt-title">{title}</span>
<span className="portal-qb__opt-sub">{sub}</span>
</button>
);
}
function AddOn({
on,
title,
sub,
onClick,
}: {
on: boolean;
title: string;
sub: string;
onClick: () => void;
}) {
return (
<button
type="button"
className="portal-qb__addon"
data-on={on || undefined}
onClick={onClick}
>
<span className="portal-qb__addon-box" aria-hidden>
{on ? "✓" : ""}
</span>
<span>
<span className="portal-qb__addon-title">{title}</span>
<span className="portal-qb__addon-sub">{sub}</span>
</span>
</button>
);
}
function estimateVolume(users: number): number {
const raw = Math.max(0, users) * 5 * 230 * 1.75;
const stepSize = raw >= 1_000_000 ? 50_000 : raw >= 100_000 ? 25_000 : 5_000;
return Math.round(raw / stepSize) * stepSize;
}
function previewAnnualMinor(cfg: QuoteConfigInput): number {
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
const usage = Math.round(cfg.volume * perPdf);
const withSla = Math.round(usage * (1 + (SLA_UPLIFT[cfg.serviceLevel] ?? 0)));
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
const disc = Math.round(
withInd * TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
);
return withInd - disc + (cfg.qbr ? 800_000 : 0);
}
@@ -9,6 +9,15 @@ export const USD = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0,
});
/** Format a minor-unit (cents) amount in the given currency, whole units (no decimals). */
export function money(minor: number, currency: string): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency || "USD",
maximumFractionDigits: 0,
}).format(minor / 100);
}
/** Document status → badge tone. Action items lean amber to pull the eye. */
export const STATUS_TONE: Record<DocStatus, StatusTone> = {
available: "success",
@@ -8,6 +8,7 @@ import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
import { procurementHandlers } from "@portal/mocks/handlers/procurement";
import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas";
import { docsHandlers } from "@portal/mocks/handlers/docs";
import { settingsHandlers } from "@portal/mocks/handlers/settings";
import { usersHandlers } from "@portal/mocks/handlers/users";
@@ -30,6 +31,7 @@ export const handlers = [
...infrastructureHandlers,
...docsHandlers,
...procurementHandlers,
...procurementSaasHandlers,
...settingsHandlers,
...usersHandlers,
...agentsHandlers,
@@ -0,0 +1,241 @@
import { http, HttpResponse } from "msw";
/**
* MSW mock for the real SaaS procurement endpoints (`apiClient.saas` → VITE_SAAS_API_URL, which is
* `http://saas.mock` in dev/Storybook). Lets the trial → quote → accept flow run without the Java
* backend (Storybook + mocks-on dev). Pricing mirrors ProcurementPricingService.
*/
const SAAS = "http://saas.mock";
const EMPTY = {
dealId: null,
stage: null,
trialStartedAt: null,
trialEndsAt: null,
trialExtensionsUsed: 0,
licensed: false,
latestQuote: null,
};
interface Cfg {
volume: number;
serviceLevel: string;
termYears: number;
indemnification: boolean;
training: boolean;
qbr: boolean;
currency: string;
businessName?: string;
}
let deal: typeof EMPTY | (Record<string, unknown> & { latestQuote: unknown }) =
EMPTY;
let seq = 0;
const SLA: Record<string, number> = {
standard: 0,
priority: 0.15,
dedicated: 0.3,
};
const TERM = [0, 0.05, 0.1, 0.12, 0.15];
function priceQuote(cfg: Cfg) {
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
const usage = Math.round(cfg.volume * perPdf);
const withSla = Math.round(usage * (1 + (SLA[cfg.serviceLevel] ?? 0)));
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
const disc = Math.round(
withInd * TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
);
const qbr = cfg.qbr ? 800_000 : 0;
const training = cfg.training ? 750_000 : 0;
const annualNetMinor = withInd - disc + qbr;
const tcvMinor = annualNetMinor * cfg.termYears + training;
type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED";
const lines: {
key: string;
label: string;
kind: Kind;
amountMinor: number;
}[] = [
{
key: "usage",
label: "PDF processing",
kind: "RECURRING",
amountMinor: usage,
},
{
key: "seats",
label: "Unlimited users + SSO / SCIM / RBAC",
kind: "INCLUDED",
amountMinor: 0,
},
];
if (withSla !== usage)
lines.push({
key: "service-level",
label:
cfg.serviceLevel === "dedicated"
? "Dedicated service level"
: "Priority service level",
kind: "RECURRING",
amountMinor: withSla - usage,
});
if (withInd !== withSla)
lines.push({
key: "indemnification",
label: "IP indemnification",
kind: "RECURRING",
amountMinor: withInd - withSla,
});
if (qbr > 0)
lines.push({
key: "qbr",
label: "Quarterly business reviews",
kind: "RECURRING",
amountMinor: qbr,
});
if (disc > 0)
lines.push({
key: "multi-year",
label: `${cfg.termYears}-year commitment`,
kind: "DISCOUNT",
amountMinor: -disc,
});
if (training > 0)
lines.push({
key: "training",
label: "Onboarding & training",
kind: "ONE_TIME",
amountMinor: training,
});
seq += 1;
return {
quoteId: seq,
quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`,
status: "draft",
currency: cfg.currency || "USD",
annualNetMinor,
tcvMinor,
lineItems: lines,
validUntil: "2026-07-31",
stripeQuoteId: null,
invoiceUrl: null,
config: {
volume: cfg.volume,
users: 0,
deployment: "cloud",
termYears: cfg.termYears,
serviceLevel: cfg.serviceLevel,
indemnification: cfg.indemnification,
training: cfg.training,
qbr: cfg.qbr,
currency: cfg.currency || "USD",
businessName: cfg.businessName ?? "",
},
};
}
export function resetProcurementSaasStore() {
deal = EMPTY;
seq = 0;
}
export const procurementSaasHandlers = [
http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)),
http.post(`${SAAS}/api/v1/procurement/trial/start`, () => {
const now = Date.now();
deal = {
dealId: 1,
stage: "trial",
trialStartedAt: new Date(now).toISOString(),
trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(),
trialExtensionsUsed: 0,
licensed: true,
latestQuote: null,
};
return HttpResponse.json(deal);
}),
http.post(`${SAAS}/api/v1/procurement/quote`, async ({ request }) => {
const cfg = (await request.json()) as Cfg;
const quote = priceQuote(cfg);
const d = (
deal.dealId ? deal : { dealId: 1, trialExtensionsUsed: 0 }
) as Record<string, unknown>;
deal = {
...d,
stage: "quote",
licensed: true,
latestQuote: quote,
} as never;
return HttpResponse.json(quote);
}),
http.post(`${SAAS}/api/v1/procurement/trial/extend`, () => {
const d = deal as Record<string, unknown>;
if (d.dealId) {
const base = d.trialEndsAt
? Date.parse(d.trialEndsAt as string)
: Date.now();
d.trialEndsAt = new Date(base + 7 * 86_400_000).toISOString();
d.trialExtensionsUsed = ((d.trialExtensionsUsed as number) ?? 0) + 1;
}
return HttpResponse.json(deal);
}),
http.post(`${SAAS}/api/v1/procurement/agreement`, () => {
(deal as Record<string, unknown>).stage = "security";
return HttpResponse.json(deal);
}),
http.post(`${SAAS}/api/v1/procurement/go-live`, () => {
const d = deal as Record<string, unknown>;
if (d.dealId) {
d.stage = "active";
d.licensed = true;
}
return HttpResponse.json(deal);
}),
http.post(`${SAAS}/api/v1/procurement/reset`, () => {
resetProcurementSaasStore();
return HttpResponse.json(EMPTY);
}),
// Stripe Quote edge functions (supabase.functions.invoke → ${url}/functions/v1/{name}).
http.post(`${SAAS}/functions/v1/issue-procurement-quote`, () => {
const q = (deal as { latestQuote: Record<string, unknown> | null })
.latestQuote;
if (q) {
q.status = "sent";
q.stripeQuoteId = `qt_mock_${q.quoteId}`;
}
return HttpResponse.json(q);
}),
http.post(`${SAAS}/functions/v1/accept-procurement-quote`, () => {
const q = (deal as { latestQuote: Record<string, unknown> | null })
.latestQuote;
const invoiceUrl = "https://invoice.stripe.com/i/mock_procurement";
if (q) {
q.status = "accepted";
q.invoiceUrl = invoiceUrl;
(deal as Record<string, unknown>).stage = "procurement";
}
return HttpResponse.json({
status: "accepted",
subscriptionId: "sub_mock_procurement",
invoiceUrl,
invoicePdf: "https://invoice.stripe.com/i/mock_procurement/pdf",
});
}),
http.post(`${SAAS}/functions/v1/get-procurement-quote-pdf`, () => {
// A minimal valid PDF so the download opens something in Storybook / mock dev.
const pdf = `%PDF-1.1
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 200]>>endobj
trailer<</Root 1 0 R>>
%%EOF`;
return new HttpResponse(pdf, {
headers: { "Content-Type": "application/pdf" },
});
}),
];
@@ -28,6 +28,7 @@ import { UsageAreaChart } from "@portal/components/UsageAreaChart";
import { RecentActivity } from "@portal/components/RecentActivity";
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
import { PolicySummary } from "@portal/components/PolicySummary";
import { PipelineForkWizard } from "@portal/components/PipelineForkWizard";
import "@portal/views/Home.css";
@@ -538,6 +539,8 @@ export function Home() {
<WelcomeCarousel onTryOp={() => setRunnerOpen(true)} />
<ProcessingStatusStrip />
<ProcurementHome />
{tier === "free" && (
<>
<TierKpiStrip />
File diff suppressed because it is too large Load Diff
@@ -1,111 +1,15 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, Skeleton, StatusBadge } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
advanceStage,
fetchProcurement,
type DealStage,
type LedgerDoc,
type ProcurementResponse,
} from "@portal/api/procurement";
import { DealJourney } from "@portal/components/procurement/DealJourney";
import { DocumentLedger } from "@portal/components/procurement/DocumentLedger";
import { ActionModal } from "@portal/components/procurement/ActionModal";
import { LockedState } from "@portal/components/procurement/LockedState";
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
import "@portal/views/Procurement.css";
/**
* Procurement: the enterprise commercial journey (trial to live) plus the
* document ledger. Enterprise-only: free/pro buyers see a locked upgrade state.
* /procurement — procurement is no longer a nav tab; it lives on Home as the deal-status hero.
* This route is kept for deep links (and the Usage "Build your quote" CTA): it renders the same
* surface, opening the takeover modal once a deal is underway.
*/
export function Procurement() {
const { t } = useTranslation();
const { tier } = useTier();
const [activeDoc, setActiveDoc] = useState<LedgerDoc | null>(null);
const [advancing, setAdvancing] = useState(false);
const state = useAsync<ProcurementResponse>(
() => fetchProcurement(tier),
[tier],
);
// Write actions return the new canonical state; we hold it here so the
// journey reflects every action immediately. Cleared when the tier (and so
// the deal) changes, falling back to whatever the GET loaded.
const [applied, setApplied] = useState<ProcurementResponse | null>(null);
useEffect(() => setApplied(null), [tier]);
const data = applied ?? (state.loading ? null : state.data);
async function onAdvance(stage: DealStage) {
setAdvancing(true);
try {
setApplied(await advanceStage(stage));
} finally {
setAdvancing(false);
}
}
return (
<div className="portal-proc">
<header className="portal-proc__header">
<div>
<h1 className="portal-proc__title">
{t("portal.procurement.title")}
</h1>
<p className="portal-proc__subtitle">
{t("portal.procurement.subtitle")}
</p>
</div>
<StatusBadge tone="purple" size="md">
{t("portal.procurement.enterpriseBadge")}
</StatusBadge>
</header>
{state.loading && (
<Card padding="loose">
<Skeleton width="12rem" height="1.25rem" />
<Skeleton height="8rem" />
</Card>
)}
{data && !data.unlocked && (
<LockedState
journey={data.journey}
onTalkToSales={() => {
// TODO(backend): POST /v1/procurement/sales-contact, for now this
// is the sidebar's upgrade path; hand off to the account team.
}}
/>
)}
{data && data.unlocked && data.deal && (
<>
<DealJourney
deal={data.deal}
journey={data.journey}
onAdvance={onAdvance}
advancing={advancing}
/>
<DocumentLedger
groups={data.ledger}
supporting={data.supporting}
journey={data.journey}
currentStage={data.deal.currentStage}
onAction={setActiveDoc}
/>
</>
)}
<ActionModal
doc={activeDoc}
onClose={() => setActiveDoc(null)}
onDone={(next) => {
setApplied(next);
setActiveDoc(null);
}}
/>
<ProcurementHome autoOpen />
</div>
);
}