From 7bd38261788be970c4cd32eadeec6e8b83945133 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:02:02 +0100 Subject: [PATCH] Portal procurement: real pricing/trial/quote spine + linked-gated checkout (vertical slice) (#6861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this is The enterprise procurement flow, built into the customer portal as a **vertical slice** — one linked account can go the whole way from trial to a paid, committed subscription, using real Stripe under the hood. Procurement no longer lives as a nav tab. It sits on **Home** as a deal-status hero and expands into a full-screen takeover, matching the marketing prototype. ## The journey (what a customer does) - **Start a trial** in one click — the deadline and next steps show on the Home hero (no card, mock licence). - **Build a quote** — a short form (volume → commitment & service → details); pricing is computed server-side. - **Generate the quote** — this creates a real **Stripe Quote** with a proper **PDF** you can download and share, and it becomes a milestone you can come back to. - **Review & sign the agreement** — one combined agreement (MSA + Order Form + EULA + DPA) with an itemised order form and an "I agree" (no e-signature yet). - **Accept** — Stripe creates the committed annual **subscription** and its **first invoice**, which you can **pay or download right in the app** (no waiting on email). - Edit a quote any time — it remembers your inputs and company name; the old Stripe quote is cancelled so it can't still be accepted. - The hero also has quick actions: **key documents**, **invite teammates**, **schedule a call**, and a **trial countdown** you can extend. ## Architecture — Supabase vs Java Pricing, deal/quote state, and the commercial journey live in **Java (`:saas`)**. Everything that touches **Stripe** (writes + PDFs) lives in **Supabase edge functions** — Java has no Stripe SDK and only reads Stripe via the sync mirror. The portal calls both. ```mermaid flowchart LR Portal["Portal (React · editor/src/portal)"] subgraph JAVA["Java :saas backend (trusted cloud)"] Pricing["Pricing engine (volume bands, SLA, term, add-ons)"] Deal["Deal + quote state, journey, snapshot"] Trial["Trial (mock Keygen licence seam)"] Authz["Auth: team resolve + leader gating"] Mirror["Reads Stripe via sync mirror (stripe.* tables)"] end subgraph SUPA["Supabase edge functions (own Stripe)"] Issue["issue-procurement-quote → create + finalize Stripe Quote"] Accept["accept-procurement-quote → subscription + finalize invoice"] Pdf["get-procurement-quote-pdf → proxy the quote PDF"] RPC["SECURITY DEFINER RPCs (read/write stirling_pdf, enforce team/leader)"] end Stripe["Stripe (Quotes · Subscription · Invoice)"] Portal -->|"price / build / trial / agreement / snapshot"| JAVA Portal -->|"issue / accept / download PDF"| SUPA SUPA --> Stripe SUPA --- RPC Mirror -. reads .-> Stripe ``` | Top-level feature | Handled in | |---|---| | Quote pricing (bands, SLA, term, add-ons) | **Java** | | Deal + quote state, journey, snapshot | **Java** | | Trial start / extend (mock licence) | **Java** | | AuthN/Z (team resolve, leader gating) | **Java** | | Issue quote → Stripe Quote + PDF | **Supabase edge fn** | | Accept → subscription + invoice | **Supabase edge fn** | | Quote PDF download | **Supabase edge fn** | | Reading Stripe state | **Java** (sync mirror) | | `stirling_pdf` writes from edge | **SECURITY DEFINER RPCs** (service-role only) | ## Screenshots **Home deal-status hero (trial)** hero-check **Issued quote milestone (with breakdown)** milestone-breakdown **Agreement step (itemised order form)** agreement-itemised **Key documents** keydocs-modal **Subscription created (pay / download invoice)** accepted-check ## Mocked for now (scaffolding, not wired to real backends) - **Key documents** ledger — static demo list. - **Schedule a call** — static solutions-engineer + time slots. - **Invite teammates** — routes to the existing Users view. - **Simulate payment received** / **Reset procurement** — demo controls, **off by default** in prod (flag-gated), 404 unless enabled. ## Deferred (separate follow-up PRs) - **Real `invoice.paid` webhook** → go-live (today a demo button stands in). - **Keygen licence controller** — real licensing (currently a mock seam). - **Document sharing**. - **Stirling admin / Deal Desk** view. - **Minimum ACV floor** — pending a number from marketing (server-side enforcement is a one-liner once decided). ## How to test - **Frontend, no backend:** runs against MSW mocks (Storybook + mocks-on dev) — the whole journey is clickable. - **Real end-to-end:** apply the migrations (Flyway `V27–V29` / Supabase `20260701–20260707`), deploy the three edge functions, ensure **Invoicing Plus** is enabled on Stripe, and set `STIRLING_PROCUREMENT_DEMO_CONTROLS_ENABLED=true` if you want the demo controls. - Paired SaaS PR: **Stirling-Tools/Stirling-PDF-SaaS#318**. ## Notes for reviewers - Pricing is server-authoritative (client sends config, never amounts). - Security review done: edge functions validate the JWT and enforce **team membership** (and **leader** for issue/accept) via the RPC; demo endpoints are flag-gated off. Only open item is the ACV floor (policy). --- .../software/saas/config/SaasJpaConfig.java | 6 +- .../api/ProcurementController.java | 321 ++++++ .../ProcurementConfigurationProperties.java | 33 + .../license/EnterpriseLicenseService.java | 25 + .../license/MockEnterpriseLicenseService.java | 54 + .../procurement/model/ProcurementDeal.java | 87 ++ .../procurement/model/ProcurementQuote.java | 119 ++ .../procurement/pricing/PricingRates.java | 53 + .../pricing/ProcurementPricingService.java | 125 ++ .../procurement/pricing/QuoteBreakdown.java | 12 + .../saas/procurement/pricing/QuoteConfig.java | 26 + .../procurement/pricing/QuoteLineItem.java | 16 + .../repository/ProcurementDealRepository.java | 17 + .../ProcurementQuoteRepository.java | 12 + .../service/ProcurementService.java | 244 ++++ .../db/migration/saas/V27__procurement.sql | 72 ++ .../saas/V28__procurement_stripe_quote.sql | 8 + .../saas/V29__procurement_business_name.sql | 5 + .../ProcurementPricingServiceTest.java | 87 ++ frontend/.gitignore | 1 + frontend/.storybook/preview.tsx | 6 +- .../public/locales/en-US/translation.toml | 129 ++- frontend/editor/src/portal/api/procurement.ts | 163 +++ .../editor/src/portal/components/Sidebar.tsx | 12 +- .../components/billing/EnterpriseUpsell.tsx | 12 +- .../procurement/DealStatusHero.stories.tsx | 47 + .../components/procurement/DealStatusHero.tsx | 162 +++ .../procurement/ProcurementAgreement.tsx | 126 +++ .../procurement/ProcurementExtras.tsx | 299 +++++ .../procurement/ProcurementHome.stories.tsx | 18 + .../procurement/ProcurementHome.tsx | 296 +++++ .../procurement/ProcurementModal.stories.tsx | 42 + .../procurement/ProcurementModal.tsx | 102 ++ .../procurement/ProcurementStages.tsx | 160 +++ .../procurement/QuoteBuilder.stories.tsx | 20 + .../components/procurement/QuoteBuilder.tsx | 436 +++++++ .../portal/components/procurement/format.ts | 9 + .../editor/src/portal/mocks/handlers/index.ts | 2 + .../portal/mocks/handlers/procurementSaas.ts | 241 ++++ frontend/editor/src/portal/views/Home.tsx | 3 + .../editor/src/portal/views/Procurement.css | 1007 +++++++++++++++++ .../editor/src/portal/views/Procurement.tsx | 106 +- 42 files changed, 4603 insertions(+), 118 deletions(-) create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V27__procurement.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql create mode 100644 app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java create mode 100644 frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/DealStatusHero.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementHome.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementModal.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementStages.tsx create mode 100644 frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx create mode 100644 frontend/editor/src/portal/mocks/handlers/procurementSaas.ts diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java index 5e90df6bf5..53a5f34c15 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java @@ -18,13 +18,15 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "stirling.software.saas.repository", "stirling.software.saas.billing.repository", "stirling.software.saas.ai.repository", - "stirling.software.saas.payg.repository" + "stirling.software.saas.payg.repository", + "stirling.software.saas.procurement.repository" }) @EntityScan({ "stirling.software.saas.accountlink", "stirling.software.saas.model", "stirling.software.saas.billing.model", "stirling.software.saas.ai.model", - "stirling.software.saas.payg" + "stirling.software.saas.payg", + "stirling.software.saas.procurement.model" }) public class SaasJpaConfig {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java new file mode 100644 index 0000000000..1f27896b87 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -0,0 +1,321 @@ +package stirling.software.saas.procurement.api; + +import java.util.List; +import java.util.Objects; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; +import stirling.software.saas.procurement.model.ProcurementDeal; +import stirling.software.saas.procurement.model.ProcurementQuote; +import stirling.software.saas.procurement.pricing.QuoteConfig; +import stirling.software.saas.procurement.pricing.QuoteLineItem; +import stirling.software.saas.procurement.service.ProcurementService; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a + * (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a + * Supabase edge function the portal calls with the accepted quote — this controller never touches + * Stripe. The caller's team is resolved from the authenticated principal; a team id is never + * trusted from the request. Mutations require the team leader. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/procurement") +@Profile("saas") +public class ProcurementController { + + // Local mapper to parse the stored line-items JSON; the saas context exposes no injectable + // ObjectMapper bean. + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ProcurementService procurement; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + private final ProcurementConfigurationProperties config; + + public ProcurementController( + ProcurementService procurement, + TeamMembershipRepository memberRepo, + UserRepository userRepository, + ProcurementConfigurationProperties config) { + this.procurement = Objects.requireNonNull(procurement); + this.memberRepo = Objects.requireNonNull(memberRepo); + this.userRepository = Objects.requireNonNull(userRepository); + this.config = Objects.requireNonNull(config); + } + + // ---- request / response DTOs ------------------------------------------- + + public record QuoteRequest( + long volume, + int users, + String deployment, + int termYears, + String serviceLevel, + boolean indemnification, + boolean training, + boolean qbr, + String currency, + String businessName) { + QuoteConfig toConfig() { + return new QuoteConfig( + volume, + users, + deployment, + termYears, + serviceLevel, + indemnification, + training, + qbr, + currency); + } + } + + public record QuoteResponse( + Long quoteId, + String quoteNumber, + String status, + String currency, + long annualNetMinor, + long tcvMinor, + List lineItems, + String validUntil, + String stripeQuoteId, + String invoiceUrl, + QuoteConfigEcho config) {} + + /** + * The inputs the quote was priced from, echoed back so the builder can seed itself when the + * buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume + * is), so it is always 0 here; the builder treats the seeded volume as manually set. + */ + public record QuoteConfigEcho( + long volume, + int users, + String deployment, + int termYears, + String serviceLevel, + boolean indemnification, + boolean training, + boolean qbr, + String currency, + String businessName) {} + + public record SnapshotResponse( + Long dealId, + String stage, + String trialStartedAt, + String trialEndsAt, + int trialExtensionsUsed, + boolean licensed, + QuoteResponse latestQuote) {} + + // ---- endpoints ---------------------------------------------------------- + + /** + * The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an + * empty snapshot ({@code dealId == null}) so the portal can render the "start" state without + * special-casing an empty body. + */ + @GetMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity snapshot(Authentication auth) { + Long teamId = resolveTeam(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok( + procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT)); + } + + private static final SnapshotResponse EMPTY_SNAPSHOT = + new SnapshotResponse(null, null, null, null, 0, false, null); + + @PostMapping("/trial/start") + @PreAuthorize("isAuthenticated()") + public ResponseEntity startTrial(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId))); + } + + @PostMapping("/trial/extend") + @PreAuthorize("isAuthenticated()") + public ResponseEntity extendTrial(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId))); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @PostMapping("/quote") + @PreAuthorize("isAuthenticated()") + public ResponseEntity buildQuote( + @RequestBody QuoteRequest request, Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok( + toQuote( + procurement.buildQuote( + teamId, request.toConfig(), request.businessName()))); + } + + // Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a + // draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription. + // Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them. + + /** + * Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees. + */ + @PostMapping("/agreement") + @PreAuthorize("isAuthenticated()") + public ResponseEntity startAgreement(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId))); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + /** + * Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the + * annual licence, advance to active). The real go-live is webhook-driven once payment settles. + */ + @PostMapping("/go-live") + @PreAuthorize("isAuthenticated()") + public ResponseEntity goLive(Authentication auth) { + if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build(); + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId))); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + /** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */ + @PostMapping("/reset") + @PreAuthorize("isAuthenticated()") + public ResponseEntity reset(Authentication auth) { + if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build(); + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + procurement.resetDeal(teamId); + return ResponseEntity.ok(EMPTY_SNAPSHOT); + } + + // ---- helpers ------------------------------------------------------------ + + /** + * Resolve the caller's team from their primary membership; null when unauthenticated/teamless. + */ + private Long resolveTeam(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return null; + } + List rows = memberRepo.findPrimaryMembership(user.getId()); + return rows.isEmpty() ? null : rows.get(0).getTeam().getId(); + } + + /** Team id only when the caller is the team leader; null otherwise (commercial actions). */ + private Long requireLeader(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return null; + } + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null; + return rows.get(0).getTeam().getId(); + } + + private SnapshotResponse toSnapshot(ProcurementDeal deal) { + QuoteResponse latest = + procurement.quotesForDeal(deal.getDealId()).stream() + .findFirst() + .map(this::toQuote) + .orElse(null); + return new SnapshotResponse( + deal.getDealId(), + deal.getStage(), + str(deal.getTrialStartedAt()), + str(deal.getTrialEndsAt()), + deal.getTrialExtensionsUsed(), + deal.getLicenseRef() != null, + latest); + } + + private QuoteResponse toQuote(ProcurementQuote q) { + return new QuoteResponse( + q.getQuoteId(), + q.getQuoteNumber(), + q.getStatus(), + q.getCurrency(), + q.getAnnualNetMinor(), + q.getTcvMinor(), + parseLineItems(q.getLineItemsJson()), + q.getValidUntil() == null ? null : q.getValidUntil().toString(), + q.getStripeQuoteId(), + q.getStripeInvoiceUrl(), + new QuoteConfigEcho( + q.getVolume(), + 0, + q.getDeployment(), + q.getTermYears(), + q.getServiceLevel(), + q.isIndemnification(), + q.isTraining(), + q.isQbr(), + q.getCurrency(), + q.getBusinessName())); + } + + private List parseLineItems(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return OBJECT_MAPPER.readValue( + json, + OBJECT_MAPPER + .getTypeFactory() + .constructCollectionType(List.class, QuoteLineItem.class)); + } catch (Exception e) { + log.warn("[procurement] failed to parse line items", e); + return List.of(); + } + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java new file mode 100644 index 0000000000..98ec0cd573 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java @@ -0,0 +1,33 @@ +package stirling.software.saas.procurement.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */ +@Getter +@Setter +@Component +@Profile("saas") +@ConfigurationProperties(prefix = "stirling.procurement") +public class ProcurementConfigurationProperties { + + /** Free trial length, in days (no card). */ + private int trialDurationDays = 14; + + /** Days added per trial extension. */ + private int trialExtensionDays = 7; + + /** Maximum number of trial extensions a buyer may take. */ + private int maxTrialExtensions = 2; + + /** + * Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement + * or mark it live without payment. Off by default; turn on ONLY in demo/dev environments — + * /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid. + */ + private boolean demoControlsEnabled = false; +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java new file mode 100644 index 0000000000..0f0c242601 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java @@ -0,0 +1,25 @@ +package stirling.software.saas.procurement.license; + +import java.time.LocalDateTime; + +/** + * Issues and modifies the customer-facing entitlement that actually unlocks the product for an + * enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam + * the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records + * intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only + * verifies this instance's own licence. + */ +public interface EnterpriseLicenseService { + + /** Issue a time-boxed trial licence for the team; returns the licence reference. */ + String issueTrialLicense(Long teamId, LocalDateTime expiresAt); + + /** Move a licence's expiry out (trial extension). */ + void extendLicense(String licenseRef, LocalDateTime newExpiry); + + /** Issue/upgrade to a committed annual licence with the quote's entitlements. */ + String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt); + + /** Suspend a licence (e.g. payment failed, deal lost). */ + void suspendLicense(String licenseRef); +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java new file mode 100644 index 0000000000..bde31a4119 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java @@ -0,0 +1,54 @@ +package stirling.software.saas.procurement.license; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and + * returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run + * end-to-end while the real Keygen management client is a later drop-in — the seam and the stored + * {@code license_ref} on the deal stay identical. + */ +@Slf4j +@Service +@Profile("saas") +public class MockEnterpriseLicenseService implements EnterpriseLicenseService { + + @Override + public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) { + String ref = "mock-trial-" + UUID.randomUUID(); + log.info( + "[procurement][mock-license] issue trial team={} expires={} ref={}", + teamId, + expiresAt, + ref); + return ref; + } + + @Override + public void extendLicense(String licenseRef, LocalDateTime newExpiry) { + log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry); + } + + @Override + public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) { + String ref = "mock-annual-" + UUID.randomUUID(); + log.info( + "[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}", + teamId, + deployment, + expiresAt, + ref); + return ref; + } + + @Override + public void suspendLicense(String licenseRef) { + log.info("[procurement][mock-license] suspend ref={}", licenseRef); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java new file mode 100644 index 0000000000..f97d66f365 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java @@ -0,0 +1,87 @@ +package stirling.software.saas.procurement.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the + * portal renders (trial -> quote -> agreement -> payment -> live). The entitlement that + * actually unlocks the product is the Keygen licence in {@code licenseRef}; the paid subscription, + * once commercial, is mirrored in {@code billing_subscriptions} and referenced by {@code + * subscriptionId}. + */ +@Entity +@Table(name = "procurement_deal") +@NoArgsConstructor +@Getter +@Setter +public class ProcurementDeal implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final String STAGE_TRIAL = "trial"; + public static final String STAGE_QUOTE = "quote"; + public static final String STAGE_AGREEMENT = "security"; + public static final String STAGE_PAYMENT = "procurement"; + public static final String STAGE_LIVE = "active"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "deal_id") + private Long dealId; + + @Column(name = "team_id", nullable = false, unique = true) + private Long teamId; + + @Column(name = "stage", nullable = false, length = 32) + private String stage = STAGE_TRIAL; + + @Column(name = "trial_started_at") + private LocalDateTime trialStartedAt; + + @Column(name = "trial_ends_at") + private LocalDateTime trialEndsAt; + + @Column(name = "trial_extensions_used", nullable = false) + private int trialExtensionsUsed; + + @Column(name = "license_ref", length = 128) + private String licenseRef; + + @Column(name = "subscription_id", length = 255) + private String subscriptionId; + + @Column(name = "accepted_quote_id") + private Long acceptedQuoteId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Version + @Column(name = "version", nullable = false) + private Long version; + + public ProcurementDeal(Long teamId) { + this.teamId = teamId; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java new file mode 100644 index 0000000000..d8f4c3edb7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java @@ -0,0 +1,119 @@ +package stirling.software.saas.procurement.model; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the + * buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the + * server-computed result (never trusted from the client). Stripe fields are populated when the + * accepted quote is turned into a checkout. + */ +@Entity +@Table(name = "procurement_quote") +@NoArgsConstructor +@Getter +@Setter +public class ProcurementQuote implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final String STATUS_DRAFT = "draft"; + public static final String STATUS_SENT = "sent"; + public static final String STATUS_ACCEPTED = "accepted"; + public static final String STATUS_EXPIRED = "expired"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "quote_id") + private Long quoteId; + + @Column(name = "deal_id", nullable = false) + private Long dealId; + + @Column(name = "quote_number", nullable = false, length = 64) + private String quoteNumber; + + @Column(name = "status", nullable = false, length = 24) + private String status = STATUS_DRAFT; + + @Column(name = "currency", nullable = false, length = 8) + private String currency = "USD"; + + @Column(name = "volume", nullable = false) + private long volume; + + @Column(name = "seats") + private Integer seats; + + @Column(name = "deployment", length = 24) + private String deployment; + + @Column(name = "term_years", nullable = false) + private int termYears; + + @Column(name = "service_level", nullable = false, length = 24) + private String serviceLevel; + + @Column(name = "indemnification", nullable = false) + private boolean indemnification; + + @Column(name = "training", nullable = false) + private boolean training; + + @Column(name = "qbr", nullable = false) + private boolean qbr; + + @Column(name = "annual_net_minor", nullable = false) + private long annualNetMinor; + + @Column(name = "tcv_minor", nullable = false) + private long tcvMinor; + + @Column(name = "line_items", columnDefinition = "text") + private String lineItemsJson; + + // The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn. + @Column(name = "stripe_quote_id", length = 128) + private String stripeQuoteId; + + // Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is + // accepted. + @Column(name = "stripe_invoice_url", columnDefinition = "text") + private String stripeInvoiceUrl; + + // Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it. + @Column(name = "business_name", length = 255) + private String businessName; + + @Column(name = "valid_until") + private LocalDate validUntil; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Version + @Column(name = "version", nullable = false) + private Long version; +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java new file mode 100644 index 0000000000..19a3ab759d --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java @@ -0,0 +1,53 @@ +package stirling.software.saas.procurement.pricing; + +/** + * The enterprise rate card: the inputs pricing multiplies against. In production these are read + * from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the + * fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source + * of the numbers the marketing prototype encodes. + * + *

Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 = + * +15%). Flat/one-time fees are in minor units. + */ +public record PricingRates( + long perPdfMinorUnder1M, + long perPdfMinorUnder5M, + long perPdfMinor5MPlus, + double priorityUplift, + double dedicatedUplift, + double indemnificationUplift, + double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr + long qbrAnnualMinor, + long trainingOneTimeMinor) { + + public static PricingRates defaults() { + return new PricingRates( + 5, // $0.05 / PDF under 1M/yr + 4, // $0.04 / PDF at 1M–5M/yr + 3, // $0.03 / PDF at 5M+/yr + 0.15, // priority +15% + 0.30, // dedicated +30% + 0.05, // IP indemnification +5% + new double[] {0.0, 0.05, 0.10, 0.12, 0.15}, + 800_000, // QBRs $8,000 / yr + 750_000); // onboarding & training $7,500 one-time + } + + /** Volume-banded per-PDF rate for an annual volume, in minor units. */ + public long perPdfMinor(long annualVolume) { + if (annualVolume >= 5_000_000) return perPdfMinor5MPlus; + if (annualVolume >= 1_000_000) return perPdfMinorUnder5M; + return perPdfMinorUnder1M; + } + + public double termDiscount(int termYears) { + int idx = Math.max(1, Math.min(termYears, 5)) - 1; + return termDiscountByYear[idx]; + } + + public double serviceLevelUplift(String serviceLevel) { + if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift; + if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift; + return 0.0; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java new file mode 100644 index 0000000000..62d5baed9d --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java @@ -0,0 +1,125 @@ +package stirling.software.saas.procurement.pricing; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +/** + * The canonical enterprise pricing engine — the single server-side definition the quote builder, + * the order form, and the Stripe checkout all derive from. A faithful port of the marketing + * prototype's {@code quotePricing}: + * + *

+ *   annual        = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
+ *   annualNet     = round(annual x (1 - termDiscount)) + qbr
+ *   tcv           = annualNet x termYears + training
+ * 
+ * + * The multi-year discount applies to usage + service level + indemnification, but NOT to the flat + * QBR fee (added after), and one-time training sits outside the recurring total. All money is in + * minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod). + */ +@Service +public class ProcurementPricingService { + + /** Bespoke/committed deals don't price below this ACV; the builder floors against it. */ + public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000 + + /** + * Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working + * days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's + * {@code users x 5 x 230 x 1.75}. + */ + public long estimateAnnualVolume(int users) { + return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75); + } + + public QuoteBreakdown price(QuoteConfig cfg) { + return price(cfg, PricingRates.defaults()); + } + + public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) { + // Never trust the client's volume: clamp to non-negative so a tampered request can't drive + // a + // negative amount. The rate card and formula are server-side, so the browser can't lower + // the + // price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed + // floor, + // a policy decision that is intentionally not force-applied here — see the review notes.) + long volume = Math.max(0, cfg.volume()); + long perPdf = rates.perPdfMinor(volume); + long usage = Math.round(volume * (double) perPdf); // base, pre-service-level + double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel()); + long withSla = Math.round(usage * (1.0 + slaUplift)); + long withIndemnity = + cfg.indemnification() + ? Math.round(withSla * (1.0 + rates.indemnificationUplift())) + : withSla; + + double termDiscount = rates.termDiscount(cfg.termYears()); + long discount = Math.round(withIndemnity * termDiscount); + long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L; + long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L; + + long annualNet = (withIndemnity - discount) + qbr; + long tcv = annualNet * cfg.termYears() + training; + + List lines = new ArrayList<>(); + lines.add( + new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage)); + lines.add( + new QuoteLineItem( + "seats", + "Unlimited users + SSO / SCIM / RBAC", + QuoteLineItem.Kind.INCLUDED, + 0L)); + if (withSla != usage) { + lines.add( + new QuoteLineItem( + "service-level", + serviceLevelLabel(cfg.serviceLevel()), + QuoteLineItem.Kind.RECURRING, + withSla - usage)); + } + if (withIndemnity != withSla) { + lines.add( + new QuoteLineItem( + "indemnification", + "IP indemnification", + QuoteLineItem.Kind.RECURRING, + withIndemnity - withSla)); + } + if (qbr > 0) { + lines.add( + new QuoteLineItem( + "qbr", + "Quarterly business reviews", + QuoteLineItem.Kind.RECURRING, + qbr)); + } + if (discount > 0) { + lines.add( + new QuoteLineItem( + "multi-year", + cfg.termYears() + "-year commitment", + QuoteLineItem.Kind.DISCOUNT, + -discount)); + } + if (training > 0) { + lines.add( + new QuoteLineItem( + "training", + "Onboarding & training", + QuoteLineItem.Kind.ONE_TIME, + training)); + } + return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency()); + } + + private static String serviceLevelLabel(String serviceLevel) { + if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level"; + if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level"; + return "Standard service level"; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java new file mode 100644 index 0000000000..10ffde5271 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java @@ -0,0 +1,12 @@ +package stirling.software.saas.procurement.pricing; + +import java.util.List; + +/** + * The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the + * order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee + * after the multi-year discount; {@code tcvMinor} is total contract value across the term including + * one-time fees. Minor units (cents). + */ +public record QuoteBreakdown( + List lineItems, long annualNetMinor, long tcvMinor, String currency) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java new file mode 100644 index 0000000000..30917a73a6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java @@ -0,0 +1,26 @@ +package stirling.software.saas.procurement.pricing; + +/** + * The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps + * (volume, commitment & service, add-ons) and is the sole input to {@link + * ProcurementPricingService}. Amounts are never carried here — the service derives them from these + * choices and the rate card. + */ +public record QuoteConfig( + long volume, // committed PDFs per year + int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden) + String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced) + int termYears, // 1..5 + String serviceLevel, // standard | priority | dedicated + boolean indemnification, + boolean training, + boolean qbr, + String currency) { // USD | EUR | GBP + + public QuoteConfig { + if (termYears < 1) termYears = 1; + if (termYears > 5) termYears = 5; + if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard"; + if (currency == null || currency.isBlank()) currency = "USD"; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java new file mode 100644 index 0000000000..a8b0d29352 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java @@ -0,0 +1,16 @@ +package stirling.software.saas.procurement.pricing; + +/** + * One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents); + * discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a + * one-time fee vs a discount line). + */ +public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) { + + public enum Kind { + RECURRING, + ONE_TIME, + DISCOUNT, + INCLUDED + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java new file mode 100644 index 0000000000..14247ba1ba --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java @@ -0,0 +1,17 @@ +package stirling.software.saas.procurement.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import stirling.software.saas.procurement.model.ProcurementDeal; + +public interface ProcurementDealRepository extends JpaRepository { + + Optional findByTeamId(Long teamId); + + boolean existsByTeamId(Long teamId); + + /** Reset: drop the team's deal (quotes + activity cascade via FK). */ + void deleteByTeamId(Long teamId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java new file mode 100644 index 0000000000..45f7af8225 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java @@ -0,0 +1,12 @@ +package stirling.software.saas.procurement.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import stirling.software.saas.procurement.model.ProcurementQuote; + +public interface ProcurementQuoteRepository extends JpaRepository { + + List findByDealIdOrderByCreatedAtDesc(Long dealId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java new file mode 100644 index 0000000000..52e19f751e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -0,0 +1,244 @@ +package stirling.software.saas.procurement.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; +import stirling.software.saas.procurement.license.EnterpriseLicenseService; +import stirling.software.saas.procurement.model.ProcurementDeal; +import stirling.software.saas.procurement.model.ProcurementQuote; +import stirling.software.saas.procurement.pricing.ProcurementPricingService; +import stirling.software.saas.procurement.pricing.QuoteBreakdown; +import stirling.software.saas.procurement.pricing.QuoteConfig; +import stirling.software.saas.procurement.repository.ProcurementDealRepository; +import stirling.software.saas.procurement.repository.ProcurementQuoteRepository; + +/** + * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a + * server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the + * portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions} + * and this service issues the annual licence. All amounts are minor units (cents). + */ +@Slf4j +@Service +@Profile("saas") +public class ProcurementService { + + // Local mapper for the line-items JSON snapshot; the saas context exposes no injectable + // ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one. + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ProcurementDealRepository dealRepo; + private final ProcurementQuoteRepository quoteRepo; + private final ProcurementPricingService pricing; + private final EnterpriseLicenseService licenses; + private final ProcurementConfigurationProperties config; + + public ProcurementService( + ProcurementDealRepository dealRepo, + ProcurementQuoteRepository quoteRepo, + ProcurementPricingService pricing, + EnterpriseLicenseService licenses, + ProcurementConfigurationProperties config) { + this.dealRepo = dealRepo; + this.quoteRepo = quoteRepo; + this.pricing = pricing; + this.licenses = licenses; + this.config = config; + } + + @Transactional(readOnly = true) + public Optional getDeal(Long teamId) { + return dealRepo.findByTeamId(teamId); + } + + @Transactional(readOnly = true) + public List quotesForDeal(Long dealId) { + return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId); + } + + /** + * Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial + * window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the + * Keygen licence, and the deal row is the journey state. + */ + @Transactional + public ProcurementDeal startTrial(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId)); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime ends = now.plusDays(config.getTrialDurationDays()); + deal.setStage(ProcurementDeal.STAGE_TRIAL); + deal.setTrialStartedAt(now); + deal.setTrialEndsAt(ends); + deal.setTrialExtensionsUsed(0); + deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends)); + deal = dealRepo.save(deal); + log.info( + "[procurement] trial started team={} deal={} ends={}", + teamId, + deal.getDealId(), + ends); + return deal; + } + + /** Extend the current trial by the configured increment, up to the cap. */ + @Transactional + public ProcurementDeal extendTrial(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + // Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at + // the committed annual licence — extending would rewind its expiry. + if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) { + throw new IllegalStateException("Trial extension only allowed during the trial stage"); + } + if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) { + throw new IllegalStateException("Trial extension cap reached"); + } + LocalDateTime base = + deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now(); + LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays()); + deal.setTrialEndsAt(newEnd); + deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1); + if (deal.getLicenseRef() != null) { + licenses.extendLicense(deal.getLicenseRef(), newEnd); + } + return dealRepo.save(deal); + } + + /** Price a quote config server-side and persist it as a draft against the team's deal. */ + @Transactional + public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId)); + if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) { + throw new IllegalStateException("Cannot rebuild a quote on a live deal"); + } + // (Re)building a quote returns the deal to the quote stage and drops any prior acceptance, + // so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer. + deal.setStage(ProcurementDeal.STAGE_QUOTE); + deal.setAcceptedQuoteId(null); + deal = dealRepo.save(deal); + + QuoteBreakdown breakdown = pricing.price(cfg); + + ProcurementQuote quote = new ProcurementQuote(); + quote.setDealId(deal.getDealId()); + quote.setQuoteNumber(nextQuoteNumber(deal.getDealId())); + // Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT. + quote.setStatus(ProcurementQuote.STATUS_DRAFT); + quote.setCurrency(cfg.currency()); + quote.setVolume(cfg.volume()); + quote.setSeats(cfg.users() > 0 ? cfg.users() : null); + quote.setDeployment(cfg.deployment()); + quote.setTermYears(cfg.termYears()); + quote.setServiceLevel(cfg.serviceLevel()); + quote.setIndemnification(cfg.indemnification()); + quote.setTraining(cfg.training()); + quote.setQbr(cfg.qbr()); + quote.setBusinessName(businessName); + quote.setAnnualNetMinor(breakdown.annualNetMinor()); + quote.setTcvMinor(breakdown.tcvMinor()); + quote.setLineItemsJson(writeLineItems(breakdown)); + quote.setValidUntil(LocalDate.now().plusDays(30)); + quote = quoteRepo.save(quote); + log.info( + "[procurement] quote built team={} quote={} annualNet={} tcv={}", + teamId, + quote.getQuoteNumber(), + quote.getAnnualNetMinor(), + quote.getTcvMinor()); + return quote; + } + + /** + * Advance the deal to the agreement (security) stage: the buyer has an issued quote and is + * reviewing the enterprise agreement before it's accepted into a subscription. Requires an + * issued quote on the deal. + */ + @Transactional + public ProcurementDeal startAgreement(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + boolean hasIssuedQuote = + quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream() + .anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus())); + if (!hasIssuedQuote) { + throw new IllegalStateException("No issued quote for team " + teamId); + } + deal.setStage(ProcurementDeal.STAGE_AGREEMENT); + deal = dealRepo.save(deal); + log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId()); + return deal; + } + + /** + * Mark the deal live: issue the annual licence and advance to the active stage. In production + * this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this + * method is the demo/manual stand-in until that webhook lands. + */ + @Transactional + public ProcurementDeal markLive(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + int term = 1; + String deployment = "cloud"; + if (deal.getAcceptedQuoteId() != null) { + ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null); + if (q != null) { + term = Math.max(1, q.getTermYears()); + if (q.getDeployment() != null && !q.getDeployment().isBlank()) { + deployment = q.getDeployment(); + } + } + } + deal.setLicenseRef( + licenses.issueAnnualLicense( + teamId, deployment, LocalDateTime.now().plusYears(term))); + deal.setStage(ProcurementDeal.STAGE_LIVE); + deal = dealRepo.save(deal); + log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId()); + return deal; + } + + /** + * Reset a team's procurement: delete the deal (quotes + activity cascade). For + * re-demos/testing. + */ + @Transactional + public void resetDeal(Long teamId) { + dealRepo.deleteByTeamId(teamId); + log.info("[procurement] deal reset team={}", teamId); + } + + private String nextQuoteNumber(Long dealId) { + int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1; + String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT); + return String.format(Locale.ROOT, "QT-%s-%04d", token, seq); + } + + private String writeLineItems(QuoteBreakdown breakdown) { + try { + return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); + } catch (JsonProcessingException e) { + log.warn("[procurement] failed to serialise line items", e); + return "[]"; + } + } +} diff --git a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql new file mode 100644 index 0000000000..0048f999c1 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql @@ -0,0 +1,72 @@ +-- Enterprise procurement: the tables that track a linked team's journey from trial to live. +-- +-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the +-- quotes built against it (the itemised, priced offers), and an append-only activity log for the +-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions +-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence +-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService). +-- +-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch. + +CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal ( + deal_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, + -- one active deal per team; the journey lives on this row. + stage VARCHAR(32) NOT NULL DEFAULT 'trial', + -- trial | quote | security (agreement) | procurement (payment) | active (live) + trial_started_at TIMESTAMP, + trial_ends_at TIMESTAMP, + trial_extensions_used INT NOT NULL DEFAULT 0, + license_ref VARCHAR(128), + -- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands. + subscription_id VARCHAR(255), + -- Stripe subscription id, mirrored into billing_subscriptions once commercial. + accepted_quote_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote ( + quote_id BIGSERIAL PRIMARY KEY, + deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE, + quote_number VARCHAR(64) NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'draft', + -- draft | sent | accepted | expired + currency VARCHAR(8) NOT NULL DEFAULT 'USD', + volume BIGINT NOT NULL, + seats INT, + deployment VARCHAR(24), + term_years INT NOT NULL, + service_level VARCHAR(24) NOT NULL, + indemnification BOOLEAN NOT NULL DEFAULT FALSE, + training BOOLEAN NOT NULL DEFAULT FALSE, + qbr BOOLEAN NOT NULL DEFAULT FALSE, + annual_net_minor BIGINT NOT NULL, + -- recurring annual fee after the multi-year discount, in minor units (cents). + tcv_minor BIGINT NOT NULL, + -- total contract value across the term incl. one-time fees, minor units. + line_items TEXT, + -- JSON snapshot of the itemised lines the order form renders. + stripe_price_id VARCHAR(128), + checkout_session_id VARCHAR(255), + checkout_url TEXT, + valid_until DATE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity ( + activity_id BIGSERIAL PRIMARY KEY, + deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE, + actor_user_id BIGINT, + -- the internal/portal user who took the action; informational (no FK). + action VARCHAR(48) NOT NULL, + -- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ... + detail TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id); +CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql new file mode 100644 index 0000000000..5d83ee35f8 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql @@ -0,0 +1,8 @@ +-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF + +-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The +-- Stripe operations live in Supabase edge functions; these columns hold the references they write +-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128), + ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT; diff --git a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql new file mode 100644 index 0000000000..ba9c74dde8 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql @@ -0,0 +1,5 @@ +-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on +-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS business_name VARCHAR(255); diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java new file mode 100644 index 0000000000..1f903a1ee2 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java @@ -0,0 +1,87 @@ +package stirling.software.saas.procurement.pricing; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind; + +/** + * Locks the pricing engine to the numbers the marketing prototype encodes — most importantly the + * canonical quote QT-AC9F-0001 (1M PDFs, priority, 3-year) = $41,400/yr, $124,200 TCV. + */ +class ProcurementPricingServiceTest { + + private final ProcurementPricingService pricing = new ProcurementPricingService(); + + private static QuoteConfig cfg(long volume, String sla, int term) { + return new QuoteConfig(volume, 0, "cloud", term, sla, false, false, false, "USD"); + } + + @Test + void canonicalQuoteMatchesPrototype() { + QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3)); + + assertThat(q.annualNetMinor()).isEqualTo(4_140_000L); // $41,400 + assertThat(q.tcvMinor()).isEqualTo(12_420_000L); // $124,200 + assertThat(lineAmount(q, "usage")).isEqualTo(4_000_000L); // $40,000 @ $0.04 + assertThat(lineAmount(q, "service-level")).isEqualTo(600_000L); // +15% + assertThat(lineAmount(q, "multi-year")).isEqualTo(-460_000L); // -10% + } + + @Test + void volumeBandsPickTheRightPerPdfRate() { + assertThat(lineAmount(pricing.price(cfg(500_000, "standard", 1)), "usage")) + .isEqualTo(2_500_000L); // 500k @ $0.05 + assertThat(lineAmount(pricing.price(cfg(1_000_000, "standard", 1)), "usage")) + .isEqualTo(4_000_000L); // 1M @ $0.04 + assertThat(lineAmount(pricing.price(cfg(5_000_000, "standard", 1)), "usage")) + .isEqualTo(15_000_000L); // 5M @ $0.03 + } + + @Test + void addOnsAndTermStack() { + QuoteConfig c = + new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD"); + QuoteBreakdown q = pricing.price(c); + + long usage = 4_000_000L; + long withSla = Math.round(usage * 1.30); // 5,200,000 + long withIndemnity = Math.round(withSla * 1.05); // 5,460,000 + long discount = Math.round(withIndemnity * 0.15); // 819,000 + long qbr = 800_000L; + long expectedAnnual = (withIndemnity - discount) + qbr; + long expectedTcv = expectedAnnual * 5 + 750_000L; // + training one-time + + assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual); + assertThat(q.tcvMinor()).isEqualTo(expectedTcv); + assertThat(q.lineItems()) + .anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME); + assertThat(q.lineItems()).anyMatch(l -> l.key().equals("qbr")); + assertThat(q.lineItems()).anyMatch(l -> l.key().equals("indemnification")); + } + + @Test + void standardSingleYearHasNoUpliftOrDiscountLines() { + QuoteBreakdown q = pricing.price(cfg(1_000_000, "standard", 1)); + assertThat(q.annualNetMinor()).isEqualTo(4_000_000L); + assertThat(q.tcvMinor()).isEqualTo(4_000_000L); + assertThat(q.lineItems()).noneMatch(l -> l.key().equals("service-level")); + assertThat(q.lineItems()).noneMatch(l -> l.key().equals("multi-year")); + } + + @Test + void volumeEstimateFromSeats() { + // ~2,013 PDFs/user/yr + assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L); + assertThat(pricing.estimateAnnualVolume(0)).isZero(); + } + + private static long lineAmount(QuoteBreakdown q, String key) { + return q.lineItems().stream() + .filter(l -> l.key().equals(key)) + .mapToLong(QuoteLineItem::amountMinor) + .findFirst() + .orElseThrow(); + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index e07dce196a..0605e6e79f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ # production /build /dist +/dist-portal /storybook-static /editor/build diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index d086613f44..dd8e379a8a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -2,7 +2,7 @@ // the decorators below transpiles to React.createElement and needs React in // scope. (The app + story files use the automatic runtime via the portal vite // config; this import is specifically for the preview config file.) -import React, { useEffect } from "react"; +import React, { Suspense, useEffect } from "react"; import type { Decorator, Preview } from "@storybook/react-vite"; import { initialize, mswLoader } from "msw-storybook-addon"; import { MemoryRouter } from "react-router-dom"; @@ -109,7 +109,9 @@ const withProviders: Decorator = (Story, context) => { - + + + diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 346ece1523..241289d1db 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7430,7 +7430,7 @@ upgrade = "Upgrade" volumeSuffix = "PDFs processed · last 30 days" [portal.procurement] -enterpriseBadge = "Enterprise" +reset = "Reset procurement (demo)" subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place." title = "Procurement" @@ -7441,6 +7441,63 @@ request = "Request" sign = "Review & sign" upload = "Upload" +[portal.procurement.agreement] +agreeCta = "Agree & subscribe" +confirm = "I have read and agree to the Stirling Enterprise Agreement." +eyebrow = "Agreement" +intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription." +title = "Review your enterprise agreement" + +[portal.procurement.builder] +addons = "Add-ons" +back = "Back" +businessName = "Business name" +businessNamePlaceholder = "Your company" +continue = "Continue" +country = "Country" +countryEuro = "Eurozone (EUR €)" +countryUK = "United Kingdom (GBP £)" +countryUS = "United States (USD $)" +eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote." +generate = "Generate quote" +included = "Included" +indemnification = "IP indemnification" +indemnificationSub = "We defend qualifying IP claims, per the EULA" +qbr = "Quarterly business reviews" +qbrSub = "Your SE reviews usage and roadmap each quarter" +running = "{{annual}} / yr · {{years}}-yr {{tcv}}" +s1Sub = "Your team, and the PDFs you expect to run each year." +# Step 1 — volume +s1Title = "How much will you process?" +s2Sub = "Longer terms discount the rate; your service level sets support." +# Step 2 — commitment & service +s2Title = "Commitment and service" +s3Sub = "For the quote and the agreement it generates." +# Step 3 — details +s3Title = "Your details" +serviceLevel = "Service level" +slDedicated = "Dedicated" +slDedicatedSub = "4 business hours · dedicated account manager · +30%" +slPriority = "Priority" +slPrioritySub = "Same business day · named CSM · +15%" +slStandard = "Standard" +slStandardSub = "Next business day · shared CSM · included" +stepOf = "Step {{n}} of {{total}}" +term = "Term" +termDiscount = "{{pct}}% multi-year commitment discount applied" +title = "Build your quote" +training = "Onboarding & training" +trainingSub = "Live sessions to get your team running" +users = "Total users" +usersPlaceholder = "e.g. 250" +volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better." +volManual = "Using your figure. Re-estimate from your team size any time." +volNoUsers = "Not sure? Enter your team size and we'll estimate it." +volume = "Annual PDF volume" +volumePlaceholder = "e.g. 1,000,000" +years_one = "{{count}} year" +years_other = "{{count}} years" + [portal.procurement.docs] count_one = "{{count}} doc" count_other = "{{count}} docs" @@ -7456,6 +7513,30 @@ supportingTitle = "Supporting your evaluation" title = "Documents" upcoming = "Upcoming" +[portal.procurement.error] +title = "Something went wrong" + +[portal.procurement.hero] +company = "Your enterprise deal" +ctaAgreement = "Review & sign agreement" +ctaLive = "You're live" +ctaPayment = "Add payment" +ctaQuote = "Review your quote" +ctaTrial = "Build your quote" +eyebrow = "Enterprise procurement" +inviteTeammates = "Invite teammates" +keyDocs = "Key documents" +nextStep = "Next step: {{action}}" +notStarted = "Not started" +open = "Open procurement" +scheduleCall = "Schedule a call" +setup1Sub = "Invite your teammates" +setup1Title = "Deploy the PDF Editor" +setup2Sub = "Turn on processing across editors and other sources" +setup2Title = "Connect the PDF Processor" +setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them" +setup3Title = "Add recommended policies" + [portal.procurement.journey] daysLeft_one = "{{count}} day left" daysLeft_other = "{{count}} days left" @@ -7467,15 +7548,39 @@ subtitle = "Your solutions engineer is on every step. One next action at a time; title = "From trial to live, one guided path" trialTitle = "Enterprise trial" +[portal.procurement.link] +cta = "Link account" +description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start." +eyebrow = "Enterprise" +title = "Link your account to begin" + +[portal.procurement.live] +description = "Your subscription is active and your licence is issued. Your team is provisioned and billing has started." +eyebrow = "Live" +title = "You're live on Stirling Enterprise" + [portal.procurement.locked] description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation." eyebrow = "Enterprise only" talkToSales = "Talk to sales" title = "The procurement track opens with Enterprise" +[portal.procurement.milestone] +accept = "Accept & continue" +description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes." +download = "Download PDF" +downloadError = "Could not download the quote PDF just yet — please try again in a moment." +edit = "Edit quote" +eyebrow = "Quote {{number}}" +perYear = " / yr" +preparedFor = "Prepared for {{company}}" +tcv = "{{value}} total contract value" +title = "Your quote is ready" + [portal.procurement.modal] cancel = "Cancel" chooseFile = "Choose file" +close = "Close" downloadBody = "Your download will begin shortly." downloadCta = "Download" downloadTitle = "Download" @@ -7494,6 +7599,13 @@ uploadBody = "Send us your PO and we invoice against it on your terms. Drag in t uploadCta = "Upload purchase order" uploadTitle = "Upload your purchase order" +[portal.procurement.payment] +description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed." +downloadInvoice = "Download invoice" +simulate = "Simulate payment received (demo)" +title = "Subscription created" +viewInvoice = "View & pay invoice" + [portal.procurement.status] action = "Action needed" available = "Available" @@ -7501,6 +7613,21 @@ complete = "Complete" pending = "Pending" request = "On request" +[portal.procurement.trial] +body = "Extending adds 7 days and notifies your solutions engineer." +bodyMaxed = "You have used all your extensions — talk to your solutions engineer if you need more time." +cancel = "Cancel trial" +extend = "Extend 7 days" +maxed = "Maxed out" +subtitle = "Your free trial runs through {{date}}. No card required." +title = "Enterprise trial" + +[portal.procurement.upsell] +homeBadge = "Enterprise" +homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE." +homeCta = "Start Trial →" +homeHeadline = "Process millions of PDFs." + [portal.recentActivity] title = "Recent activity" viewAll = "View all" diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 3a2b9fe0f2..9327179c03 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -1,4 +1,5 @@ import { apiClient } from "@portal/api/http"; +import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; import type { Tier } from "@portal/contexts/TierContext"; import type { DealStage, @@ -92,3 +93,165 @@ export async function requestDocument( { method: "POST", body: { action } }, ); } + +// ============================================================================ +// Enterprise procurement — real SaaS backend (/api/v1/procurement). +// +// The journey/ledger visuals above still ride the MSW mock; the commercial spine +// below (trial, server-priced quote, accept -> Stripe checkout) is the real thing, +// served by the saas Java backend and gated on a linked account. +// ============================================================================ + +export type QuoteLineItemKind = + | "RECURRING" + | "ONE_TIME" + | "DISCOUNT" + | "INCLUDED"; + +export interface QuoteLineItem { + key: string; + label: string; + kind: QuoteLineItemKind; + amountMinor: number; +} + +export interface QuoteResult { + quoteId: number; + quoteNumber: string; + /** draft (priced, editable) | sent (issued Stripe quote — PDF + shareable) | accepted | expired. */ + status: string; + currency: string; + annualNetMinor: number; + tcvMinor: number; + lineItems: QuoteLineItem[]; + validUntil: string | null; + /** The Stripe Quote id once issued; null while still a local draft. */ + stripeQuoteId: string | null; + /** Hosted Stripe invoice URL, present once the quote is accepted and the subscription invoice exists. */ + invoiceUrl: string | null; + /** The inputs this quote was priced from, so the builder can seed itself on re-edit. */ + config: QuoteConfigInput; +} + +/** Outcome of accepting an issued quote: Stripe creates the subscription + first invoice. */ +export interface AcceptResult { + status: string; + subscriptionId: string | null; + invoiceUrl: string | null; + invoicePdf: string | null; +} + +/** One shape for every state; an unstarted procurement has {@link ProcurementSnapshot.dealId} null. */ +export interface ProcurementSnapshot { + dealId: number | null; + stage: DealStage | null; + trialStartedAt: string | null; + trialEndsAt: string | null; + trialExtensionsUsed: number; + licensed: boolean; + latestQuote: QuoteResult | null; +} + +export interface QuoteConfigInput { + volume: number; + users: number; + deployment: string; + termYears: number; + serviceLevel: string; + indemnification: boolean; + training: boolean; + qbr: boolean; + currency: string; + /** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */ + businessName: string; +} + +export function fetchSnapshot(): Promise { + return apiClient.saas.json("/api/v1/procurement"); +} + +export function startTrial(): Promise { + return apiClient.saas.json( + "/api/v1/procurement/trial/start", + { method: "POST" }, + ); +} + +export function extendTrial(): Promise { + return apiClient.saas.json( + "/api/v1/procurement/trial/extend", + { method: "POST" }, + ); +} + +/** Advance an issued quote to the agreement (security) stage for review + agree. */ +export function startAgreement(): Promise { + return apiClient.saas.json( + "/api/v1/procurement/agreement", + { method: "POST" }, + ); +} + +/** Price a config server-side and persist it as a local DRAFT (no Stripe object yet). */ +export function buildQuote(cfg: QuoteConfigInput): Promise { + return apiClient.saas.json("/api/v1/procurement/quote", { + method: "POST", + body: cfg, + }); +} + +// ---- Stripe Quote operations (Supabase edge functions) --------------------- +// Java has no Stripe SDK, so issuing/accepting the quote and fetching its PDF run in edge functions +// that own Stripe; they persist results back through SECURITY DEFINER RPCs. The portal invokes them +// directly (same pattern the PAYG checkout uses). + +async function invokeEdge(fn: string, quoteId: number): Promise { + const supabase = getSupabaseClient(); + if (!supabase) throw new Error("No SaaS session"); + const { data, error } = await supabase.functions.invoke(fn, { + body: { quote_id: quoteId }, + }); + if (error) throw error; + if (data == null) throw new Error(`${fn} returned no data`); + return data; +} + +/** Turn a draft into an issued Stripe Quote (finalized → gets a number + PDF, shareable). */ +export function issueQuote(quoteId: number): Promise { + return invokeEdge("issue-procurement-quote", quoteId); +} + +/** Accept an issued quote → Stripe creates the committed subscription + first invoice. */ +export function acceptQuote(quoteId: number): Promise { + return invokeEdge("accept-procurement-quote", quoteId); +} + +/** Fetch the Stripe-generated quote PDF as a blob (for download / share). */ +export async function fetchQuotePdf(quoteId: number): Promise { + const supabase = getSupabaseClient(); + if (!supabase) throw new Error("No SaaS session"); + const { data, error } = await supabase.functions.invoke( + "get-procurement-quote-pdf", + { body: { quote_id: quoteId } }, + ); + if (error) throw error; + if (!data) throw new Error("No PDF returned"); + return data; +} + +/** + * Demo/manual stand-in for the invoice.paid webhook: mark the deal live (issue licence, go active). + */ +export function goLive(): Promise { + return apiClient.saas.json( + "/api/v1/procurement/go-live", + { method: "POST" }, + ); +} + +/** Reset the team's procurement (delete the deal) and get the fresh empty snapshot. */ +export function resetProcurement(): Promise { + return apiClient.saas.json("/api/v1/procurement/reset", { + method: "POST", + }); +} diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index d9ced4ae65..0deb3c0dda 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -22,7 +22,6 @@ import { UsageIcon, LinkIcon, DocsIcon, - ProcurementIcon, SettingsIcon, ChevronDownIcon, } from "@portal/components/icons"; @@ -136,15 +135,10 @@ export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); const { openSettings } = useUI(); - const { tier } = useTier(); const { t } = useTranslation(); - // Procurement is the enterprise buyer's commercial journey — surfaced only to - // enterprise tenants (it has no free/pro equivalent). - const platformGroup: NavEntry[] = - tier === "enterprise" - ? [{ id: "procurement", icon: }, ...GROUP_PLATFORM] - : GROUP_PLATFORM; + // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into + // a takeover modal (matching the marketing prototype). function renderGroup(entries: NavEntry[]) { return entries.map((entry) => ( @@ -227,7 +221,7 @@ export function Sidebar() {
- {renderGroup(platformGroup)} + {renderGroup(GROUP_PLATFORM)}
diff --git a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx index b303c530ff..6525e66970 100644 --- a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx +++ b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; +import { useView } from "@portal/contexts/ViewContext"; interface Props { /** Render without the Card wrapper, to embed inside another card's column. */ @@ -8,10 +9,12 @@ interface Props { /** * Volume-discount / Enterprise upsell, shared by the free and subscribed billing - * views. The CTA is intentionally inert until the sales/quote URL is confirmed. + * views. The CTA opens the procurement journey (/procurement auto-opens the quote + * builder in the takeover modal). */ export function EnterpriseUpsell({ bare = false }: Props) { const { t } = useTranslation(); + const { setActiveView } = useView(); const body = ( <> @@ -32,8 +35,11 @@ export function EnterpriseUpsell({ bare = false }: Props) { )}

- {/* Destination wired when the enterprise/sales URL is confirmed. */} - + )} + {stage !== "active" && ( + + )} + {stage !== "active" && ( + + )} + + + + +
+ +
+ + {inTrial && ( +
    + {setupSteps.map((s) => ( +
  • + +
  • + ))} +
+ )} + +
+ + + {t("portal.procurement.hero.nextStep", { action: cta })} + +
+ +
+
+ + ); +} + +function daysLeft(iso: string): number { + const end = new Date(iso).getTime(); + return Math.max(0, Math.ceil((end - Date.now()) / 86_400_000)); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx new file mode 100644 index 0000000000..bd00a93904 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@app/ui"; +import type { QuoteResult } from "@portal/api/procurement"; +import { money } from "@portal/components/procurement/format"; +import "@portal/views/Procurement.css"; + +/** + * The agreement (security) step: a single combined Stirling Enterprise Agreement — Master Service + * Agreement + Order Form (from the issued quote) + EULA + Data Processing Agreement — that the buyer + * reviews and agrees to before it's accepted into a subscription. No e-signature for now: an explicit + * "I agree" click stands in (the terms reference the accepted quote). Document body is static legal + * copy; the surrounding UI is translated. + */ +export function ProcurementAgreement({ + quote, + busy, + onAgree, +}: { + quote: QuoteResult; + busy: boolean; + onAgree: () => void; +}) { + const { t } = useTranslation(); + const [checked, setChecked] = useState(false); + const annual = money(quote.annualNetMinor, quote.currency); + const tcv = money(quote.tcvMinor, quote.currency); + const years = quote.config.termYears; + + return ( + + + {t("portal.procurement.agreement.eyebrow")} + +

+ {t("portal.procurement.agreement.title")} +

+

+ {t("portal.procurement.agreement.intro")} +

+ +
+

1. Master Service Agreement

+

+ This Stirling Enterprise Agreement ("Agreement") is entered into + between Stirling PDF Inc. ("Stirling") and the customer identified on + the Order Form ("Customer"). It governs Customer's access to and use + of the Stirling enterprise platform and related services (the + "Service"). Stirling will provide the Service with commercially + reasonable skill and care and in accordance with the service levels + set out in the Order Form. +

+ +

2. Order Form

+

+ Quote {quote.quoteNumber} forms the Order Form for + this Agreement. Customer commits to a {years}-year term at{" "} + {annual} per year (total contract value{" "} + {tcv}), billed annually in advance by invoice. Fees + are exclusive of taxes. The committed volume, service level, and + add-ons are itemised below: +

+
    + {quote.lineItems.map((li) => ( +
  • + {li.label} + + {li.kind === "INCLUDED" + ? t("portal.procurement.builder.included") + : money(li.amountMinor, quote.currency)} + +
  • + ))} +
+ +

3. 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 + internal business purposes during the term. Customer is responsible + for its users' compliance and for the content it processes. The + Service, and all intellectual property in it, remains Stirling's. +

+ +

4. Data Processing Agreement

+

+ Where Stirling processes personal data on Customer's behalf, it does + so only on Customer's documented instructions and applies appropriate + technical and organisational measures. Sub-processors, international + transfers, and security commitments are as described in Stirling's + Data Processing Agreement and Trust Center, incorporated here by + reference. +

+ +

5. Acceptance

+

+ By agreeing below, Customer accepts this Agreement and the Order Form. + On acceptance, Stirling will issue the committed annual subscription + and its first invoice. This preview stands in for e-signature during + the pilot. +

+
+ + + +
+ +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx new file mode 100644 index 0000000000..ce6620da78 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -0,0 +1,299 @@ +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import type { ProcurementSnapshot } from "@portal/api/procurement"; +import { useFocusTrap } from "@portal/components/procurement/ProcurementModal"; +import "@portal/views/Procurement.css"; + +/** + * Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule + * a call, and trial management. Content is mocked for the pilot (static demo data); the shells and + * wiring are real so the hero behaves like the marketing prototype. + */ + +function SideModal({ + open, + onClose, + title, + subtitle, + children, + footer, +}: { + open: boolean; + onClose: () => void; + title: string; + subtitle?: string; + children: React.ReactNode; + footer?: React.ReactNode; +}) { + const { t } = useTranslation(); + const trapRef = useFocusTrap(open); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + return createPortal( +
e.target === e.currentTarget && onClose()} + > +
+ +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+ {footer &&
{footer}
} +
+
, + document.body, + ); +} + +// ── Key documents ──────────────────────────────────────────────────────────── +type DocStatus = "available" | "action" | "request"; +interface DocRow { + name: string; + sub: string; + status: DocStatus; + fee?: number; +} +const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [ + { + group: "Your deal", + docs: [ + { + name: "Formal quote", + sub: "Built to your volume, term, and service level", + status: "available", + }, + { + name: "Master Services Agreement", + sub: "One signature — MSA, order form, EULA, and DPA combined", + status: "action", + }, + { + name: "Bank transfer instructions", + sub: "Wire details for your AP team", + status: "available", + }, + { + name: "Purchase order", + sub: "Issuing a PO? Upload it and we invoice against it", + status: "request", + }, + ], + }, + { + group: "Supporting your evaluation", + docs: [ + { + name: "SOC 2 Type II report", + sub: "Audited · NDA-gated", + status: "available", + }, + { + name: "Custom security review", + sub: "We complete your questionnaire and join your review call", + status: "request", + fee: 5000, + }, + { + name: "Business Associate Agreement", + sub: "HIPAA · available on request", + status: "request", + fee: 2500, + }, + { name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" }, + { + name: "Certificate of Insurance", + sub: "Cyber + E&O · current policy", + status: "available", + }, + ], + }, +]; +const STATUS_LABEL: Record = { + available: "Download", + action: "Action needed", + request: "Request", +}; + +export function KeyDocumentsModal({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + return ( + + {STAGE_DOCS.map((g) => ( +
+
{g.group}
+
    + {g.docs.map((d) => ( +
  • +
    + {d.name} + + {d.sub} + {d.fee ? ` · one-time $${d.fee.toLocaleString()}` : ""} + +
    + + {STATUS_LABEL[d.status]} + +
  • + ))} +
+
+ ))} +
+ ); +} + +// ── Schedule a call ────────────────────────────────────────────────────────── +const SLOTS = [ + "Tomorrow · 10:00", + "Tomorrow · 15:30", + "Thursday · 11:00", + "Friday · 09:30", +]; + +export function ScheduleCallModal({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + return ( + +
+ + SE + +
+
Your solutions engineer
+
+ Dedicated to your evaluation and rollout +
+
+
+
+ {SLOTS.map((s) => ( + + ))} +
+
+ ); +} + +// ── Trial management ───────────────────────────────────────────────────────── +export function TrialManageModal({ + open, + onClose, + snapshot, + busy, + onExtend, + onCancel, +}: { + open: boolean; + onClose: () => void; + snapshot: ProcurementSnapshot; + busy: boolean; + onExtend: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const ends = snapshot.trialEndsAt + ? new Date(snapshot.trialEndsAt).toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }) + : ""; + const maxed = snapshot.trialExtensionsUsed >= 2; + return ( + + + + + } + > +

