Portal procurement: pricing realignment, combined accept flow, licence & invoice fixes (#6946)

## What this PR does

Brings the enterprise procurement flow in line with the new D71 pricing,
tidies up the buyer journey, and fixes a handful of things we found
testing it end to end.

### Pricing
- Priced on the new run-based model (per PDF, per policy), USD only.
Dropped the old currency picker.
- Added the policy posture choice (Essentials / Governed / Regulated)
and show roughly how many policies each covers (~2 / ~4 / ~7).
- The live estimate in the quote builder now matches the real quote the
backend produces.
- Contracts renew each year with a fixed 3% increase. The agreement
shows this plus the first renewal figure, and we save that figure on the
quote so it can't drift later.

### Trial and journey
- Starting a trial now asks for your deployment (Cloud / Self-hosted /
Air-gapped) and team size up front, and that seeds the quote.
- Quote and agreement are now one step: you review the quote and the
agreement together and click "Accept & subscribe" once. No more
accepting a quote and then separately signing.
- "Start a trial" on the home page opens the setup popup right there
instead of sending you off to another page.
- The calculator asks for number of users again and works the volume out
from that.
- Removed the demo-only buttons (reset, simulate payment) and the "Key
documents" button (it wasn't real).
- The licence key now lives behind its own "Licence key" button instead
of being shown inside every popup.

### Air-gapped licence file
- Air-gapped teams can download their licence file (.lic) during the
trial, not only after they pay.
- The popup warns that a trial file needs re-downloading once the
agreement is done, because the file is a snapshot and doesn't refresh
itself the way the online key does.

### Fixes found while testing
- Accepting a quote now upgrades the licence from trial to full straight
away (it wasn't before).
- The "Download invoice" button keeps working after a page refresh (we
now save the invoice PDF link).
- Invoice line items read differently from each other instead of all
showing the same name.

### Notes for reviewers
- The matching backend changes (Stripe quote/accept functions, database
migrations) live in the Stirling-PDF-SaaS repo on `v3`. They ship when
we do the full v3 release.
- All checks are green.
This commit is contained in:
ConnorYoh
2026-07-10 11:51:00 +00:00
committed by GitHub
parent 7529190587
commit 75ea3c9a1f
32 changed files with 1100 additions and 520 deletions
@@ -32,6 +32,8 @@ import stirling.software.proprietary.security.repository.TeamMembershipRepositor
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.model.QuoteDetails;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.pricing.QuoteLineItem;
import stirling.software.saas.procurement.service.ProcurementService;
@@ -56,16 +58,19 @@ public class ProcurementController {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ProcurementService procurement;
private final ProcurementPricingService pricing;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
private final ProcurementConfigurationProperties config;
public ProcurementController(
ProcurementService procurement,
ProcurementPricingService pricing,
TeamMembershipRepository memberRepo,
UserRepository userRepository,
ProcurementConfigurationProperties config) {
this.procurement = Objects.requireNonNull(procurement);
this.pricing = Objects.requireNonNull(pricing);
this.memberRepo = Objects.requireNonNull(memberRepo);
this.userRepository = Objects.requireNonNull(userRepository);
this.config = Objects.requireNonNull(config);
@@ -77,29 +82,53 @@ public class ProcurementController {
long volume,
int users,
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
double sizeMult, // PDF-size tier multiplier: 1.0 / 1.4 / 2.4; 0 → no uplift
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {
String businessName,
// Buyer / AP details (all optional). Country + currency intentionally out of scope.
String contactName,
String contactEmail,
String addressLine1,
String addressLine2,
String city,
String region,
String postalCode,
String poNumber,
String taxId) {
QuoteConfig toConfig() {
return new QuoteConfig(
volume,
users,
intensity,
sizeMult,
deployment,
termYears,
serviceLevel,
indemnification,
training,
qbr,
offlineLicense,
currency);
}
QuoteDetails toDetails() {
return new QuoteDetails(
businessName,
contactName,
contactEmail,
addressLine1,
addressLine2,
city,
region,
postalCode,
poNumber,
taxId);
}
}
public record QuoteResponse(
@@ -109,10 +138,15 @@ public class ProcurementController {
String currency,
long annualNetMinor,
long tcvMinor,
// First post-term renewal fee after the CPI escalator, and that escalator as a whole
// percent — the committed term is flat, so these describe only the auto-renewal.
long renewalAnnualNetMinor,
int cpiRatePct,
List<QuoteLineItem> lineItems,
String validUntil,
String stripeQuoteId,
String invoiceUrl,
String invoicePdf,
QuoteConfigEcho config) {}
/**
@@ -124,19 +158,33 @@ public class ProcurementController {
long volume,
int users,
int intensity,
double sizeMult,
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {}
String businessName,
String contactName,
String contactEmail,
String addressLine1,
String addressLine2,
String city,
String region,
String postalCode,
String poNumber,
String taxId) {}
/** Trial setup captured before the trial starts: deployment target + seat count. */
public record StartTrialRequest(String deployment, int users) {}
public record SnapshotResponse(
Long dealId,
String stage,
String deployment,
int seats,
String trialStartedAt,
String trialEndsAt,
int trialExtensionsUsed,
@@ -165,12 +213,12 @@ public class ProcurementController {
}
private static final SnapshotResponse EMPTY_SNAPSHOT =
new SnapshotResponse(null, null, null, null, 0, false, null, null);
new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null);
/**
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
* that a licence exists to a team without the add-on.
* Download the offline / air-gapped licence file (.lic) for the team — available for an
* air-gapped deployment from the trial licence onward. 404 when there's no licence yet or the
* deployment isn't air-gapped, so we don't leak that a licence exists.
*/
@GetMapping("/license/file")
@PreAuthorize("isAuthenticated()")
@@ -193,10 +241,15 @@ public class ProcurementController {
@PostMapping("/trial/start")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
public ResponseEntity<SnapshotResponse> startTrial(
@RequestBody(required = false) StartTrialRequest request, Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
// Body is optional so an older client (no setup step) still starts a cloud trial.
String deployment = request != null ? request.deployment() : null;
int seats = request != null ? request.users() : 0;
return ResponseEntity.ok(
toSnapshot(procurement.startTrial(teamId, deployment, seats), true));
}
@PostMapping("/trial/extend")
@@ -218,9 +271,7 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(
toQuote(
procurement.buildQuote(
teamId, request.toConfig(), request.businessName())));
toQuote(procurement.buildQuote(teamId, request.toConfig(), request.toDetails())));
}
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
@@ -323,6 +374,8 @@ public class ProcurementController {
return new SnapshotResponse(
deal.getDealId(),
deal.getStage(),
deal.getDeployment(),
deal.getSeats(),
str(deal.getTrialStartedAt()),
str(deal.getTrialEndsAt()),
deal.getTrialExtensionsUsed(),
@@ -339,23 +392,40 @@ public class ProcurementController {
q.getCurrency(),
q.getAnnualNetMinor(),
q.getTcvMinor(),
// Prefer the renewal locked at quote time; fall back to a live projection for
// quotes
// priced before the column existed.
q.getRenewalAnnualMinor() > 0
? q.getRenewalAnnualMinor()
: pricing.renewalAnnualMinor(q.getAnnualNetMinor()),
pricing.cpiRatePct(),
parseLineItems(q.getLineItemsJson()),
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
q.getStripeQuoteId(),
q.getStripeInvoiceUrl(),
q.getStripeInvoicePdf(),
new QuoteConfigEcho(
q.getVolume(),
0,
q.getIntensity(),
q.getSizeMult(),
q.getDeployment(),
q.getTermYears(),
q.getServiceLevel(),
q.isIndemnification(),
q.isTraining(),
q.isQbr(),
q.isOfflineLicense(),
q.getCurrency(),
q.getBusinessName()));
q.getBusinessName(),
q.getContactName(),
q.getContactEmail(),
q.getAddressLine1(),
q.getAddressLine2(),
q.getCity(),
q.getRegion(),
q.getPostalCode(),
q.getPoNumber(),
q.getTaxId()));
}
private List<QuoteLineItem> parseLineItems(String json) {
@@ -51,6 +51,15 @@ public class ProcurementDeal implements Serializable {
@Column(name = "stage", nullable = false, length = 32)
private String stage = STAGE_TRIAL;
// Deployment target + seat count captured at trial start (the setup step); they seed the quote
// builder so it opens on the buyer's real environment. The quote remains the commercial source
// of truth — these are just the starting point, editable when the quote is built.
@Column(name = "deployment", nullable = false, length = 16)
private String deployment = "cloud";
@Column(name = "seats", nullable = false)
private int seats;
@Column(name = "trial_started_at")
private LocalDateTime trialStartedAt;
@@ -66,6 +66,10 @@ public class ProcurementQuote implements Serializable {
@Column(name = "intensity", nullable = false)
private int intensity = 4;
/** File-size tier multiplier on the rate (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */
@Column(name = "size_mult", nullable = false)
private double sizeMult = 1.0;
@Column(name = "deployment", length = 24)
private String deployment;
@@ -84,15 +88,17 @@ public class ProcurementQuote implements Serializable {
@Column(name = "qbr", nullable = false)
private boolean qbr;
@Column(name = "offline_license", nullable = false)
private boolean offlineLicense;
@Column(name = "annual_net_minor", nullable = false)
private long annualNetMinor;
@Column(name = "tcv_minor", nullable = false)
private long tcvMinor;
// First post-term renewal fee (annual net + one CPI step), locked at quote time so the buyer's
// quoted renewal doesn't drift if the rate card changes later.
@Column(name = "renewal_annual_minor", nullable = false)
private long renewalAnnualMinor;
@Column(name = "line_items", columnDefinition = "text")
private String lineItemsJson;
@@ -105,10 +111,47 @@ public class ProcurementQuote implements Serializable {
@Column(name = "stripe_invoice_url", columnDefinition = "text")
private String stripeInvoiceUrl;
// Direct PDF link for that first invoice (Stripe invoice_pdf), set at accept alongside the URL;
// persisted so the portal's download button works after a reload, not just in the accept
// response.
@Column(name = "stripe_invoice_pdf", columnDefinition = "text")
private String stripeInvoicePdf;
// 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;
// Buyer/AP details captured on the quote's "Your details" step. All optional — they never gate
// quote generation; they flow onto the Stripe customer (name + bill-to address) and the invoice
// (PO number + tax id as invoice custom fields), and seed the builder on a re-edit. Country and
// currency are intentionally out of scope for now.
@Column(name = "contact_name", length = 255)
private String contactName;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "address_line1", length = 255)
private String addressLine1;
@Column(name = "address_line2", length = 255)
private String addressLine2;
@Column(name = "city", length = 128)
private String city;
@Column(name = "region", length = 128)
private String region;
@Column(name = "postal_code", length = 32)
private String postalCode;
@Column(name = "po_number", length = 128)
private String poNumber;
@Column(name = "tax_id", length = 64)
private String taxId;
@Column(name = "valid_until")
private LocalDate validUntil;
@@ -0,0 +1,21 @@
package stirling.software.saas.procurement.model;
/**
* Buyer / AP details captured on the quote's "Your details" step: the company and signatory
* contact, a billing address, and a PO number / tax id for the invoice. These are not pricing
* inputs (they never touch {@link stirling.software.saas.procurement.pricing.QuoteConfig}); they
* ride alongside the priced config so the quote can be re-seeded on an edit and the fields can flow
* onto the Stripe customer and invoice. All fields are optional. Country and currency are out of
* scope for now.
*/
public record QuoteDetails(
String businessName,
String contactName,
String contactEmail,
String addressLine1,
String addressLine2,
String city,
String region,
String postalCode,
String poNumber,
String taxId) {}
@@ -21,7 +21,8 @@ public record PricingRates(
long selfHostDeployMinor, // flat: self-hosted deployment
long airgapDeployMinor, // flat: air-gapped deployment
long qbrAnnualMinor, // flat: quarterly business reviews
long trainingOneTimeMinor) { // one-time: onboarding & training
long trainingOneTimeMinor, // one-time: onboarding & training
double cpiEscalator) { // fixed CPI uplift on the annual fee at each post-term renewal
public static PricingRates defaults() {
return new PricingRates(
@@ -34,7 +35,8 @@ public record PricingRates(
1_200_000, // self-hosted $12,000 / yr
3_600_000, // air-gapped $36,000 / yr
800_000, // QBRs $8,000 / yr
750_000); // onboarding & training $7,500 one-time
750_000, // onboarding & training $7,500 one-time
0.03); // 3% CPI escalator per renewal (committed term stays flat)
}
public double termDiscount(int termYears) {
@@ -61,6 +61,11 @@ public class ProcurementPricingService {
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
// File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds
// into the per-run rate after the floor, so it flows through the meter, TCV and renewal.
// QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a
// cheaper factor in.
rate *= cfg.sizeMult();
double termDisc = rates.termDiscount(cfg.termYears());
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
@@ -82,6 +87,7 @@ public class ProcurementPricingService {
long annualNet = meterNetMinor + support + deploy + indemnity + qbr;
long tcv = annualNet * cfg.termYears() + training;
long renewalAnnual = renewalAnnualMinor(annualNet, rates);
double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run
@@ -151,7 +157,29 @@ public class ProcurementPricingService {
QuoteLineItem.Kind.ONE_TIME,
training));
}
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency());
}
/** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */
public double cpiEscalator() {
return PricingRates.defaults().cpiEscalator();
}
/** The CPI escalator as a whole-percent figure for buyer-facing copy (3% → 3). */
public int cpiRatePct() {
return (int) Math.round(cpiEscalator() * 100.0);
}
/**
* The escalated annual fee at the first renewal: the committed annual plus one CPI step. Used
* both when pricing a fresh quote and when echoing a stored one, so the two always agree.
*/
public long renewalAnnualMinor(long annualNetMinor) {
return renewalAnnualMinor(annualNetMinor, PricingRates.defaults());
}
private static long renewalAnnualMinor(long annualNetMinor, PricingRates rates) {
return Math.round(annualNetMinor * (1.0 + rates.cpiEscalator()));
}
private static long deployFeeMinor(String deployment, PricingRates rates) {
@@ -3,10 +3,15 @@ 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
* The priced result of a {@link QuoteConfig}: the itemised lines plus the 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).
* after the multi-year discount; {@code tcvMinor} is total contract value across the committed term
* including one-time fees; {@code renewalAnnualNetMinor} is the annual fee at the first post-term
* renewal after the fixed CPI escalator (the committed term itself is flat). Minor units (cents).
*/
public record QuoteBreakdown(
List<QuoteLineItem> lineItems, long annualNetMinor, long tcvMinor, String currency) {}
List<QuoteLineItem> lineItems,
long annualNetMinor,
long tcvMinor,
long renewalAnnualNetMinor,
String currency) {}
@@ -10,23 +10,35 @@ public record QuoteConfig(
long volume, // committed PDFs per year
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7
double sizeMult, // file-size tier multiplier on the rate — Compact 1.0 / Standard 1.4 /
// Heavy 2.4
String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial)
int termYears, // 1..5
String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee)
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense, // offline .lic availability (gates download; no longer priced here)
String currency) { // USD only for now
/** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */
public static final int DEFAULT_INTENSITY = 4;
/** Known file-size tier multipliers (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */
private static final double[] SIZE_MULTS = {1.0, 1.4, 2.4};
public QuoteConfig {
if (termYears < 1) termYears = 1;
if (termYears > 5) termYears = 5;
if (intensity < 1) intensity = DEFAULT_INTENSITY;
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
if (currency == null || currency.isBlank()) currency = "USD";
// Snap the file-size multiplier to a known tier so a tampered request can't invent a
// cheaper
// one; absent (0.0) or unknown falls back to 1.0 (no uplift).
double snapped = 1.0;
for (double s : SIZE_MULTS) {
if (Math.abs(s - sizeMult) < 1e-9) snapped = s;
}
sizeMult = snapped;
}
}
@@ -24,6 +24,7 @@ import stirling.software.saas.procurement.license.EnterpriseLicenseService;
import stirling.software.saas.procurement.license.LicenseEntitlements;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.model.QuoteDetails;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
import stirling.software.saas.procurement.pricing.QuoteConfig;
@@ -96,28 +97,46 @@ public class ProcurementService {
/**
* 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.
* Keygen licence, and the deal row is the journey state. The buyer's chosen deployment target
* ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote
* builder opens seeded to their environment; both are still editable when the quote is built.
*/
@Transactional
public ProcurementDeal startTrial(Long teamId) {
public ProcurementDeal startTrial(Long teamId, String deployment, int seats) {
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.setDeployment(normalizeDeployment(deployment));
deal.setSeats(Math.max(0, seats));
deal.setTrialStartedAt(now);
deal.setTrialEndsAt(ends);
deal.setTrialExtensionsUsed(0);
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
deal = dealRepo.save(deal);
log.info(
"[procurement] trial started team={} deal={} ends={}",
"[procurement] trial started team={} deal={} deployment={} seats={} ends={}",
teamId,
deal.getDealId(),
deal.getDeployment(),
deal.getSeats(),
ends);
return deal;
}
/**
* Constrain a caller-supplied deployment to the known set; anything else falls back to cloud.
*/
private static String normalizeDeployment(String deployment) {
if (deployment == null) return "cloud";
String d = deployment.trim().toLowerCase(Locale.ROOT);
return switch (d) {
case "selfhost", "airgap", "cloud" -> d;
default -> "cloud";
};
}
/** Extend the current trial by the configured increment, up to the cap. */
@Transactional
public ProcurementDeal extendTrial(Long teamId) {
@@ -145,7 +164,7 @@ public class ProcurementService {
/** 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) {
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, QuoteDetails details) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
@@ -168,16 +187,26 @@ public class ProcurementService {
quote.setVolume(cfg.volume());
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
quote.setIntensity(cfg.intensity());
quote.setSizeMult(cfg.sizeMult());
quote.setDeployment(cfg.deployment());
quote.setTermYears(cfg.termYears());
quote.setServiceLevel(cfg.serviceLevel());
quote.setIndemnification(cfg.indemnification());
quote.setTraining(cfg.training());
quote.setQbr(cfg.qbr());
quote.setOfflineLicense(cfg.offlineLicense());
quote.setBusinessName(businessName);
quote.setBusinessName(details.businessName());
quote.setContactName(details.contactName());
quote.setContactEmail(details.contactEmail());
quote.setAddressLine1(details.addressLine1());
quote.setAddressLine2(details.addressLine2());
quote.setCity(details.city());
quote.setRegion(details.region());
quote.setPostalCode(details.postalCode());
quote.setPoNumber(details.poNumber());
quote.setTaxId(details.taxId());
quote.setAnnualNetMinor(breakdown.annualNetMinor());
quote.setTcvMinor(breakdown.tcvMinor());
quote.setRenewalAnnualMinor(breakdown.renewalAnnualNetMinor());
quote.setLineItemsJson(writeLineItems(breakdown));
quote.setValidUntil(LocalDate.now().plusDays(30));
quote = quoteRepo.save(quote);
@@ -275,7 +304,7 @@ public class ProcurementService {
q != null && q.isIndemnification(),
q != null && q.isTraining(),
q != null && q.isQbr(),
q != null && q.isOfflineLicense(),
"airgap".equalsIgnoreCase(deployment), // offline .lic = air-gapped deploy
deal.getDealId(),
deal.getSubscriptionId());
return licenses.issueAnnualLicense(
@@ -287,30 +316,26 @@ public class ProcurementService {
}
/**
* Check out the offline/air-gapped licence file for a team, when the offline add-on was
* purchased. Requires an issued licence on the deal and the accepted quote to carry the offline
* add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence
* exists). The certificate is generated on demand by Keygen and never stored.
* Check out the offline/air-gapped licence file (.lic) for a team. Available for an air-gapped
* deployment (chosen at trial setup) from the trial licence onward — cloud/self-hosted verify
* online against Keygen and don't get a file. Returns empty when there's no licence yet or the
* deployment isn't air-gapped, so the controller can 404 rather than leak that a licence
* exists. The certificate is generated on demand by Keygen (from whatever licence the deal
* currently holds — trial or committed annual) and never stored.
*
* <p>By design a team can self-select air-gapped at trial and download a real signed .lic
* before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so
* the file the verifier accepts self-expires at trial end. The buyer must re-download after
* provisioning to get the committed-term file (the portal warns about this).
*/
@Transactional(readOnly = true)
public Optional<String> offlineLicenseFile(Long teamId) {
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
if (!hasOfflineAddOn(deal)) return Optional.empty();
if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty();
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
}
/**
* Whether the deal's <b>accepted</b> quote carries the paid offline-licence add-on. Gated on
* the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft
* can't unlock the offline file — it's only available once the add-on has actually been bought.
*/
private boolean hasOfflineAddOn(ProcurementDeal deal) {
if (deal.getAcceptedQuoteId() == null) return false;
ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
return quote != null && quote.isOfflineLicense();
}
/**
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
* re-demos/testing.
@@ -0,0 +1,7 @@
-- Direct PDF link for a procurement quote's first invoice (Stripe invoice_pdf), stored at accept
-- alongside stripe_invoice_url so the portal's "Download invoice" button survives a reload instead
-- of relying on the transient accept response. Written by the accept edge function via the
-- procurement_set_quote_accepted RPC; read by the Java backend via JPA. A Supabase twin mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS stripe_invoice_pdf TEXT;
@@ -0,0 +1,10 @@
-- Deployment target + seat count captured at the trial-start step (the setup dialog the demo shows
-- before a trial begins), stored on the deal so the quote builder seeds from the buyer's real
-- environment instead of a hardcoded default. deployment: cloud | selfhost | airgap. seats: 0 =
-- unspecified. Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
ALTER TABLE stirling_pdf.procurement_deal
ADD COLUMN IF NOT EXISTS deployment VARCHAR(16) NOT NULL DEFAULT 'cloud';
ALTER TABLE stirling_pdf.procurement_deal
ADD COLUMN IF NOT EXISTS seats INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
-- Persist the first post-term renewal fee (annual net + one CPI step) computed at quote time, so the
-- figure shown to the buyer is locked to what they were quoted rather than recomputed from the
-- current rate card on every read. Minor units. Written and read by the Java backend via JPA; a
-- Supabase twin migration mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS renewal_annual_minor BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
-- File-size tier multiplier on the quote (D93): larger, image-heavy PDFs cost more, so the buyer
-- picks a size tier (Compact 1.0 / Standard 1.4 / Heavy 2.4) that scales the per-run rate. Persisted
-- so the quote re-prices and re-seeds the builder consistently. Defaults to 1.0 (no uplift) for rows
-- that predate the column. Written and read by the Java backend via JPA. A Supabase twin mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS size_mult DOUBLE PRECISION NOT NULL DEFAULT 1.0;
@@ -0,0 +1,17 @@
-- Buyer / AP details captured on the quote's "Your details" step: the signatory contact and a
-- billing address, plus a PO number and tax id for the invoice. All optional (never gate quote
-- generation). Persisted so the quote re-seeds the builder on a re-edit and so the issue edge
-- function can put them on the Stripe customer (name + bill-to address) and invoice (PO / tax id
-- as custom fields). Country and currency are intentionally out of scope for now. Written and read
-- by the Java backend via JPA. A Supabase twin mirrors these columns.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(255),
ADD COLUMN IF NOT EXISTS address_line2 VARCHAR(255),
ADD COLUMN IF NOT EXISTS city VARCHAR(128),
ADD COLUMN IF NOT EXISTS region VARCHAR(128),
ADD COLUMN IF NOT EXISTS postal_code VARCHAR(32),
ADD COLUMN IF NOT EXISTS po_number VARCHAR(128),
ADD COLUMN IF NOT EXISTS tax_id VARCHAR(64);
@@ -18,8 +18,13 @@ class ProcurementPricingServiceTest {
private static QuoteConfig cfg(
long volume, int intensity, String deployment, int term, String sla) {
return cfgSize(volume, intensity, deployment, term, sla, 1.0);
}
private static QuoteConfig cfgSize(
long volume, int intensity, String deployment, int term, String sla, double sizeMult) {
return new QuoteConfig(
volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD");
volume, 0, intensity, sizeMult, deployment, term, sla, false, false, false, "USD");
}
@Test
@@ -30,6 +35,7 @@ class ProcurementPricingServiceTest {
assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000
assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000
assertThat(q.renewalAnnualNetMinor()).isEqualTo(180_456_000L); // $1,752,000 + 3% CPI
assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K
assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K
}
@@ -46,6 +52,41 @@ class ProcurementPricingServiceTest {
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support"));
}
@Test
void renewalAppliesCpiEscalatorAfterAFlatTerm() {
// The committed term is flat (TCV = annual × years, asserted above). The 3% CPI escalator
// describes only the first post-term renewal: annual + one 3% step. It never touches TCV.
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
assertThat(q.renewalAnnualNetMinor())
.isEqualTo(Math.round(q.annualNetMinor() * 1.03)); // 16,527,800 → 17,023,634
assertThat(q.tcvMinor()).isEqualTo(q.annualNetMinor() * 3); // renewal is outside the TCV
assertThat(pricing.cpiRatePct()).isEqualTo(3);
assertThat(pricing.renewalAnnualMinor(q.annualNetMinor()))
.isEqualTo(q.renewalAnnualNetMinor()); // stored-quote echo agrees with pricing
}
@Test
void fileSizeTierMultipliesTheMeter() {
// D93: the size tier scales the per-run rate, so the meter (hence annual/TCV/renewal) grows
// while flat fees stay put. Compact (1.0) is the anchor; Standard is ×1.4, Heavy ×2.4.
long compact =
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.0)).annualNetMinor();
long standard =
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.4)).annualNetMinor();
long heavy =
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 2.4)).annualNetMinor();
assertThat(compact).isEqualTo(16_527_800L); // == the Northwind anchor (size 1.0)
assertThat(standard).isEqualTo(23_138_900L); // rate ×1.4
assertThat(compact).isLessThan(standard);
assertThat(standard).isLessThan(heavy);
// An unknown/tampered multiplier snaps back to 1.0 (no cheaper factor sneaks through).
assertThat(
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 0.3))
.annualNetMinor())
.isEqualTo(compact);
}
@Test
void rateFloorsAtHalfACent() {
// 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to
@@ -104,7 +145,7 @@ class ProcurementPricingServiceTest {
long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
QuoteConfig c =
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD");
6_000_000, 0, 4, 1.0, "cloud", 3, "standard", true, false, false, "USD");
QuoteBreakdown q = pricing.price(c);
assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05));
}
@@ -113,7 +154,7 @@ class ProcurementPricingServiceTest {
void trainingIsOneTimeOutsideTheAnnual() {
QuoteConfig withTraining =
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD");
6_000_000, 0, 4, 1.0, "cloud", 3, "standard", false, true, false, "USD");
QuoteBreakdown q = pricing.price(withTraining);
long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
@@ -7727,7 +7727,6 @@ managePlan = "Manage plan"
volumeSuffix = "PDFs processed · last 30 days"
[portal.procurement]
reset = "Reset procurement (demo)"
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -7747,23 +7746,42 @@ title = "Review your enterprise agreement"
[portal.procurement.builder]
addons = "Add-ons"
addressLine1 = "Address line 1"
addressLine1Placeholder = "500 Howard St"
addressLine2 = "Address line 2"
addressLine2Placeholder = "Suite, floor, building"
back = "Back"
businessName = "Business name"
businessNamePlaceholder = "Your company"
city = "City"
cityPlaceholder = "San Francisco"
contactEmail = "Contact email"
contactEmailPlaceholder = "jane@acme.com"
contactName = "Contact name"
contactNamePlaceholder = "Jane Doe"
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"
offlineLicense = "Offline / air-gapped licence"
offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance"
pdfSize = "PDF size"
poNumber = "PO number"
poNumberPlaceholder = "Optional"
postalCode = "Postal code"
postalCodePlaceholder = "94105"
posture = "Governance"
posture_count = "~{{count}} policies"
posture_essentials = "Essentials"
posture_essentialsSub = "Classification + Sharing — the defaults"
posture_governed = "Governed"
posture_governedSub = "Adds Security and Routing"
posture_regulated = "Regulated"
posture_regulatedSub = "Every category — Compliance, Retention, Ingestion"
qbr = "Quarterly business reviews"
qbrSub = "Your SE reviews usage and roadmap each quarter"
region = "State / region"
regionPlaceholder = "California"
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
s1Sub = "Your team, and the PDFs you expect to run each year."
# Step 1 — volume
@@ -7775,13 +7793,21 @@ s3Sub = "For the quote and the agreement it generates."
# Step 3 — details
s3Title = "Your details"
serviceLevel = "Service level"
size_compact = "Compact"
size_compactSub = "Mostly text, under 1 MB"
size_heavy = "Heavy"
size_heavySub = "Scanned or image-heavy, 5 MB+"
size_standard = "Standard"
size_standardSub = "Mixed text and images, 1 to 5 MB"
slDedicated = "Dedicated"
slDedicatedSub = "4 business hours · dedicated account manager · +30%"
slDedicatedSub = "4 business hours · dedicated SE / CSM · +$30,000/yr"
slPriority = "Priority"
slPrioritySub = "Same business day · named CSM · +15%"
slPrioritySub = "Same business day · named CSM · included"
slStandard = "Standard"
slStandardSub = "Next business day · shared CSM · included"
stepOf = "Step {{n}} of {{total}}"
taxId = "VAT / Tax ID"
taxIdPlaceholder = "Optional"
term = "Term"
termDiscount = "{{pct}}% multi-year commitment discount applied"
title = "Build your quote"
@@ -7817,14 +7843,13 @@ 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"
licenseKey = "Licence key"
nextStep = "Next step: {{action}}"
notStarted = "Not started"
open = "Open procurement"
@@ -7872,56 +7897,6 @@ blurb = "Evaluate Stirling against your documents and workflows."
gatingAction = "Build your quote"
label = "Trial"
[portal.procurement.keyDocs]
oneTimeFee = " · one-time {{amount}}"
subtitle = "Everything for each stage of your rollout, in one place."
title = "Key documents"
[portal.procurement.keyDocs.docs.baa]
name = "Business Associate Agreement"
sub = "HIPAA · available on request"
[portal.procurement.keyDocs.docs.bankTransfer]
name = "Bank transfer instructions"
sub = "Wire details for your AP team"
[portal.procurement.keyDocs.docs.coi]
name = "Certificate of Insurance"
sub = "Cyber + E&O · current policy"
[portal.procurement.keyDocs.docs.formalQuote]
name = "Formal quote"
sub = "Built to your volume, term, and service level"
[portal.procurement.keyDocs.docs.msa]
name = "Master Services Agreement"
sub = "One signature - MSA, order form, EULA, and DPA combined"
[portal.procurement.keyDocs.docs.purchaseOrder]
name = "Purchase order"
sub = "Issuing a PO? Upload it and we invoice against it"
[portal.procurement.keyDocs.docs.securityReview]
name = "Custom security review"
sub = "We complete your questionnaire and join your review call"
[portal.procurement.keyDocs.docs.soc2]
name = "SOC 2 Type II report"
sub = "Audited · NDA-gated"
[portal.procurement.keyDocs.docs.w9]
name = "IRS Form W-9"
sub = "Stirling PDF Inc."
[portal.procurement.keyDocs.groups]
evaluation = "Supporting your evaluation"
yourDeal = "Your deal"
[portal.procurement.keyDocs.status]
action = "Action needed"
available = "Download"
request = "Request"
[portal.procurement.license]
copied = "Copied"
copy = "Copy key"
@@ -7929,6 +7904,9 @@ downloadError = "Could not generate the offline licence file just yet — please
downloadOffline = "Download offline licence (.lic)"
hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe."
label = "Your licence key"
subtitle = "Activate a self-hosted instance with this key, or keep it for your records."
title = "Your licence key"
trialFileHint = "This is your trial licence file. Once your agreement is in place, come back and download it again — unlike the online licence key, the .lic file won't update on its own."
[portal.procurement.link]
cta = "Link account"
@@ -7948,16 +7926,9 @@ 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"
@@ -7984,7 +7955,6 @@ uploadTitle = "Upload your purchase order"
[portal.procurement.payment]
description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed."
downloadInvoice = "Download invoice"
simulate = "Simulate payment received (demo)"
title = "Subscription created"
viewInvoice = "View & pay invoice"
@@ -7994,6 +7964,21 @@ fallbackLink = "Open scheduling in a new tab"
subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you."
title = "Schedule a call"
[portal.procurement.setup]
airgap = "Air-gapped"
airgapSub = "Fully offline, isolated network. Includes a downloadable licence file."
cloud = "Cloud"
cloudSub = "Fully managed by Stirling. Nothing for you to run."
deployment = "Where will you run Stirling?"
seats = "Team size"
seatsHint = "Roughly how many people will use it. You can refine this when you build your quote."
seatsPlaceholder = "e.g. 250"
selfhost = "Self-hosted"
selfhostSub = "Run it in your own cloud or data centre."
start = "Start trial"
subtitle = "Tell us how you plan to run Stirling so we can tailor your trial and quote. No card required."
title = "Set up your trial"
[portal.procurement.status]
action = "Action needed"
available = "Available"
+50 -5
View File
@@ -73,6 +73,16 @@ export const JOURNEY: JourneyStep[] = [
},
];
/**
* The commercial flow's stepper stages. The real backend collapses quote + agreement into one
* accept step (accepting the issued quote is accepting the agreement), so the flow shows one fewer
* step than the mock ledger's {@link JOURNEY} — the separate "Agreement" step is dropped. Reuses
* JOURNEY's i18n keys.
*/
export const FLOW_JOURNEY: JourneyStep[] = JOURNEY.filter(
(s) => s.stage !== "security",
);
/* ──────────────────────────────────────────────────────────────────────── */
/* Deal header */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -284,12 +294,18 @@ export interface QuoteResult {
currency: string;
annualNetMinor: number;
tcvMinor: number;
/** First post-term renewal fee after the CPI escalator; the committed term itself is flat. */
renewalAnnualNetMinor: number;
/** The fixed CPI escalator applied per renewal, as a whole percent (e.g. 3). */
cpiRatePct: 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;
/** Direct PDF link for that invoice; persisted so the download button survives a reload. */
invoicePdf: string | null;
/** The inputs this quote was priced from, so the builder can seed itself on re-edit. */
config: QuoteConfigInput;
}
@@ -306,6 +322,10 @@ export interface AcceptResult {
export interface ProcurementSnapshot {
dealId: number | null;
stage: DealStage | null;
/** cloud | selfhost | airgap — chosen at the trial-setup step; seeds the quote builder. */
deployment: string;
/** Seat count captured at trial setup (0 = unspecified); seeds the builder's volume estimate. */
seats: number;
trialStartedAt: string | null;
trialEndsAt: string | null;
trialExtensionsUsed: number;
@@ -318,17 +338,35 @@ export interface ProcurementSnapshot {
export interface QuoteConfigInput {
volume: number;
users: number;
/** Policy posture (governance) as runs per PDF: Essentials 2, Governed 4, Regulated 7. */
intensity: number;
/** PDF-size tier multiplier on the rate: Compact 1.0, Standard 1.4, Heavy 2.4. */
sizeMult: number;
/** cloud | selfhost | airgap — set at the trial; drives the flat deployment fee + offline .lic. */
deployment: string;
termYears: number;
serviceLevel: string;
indemnification: boolean;
training: boolean;
qbr: boolean;
/** Offline / air-gapped licence file — a paid add-on. */
offlineLicense: boolean;
currency: string;
/** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */
businessName: string;
// Buyer / AP details (all optional). They flow onto the Stripe customer (name + bill-to address)
// and the invoice (PO number + tax id as custom fields). Country + currency are out of scope.
/** Signatory / main contact name. */
contactName?: string;
/** Contact email (billing / signatory). */
contactEmail?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
/** State / province / region. */
region?: string;
postalCode?: string;
/** Purchase-order number, shown on the invoice for AP matching. */
poNumber?: string;
/** VAT / Tax ID, shown on the invoice. */
taxId?: string;
}
export function fetchSnapshot(): Promise<ProcurementSnapshot> {
@@ -343,10 +381,17 @@ export function fetchLicenseFile(): Promise<string> {
return apiClient.saas.text("/api/v1/procurement/license/file");
}
export function startTrial(): Promise<ProcurementSnapshot> {
/**
* Start the trial with the buyer's chosen deployment target and seat count (captured in the setup
* step). These seed the quote builder; both remain editable when the quote is built.
*/
export function startTrial(
deployment: string,
seats: number,
): Promise<ProcurementSnapshot> {
return apiClient.saas.json<ProcurementSnapshot>(
"/api/v1/procurement/trial/start",
{ method: "POST" },
{ method: "POST", body: { deployment, users: seats } },
);
}
@@ -1,4 +1,5 @@
import type { Tier } from "@portal/contexts/TierContext";
import { useUI } from "@portal/contexts/UIContext";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
import { SetupChecklist } from "@portal/components/SetupChecklist";
@@ -19,14 +20,22 @@ import { useProcurement } from "@portal/components/procurement/useProcurement";
* open them.
*/
export function HomeHero({ tier }: { tier: Tier }) {
const { openLinkModal } = useUI();
const procurement = useProcurement();
const dealActive =
procurement.isLinked && procurement.started && !!procurement.data;
// Start the enterprise flow right here on Home: open the trial-setup modal when the account is
// linked, otherwise prompt to link first — no navigating off to the procurement view.
const onStartEnterprise = () => {
if (procurement.isLinked) procurement.onStartTrial();
else openLinkModal();
};
const footer = dealActive ? (
<ControlledDealStatusHero controller={procurement} />
) : (
<SetupChecklist />
<SetupChecklist onStartEnterprise={onStartEnterprise} />
);
return (
@@ -18,10 +18,17 @@ const EDITOR_DOWNLOAD_URL = "https://stirling.com/download";
/**
* Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided
* trial, subscribed (paying) orgs jump straight to a quote — both land in the
* procurement flow.
* trial, subscribed (paying) orgs jump straight to a quote — both open the
* procurement flow. When {@code onStart} is given the CTA opens the flow's setup
* modal over Home; otherwise it falls back to navigating to the procurement view.
*/
function EnterpriseRung({ paying }: { paying: boolean }) {
function EnterpriseRung({
paying,
onStart,
}: {
paying: boolean;
onStart?: () => void;
}) {
const { t } = useTranslation();
const { setActiveView } = useView();
return (
@@ -38,7 +45,7 @@ function EnterpriseRung({ paying }: { paying: boolean }) {
<Button
variant="secondary"
size="sm"
onClick={() => setActiveView("procurement")}
onClick={onStart ?? (() => setActiveView("procurement"))}
rightSection={<span aria-hidden></span>}
>
{t(
@@ -77,7 +84,13 @@ interface Step {
* (the same data the Policies / Sources pages show). The header doubles as a
* dismiss control; the Enterprise rung persists regardless.
*/
export function SetupChecklist() {
export function SetupChecklist({
onStartEnterprise,
}: {
/** Start the enterprise flow in place (opens the setup modal over Home). Falls back to
* navigating to the procurement view when omitted (e.g. in isolated stories). */
onStartEnterprise?: () => void;
} = {}) {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
@@ -212,7 +225,7 @@ export function SetupChecklist() {
</>
)}
<EnterpriseRung paying={tier !== "free"} />
<EnterpriseRung paying={tier !== "free"} onStart={onStartEnterprise} />
</div>
);
}
@@ -5,6 +5,8 @@ import type { ProcurementSnapshot } from "@portal/api/procurement";
const base: ProcurementSnapshot = {
dealId: 1,
stage: "trial",
deployment: "cloud",
seats: 250,
trialStartedAt: "2026-06-25T00:00:00Z",
trialEndsAt: "2026-07-09T00:00:00Z",
trialExtensionsUsed: 0,
@@ -21,7 +23,7 @@ const meta: Meta<typeof DealStatusHero> = {
args: {
canSchedule: true,
onExpand: () => {},
onKeyDocs: () => {},
onLicense: () => {},
onInvite: () => {},
onSchedule: () => {},
onManageTrial: () => {},
@@ -1,13 +1,16 @@
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 {
FLOW_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,
* deal stage: quick-action chips (trial countdown → manage, licence key, 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.
*/
@@ -16,7 +19,7 @@ export function DealStatusHero({
busy = false,
canSchedule,
onExpand,
onKeyDocs,
onLicense,
onInvite,
onSchedule,
onManageTrial,
@@ -28,7 +31,7 @@ export function DealStatusHero({
* "Schedule a call" action only appears when the org has linked its account. */
canSchedule: boolean;
onExpand: () => void;
onKeyDocs: () => void;
onLicense: () => void;
onInvite: () => void;
onSchedule: () => void;
onManageTrial: () => void;
@@ -42,11 +45,9 @@ export function DealStatusHero({
? 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");
: stage === "procurement"
? t("portal.procurement.hero.ctaPayment")
: t("portal.procurement.hero.ctaLive");
const setupSteps: { title: string; sub: string; view: ViewId }[] = [
{
@@ -89,13 +90,13 @@ export function DealStatusHero({
})}
</button>
)}
{stage !== "active" && (
{snapshot.licenseKey && (
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onKeyDocs}
onClick={onLicense}
>
{t("portal.procurement.hero.keyDocs")}
{t("portal.procurement.hero.licenseKey")}
</button>
)}
{stage !== "active" && (
@@ -120,7 +121,7 @@ export function DealStatusHero({
</div>
<div className="portal-hero__stepper">
<StageStepper journey={JOURNEY} currentStage={stage} />
<StageStepper journey={FLOW_JOURNEY} currentStage={stage} />
</div>
{inTrial && (
@@ -15,16 +15,24 @@ import "@portal/views/Procurement.css";
export function ProcurementAgreement({
quote,
busy,
downloading,
onAgree,
onDownload,
onEdit,
}: {
quote: QuoteResult;
busy: boolean;
downloading: boolean;
/** Accept the quote straight into a committed subscription (this is also the agreement). */
onAgree: () => void;
onDownload: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation();
const [checked, setChecked] = useState(false);
const annual = money(quote.annualNetMinor, quote.currency);
const tcv = money(quote.tcvMinor, quote.currency);
const renewal = money(quote.renewalAnnualNetMinor, quote.currency);
const years = quote.config.termYears;
return (
@@ -73,7 +81,19 @@ export function ProcurementAgreement({
))}
</ul>
<h4>3. End-User License Agreement</h4>
<h4>3. Term, renewal and annual fee adjustment</h4>
<p>
This Agreement runs for the committed {years}-year term set out in the
Order Form. It then renews automatically for successive one-year terms
unless either party gives written notice of non-renewal at least 30
days before the end of the then-current term. On each renewal the
annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment.
Based on this quote, the first renewal year would be approximately{" "}
<strong>{renewal}</strong> per year; the committed term above is
billed at the rate in the Order Form and is not affected.
</p>
<h4>4. 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
@@ -82,7 +102,7 @@ export function ProcurementAgreement({
Service, and all intellectual property in it, remains Stirling's.
</p>
<h4>4. Data Processing Agreement</h4>
<h4>5. 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
@@ -92,7 +112,7 @@ export function ProcurementAgreement({
reference.
</p>
<h4>5. Acceptance</h4>
<h4>6. Acceptance</h4>
<p>
By agreeing below, Customer accepts this Agreement and the Order Form.
On acceptance, Stirling will issue the committed annual subscription
@@ -120,6 +140,12 @@ export function ProcurementAgreement({
>
{t("portal.procurement.agreement.agreeCta")}
</Button>
<Button variant="secondary" loading={downloading} onClick={onDownload}>
{t("portal.procurement.milestone.download")}
</Button>
<Button variant="tertiary" onClick={onEdit}>
{t("portal.procurement.milestone.edit")}
</Button>
</div>
</Card>
);
@@ -22,7 +22,7 @@ export function ControlledDealStatusHero({
busy={controller.busy}
canSchedule={controller.isLinked}
onExpand={() => controller.setOpen(true)}
onKeyDocs={() => controller.setExtra("docs")}
onLicense={() => controller.setExtra("license")}
onInvite={() => setActiveView("users")}
onSchedule={() => controller.setExtra("schedule")}
onManageTrial={() => controller.setExtra("trial")}
@@ -1,17 +1,17 @@
import { useEffect } from "react";
import { useEffect, useState } 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 { CalendlyInline } from "@portal/components/procurement/CalendlyInline";
import { LicensePanel } from "@portal/components/procurement/ProcurementStages";
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. Schedule a call embeds the live Calendly scheduler; Key documents is
* still mocked for the pilot (static demo data). The shells and wiring are real so the hero behaves
* like the marketing prototype.
* Small centred dialogs that hang off the deal-status hero's quick actions — the licence key,
* schedule a call, trial management, and trial setup. Schedule a call embeds the live Calendly
* scheduler. The shells and wiring are real so the hero behaves like the marketing prototype.
*/
function SideModal({
@@ -74,124 +74,45 @@ function SideModal({
);
}
// ── Key documents ────────────────────────────────────────────────────────────
type DocStatus = "available" | "action" | "request";
interface DocRow {
nameKey: string;
subKey: string;
status: DocStatus;
fee?: number;
}
// Static demo catalogue for the pilot; the copy lives in the locale under
// portal.procurement.keyDocs and is resolved via t() at render time.
const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [
{
groupKey: "portal.procurement.keyDocs.groups.yourDeal",
docs: [
{
nameKey: "portal.procurement.keyDocs.docs.formalQuote.name",
subKey: "portal.procurement.keyDocs.docs.formalQuote.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.msa.name",
subKey: "portal.procurement.keyDocs.docs.msa.sub",
status: "action",
},
{
nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name",
subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name",
subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub",
status: "request",
},
],
},
{
groupKey: "portal.procurement.keyDocs.groups.evaluation",
docs: [
{
nameKey: "portal.procurement.keyDocs.docs.soc2.name",
subKey: "portal.procurement.keyDocs.docs.soc2.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.securityReview.name",
subKey: "portal.procurement.keyDocs.docs.securityReview.sub",
status: "request",
fee: 5000,
},
{
nameKey: "portal.procurement.keyDocs.docs.baa.name",
subKey: "portal.procurement.keyDocs.docs.baa.sub",
status: "request",
fee: 2500,
},
{
nameKey: "portal.procurement.keyDocs.docs.w9.name",
subKey: "portal.procurement.keyDocs.docs.w9.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.coi.name",
subKey: "portal.procurement.keyDocs.docs.coi.sub",
status: "available",
},
],
},
];
const STATUS_LABEL: Record<DocStatus, string> = {
available: "portal.procurement.keyDocs.status.available",
action: "portal.procurement.keyDocs.status.action",
request: "portal.procurement.keyDocs.status.request",
};
export function KeyDocumentsModal({
// ── Licence key ──────────────────────────────────────────────────────────────
export function LicenseModal({
open,
onClose,
licenseKey,
offlineAvailable,
downloadingLicense,
onDownloadOffline,
trial = false,
}: {
open: boolean;
onClose: () => void;
licenseKey: string;
offlineAvailable: boolean;
downloadingLicense: boolean;
onDownloadOffline: () => void;
/** Licence is still the trial one (not yet upgraded on accept) — the downloadable .lic is a
* snapshot, so warn that it must be re-downloaded once the agreement is in place. */
trial?: boolean;
}) {
const { t } = useTranslation();
return (
<SideModal
open={open}
onClose={onClose}
title={t("portal.procurement.keyDocs.title")}
subtitle={t("portal.procurement.keyDocs.subtitle")}
title={t("portal.procurement.license.title")}
subtitle={t("portal.procurement.license.subtitle")}
>
{STAGE_DOCS.map((g) => (
<div key={g.groupKey} className="portal-docs__group">
<div className="portal-docs__group-title">{t(g.groupKey)}</div>
<ul className="portal-docs__list">
{g.docs.map((d) => (
<li key={d.nameKey} className="portal-docs__row">
<div className="portal-docs__row-text">
<span className="portal-docs__row-name">{t(d.nameKey)}</span>
<span className="portal-docs__row-sub">
{t(d.subKey)}
{d.fee
? t("portal.procurement.keyDocs.oneTimeFee", {
amount: `$${d.fee.toLocaleString()}`,
})
: ""}
</span>
</div>
<span
className="portal-docs__row-action"
data-status={d.status}
>
{t(STATUS_LABEL[d.status])}
</span>
</li>
))}
</ul>
</div>
))}
<LicensePanel
licenseKey={licenseKey}
offlineAvailable={offlineAvailable}
downloadingLicense={downloadingLicense}
onDownloadOffline={onDownloadOffline}
/>
{offlineAvailable && trial && (
<p className="portal-proc__license-hint">
{t("portal.procurement.license.trialFileHint")}
</p>
)}
</SideModal>
);
}
@@ -221,6 +142,97 @@ export function ScheduleCallModal({
);
}
// ── Trial setup ────────────────────────────────────────────────────────────
const DEPLOYMENTS = ["cloud", "selfhost", "airgap"] as const;
/**
* Captured before the trial starts: where the buyer will run Stirling (which drives the deployment
* fee and, for air-gapped, the offline licence) and their team size. Both seed the quote builder so
* it opens on their real environment; the trial only begins once this is confirmed.
*/
export function TrialSetupModal({
open,
onClose,
busy,
onConfirm,
}: {
open: boolean;
onClose: () => void;
busy: boolean;
onConfirm: (deployment: string, seats: number) => void;
}) {
const { t } = useTranslation();
const [deployment, setDeployment] = useState<string>("cloud");
const [seats, setSeats] = useState("");
// Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger.
useEffect(() => {
if (open) {
setDeployment("cloud");
setSeats("");
}
}, [open]);
return (
<SideModal
open={open}
onClose={onClose}
title={t("portal.procurement.setup.title")}
subtitle={t("portal.procurement.setup.subtitle")}
footer={
<Button
variant="primary"
accent="premium"
loading={busy}
onClick={() => onConfirm(deployment, Math.max(0, Number(seats) || 0))}
>
{t("portal.procurement.setup.start")}
</Button>
}
>
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.deployment")}
</span>
<div className="portal-qb__opts">
{DEPLOYMENTS.map((d) => (
<button
key={d}
type="button"
className="portal-qb__opt"
data-on={deployment === d || undefined}
onClick={() => setDeployment(d)}
>
<span className="portal-qb__opt-title">
{t(`portal.procurement.setup.${d}`)}
</span>
<span className="portal-qb__opt-sub">
{t(`portal.procurement.setup.${d}Sub`)}
</span>
</button>
))}
</div>
</label>
<label className="portal-qb__field">
<span className="portal-qb__field-label">
{t("portal.procurement.setup.seats")}
</span>
<input
type="number"
min={0}
placeholder={t("portal.procurement.setup.seatsPlaceholder")}
value={seats}
onChange={(e) => setSeats(e.target.value)}
/>
</label>
<p className="portal-sidemodal__text">
{t("portal.procurement.setup.seatsHint")}
</p>
</SideModal>
);
}
// ── Trial management ─────────────────────────────────────────────────────────
export function TrialManageModal({
open,
@@ -2,19 +2,18 @@ import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Skeleton } from "@app/ui";
import { useUI } from "@portal/contexts/UIContext";
import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail";
import { JOURNEY } from "@portal/api/procurement";
import { FLOW_JOURNEY } from "@portal/api/procurement";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
KeyDocumentsModal,
LicenseModal,
ScheduleCallModal,
TrialManageModal,
TrialSetupModal,
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LicensePanel,
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
} from "@portal/components/procurement/ProcurementStages";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import { StageStepper } from "@portal/components/procurement/StageStepper";
@@ -22,7 +21,7 @@ import type { ProcurementController } from "@portal/components/procurement/usePr
/**
* The procurement takeover flow: the full-screen journey modal (quote builder →
* milestone agreement → payment → live) plus the key-documents, schedule-call,
* quote & agreement → payment → live) plus the licence-key, schedule-call, trial-setup,
* and trial-management modals. Driven entirely by a shared ProcurementController
* so it can sit next to a deal-status hero rendered elsewhere (e.g. inside the
* tier hero card on Home).
@@ -56,12 +55,11 @@ export function ProcurementFlow({
extra,
setExtra,
invoicePdf,
onConfirmSetup,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
onDownloadOfflineLicense,
} = controller;
@@ -106,70 +104,66 @@ export function ProcurementFlow({
{isLinked && started && (
<>
<div className="portal-proc__modal-stepper">
<StageStepper journey={JOURNEY} currentStage={stage!} />
<StageStepper journey={FLOW_JOURNEY} currentStage={stage!} />
</div>
{(editing ||
(isDraft && (stage === "trial" || stage === "quote"))) && (
<QuoteBuilder
deployment="cloud"
deployment={data?.deployment ?? "cloud"}
seats={data?.seats ?? 0}
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}
/>
)}
{/* Quote + agreement are one step: review the itemised quote and the agreement, then
accept straight into a committed subscription. Once accepted you can't go back.
("security" is the retired agreement stage — still handled so an older deal that
stopped there isn't left blank.) */}
{!editing &&
isIssued &&
(stage === "quote" || stage === "security") &&
latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
downloading={downloading}
onAgree={onAgree}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "procurement" && latest && (
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={invoicePdf}
busy={busy}
onSimulate={onGoLive}
invoicePdf={latest.invoicePdf ?? invoicePdf}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
{data?.licenseKey && (
<LicensePanel
licenseKey={data.licenseKey}
offlineAvailable={!!latest?.config.offlineLicense}
downloadingLicense={downloadingLicense}
onDownloadOffline={onDownloadOfflineLicense}
/>
)}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
</button>
</div>
</>
)}
</ProcurementModal>
<KeyDocumentsModal
open={extra === "docs"}
<TrialSetupModal
open={extra === "setup"}
onClose={() => setExtra(null)}
busy={busy}
onConfirm={onConfirmSetup}
/>
{data?.licenseKey && (
<LicenseModal
open={extra === "license"}
onClose={() => setExtra(null)}
licenseKey={data.licenseKey}
offlineAvailable={data.deployment === "airgap"}
downloadingLicense={downloadingLicense}
onDownloadOffline={onDownloadOfflineLicense}
trial={data.stage !== "procurement" && data.stage !== "active"}
/>
)}
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
@@ -1,8 +1,6 @@
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";
/**
@@ -11,97 +9,13 @@ import "@portal/views/Procurement.css";
* 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="primary"
accent="premium"
loading={busy}
onClick={onAccept}
>
{t("portal.procurement.milestone.accept")}
</Button>
<Button variant="secondary" loading={downloading} onClick={onDownload}>
{t("portal.procurement.milestone.download")}
</Button>
<Button variant="tertiary" onClick={onEdit}>
{t("portal.procurement.milestone.edit")}
</Button>
</div>
</Card>
);
}
/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */
/** The subscription-created step: pay or download the first invoice. */
export function PaymentStageCard({
invoiceUrl,
invoicePdf,
busy,
onSimulate,
}: {
invoiceUrl?: string | null;
invoicePdf?: string | null;
busy: boolean;
onSimulate: () => void;
}) {
const { t } = useTranslation();
return (
@@ -133,11 +47,6 @@ export function PaymentStageCard({
)}
</div>
)}
<div className="portal-proc__reset">
<button type="button" onClick={onSimulate} disabled={busy}>
{t("portal.procurement.payment.simulate")}
</button>
</div>
</Card>
);
}
@@ -15,12 +15,19 @@ import {
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,
};
const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71)
// Governance posture: the intensity (runs per PDF) fed to the committed-volume curve.
const POSTURES = [
{ intensity: 2, key: "essentials" },
{ intensity: 4, key: "governed" },
{ intensity: 7, key: "regulated" },
] as const;
// PDF-size tiers (D93): a multiplier on the rate. Default Standard (×1.4). Mirrors the server.
const SIZE_TIERS = [
{ mult: 1.0, key: "compact" },
{ mult: 1.4, key: "standard" },
{ mult: 2.4, key: "heavy" },
] as const;
/**
* The enterprise quote builder — volume → commitment &amp; service → details. A client-side preview
@@ -30,10 +37,13 @@ const SLA_UPLIFT: Record<string, number> = {
*/
export function QuoteBuilder({
deployment,
seats = 0,
initial,
onGenerate,
}: {
deployment: string;
/** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */
seats?: number;
/** 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. */
@@ -43,17 +53,28 @@ export function QuoteBuilder({
const [step, setStep] = useState(0);
const [cfg, setCfg] = useState<QuoteConfigInput>(
initial ?? {
volume: 1_000_000,
users: 0,
// Users-first: with no seats from the trial, leave volume empty so entering the team size
// auto-fills it (rather than pre-seeding a figure that hides the users → volume estimate).
volume: seats > 0 ? estimateVolume(seats) : 0,
users: Math.max(0, seats),
intensity: 4, // Governed — the default governance posture per the pricing alignment
sizeMult: 1.4, // Standard — the default PDF-size tier (D93)
deployment,
termYears: 3,
serviceLevel: "priority",
indemnification: false,
training: false,
qbr: false,
offlineLicense: false,
currency: "USD",
businessName: "",
contactName: "",
contactEmail: "",
addressLine1: "",
addressLine2: "",
city: "",
region: "",
postalCode: "",
poNumber: "",
taxId: "",
},
);
// A seeded quote carries a volume but no user count, so treat it as manually set.
@@ -159,6 +180,36 @@ export function QuoteBuilder({
title={t("portal.procurement.builder.s2Title")}
sub={t("portal.procurement.builder.s2Sub")}
>
<Field label={t("portal.procurement.builder.posture")}>
<div className="portal-qb__opts">
{POSTURES.map((p) => (
<OptCard
key={p.key}
on={cfg.intensity === p.intensity}
title={t(`portal.procurement.builder.posture_${p.key}`)}
sub={`${t("portal.procurement.builder.posture_count", {
count: p.intensity,
})} · ${t(`portal.procurement.builder.posture_${p.key}Sub`)}`}
onClick={() => set("intensity", p.intensity)}
/>
))}
</div>
</Field>
<Field label={t("portal.procurement.builder.pdfSize")}>
<div className="portal-qb__opts">
{SIZE_TIERS.map((s) => (
<OptCard
key={s.key}
on={cfg.sizeMult === s.mult}
title={t(`portal.procurement.builder.size_${s.key}`)}
sub={`×${s.mult} · ${t(`portal.procurement.builder.size_${s.key}Sub`)}`}
onClick={() => set("sizeMult", s.mult)}
/>
))}
</div>
</Field>
<Field label={t("portal.procurement.builder.term")}>
<div className="portal-qb__pills">
{[1, 2, 3, 4, 5].map((y) => (
@@ -224,12 +275,6 @@ export function QuoteBuilder({
sub={t("portal.procurement.builder.qbrSub")}
onClick={() => set("qbr", !cfg.qbr)}
/>
<AddOn
on={cfg.offlineLicense}
title={t("portal.procurement.builder.offlineLicense")}
sub={t("portal.procurement.builder.offlineLicenseSub")}
onClick={() => set("offlineLicense", !cfg.offlineLicense)}
/>
</div>
</Field>
</Step>
@@ -241,31 +286,97 @@ export function QuoteBuilder({
title={t("portal.procurement.builder.s3Title")}
sub={t("portal.procurement.builder.s3Sub")}
>
<Field label={t("portal.procurement.builder.businessName")}>
<div className="portal-qb__row">
<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>
<Field label={t("portal.procurement.builder.contactName")}>
<input
placeholder={t(
"portal.procurement.builder.contactNamePlaceholder",
)}
value={cfg.contactName ?? ""}
onChange={(e) => set("contactName", e.target.value)}
/>
</Field>
</div>
<Field label={t("portal.procurement.builder.contactEmail")}>
<input
type="email"
placeholder={t(
"portal.procurement.builder.contactEmailPlaceholder",
)}
value={cfg.contactEmail ?? ""}
onChange={(e) => set("contactEmail", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.addressLine1")}>
<input
placeholder={t(
"portal.procurement.builder.businessNamePlaceholder",
"portal.procurement.builder.addressLine1Placeholder",
)}
value={cfg.businessName}
onChange={(e) => set("businessName", e.target.value)}
value={cfg.addressLine1 ?? ""}
onChange={(e) => set("addressLine1", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.addressLine2")}>
<input
placeholder={t(
"portal.procurement.builder.addressLine2Placeholder",
)}
value={cfg.addressLine2 ?? ""}
onChange={(e) => set("addressLine2", 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 label={t("portal.procurement.builder.city")}>
<input
placeholder={t("portal.procurement.builder.cityPlaceholder")}
value={cfg.city ?? ""}
onChange={(e) => set("city", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.region")}>
<input
placeholder={t(
"portal.procurement.builder.regionPlaceholder",
)}
value={cfg.region ?? ""}
onChange={(e) => set("region", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.postalCode")}>
<input
placeholder={t(
"portal.procurement.builder.postalCodePlaceholder",
)}
value={cfg.postalCode ?? ""}
onChange={(e) => set("postalCode", e.target.value)}
/>
</Field>
</div>
<div className="portal-qb__row">
<Field label={t("portal.procurement.builder.poNumber")}>
<input
placeholder={t(
"portal.procurement.builder.poNumberPlaceholder",
)}
value={cfg.poNumber ?? ""}
onChange={(e) => set("poNumber", e.target.value)}
/>
</Field>
<Field label={t("portal.procurement.builder.taxId")}>
<input
placeholder={t("portal.procurement.builder.taxIdPlaceholder")}
value={cfg.taxId ?? ""}
onChange={(e) => set("taxId", e.target.value)}
/>
</Field>
</div>
<label className="portal-qb__eula">
@@ -283,9 +394,9 @@ export function QuoteBuilder({
<div className="portal-qb__foot">
<span className="portal-qb__running">
{t("portal.procurement.builder.running", {
annual: money(preview, cfg.currency),
annual: money(preview),
years: cfg.termYears,
tcv: money(tcvPreview, cfg.currency),
tcv: money(tcvPreview),
})}
</span>
<div className="portal-qb__foot-btns">
@@ -431,20 +542,31 @@ function estimateVolume(users: number): number {
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],
);
// Flat annual add-ons (QBR, offline licence) sit outside the multi-year discount, mirroring the
// server (PricingRates: qbr 800_000, offline licence 1_200_000). TCV preview derives from this.
return (
withInd -
disc +
(cfg.qbr ? 800_000 : 0) +
(cfg.offlineLicense ? 1_200_000 : 0)
);
// Client mirror of the server pricing curve (ProcurementPricingService / quotePricing). The server
// is authoritative; this only drives the live footer estimate. Minor units (cents); the meter
// rounds to whole dollars, exactly like the backend, so the preview matches the issued quote.
// Exported for the pricing-parity test, which pins this client estimate to the mock and the
// server's published fixtures so a rate-card change can't silently desync the footer from the
// issued quote. This copy stays non-authoritative — the backend prices the real quote.
export function previewAnnualMinor(cfg: QuoteConfigInput): number {
const LIST = 0.01;
const FLOOR = 0.005;
const runVol = Math.max(0, cfg.volume) * Math.max(1, cfg.intensity);
const volDisc =
runVol > 1_000_000
? Math.min(0.5, 0.06 * Math.log2(runVol / 1_000_000))
: 0;
const rate = Math.max(FLOOR, LIST * (1 - volDisc)) * (cfg.sizeMult || 1);
const termDisc = TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1];
const meterNet = Math.round(runVol * rate * (1 - termDisc)) * 100; // whole $ → minor units
const support = cfg.serviceLevel === "dedicated" ? 3_000_000 : 0; // std + priority included
const deploy =
cfg.deployment === "airgap"
? 3_600_000
: cfg.deployment === "selfhost"
? 1_200_000
: 0;
const indemnity = cfg.indemnification ? Math.round(meterNet * 0.05) : 0;
const qbr = cfg.qbr ? 800_000 : 0;
return meterNet + support + deploy + indemnity + qbr;
}
@@ -9,8 +9,8 @@ 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 {
/** Format a minor-unit (cents) amount as whole USD (no decimals). USD only for now. */
export function money(minor: number, currency: string = "USD"): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency || "USD",
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { QuoteConfigInput } from "@portal/api/procurement";
import { previewAnnualMinor } from "@portal/components/procurement/QuoteBuilder";
import { priceQuote } from "@portal/mocks/handlers/procurementSaas";
/**
* The D71 run-based curve is written three times: the authoritative Java engine
* (ProcurementPricingService), the client footer estimate (QuoteBuilder.previewAnnualMinor), and
* the MSW mock (procurementSaas.priceQuote). The client + mock are deliberately non-authoritative,
* but nothing stopped them silently drifting from the server.
*
* This pins both TypeScript copies to each other AND to the exact fixtures locked in the Java
* ProcurementPricingServiceTest. If the rate card changes on the server, that Java test breaks
* first; updating these fixtures to match is the reminder to keep the client/mock in step. If the
* client and mock diverge from one another, this breaks on its own.
*
* Keep these numbers identical to ProcurementPricingServiceTest.
*/
function cfg(overrides: Partial<QuoteConfigInput>): QuoteConfigInput {
return {
volume: 0,
users: 0,
intensity: 4,
sizeMult: 1.0,
deployment: "cloud",
termYears: 3,
serviceLevel: "standard",
indemnification: false,
training: false,
qbr: false,
businessName: "",
...overrides,
};
}
// annualNetMinor (USD minor units) — each mirrors an assertion in ProcurementPricingServiceTest.
const FIXTURES: {
name: string;
cfg: QuoteConfigInput;
annualNetMinor: number;
}[] = [
{
name: "Northwind — 6M · Governed · cloud · standard · 3yr",
cfg: cfg({ volume: 6_000_000 }),
annualNetMinor: 16_527_800,
},
{
name: "Northwind, Standard PDF size (rate ×1.4)",
cfg: cfg({ volume: 6_000_000, sizeMult: 1.4 }),
annualNetMinor: 23_138_900,
},
{
name: "acme — 90M · Governed · self-hosted · dedicated · 3yr",
cfg: cfg({
volume: 90_000_000,
deployment: "selfhost",
serviceLevel: "dedicated",
}),
annualNetMinor: 175_200_000,
},
{
name: "1-year term — no meter discount",
cfg: cfg({ volume: 6_000_000, termYears: 1 }),
annualNetMinor: 17_397_700,
},
{
name: "2-year term — 3% off the meter",
cfg: cfg({ volume: 6_000_000, termYears: 2 }),
annualNetMinor: 16_875_700,
},
{
name: "rate floors at half a cent — 100M · Regulated · 1yr",
cfg: cfg({ volume: 100_000_000, intensity: 7, termYears: 1 }),
annualNetMinor: 350_000_000,
},
];
describe("procurement pricing parity (client ↔ mock ↔ server fixtures)", () => {
for (const f of FIXTURES) {
it(`agrees on ${f.name}`, () => {
expect(previewAnnualMinor(f.cfg)).toBe(f.annualNetMinor);
expect(priceQuote(f.cfg).annualNetMinor).toBe(f.annualNetMinor);
});
}
});
@@ -8,16 +8,19 @@ import {
fetchLicenseFile,
fetchQuotePdf,
fetchSnapshot,
goLive,
issueQuote,
resetProcurement,
startAgreement,
startTrial,
type ProcurementSnapshot,
type QuoteResult,
} from "@portal/api/procurement";
export type ProcurementExtra = null | "docs" | "schedule" | "trial";
export type ProcurementExtra =
| null
| "license"
| "schedule"
| "trial"
| "setup";
/**
* Owns the procurement deal state and actions shared by the Home hero footer
@@ -46,13 +49,14 @@ export interface ProcurementController {
extra: ProcurementExtra;
setExtra: (e: ProcurementExtra) => void;
invoicePdf: string | null;
/** Open the trial-setup dialog (deployment + seats) — the trial only starts once it's confirmed. */
onStartTrial: () => void;
/** Confirm the setup dialog: start the trial with the chosen deployment/seats, then open the flow. */
onConfirmSetup: (deployment: string, seats: number) => void;
onExtendTrial: () => void;
onReset: () => void;
onGenerate: (draft: QuoteResult) => void;
onAcceptQuote: () => void;
onAgree: () => void;
onGoLive: () => void;
onDownloadPdf: () => Promise<void>;
onDownloadOfflineLicense: () => Promise<void>;
}
@@ -99,7 +103,14 @@ export function useProcurement(autoOpen = false): ProcurementController {
}
}
const onStartTrial = () => run(startTrial);
// The setup dialog collects deployment + seats first; the trial starts on confirm.
const onStartTrial = () => setExtra("setup");
const onConfirmSetup = (deployment: string, seats: number) =>
run(async () => {
await startTrial(deployment, seats);
setExtra(null);
setOpen(true);
});
const onExtendTrial = () => run(extendTrial);
const onReset = () =>
run(async () => {
@@ -112,15 +123,14 @@ export function useProcurement(autoOpen = false): ProcurementController {
await issueQuote(draft.quoteId);
setEditing(false);
});
// Milestone agreement (security) stage; then agreeing accepts into a subscription.
const onAcceptQuote = () => run(startAgreement);
// Quote + agreement are one step now: agreeing accepts the issued quote straight into a
// committed subscription (Stripe), and provisioning upgrades the licence server-side.
const onAgree = () =>
run(async () => {
if (!latest) return;
const res = await acceptQuote(latest.quoteId);
setInvoicePdf(res.invoicePdf);
});
const onGoLive = () => run(goLive);
async function onDownloadPdf() {
if (!latest) return;
@@ -194,12 +204,11 @@ export function useProcurement(autoOpen = false): ProcurementController {
setExtra,
invoicePdf,
onStartTrial,
onConfirmSetup,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
onDownloadOfflineLicense,
};
@@ -10,6 +10,8 @@ const SAAS = "http://saas.mock";
const EMPTY = {
dealId: null,
stage: null,
deployment: "cloud",
seats: 0,
trialStartedAt: null,
trialEndsAt: null,
trialExtensionsUsed: 0,
@@ -20,41 +22,79 @@ const EMPTY = {
interface Cfg {
volume: number;
users?: number;
intensity: number;
sizeMult: number;
deployment: string;
serviceLevel: string;
termYears: number;
indemnification: boolean;
training: boolean;
qbr: boolean;
offlineLicense: boolean;
currency: string;
businessName?: string;
contactName?: string;
contactEmail?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
region?: string;
postalCode?: string;
poNumber?: string;
taxId?: 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];
const TERM = [0, 0.03, 0.05, 0.06, 0.07]; // meter-only, 1..5 years
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],
);
// Mirror of ProcurementPricingService (D71): run-based curve, flat priced needs, USD.
// Exported for the pricing-parity test (see pricingParity.test.ts).
export function priceQuote(cfg: Cfg) {
const LIST = 0.01;
const FLOOR = 0.005;
const intensity = Math.max(1, cfg.intensity || 4);
const runVol = Math.max(0, cfg.volume) * intensity;
const volDisc =
runVol > 1_000_000
? Math.min(0.5, 0.06 * Math.log2(runVol / 1_000_000))
: 0;
// File-size tier (D93) scales the rate after the floor; snap to a known multiplier.
const sizeMult = [1.0, 1.4, 2.4].includes(cfg.sizeMult) ? cfg.sizeMult : 1.0;
const rate = Math.max(FLOOR, LIST * (1 - volDisc)) * sizeMult;
const termDisc = TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1];
const annualBase = Math.round(runVol * rate) * 100; // whole $ → minor
const meterNet = Math.round(runVol * rate * (1 - termDisc)) * 100;
const termDiscount = meterNet - annualBase; // <= 0
const support = cfg.serviceLevel === "dedicated" ? 3_000_000 : 0;
const deploy =
cfg.deployment === "airgap"
? 3_600_000
: cfg.deployment === "selfhost"
? 1_200_000
: 0;
const indemnity = cfg.indemnification ? Math.round(meterNet * 0.05) : 0;
const qbr = cfg.qbr ? 800_000 : 0;
const offline = cfg.offlineLicense ? 1_200_000 : 0;
const training = cfg.training ? 750_000 : 0;
const annualNetMinor = withInd - disc + qbr + offline;
const annualNetMinor = meterNet + support + deploy + indemnity + qbr;
const tcvMinor = annualNetMinor * cfg.termYears + training;
const posture =
intensity === 2
? "Essentials"
: intensity === 7
? "Regulated"
: intensity === 4
? "Governed"
: `${intensity}-policy`;
const deployName =
cfg.deployment === "airgap"
? "Air-gapped"
: cfg.deployment === "selfhost"
? "Self-hosted"
: "Stirling Cloud";
type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED";
const lines: {
key: string;
@@ -64,33 +104,44 @@ function priceQuote(cfg: Cfg) {
}[] = [
{
key: "usage",
label: "PDF processing",
label: `PDF processing${cfg.volume.toLocaleString()} PDFs/yr at $${(rate * intensity).toFixed(4)}/PDF (${posture} posture)`,
kind: "RECURRING",
amountMinor: usage,
amountMinor: annualBase,
},
{
key: "seats",
label: "Unlimited users + SSO / SCIM / RBAC",
label: "Unlimited users + SSO / SCIM / RBAC / audit",
kind: "INCLUDED",
amountMinor: 0,
},
];
if (withSla !== usage)
if (termDiscount < 0)
lines.push({
key: "service-level",
label:
cfg.serviceLevel === "dedicated"
? "Dedicated service level"
: "Priority service level",
kind: "RECURRING",
amountMinor: withSla - usage,
key: "multi-year",
label: `${cfg.termYears}-year commitment`,
kind: "DISCOUNT",
amountMinor: termDiscount,
});
if (withInd !== withSla)
if (support > 0)
lines.push({
key: "support",
label: "Dedicated SE / CSM",
kind: "RECURRING",
amountMinor: support,
});
if (deploy > 0)
lines.push({
key: "deployment",
label: `${deployName} deployment`,
kind: "RECURRING",
amountMinor: deploy,
});
if (indemnity > 0)
lines.push({
key: "indemnification",
label: "IP indemnification",
kind: "RECURRING",
amountMinor: withInd - withSla,
amountMinor: indemnity,
});
if (qbr > 0)
lines.push({
@@ -99,20 +150,6 @@ function priceQuote(cfg: Cfg) {
kind: "RECURRING",
amountMinor: qbr,
});
if (offline > 0)
lines.push({
key: "offline-license",
label: "Offline / air-gapped licence",
kind: "RECURRING",
amountMinor: offline,
});
if (disc > 0)
lines.push({
key: "multi-year",
label: `${cfg.termYears}-year commitment`,
kind: "DISCOUNT",
amountMinor: -disc,
});
if (training > 0)
lines.push({
key: "training",
@@ -126,25 +163,37 @@ function priceQuote(cfg: Cfg) {
quoteId: seq,
quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`,
status: "draft",
currency: cfg.currency || "USD",
currency: "USD",
annualNetMinor,
tcvMinor,
renewalAnnualNetMinor: Math.round(annualNetMinor * 1.03), // +3% CPI on renewal
cpiRatePct: 3,
lineItems: lines,
validUntil: "2026-07-31",
stripeQuoteId: null,
invoiceUrl: null,
invoicePdf: null,
config: {
volume: cfg.volume,
users: 0,
deployment: "cloud",
intensity,
sizeMult,
deployment: cfg.deployment || "cloud",
termYears: cfg.termYears,
serviceLevel: cfg.serviceLevel,
indemnification: cfg.indemnification,
training: cfg.training,
qbr: cfg.qbr,
offlineLicense: cfg.offlineLicense,
currency: cfg.currency || "USD",
businessName: cfg.businessName ?? "",
contactName: cfg.contactName ?? "",
contactEmail: cfg.contactEmail ?? "",
addressLine1: cfg.addressLine1 ?? "",
addressLine2: cfg.addressLine2 ?? "",
city: cfg.city ?? "",
region: cfg.region ?? "",
postalCode: cfg.postalCode ?? "",
poNumber: cfg.poNumber ?? "",
taxId: cfg.taxId ?? "",
},
};
}
@@ -156,11 +205,20 @@ export function resetProcurementSaasStore() {
export const procurementSaasHandlers = [
http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)),
http.post(`${SAAS}/api/v1/procurement/trial/start`, () => {
http.post(`${SAAS}/api/v1/procurement/trial/start`, async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as Partial<{
deployment: string;
users: number;
}>;
const allowed = ["cloud", "selfhost", "airgap"];
const now = Date.now();
deal = {
dealId: 1,
stage: "trial",
deployment: allowed.includes(body.deployment ?? "")
? body.deployment
: "cloud",
seats: Math.max(0, Number(body.users) || 0),
trialStartedAt: new Date(now).toISOString(),
trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(),
trialExtensionsUsed: 0,
@@ -210,7 +268,8 @@ export const procurementSaasHandlers = [
}),
http.get(`${SAAS}/api/v1/procurement/license/file`, () => {
const q = (deal as { latestQuote: { config?: Cfg } | null }).latestQuote;
if (!q?.config?.offlineLicense) {
// Offline .lic is available only for an air-gapped deployment (matches the Java backend).
if (q?.config?.deployment !== "airgap") {
return new HttpResponse(null, { status: 404 });
}
return new HttpResponse(
@@ -237,16 +296,18 @@ export const procurementSaasHandlers = [
const q = (deal as { latestQuote: Record<string, unknown> | null })
.latestQuote;
const invoiceUrl = "https://invoice.stripe.com/i/mock_procurement";
const invoicePdf = "https://invoice.stripe.com/i/mock_procurement/pdf";
if (q) {
q.status = "accepted";
q.invoiceUrl = invoiceUrl;
q.invoicePdf = invoicePdf;
(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",
invoicePdf,
});
}),
http.post(`${SAAS}/functions/v1/get-procurement-quote-pdf`, () => {
@@ -28,6 +28,8 @@ export const SubscribedInProcurement: Story = {
HttpResponse.json({
dealId: 1,
stage: "trial",
deployment: "cloud",
seats: 250,
trialStartedAt: "2026-07-01T00:00:00.000Z",
trialEndsAt: "2026-07-21T00:00:00.000Z",
trialExtensionsUsed: 0,