diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 57f7e6a3a3..d67a0fddbf 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -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 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 startTrial(Authentication auth) { + public ResponseEntity 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 parseLineItems(String json) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java index f97d66f365..3a5d3db902 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java index dd635a2cf6..aa94128172 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java new file mode 100644 index 0000000000..18683c5907 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java @@ -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) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java index d10f216e8a..61dc5f499a 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java @@ -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) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java index 4707c94197..08ade5a157 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java @@ -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) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java index 10ffde5271..05b136f539 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java @@ -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 lineItems, long annualNetMinor, long tcvMinor, String currency) {} + List lineItems, + long annualNetMinor, + long tcvMinor, + long renewalAnnualNetMinor, + String currency) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java index cc6612c530..7511f2ff95 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java @@ -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; } } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index 225e0327e8..cae1cf36fb 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -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. + * + *

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 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 accepted 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. diff --git a/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql new file mode 100644 index 0000000000..946b8415c6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql new file mode 100644 index 0000000000..186e180cb2 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql new file mode 100644 index 0000000000..75a4479c31 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql new file mode 100644 index 0000000000..7c213cb3dc --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql new file mode 100644 index 0000000000..3f07f201bb --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql @@ -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); diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java index 1676541db2..e62c6ff00b 100644 --- a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java @@ -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(); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index f3fe7bfefd..847b3be863 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 26d73e5814..4d8a28e057 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -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 { @@ -343,10 +381,17 @@ export function fetchLicenseFile(): Promise { return apiClient.saas.text("/api/v1/procurement/license/file"); } -export function startTrial(): Promise { +/** + * 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 { return apiClient.saas.json( "/api/v1/procurement/trial/start", - { method: "POST" }, + { method: "POST", body: { deployment, users: seats } }, ); } diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 643703f447..4c8c460405 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -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 ? ( ) : ( - + ); return ( diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx index bc41c30dd6..b2f2df3b42 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.tsx @@ -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 }) { )} - {stage !== "active" && ( + {snapshot.licenseKey && ( )} {stage !== "active" && ( @@ -120,7 +121,7 @@ export function DealStatusHero({

- +
{inTrial && ( diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 640fcdb2e3..336b6af5cb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -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({ ))} -

3. End-User License Agreement

+

3. Term, renewal and annual fee adjustment

+

+ 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{" "} + {renewal} per year; the committed term above is + billed at the rate in the Order Form and is not affected. +

+ +

4. End-User License Agreement

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.

-

4. Data Processing Agreement

+

5. Data Processing Agreement

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.

-

5. Acceptance

+

6. Acceptance

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")} + + ); diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index 902bb14c0d..d47ffa0b48 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -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")} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 048bc2d606..44bbea1264 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -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 = { - 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 ( - {STAGE_DOCS.map((g) => ( -

-
{t(g.groupKey)}
-
    - {g.docs.map((d) => ( -
  • -
    - {t(d.nameKey)} - - {t(d.subKey)} - {d.fee - ? t("portal.procurement.keyDocs.oneTimeFee", { - amount: `$${d.fee.toLocaleString()}`, - }) - : ""} - -
    - - {t(STATUS_LABEL[d.status])} - -
  • - ))} -
-
- ))} + + {offlineAvailable && trial && ( +

+ {t("portal.procurement.license.trialFileHint")} +

+ )} ); } @@ -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("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 ( + onConfirm(deployment, Math.max(0, Number(seats) || 0))} + > + {t("portal.procurement.setup.start")} + + } + > + + + +

+ {t("portal.procurement.setup.seatsHint")} +

+
+ ); +} + // ── Trial management ───────────────────────────────────────────────────────── export function TrialManageModal({ open, diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx index 94ee979b86..d1527c32bb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -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 && ( <>
- +
{(editing || (isDraft && (stage === "trial" || stage === "quote"))) && ( )} - {!editing && isIssued && stage === "quote" && latest && ( - setEditing(true)} - /> - )} - - {!editing && stage === "security" && latest && ( - - )} + {/* 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 && ( + setEditing(true)} + /> + )} {!editing && stage === "procurement" && latest && ( )} {!editing && stage === "active" && } - - {data?.licenseKey && ( - - )} - -
- -
)} - setExtra(null)} + busy={busy} + onConfirm={onConfirmSetup} /> + {data?.licenseKey && ( + setExtra(null)} + licenseKey={data.licenseKey} + offlineAvailable={data.deployment === "airgap"} + downloadingLicense={downloadingLicense} + onDownloadOffline={onDownloadOfflineLicense} + trial={data.stage !== "procurement" && data.stage !== "active"} + /> + )} setExtra(null)} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index bd5c0e3fc5..35460a7ba5 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -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 ( - - - {t("portal.procurement.milestone.eyebrow", { - number: quote.quoteNumber, - })} - -

- {t("portal.procurement.milestone.title")} -

- {quote.config.businessName && ( -

- {t("portal.procurement.milestone.preparedFor", { - company: quote.config.businessName, - })} -

- )} -

- {t("portal.procurement.milestone.description")} -

-
    - {quote.lineItems.map((li) => ( -
  • - {li.label} - - {li.kind === "INCLUDED" - ? t("portal.procurement.builder.included") - : money(li.amountMinor, quote.currency)} - -
  • - ))} -
-
- - {money(quote.annualNetMinor, quote.currency)} - {t("portal.procurement.milestone.perYear")} - - - {t("portal.procurement.milestone.tcv", { - value: money(quote.tcvMinor, quote.currency), - })} - -
-
- - - -
-
- ); -} - -/** 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({ )} )} -
- -
); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index 1ba6016a4a..b392247563 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -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 = { - 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 = { */ 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( 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")} > + +
+ {POSTURES.map((p) => ( + set("intensity", p.intensity)} + /> + ))} +
+
+ + +
+ {SIZE_TIERS.map((s) => ( + set("sizeMult", s.mult)} + /> + ))} +
+
+
{[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)} /> - set("offlineLicense", !cfg.offlineLicense)} - />
@@ -241,31 +286,97 @@ export function QuoteBuilder({ title={t("portal.procurement.builder.s3Title")} sub={t("portal.procurement.builder.s3Sub")} > - +
+ + set("businessName", e.target.value)} + /> + + + set("contactName", e.target.value)} + /> + +
+ + set("contactEmail", e.target.value)} + /> + + set("businessName", e.target.value)} + value={cfg.addressLine1 ?? ""} + onChange={(e) => set("addressLine1", e.target.value)} + /> + + + set("addressLine2", e.target.value)} />
- - + + set("city", e.target.value)} + /> + + + set("region", e.target.value)} + /> + + + set("postalCode", e.target.value)} + /> + +
+
+ + set("poNumber", e.target.value)} + /> + + + set("taxId", e.target.value)} + />