+ {maxed + ? t("portal.procurement.trial.bodyMaxed") + : t("portal.procurement.trial.body")} +

+
+ ); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx new file mode 100644 index 0000000000..ae424922ad --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; + +/** + * The end-to-end procurement experience (Home hero + takeover modal), driven by the `procurementSaas` + * MSW handlers: start trial → build quote → generate (issue Stripe Quote) → milestone (download PDF / + * accept). `autoOpen` opens the modal so the flow is immediately clickable. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementHome", + component: ProcurementHome, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: { autoOpen: true } }; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx new file mode 100644 index 0000000000..a10057a3d8 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx @@ -0,0 +1,296 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Card, EmptyState, Skeleton } from "@app/ui"; +import { useLink } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { useView } from "@portal/contexts/ViewContext"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + acceptQuote, + extendTrial, + fetchQuotePdf, + fetchSnapshot, + goLive, + issueQuote, + JOURNEY, + resetProcurement, + startAgreement, + startTrial, + type ProcurementSnapshot, + type QuoteResult, +} from "@portal/api/procurement"; +import { DealStatusHero } from "@portal/components/procurement/DealStatusHero"; +import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement"; +import { + KeyDocumentsModal, + ScheduleCallModal, + TrialManageModal, +} from "@portal/components/procurement/ProcurementExtras"; +import { ProcurementModal } from "@portal/components/procurement/ProcurementModal"; +import { + LiveStageCard, + PaymentStageCard, + QuoteMilestoneCard, +} from "@portal/components/procurement/ProcurementStages"; +import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder"; +import { StageStepper } from "@portal/components/procurement/StageStepper"; +import "@portal/views/Procurement.css"; + +/** + * The procurement experience on Home: a compact deal-status hero once a trial is running (or the + * enterprise upsell on-ramp before it), both expanding into the full-screen takeover modal that + * holds the journey — build + issue a quote (a Stripe Quote with a real PDF, the milestone the buyer + * can share and return to), review + agree to the enterprise agreement, then accept into a committed + * subscription. Starting a trial is a single click (no "start a trial" prompt); the deadline + next + * steps then show on the hero. Rendered on Home and at /procurement (autoOpen). Gated on a link. + */ +export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) { + const { t } = useTranslation(); + const { isLinked } = useLink(); + const { openLinkModal } = useUI(); + const { setActiveView } = useView(); + + const state = useAsync( + () => (isLinked ? fetchSnapshot() : Promise.resolve(null)), + [isLinked], + ); + const [snap, setSnap] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [editing, setEditing] = useState(false); + const [downloading, setDownloading] = useState(false); + const [invoicePdf, setInvoicePdf] = useState(null); + const [error, setError] = useState(null); + const [extra, setExtra] = useState( + null, + ); + + const data = snap ?? (state.loading ? null : state.data); + const started = data?.dealId != null; + const stage = data?.stage; + const latest = data?.latestQuote ?? null; + const isIssued = latest?.status === "sent" || latest?.status === "open"; + // No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds. + const isDraft = + !latest || + ["draft", "expired", "canceled", "cancelled"].includes(latest.status); + + async function run(fn: () => Promise) { + setBusy(true); + setError(null); + try { + await fn(); + setSnap(await fetchSnapshot()); + } catch (e) { + console.error("[procurement] action failed", e); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + const onStartTrial = () => run(startTrial); + const onExtendTrial = () => run(extendTrial); + const onReset = () => + run(async () => { + await resetProcurement(); + setEditing(false); + setInvoicePdf(null); + }); + const onGenerate = (draft: QuoteResult) => + run(async () => { + await issueQuote(draft.quoteId); + setEditing(false); + }); + // Milestone → agreement (security) stage; then agreeing accepts into a subscription. + const onAcceptQuote = () => run(startAgreement); + const onAgree = () => + run(async () => { + if (!latest) return; + const res = await acceptQuote(latest.quoteId); + setInvoicePdf(res.invoicePdf); + }); + + async function onDownloadPdf() { + if (!latest) return; + setDownloading(true); + try { + const blob = await fetchQuotePdf(latest.quoteId); + // A same-gesture click is reliable; window.open after an await is often + // popup-blocked (which is what made this take "a few goes"). + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${latest.quoteNumber || "quote"}.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch (e) { + console.error("[procurement] quote PDF download failed", e); + setError(t("portal.procurement.milestone.downloadError")); + } finally { + setDownloading(false); + } + } + + // A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal + // yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows. + useEffect(() => { + if (autoOpen && started) setOpen(true); + }, [autoOpen, started]); + + const banner = + isLinked && started && data ? ( + setOpen(true)} + onKeyDocs={() => setExtra("docs")} + onInvite={() => setActiveView("users")} + onSchedule={() => setExtra("schedule")} + onManageTrial={() => setExtra("trial")} + onNavigate={setActiveView} + /> + ) : ( + +
+ + {t("portal.procurement.upsell.homeBadge")} + +

+ {t("portal.procurement.upsell.homeHeadline")} + {t("portal.procurement.upsell.homeBody")} +

+
+ +
+ ); + + return ( + <> + {banner} + setOpen(false)} + title={t("portal.procurement.title")} + subtitle={t("portal.procurement.subtitle")} + > + {error && ( + setError(null)} + > + {error} + + )} + + {!isLinked && ( + openLinkModal()} + > + {t("portal.procurement.link.cta")} + + } + /> + )} + + {isLinked && (state.loading || !started) && } + + {isLinked && started && ( + <> +
+ +
+ + {(editing || + (isDraft && (stage === "trial" || stage === "quote"))) && ( + + )} + + {!editing && isIssued && stage === "quote" && latest && ( + setEditing(true)} + /> + )} + + {!editing && stage === "security" && latest && ( + + )} + + {!editing && stage === "procurement" && latest && ( + run(goLive)} + /> + )} + + {!editing && stage === "active" && } + +
+ +
+ + )} +
+ + setExtra(null)} + /> + setExtra(null)} + /> + {data && ( + setExtra(null)} + snapshot={data} + busy={busy} + onExtend={async () => { + await onExtendTrial(); + setExtra(null); + }} + onCancel={async () => { + await onReset(); + setExtra(null); + }} + /> + )} + + ); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx new file mode 100644 index 0000000000..7074e390e7 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button, Card } from "@app/ui"; +import { ProcurementModal } from "@portal/components/procurement/ProcurementModal"; + +/** + * The full-screen takeover modal shell. Rendered always-open here to verify the panel has a solid + * (not see-through) surface over the dimmed, blurred backdrop. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementModal", + component: ProcurementModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + onClose: () => {}, + title: "Enterprise procurement", + subtitle: "Get your team evaluated, contracted, and onboarded.", + }, +}; +export default meta; + +type Story = StoryObj; + +export const Open: Story = { + args: { + children: ( + +

Ready for payment

+

+ Your quote is accepted. Continue to checkout to pay your committed + contract and go live. +

+
+ + +
+
+ ), + }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx new file mode 100644 index 0000000000..bf3f463585 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import "@portal/views/Procurement.css"; + +/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */ +export function useFocusTrap(open: boolean) { + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const panel = ref.current; + if (!panel) return; + const prev = document.activeElement as HTMLElement | null; + const focusables = () => + Array.from( + panel.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).filter((el) => !el.hasAttribute("disabled")); + (focusables()[0] ?? panel).focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + const items = focusables(); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + panel.addEventListener("keydown", onKey); + return () => { + panel.removeEventListener("keydown", onKey); + prev?.focus?.(); + }; + }, [open]); + return ref; +} + +/** + * Full-screen takeover modal for the procurement flow, copying the prototype's modal design + * (portaled to body, dimmed + blurred backdrop, rounded panel, close button). The Home deal-status + * hero expands into this. + */ +export function ProcurementModal({ + open, + onClose, + title, + subtitle, + children, +}: { + open: boolean; + onClose: () => void; + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + const trapRef = useFocusTrap(open); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
e.target === e.currentTarget && onClose()} + > +
+ +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+
, + document.body, + ); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx new file mode 100644 index 0000000000..fdebad61f6 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -0,0 +1,160 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@app/ui"; +import type { QuoteResult } from "@portal/api/procurement"; +import { money } from "@portal/components/procurement/format"; +import "@portal/views/Procurement.css"; + +/** + * The stage-specific cards shown inside the procurement takeover modal once a quote exists: the + * issued-quote milestone, the subscription-created payment step, and the live confirmation. Each is + * a pure presentational view driven by props; ProcurementHome owns the state and the actions. + */ + +/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */ +export function QuoteMilestoneCard({ + quote, + busy, + downloading, + onAccept, + onDownload, + onEdit, +}: { + quote: QuoteResult; + busy: boolean; + downloading: boolean; + onAccept: () => void; + onDownload: () => void; + onEdit: () => void; +}) { + const { t } = useTranslation(); + return ( + + + {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. */ +export function PaymentStageCard({ + invoiceUrl, + invoicePdf, + busy, + onSimulate, +}: { + invoiceUrl?: string | null; + invoicePdf?: string | null; + busy: boolean; + onSimulate: () => void; +}) { + const { t } = useTranslation(); + return ( + +

