mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
+86
-16
@@ -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;
|
||||
|
||||
|
||||
+46
-3
@@ -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) {
|
||||
|
||||
+29
-1
@@ -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) {
|
||||
|
||||
+9
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+48
-23
@@ -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;
|
||||
+17
@@ -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);
|
||||
+44
-3
@@ -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"
|
||||
|
||||
@@ -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 & 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,
|
||||
|
||||
Reference in New Issue
Block a user