+ {t("portal.procurement.payment.title")} +

+

+ {t("portal.procurement.payment.description")} +

+ {(invoiceUrl || invoicePdf) && ( +
+ {invoiceUrl && ( + + )} + {invoicePdf && ( + + )} +
+ )} +
+ +
+
+ ); +} + +/** The live confirmation once the deal is active. */ +export function LiveStageCard() { + const { t } = useTranslation(); + return ( + + + {t("portal.procurement.live.eyebrow")} + +

+ {t("portal.procurement.live.title")} +

+

+ {t("portal.procurement.live.description")} +

+
+ ); +} diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx new file mode 100644 index 0000000000..430bcf23cf --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder"; +import "@portal/views/Procurement.css"; + +/** + * The enterprise quote builder. The "Review quote" step calls the SaaS backend, answered here by + * the `procurementSaas` MSW handler, so all four steps (incl. the itemised quote paper) work in + * Storybook. Click through Volume → Commitment & service → Details → Quote. + */ +const meta: Meta = { + title: "Portal/Procurement/QuoteBuilder", + component: QuoteBuilder, + parameters: { layout: "padded" }, + args: { deployment: "cloud", onGenerate: () => {} }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx new file mode 100644 index 0000000000..b180c986bf --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -0,0 +1,436 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { + DocumentsIcon, + PoliciesIcon, + UsersIcon, +} from "@portal/components/icons"; +import { money } from "@portal/components/procurement/format"; +import { + buildQuote, + type QuoteConfigInput, + type QuoteResult, +} from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const STEPS = ["volume", "plan", "details"] as const; +const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years +const SLA_UPLIFT: Record = { + standard: 0, + priority: 0.15, + dedicated: 0.3, +}; + +/** + * The enterprise quote builder — volume → commitment & service → details. A client-side preview + * drives the live footer total; the backend is authoritative. Completing the form generates the + * quote directly (build + issue in one step) — the issued quote is then shown as the milestone, so + * there's no redundant in-builder preview. + */ +export function QuoteBuilder({ + deployment, + initial, + onGenerate, +}: { + deployment: string; + /** Seed the builder from an existing quote's config (re-editing a quote). */ + initial?: QuoteConfigInput; + /** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */ + onGenerate: (quote: QuoteResult) => void; +}) { + const { t } = useTranslation(); + const [step, setStep] = useState(0); + const [cfg, setCfg] = useState( + initial ?? { + volume: 1_000_000, + users: 0, + deployment, + termYears: 3, + serviceLevel: "priority", + indemnification: false, + training: false, + qbr: false, + currency: "USD", + businessName: "", + }, + ); + // A seeded quote carries a volume but no user count, so treat it as manually set. + const [manualVolume, setManualVolume] = useState(initial != null); + const [eula, setEula] = useState(initial != null); + const [busy, setBusy] = useState(false); + + function set(k: K, v: QuoteConfigInput[K]) { + setCfg((c) => ({ ...c, [k]: v })); + } + + // Re-editing an existing quote: everything is seeded, so jump to the last step (details) with the + // agreement pre-accepted — one click re-generates, or Back to change a field. No walking from step 1. + // Mount-only: seed the step from `initial` once (deliberately no deps). + useEffect(() => { + if (initial) setStep(STEPS.length - 1); + }, []); + + const preview = previewAnnualMinor(cfg); + const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0); + + // Fully filled → price + hand the draft to the parent to issue as a Stripe Quote (which then shows + // as the milestone). No separate in-builder preview step. + async function generate() { + setBusy(true); + try { + onGenerate(await buildQuote(cfg)); + } finally { + setBusy(false); + } + } + + return ( +
+
+

+ {t("portal.procurement.builder.title")} +

+ + {t("portal.procurement.builder.stepOf", { + n: step + 1, + total: STEPS.length, + })} + +
+
+ {STEPS.map((s, i) => ( + + ))} +
+ +
+ {step === 0 && ( + } + title={t("portal.procurement.builder.s1Title")} + sub={t("portal.procurement.builder.s1Sub")} + > +
+ + { + const users = Number(e.target.value); + set("users", users); + if (!manualVolume) set("volume", estimateVolume(users)); + }} + /> + + + { + setManualVolume(true); + set("volume", Number(e.target.value)); + }} + /> + +
+

+ {cfg.users > 0 && !manualVolume + ? t("portal.procurement.builder.volEstimated", { + count: cfg.users, + }) + : cfg.users > 0 + ? t("portal.procurement.builder.volManual") + : t("portal.procurement.builder.volNoUsers")} +

+
+ )} + + {step === 1 && ( + } + title={t("portal.procurement.builder.s2Title")} + sub={t("portal.procurement.builder.s2Sub")} + > + +
+ {[1, 2, 3, 4, 5].map((y) => ( + + ))} +
+ {TERM_DISCOUNT[cfg.termYears - 1] > 0 && ( +

+ {t("portal.procurement.builder.termDiscount", { + pct: Math.round(TERM_DISCOUNT[cfg.termYears - 1] * 100), + })} +

+ )} +
+ + +
+ set("serviceLevel", "standard")} + /> + set("serviceLevel", "priority")} + /> + set("serviceLevel", "dedicated")} + /> +
+
+ + +
+ set("indemnification", !cfg.indemnification)} + /> + set("training", !cfg.training)} + /> + set("qbr", !cfg.qbr)} + /> +
+
+
+ )} + + {step === 2 && ( + } + title={t("portal.procurement.builder.s3Title")} + sub={t("portal.procurement.builder.s3Sub")} + > + + set("businessName", e.target.value)} + /> + +
+ + + +
+ +
+ )} +
+ +
+ + {t("portal.procurement.builder.running", { + annual: money(preview, cfg.currency), + years: cfg.termYears, + tcv: money(tcvPreview, cfg.currency), + })} + +
+ {step > 0 && ( + + )} + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} +
+
+
+ ); +} + +function Step({ + icon, + title, + sub, + children, +}: { + icon: ReactNode; + title: string; + sub: string; + children: React.ReactNode; +}) { + return ( + <> +
+ + {icon} + +
+
{title}
+
{sub}
+
+
+ {children} + + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function OptCard({ + on, + title, + sub, + onClick, +}: { + on: boolean; + title: string; + sub: string; + onClick: () => void; +}) { + return ( + + ); +} + +function AddOn({ + on, + title, + sub, + onClick, +}: { + on: boolean; + title: string; + sub: string; + onClick: () => void; +}) { + return ( + + ); +} + +function estimateVolume(users: number): number { + const raw = Math.max(0, users) * 5 * 230 * 1.75; + const stepSize = raw >= 1_000_000 ? 50_000 : raw >= 100_000 ? 25_000 : 5_000; + return Math.round(raw / stepSize) * stepSize; +} + +function previewAnnualMinor(cfg: QuoteConfigInput): number { + const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5; + const usage = Math.round(cfg.volume * perPdf); + const withSla = Math.round(usage * (1 + (SLA_UPLIFT[cfg.serviceLevel] ?? 0))); + const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla; + const disc = Math.round( + withInd * TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1], + ); + return withInd - disc + (cfg.qbr ? 800_000 : 0); +} diff --git a/frontend/editor/src/portal/components/procurement/format.ts b/frontend/editor/src/portal/components/procurement/format.ts index bba7e63393..75f5b2fb16 100644 --- a/frontend/editor/src/portal/components/procurement/format.ts +++ b/frontend/editor/src/portal/components/procurement/format.ts @@ -9,6 +9,15 @@ export const USD = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0, }); +/** Format a minor-unit (cents) amount in the given currency, whole units (no decimals). */ +export function money(minor: number, currency: string): string { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || "USD", + maximumFractionDigits: 0, + }).format(minor / 100); +} + /** Document status → badge tone. Action items lean amber to pull the eye. */ export const STATUS_TONE: Record = { available: "success", diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index bd1d471988..9341ce6304 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -8,6 +8,7 @@ import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines"; import { sourcesHandlers } from "@portal/mocks/handlers/sources"; import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; import { procurementHandlers } from "@portal/mocks/handlers/procurement"; +import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas"; import { docsHandlers } from "@portal/mocks/handlers/docs"; import { settingsHandlers } from "@portal/mocks/handlers/settings"; import { usersHandlers } from "@portal/mocks/handlers/users"; @@ -30,6 +31,7 @@ export const handlers = [ ...infrastructureHandlers, ...docsHandlers, ...procurementHandlers, + ...procurementSaasHandlers, ...settingsHandlers, ...usersHandlers, ...agentsHandlers, diff --git a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts new file mode 100644 index 0000000000..4a4cc4bcb8 --- /dev/null +++ b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts @@ -0,0 +1,241 @@ +import { http, HttpResponse } from "msw"; + +/** + * MSW mock for the real SaaS procurement endpoints (`apiClient.saas` → VITE_SAAS_API_URL, which is + * `http://saas.mock` in dev/Storybook). Lets the trial → quote → accept flow run without the Java + * backend (Storybook + mocks-on dev). Pricing mirrors ProcurementPricingService. + */ +const SAAS = "http://saas.mock"; + +const EMPTY = { + dealId: null, + stage: null, + trialStartedAt: null, + trialEndsAt: null, + trialExtensionsUsed: 0, + licensed: false, + latestQuote: null, +}; + +interface Cfg { + volume: number; + serviceLevel: string; + termYears: number; + indemnification: boolean; + training: boolean; + qbr: boolean; + currency: string; + businessName?: string; +} + +let deal: typeof EMPTY | (Record & { latestQuote: unknown }) = + EMPTY; +let seq = 0; + +const SLA: Record = { + standard: 0, + priority: 0.15, + dedicated: 0.3, +}; +const TERM = [0, 0.05, 0.1, 0.12, 0.15]; + +function priceQuote(cfg: Cfg) { + const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5; + const usage = Math.round(cfg.volume * perPdf); + const withSla = Math.round(usage * (1 + (SLA[cfg.serviceLevel] ?? 0))); + const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla; + const disc = Math.round( + withInd * TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1], + ); + const qbr = cfg.qbr ? 800_000 : 0; + const training = cfg.training ? 750_000 : 0; + const annualNetMinor = withInd - disc + qbr; + const tcvMinor = annualNetMinor * cfg.termYears + training; + + type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED"; + const lines: { + key: string; + label: string; + kind: Kind; + amountMinor: number; + }[] = [ + { + key: "usage", + label: "PDF processing", + kind: "RECURRING", + amountMinor: usage, + }, + { + key: "seats", + label: "Unlimited users + SSO / SCIM / RBAC", + kind: "INCLUDED", + amountMinor: 0, + }, + ]; + if (withSla !== usage) + lines.push({ + key: "service-level", + label: + cfg.serviceLevel === "dedicated" + ? "Dedicated service level" + : "Priority service level", + kind: "RECURRING", + amountMinor: withSla - usage, + }); + if (withInd !== withSla) + lines.push({ + key: "indemnification", + label: "IP indemnification", + kind: "RECURRING", + amountMinor: withInd - withSla, + }); + if (qbr > 0) + lines.push({ + key: "qbr", + label: "Quarterly business reviews", + kind: "RECURRING", + amountMinor: qbr, + }); + if (disc > 0) + lines.push({ + key: "multi-year", + label: `${cfg.termYears}-year commitment`, + kind: "DISCOUNT", + amountMinor: -disc, + }); + if (training > 0) + lines.push({ + key: "training", + label: "Onboarding & training", + kind: "ONE_TIME", + amountMinor: training, + }); + + seq += 1; + return { + quoteId: seq, + quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`, + status: "draft", + currency: cfg.currency || "USD", + annualNetMinor, + tcvMinor, + lineItems: lines, + validUntil: "2026-07-31", + stripeQuoteId: null, + invoiceUrl: null, + config: { + volume: cfg.volume, + users: 0, + deployment: "cloud", + termYears: cfg.termYears, + serviceLevel: cfg.serviceLevel, + indemnification: cfg.indemnification, + training: cfg.training, + qbr: cfg.qbr, + currency: cfg.currency || "USD", + businessName: cfg.businessName ?? "", + }, + }; +} + +export function resetProcurementSaasStore() { + deal = EMPTY; + seq = 0; +} + +export const procurementSaasHandlers = [ + http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)), + http.post(`${SAAS}/api/v1/procurement/trial/start`, () => { + const now = Date.now(); + deal = { + dealId: 1, + stage: "trial", + trialStartedAt: new Date(now).toISOString(), + trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(), + trialExtensionsUsed: 0, + licensed: true, + latestQuote: null, + }; + return HttpResponse.json(deal); + }), + http.post(`${SAAS}/api/v1/procurement/quote`, async ({ request }) => { + const cfg = (await request.json()) as Cfg; + const quote = priceQuote(cfg); + const d = ( + deal.dealId ? deal : { dealId: 1, trialExtensionsUsed: 0 } + ) as Record; + deal = { + ...d, + stage: "quote", + licensed: true, + latestQuote: quote, + } as never; + return HttpResponse.json(quote); + }), + http.post(`${SAAS}/api/v1/procurement/trial/extend`, () => { + const d = deal as Record; + if (d.dealId) { + const base = d.trialEndsAt + ? Date.parse(d.trialEndsAt as string) + : Date.now(); + d.trialEndsAt = new Date(base + 7 * 86_400_000).toISOString(); + d.trialExtensionsUsed = ((d.trialExtensionsUsed as number) ?? 0) + 1; + } + return HttpResponse.json(deal); + }), + http.post(`${SAAS}/api/v1/procurement/agreement`, () => { + (deal as Record).stage = "security"; + return HttpResponse.json(deal); + }), + http.post(`${SAAS}/api/v1/procurement/go-live`, () => { + const d = deal as Record; + if (d.dealId) { + d.stage = "active"; + d.licensed = true; + } + return HttpResponse.json(deal); + }), + http.post(`${SAAS}/api/v1/procurement/reset`, () => { + resetProcurementSaasStore(); + return HttpResponse.json(EMPTY); + }), + + // Stripe Quote edge functions (supabase.functions.invoke → ${url}/functions/v1/{name}). + http.post(`${SAAS}/functions/v1/issue-procurement-quote`, () => { + const q = (deal as { latestQuote: Record | null }) + .latestQuote; + if (q) { + q.status = "sent"; + q.stripeQuoteId = `qt_mock_${q.quoteId}`; + } + return HttpResponse.json(q); + }), + http.post(`${SAAS}/functions/v1/accept-procurement-quote`, () => { + const q = (deal as { latestQuote: Record | null }) + .latestQuote; + const invoiceUrl = "https://invoice.stripe.com/i/mock_procurement"; + if (q) { + q.status = "accepted"; + q.invoiceUrl = invoiceUrl; + (deal as Record).stage = "procurement"; + } + return HttpResponse.json({ + status: "accepted", + subscriptionId: "sub_mock_procurement", + invoiceUrl, + invoicePdf: "https://invoice.stripe.com/i/mock_procurement/pdf", + }); + }), + http.post(`${SAAS}/functions/v1/get-procurement-quote-pdf`, () => { + // A minimal valid PDF so the download opens something in Storybook / mock dev. + const pdf = `%PDF-1.1 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>endobj +trailer<> +%%EOF`; + return new HttpResponse(pdf, { + headers: { "Content-Type": "application/pdf" }, + }); + }), +]; diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index b9478ead32..92d7f69318 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -28,6 +28,7 @@ import { UsageAreaChart } from "@portal/components/UsageAreaChart"; import { RecentActivity } from "@portal/components/RecentActivity"; import { SingleOpRunner } from "@portal/components/SingleOpRunner"; import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip"; +import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; import { PolicySummary } from "@portal/components/PolicySummary"; import { PipelineForkWizard } from "@portal/components/PipelineForkWizard"; import "@portal/views/Home.css"; @@ -538,6 +539,8 @@ export function Home() { setRunnerOpen(true)} /> + + {tier === "free" && ( <> diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index 90da816a53..8294709e5d 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -451,3 +451,1010 @@ overflow-x: auto; } } + +/* ── Enterprise upsell CTA (Home / Usage on-ramp) ─────────────────────────── */ +.portal-proc__upsell { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.portal-proc__upsell-badge { + display: inline-block; + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-primary, #2383e2); + background: var(--color-primary-light, #eaf2fb); + padding: 0.15rem 0.5rem; + border-radius: 0.375rem; + margin-bottom: 0.4rem; +} +.portal-proc__upsell-copy { + margin: 0; + font-size: 0.8125rem; + line-height: 1.45; + color: var(--color-text-3); + max-width: 44rem; +} +.portal-proc__upsell-copy strong { + color: var(--color-text-1); +} + +/* ── Quote builder ────────────────────────────────────────────────────────── */ +.portal-proc__builder-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 1rem; +} +.portal-proc__builder-title { + margin: 0; + font-size: 1rem; + font-weight: 650; + color: var(--color-text-1); +} +.portal-proc__builder-step { + font-size: 0.75rem; + color: var(--color-text-5); +} +.portal-proc__builder-body { + display: flex; + flex-direction: column; + gap: 0.85rem; +} +.portal-proc__field { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.8125rem; + color: var(--color-text-3); +} +.portal-proc__field input, +.portal-proc__field select { + padding: 0.45rem 0.6rem; + border: 1px solid var(--color-border, #eae8e3); + border-radius: 0.5rem; + font-size: 0.875rem; + background: var(--color-bg-elevated, #fff); + color: var(--color-text-1); +} +.portal-proc__builder-addons { + display: flex; + flex-direction: column; + gap: 0.4rem; + font-size: 0.8125rem; + color: var(--color-text-2); +} +.portal-proc__builder-addons label { + display: flex; + align-items: center; + gap: 0.5rem; +} +.portal-proc__builder-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + margin-top: 0.5rem; +} +.portal-proc__quote-head { + display: flex; + align-items: baseline; + justify-content: space-between; + border-bottom: 1px solid var(--color-border, #eae8e3); + padding-bottom: 0.5rem; +} +.portal-proc__quote-number { + font-weight: 650; + color: var(--color-text-1); +} +.portal-proc__quote-valid { + font-size: 0.75rem; + color: var(--color-text-5); +} +.portal-proc__quote-lines { + list-style: none; + margin: 0; + padding: 0; +} +.portal-proc__quote-lines li { + display: flex; + justify-content: space-between; + padding: 0.4rem 0; + font-size: 0.8125rem; + color: var(--color-text-2); + border-bottom: 1px solid var(--color-border-light, #f0eee9); +} +.portal-proc__quote-lines li[data-kind="DISCOUNT"] { + color: var(--color-success, #0f7b6c); +} +.portal-proc__quote-total { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.6rem 0 0.2rem; + font-size: 0.9375rem; +} +.portal-proc__quote-total strong { + font-size: 1.25rem; + color: var(--color-text-1); +} +.portal-proc__quote-tcv { + font-size: 0.75rem; + color: var(--color-text-5); +} + +/* ══ Quote builder — copied from the marketing prototype (tight density) ═════ */ +.portal-qb { + background: #ffffff; + border: 1px solid #e3e1dc; + border-radius: 14px; + box-shadow: inset 0 0 0 1px #eae8e3; + overflow: hidden; +} +.portal-qb__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px 14px; + border-bottom: 1px solid #f0eee9; + background: linear-gradient( + 180deg, + rgba(35, 131, 226, 0.055) 0%, + transparent 100% + ); +} +.portal-qb__title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: #37352f; +} +.portal-qb__stepchip { + font-size: 11px; + font-weight: 700; + color: #9b9a97; + background: #f5f4f1; + padding: 3px 10px; + border-radius: 999px; +} +.portal-qb__progress { + display: flex; + gap: 6px; + padding: 12px 24px 0; +} +.portal-qb__progress span { + flex: 1; + height: 6px; + border-radius: 999px; + background: #f0eee9; + transition: background 0.3s; +} +.portal-qb__progress span[data-on] { + background: #2383e2; +} +.portal-qb__body { + padding: 20px 24px; + max-height: 56vh; + overflow-y: auto; +} +.portal-qb__intro { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 18px; +} +.portal-qb__intro-icon { + width: 38px; + height: 38px; + border-radius: 10px; + background: #eaf2fb; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} +.portal-qb__intro-title { + font-size: 15px; + font-weight: 700; + color: #37352f; +} +.portal-qb__intro-sub { + font-size: 12.5px; + color: #9b9a97; + margin-top: 1px; +} +.portal-qb__field { + display: block; + margin-bottom: 18px; +} +.portal-qb__field-label { + display: block; + font-size: 12px; + font-weight: 600; + color: #787774; + margin-bottom: 6px; +} +.portal-qb__field input, +.portal-qb__field select { + width: 100%; + box-sizing: border-box; + padding: 9px 11px; + font-size: 13.5px; + border-radius: 8px; + border: 1px solid #e3e1dc; + font-family: inherit; + outline: none; + background: #fff; + color: #37352f; +} +.portal-qb__row { + display: flex; + gap: 14px; + flex-wrap: wrap; +} +.portal-qb__row .portal-qb__field { + flex: 1; + min-width: 190px; +} +.portal-qb__hint { + margin: 7px 0 0; + font-size: 11.5px; + color: #9b9a97; + line-height: 1.4; +} +.portal-qb__pills { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.portal-qb__pills button { + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + border-radius: 8px; + border: 1px solid #e3e1dc; + background: #fff; + color: #37352f; + cursor: pointer; +} +.portal-qb__pills button[data-on] { + background: #2383e2; + border-color: #2383e2; + color: #fff; +} +.portal-qb__discount { + margin: 6px 0 0; + font-size: 11.5px; + color: #0f7b6c; +} +.portal-qb__opts { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.portal-qb__opt { + text-align: left; + flex: 1; + min-width: 150px; + padding: 12px 14px; + border-radius: 9px; + border: 1px solid #e3e1dc; + background: #fff; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} +.portal-qb__opt[data-on] { + border-color: #2383e2; + background: #eaf2fb; +} +.portal-qb__opt-title { + font-size: 13px; + font-weight: 700; + color: #37352f; +} +.portal-qb__opt[data-on] .portal-qb__opt-title { + color: #1b6ec2; +} +.portal-qb__opt-sub { + font-size: 11.5px; + color: #9b9a97; + line-height: 1.4; +} +.portal-qb__addons { + display: flex; + flex-direction: column; + gap: 8px; +} +.portal-qb__addon { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 11px 13px; + border-radius: 9px; + border: 1px solid #e3e1dc; + background: #fff; + cursor: pointer; + text-align: left; +} +.portal-qb__addon[data-on] { + border-color: #2383e2; + background: #eaf2fb; +} +.portal-qb__addon-box { + width: 18px; + height: 18px; + flex-shrink: 0; + border-radius: 5px; + border: 1px solid #d3d1cb; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: transparent; + background: #fff; +} +.portal-qb__addon[data-on] .portal-qb__addon-box { + background: #2383e2; + border-color: #2383e2; + color: #fff; +} +.portal-qb__addon-title { + display: block; + font-size: 13px; + font-weight: 600; + color: #37352f; +} +.portal-qb__addon-sub { + display: block; + font-size: 11.5px; + color: #9b9a97; + margin-top: 1px; +} +.portal-qb__eula { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px; + border-radius: 9px; + border: 1px solid #f0eee9; + background: #f5f4f1; + font-size: 12.5px; + color: #37352f; + line-height: 1.5; + cursor: pointer; +} +.portal-qb__foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 24px; + border-top: 1px solid #f0eee9; + flex-wrap: wrap; +} +.portal-qb__running { + font-size: 11.5px; + color: #9b9a97; +} +.portal-qb__foot-btns { + display: flex; + gap: 10px; +} +/* Step 4 — the itemised quote paper */ +.portal-qb__papertray { + background: #f5f4f1; + padding: 18px; + max-height: 56vh; + overflow-y: auto; + margin: -20px -24px; +} +.portal-qb__paper { + background: #fff; + border: 1px solid #f0eee9; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); +} +.portal-qb__paper-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid #f0eee9; +} +.portal-qb__paper-brand { + font-size: 13.5px; + font-weight: 800; + color: #37352f; +} +.portal-qb__paper-eyebrow { + font-size: 11px; + color: #9b9a97; +} +.portal-qb__paper-meta { + text-align: right; +} +.portal-qb__quote-number { + font-size: 12.5px; + font-weight: 700; + color: #37352f; +} +.portal-qb__paper-meta div:not(.portal-qb__quote-number) { + font-size: 11px; + color: #9b9a97; +} +.portal-qb__paper-for { + padding: 18px 24px 0; +} +.portal-qb__paper-company { + font-size: 14px; + font-weight: 700; + color: #37352f; +} +.portal-qb__lines { + list-style: none; + margin: 0; + padding: 12px 24px 0; +} +.portal-qb__lines li { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 10px 0; + border-bottom: 1px solid #f0eee9; + font-size: 13px; + font-weight: 600; + color: #37352f; +} +.portal-qb__lines li[data-kind="DISCOUNT"] { + color: #0f7b6c; +} +.portal-qb__lines li[data-kind="INCLUDED"] span:last-child { + color: #9b9a97; + font-weight: 500; +} +.portal-qb__total { + display: flex; + align-items: center; + justify-content: space-between; + margin: 16px 24px 24px; + padding: 16px 18px; + border-radius: 10px; + background: #eaf2fb; + border: 1px solid #b8d5f2; +} +.portal-qb__total-label { + font-size: 13px; + font-weight: 700; + color: #37352f; +} +.portal-qb__total-tcv { + font-size: 11.5px; + color: #787774; + margin-top: 2px; +} +.portal-qb__total-num { + text-align: right; +} +.portal-qb__total-num strong { + display: block; + font-size: 23px; + font-weight: 800; + color: #37352f; + line-height: 1; +} +.portal-qb__total-num span { + font-size: 11px; + color: #9b9a97; + margin-top: 3px; +} + +/* ── Enterprise upsell text wrapper (Home on-ramp) ────────────────────────── */ +.portal-proc__upsell-text { + flex: 1 1 20rem; +} + +/* ── Deal-status hero (Home, active deal) ─────────────────────────────────── */ +.portal-hero { + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 1.1rem 1.25rem; + background: + radial-gradient( + 120% 140% at 100% 0%, + rgba(124, 58, 237, 0.08), + transparent 55% + ), + var(--color-surface); + display: flex; + flex-direction: column; + gap: 1rem; +} +.portal-hero__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.portal-hero__eyebrow { + display: block; + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-primary, #2383e2); +} +.portal-hero__company { + display: block; + font-size: 1.0625rem; + font-weight: 650; + color: var(--color-text-1); + margin-top: 0.2rem; +} +.portal-hero__chips { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} +.portal-hero__chip { + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-3); + background: var(--color-border-light); + border-radius: 999px; + padding: 0.2rem 0.6rem; +} +.portal-hero__stepper { + overflow-x: auto; +} +.portal-hero__next { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + padding-top: 0.85rem; + border-top: 1px solid var(--color-border-light); +} +.portal-hero__next-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-2); +} +.portal-hero__next-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--color-primary, #2383e2); + box-shadow: 0 0 0 3px rgba(35, 131, 226, 0.18); +} + +/* ── Full-screen takeover modal (procurement flow) ────────────────────────── */ +.portal-procmodal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding: clamp(0.5rem, 4vh, 3rem) 1rem; + overflow-y: auto; + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(6px) saturate(160%); + -webkit-backdrop-filter: blur(6px) saturate(160%); + animation: portal-procmodal-fade 0.15s ease-out; +} +@keyframes portal-procmodal-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.portal-procmodal__panel { + position: relative; + width: 100%; + max-width: 62rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 14px; + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28); + padding: 1.5rem 1.5rem 1.75rem; +} +.portal-procmodal__close { + position: absolute; + top: 0.85rem; + right: 0.85rem; + border: none; + background: var(--color-border-light); + color: var(--color-text-3); + width: 1.9rem; + height: 1.9rem; + border-radius: 8px; + font-size: 0.85rem; + cursor: pointer; +} +.portal-procmodal__close:hover { + background: var(--color-border); + color: var(--color-text-1); +} +.portal-procmodal__header { + margin-bottom: 1.25rem; + padding-right: 2.5rem; +} +.portal-procmodal__title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; + color: var(--color-text-1); +} +.portal-procmodal__sub { + margin: 0.3rem 0 0; + font-size: 0.875rem; + color: var(--color-text-3); +} +.portal-procmodal__body { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.portal-proc__modal-stepper { + overflow-x: auto; +} +.portal-proc__payment-actions { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + margin-top: 1rem; +} +.portal-proc__milestone-for { + margin: 0.15rem 0 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-2); +} +.portal-proc__milestone-lines { + margin: 0.85rem 0 0.5rem; +} +.portal-proc__milestone-totals { + display: flex; + align-items: baseline; + gap: 1rem; + flex-wrap: wrap; + margin: 0.75rem 0 0.25rem; +} +.portal-proc__milestone-annual { + font-size: 1.75rem; + font-weight: 700; + color: var(--color-text-1); +} +.portal-proc__milestone-annual small { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-4); +} +.portal-proc__milestone-tcv { + font-size: 0.8125rem; + color: var(--color-text-3); +} + +/* Hero next-step action row (primary CTA + optional extend-trial). */ +.portal-hero__next-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Hero quick-action chips (clickable pills next to the company name). */ +.portal-hero__chip--action { + border: 1px solid var(--color-border); + cursor: pointer; + transition: + background 0.12s, + box-shadow 0.12s, + transform 0.12s; +} +.portal-hero__chip--action:hover { + background: var(--color-surface); + box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1); + transform: translateY(-1px); +} + +/* Hero rollout checklist (trial): the "do this now" setup steps. */ +.portal-hero__checklist { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--color-border-light); +} +.portal-hero__checklist li { + border-bottom: 1px solid var(--color-border-light); +} +.portal-hero__checklist button { + display: flex; + align-items: center; + gap: 0.85rem; + width: 100%; + padding: 0.7rem 0.25rem; + background: none; + border: none; + cursor: pointer; + text-align: left; +} +.portal-hero__checklist button:hover { + background: var(--color-bg-hover, var(--color-border-light)); +} +.portal-hero__check-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--color-border); + flex-shrink: 0; +} +.portal-hero__check-text { + flex: 1; + min-width: 0; +} +.portal-hero__check-title { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text-1); +} +.portal-hero__check-sub { + display: block; + font-size: 0.75rem; + color: var(--color-text-4); + margin-top: 0.05rem; +} +.portal-hero__check-pill { + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-5); + background: var(--color-border-light); + border-radius: 999px; + padding: 0.15rem 0.55rem; + flex-shrink: 0; +} + +/* ── Side dialogs (Key documents / Schedule a call / Trial) ───────────────── */ +.portal-sidemodal { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + overflow-y: auto; + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + animation: portal-procmodal-fade 0.15s ease-out; +} +.portal-sidemodal__panel { + position: relative; + width: 100%; + max-width: 30rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 14px; + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28); + padding: 1.35rem 1.4rem 1.4rem; + max-height: 86vh; + overflow-y: auto; +} +.portal-sidemodal__header { + margin-bottom: 1rem; + padding-right: 2rem; +} +.portal-sidemodal__title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--color-text-1); +} +.portal-sidemodal__sub { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--color-text-4); + line-height: 1.5; +} +.portal-sidemodal__text { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-3); + line-height: 1.55; +} +.portal-sidemodal__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: 1.1rem; + padding-top: 0.9rem; + border-top: 1px solid var(--color-border-light); +} +.portal-sidemodal__ghost { + border: none; + background: none; + font-size: 0.8125rem; + color: var(--color-text-4); + cursor: pointer; +} +.portal-sidemodal__ghost:hover:not(:disabled) { + color: var(--color-text-2); +} + +/* Key documents ledger. */ +.portal-docs__group + .portal-docs__group { + margin-top: 1rem; +} +.portal-docs__group-title { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-5); + margin-bottom: 0.4rem; +} +.portal-docs__list { + list-style: none; + margin: 0; + padding: 0; +} +.portal-docs__row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0; + border-top: 1px solid var(--color-border-light); +} +.portal-docs__row-text { + flex: 1; + min-width: 0; +} +.portal-docs__row-name { + display: block; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} +.portal-docs__row-sub { + display: block; + font-size: 0.72rem; + color: var(--color-text-4); + margin-top: 0.05rem; +} +.portal-docs__row-action { + font-size: 0.6875rem; + font-weight: 600; + border-radius: 999px; + padding: 0.2rem 0.6rem; + flex-shrink: 0; + color: var(--color-text-3); + background: var(--color-border-light); +} +.portal-docs__row-action[data-status="action"] { + color: var(--color-primary, #2383e2); + background: var(--color-primary-light, #eaf2fb); +} +.portal-docs__row-action[data-status="request"] { + color: var(--color-text-5); +} + +/* Solutions-engineer + time slots (Schedule a call). */ +.portal-se { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} +.portal-se__avatar { + width: 2.4rem; + height: 2.4rem; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + color: var(--color-primary, #2383e2); + background: var(--color-primary-light, #eaf2fb); + flex-shrink: 0; +} +.portal-se__name { + font-size: 0.875rem; + font-weight: 650; + color: var(--color-text-1); +} +.portal-se__role { + font-size: 0.75rem; + color: var(--color-text-4); +} +.portal-slots { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} +.portal-slots__slot { + padding: 0.6rem 0.75rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 9px; + cursor: pointer; +} +.portal-slots__slot:hover { + border-color: var(--color-primary, #2383e2); + color: var(--color-primary, #2383e2); +} + +/* ── Agreement (security) step ────────────────────────────────────────────── */ +.portal-agreement__doc { + margin: 1rem 0; + max-height: 22rem; + overflow-y: auto; + padding: 1rem 1.1rem; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-bg-subtle, var(--color-bg)); + font-size: 0.8125rem; + line-height: 1.55; + color: var(--color-text-3); +} +.portal-agreement__doc h4 { + margin: 1rem 0 0.35rem; + font-size: 0.8125rem; + font-weight: 650; + color: var(--color-text-1); +} +.portal-agreement__doc h4:first-child { + margin-top: 0; +} +.portal-agreement__doc p { + margin: 0 0 0.5rem; +} +.portal-agreement__doc strong { + color: var(--color-text-1); +} +.portal-agreement__accept { + margin-top: 0.25rem; +} +.portal-agreement__lines { + margin: 0.4rem 0 0.6rem; +} +.portal-proc__reset { + display: flex; + justify-content: center; + padding-top: 0.5rem; +} +.portal-proc__reset button { + border: none; + background: none; + font-size: 0.75rem; + color: var(--color-text-5); + cursor: pointer; + text-decoration: underline; +} +.portal-proc__reset button:hover:not(:disabled) { + color: var(--color-text-3); +} +.portal-proc__reset button:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/frontend/editor/src/portal/views/Procurement.tsx b/frontend/editor/src/portal/views/Procurement.tsx index f4fa25178a..1f6e2329a9 100644 --- a/frontend/editor/src/portal/views/Procurement.tsx +++ b/frontend/editor/src/portal/views/Procurement.tsx @@ -1,111 +1,15 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Card, Skeleton, StatusBadge } from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - advanceStage, - fetchProcurement, - type DealStage, - type LedgerDoc, - type ProcurementResponse, -} from "@portal/api/procurement"; -import { DealJourney } from "@portal/components/procurement/DealJourney"; -import { DocumentLedger } from "@portal/components/procurement/DocumentLedger"; -import { ActionModal } from "@portal/components/procurement/ActionModal"; -import { LockedState } from "@portal/components/procurement/LockedState"; +import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; import "@portal/views/Procurement.css"; /** - * Procurement: the enterprise commercial journey (trial to live) plus the - * document ledger. Enterprise-only: free/pro buyers see a locked upgrade state. + * /procurement — procurement is no longer a nav tab; it lives on Home as the deal-status hero. + * This route is kept for deep links (and the Usage "Build your quote" CTA): it renders the same + * surface, opening the takeover modal once a deal is underway. */ export function Procurement() { - const { t } = useTranslation(); - const { tier } = useTier(); - const [activeDoc, setActiveDoc] = useState(null); - const [advancing, setAdvancing] = useState(false); - - const state = useAsync( - () => fetchProcurement(tier), - [tier], - ); - - // Write actions return the new canonical state; we hold it here so the - // journey reflects every action immediately. Cleared when the tier (and so - // the deal) changes, falling back to whatever the GET loaded. - const [applied, setApplied] = useState(null); - useEffect(() => setApplied(null), [tier]); - const data = applied ?? (state.loading ? null : state.data); - - async function onAdvance(stage: DealStage) { - setAdvancing(true); - try { - setApplied(await advanceStage(stage)); - } finally { - setAdvancing(false); - } - } - return (
-
-
-

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

-

- {t("portal.procurement.subtitle")} -

-
- - {t("portal.procurement.enterpriseBadge")} - -
- - {state.loading && ( - - - - - )} - - {data && !data.unlocked && ( - { - // TODO(backend): POST /v1/procurement/sales-contact, for now this - // is the sidebar's upgrade path; hand off to the account team. - }} - /> - )} - - {data && data.unlocked && data.deal && ( - <> - - - - )} - - setActiveDoc(null)} - onDone={(next) => { - setApplied(next); - setActiveDoc(null); - }} - /> +
); }