Portal procurement: real pricing/trial/quote spine + linked-gated checkout (vertical slice) (#6861)
## What this is
The enterprise procurement flow, built into the customer portal as a
**vertical slice** — one linked account can go the whole way from trial
to a paid, committed subscription, using real Stripe under the hood.
Procurement no longer lives as a nav tab. It sits on **Home** as a
deal-status hero and expands into a full-screen takeover, matching the
marketing prototype.
## The journey (what a customer does)
- **Start a trial** in one click — the deadline and next steps show on
the Home hero (no card, mock licence).
- **Build a quote** — a short form (volume → commitment & service →
details); pricing is computed server-side.
- **Generate the quote** — this creates a real **Stripe Quote** with a
proper **PDF** you can download and share, and it becomes a milestone
you can come back to.
- **Review & sign the agreement** — one combined agreement (MSA + Order
Form + EULA + DPA) with an itemised order form and an "I agree" (no
e-signature yet).
- **Accept** — Stripe creates the committed annual **subscription** and
its **first invoice**, which you can **pay or download right in the
app** (no waiting on email).
- Edit a quote any time — it remembers your inputs and company name; the
old Stripe quote is cancelled so it can't still be accepted.
- The hero also has quick actions: **key documents**, **invite
teammates**, **schedule a call**, and a **trial countdown** you can
extend.
## Architecture — Supabase vs Java
Pricing, deal/quote state, and the commercial journey live in **Java
(`:saas`)**. Everything that touches **Stripe** (writes + PDFs) lives in
**Supabase edge functions** — Java has no Stripe SDK and only reads
Stripe via the sync mirror. The portal calls both.
```mermaid
flowchart LR
Portal["Portal (React · editor/src/portal)"]
subgraph JAVA["Java :saas backend (trusted cloud)"]
Pricing["Pricing engine (volume bands, SLA, term, add-ons)"]
Deal["Deal + quote state, journey, snapshot"]
Trial["Trial (mock Keygen licence seam)"]
Authz["Auth: team resolve + leader gating"]
Mirror["Reads Stripe via sync mirror (stripe.* tables)"]
end
subgraph SUPA["Supabase edge functions (own Stripe)"]
Issue["issue-procurement-quote → create + finalize Stripe Quote"]
Accept["accept-procurement-quote → subscription + finalize invoice"]
Pdf["get-procurement-quote-pdf → proxy the quote PDF"]
RPC["SECURITY DEFINER RPCs (read/write stirling_pdf, enforce team/leader)"]
end
Stripe["Stripe (Quotes · Subscription · Invoice)"]
Portal -->|"price / build / trial / agreement / snapshot"| JAVA
Portal -->|"issue / accept / download PDF"| SUPA
SUPA --> Stripe
SUPA --- RPC
Mirror -. reads .-> Stripe
```
| Top-level feature | Handled in |
|---|---|
| Quote pricing (bands, SLA, term, add-ons) | **Java** |
| Deal + quote state, journey, snapshot | **Java** |
| Trial start / extend (mock licence) | **Java** |
| AuthN/Z (team resolve, leader gating) | **Java** |
| Issue quote → Stripe Quote + PDF | **Supabase edge fn** |
| Accept → subscription + invoice | **Supabase edge fn** |
| Quote PDF download | **Supabase edge fn** |
| Reading Stripe state | **Java** (sync mirror) |
| `stirling_pdf` writes from edge | **SECURITY DEFINER RPCs**
(service-role only) |
## Screenshots
<!-- Drag each PNG into the box below it before publishing. -->
**Home deal-status hero (trial)**
<img width="1920" height="1009" alt="hero-check"
src="https://github.com/user-attachments/assets/7ae21831-9578-4f4d-b91a-d3ab2cb171dc"
/>
**Issued quote milestone (with breakdown)**
<img width="1920" height="1009" alt="milestone-breakdown"
src="https://github.com/user-attachments/assets/3a463c67-3c6e-4f93-a1cc-59b250d54cc9"
/>
**Agreement step (itemised order form)**
<img width="1920" height="1009" alt="agreement-itemised"
src="https://github.com/user-attachments/assets/1a4efa16-d0a3-41d0-849c-a125b1492a34"
/>
**Key documents**
<img width="1920" height="1009" alt="keydocs-modal"
src="https://github.com/user-attachments/assets/5672d1d2-99d9-499e-9edf-d485df378e7f"
/>
**Subscription created (pay / download invoice)**
<img width="1920" height="1009" alt="accepted-check"
src="https://github.com/user-attachments/assets/3a8cbe9f-e72c-4389-b365-0c1749108b6f"
/>
## Mocked for now (scaffolding, not wired to real backends)
- **Key documents** ledger — static demo list.
- **Schedule a call** — static solutions-engineer + time slots.
- **Invite teammates** — routes to the existing Users view.
- **Simulate payment received** / **Reset procurement** — demo controls,
**off by default** in prod (flag-gated), 404 unless enabled.
## Deferred (separate follow-up PRs)
- **Real `invoice.paid` webhook** → go-live (today a demo button stands
in).
- **Keygen licence controller** — real licensing (currently a mock
seam).
- **Document sharing**.
- **Stirling admin / Deal Desk** view.
- **Minimum ACV floor** — pending a number from marketing (server-side
enforcement is a one-liner once decided).
## How to test
- **Frontend, no backend:** runs against MSW mocks (Storybook + mocks-on
dev) — the whole journey is clickable.
- **Real end-to-end:** apply the migrations (Flyway `V27–V29` / Supabase
`20260701–20260707`), deploy the three edge functions, ensure
**Invoicing Plus** is enabled on Stripe, and set
`STIRLING_PROCUREMENT_DEMO_CONTROLS_ENABLED=true` if you want the demo
controls.
- Paired SaaS PR: **Stirling-Tools/Stirling-PDF-SaaS#318**.
## Notes for reviewers
- Pricing is server-authoritative (client sends config, never amounts).
- Security review done: edge functions validate the JWT and enforce
**team membership** (and **leader** for issue/accept) via the RPC; demo
endpoints are flag-gated off. Only open item is the ACV floor (policy).
This commit is contained in:
@@ -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 {}
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package stirling.software.saas.procurement.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem;
|
||||
import stirling.software.saas.procurement.service.ProcurementService;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
* The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
|
||||
* (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
|
||||
* Supabase edge function the portal calls with the accepted quote — this controller never touches
|
||||
* Stripe. The caller's team is resolved from the authenticated principal; a team id is never
|
||||
* trusted from the request. Mutations require the team leader.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/procurement")
|
||||
@Profile("saas")
|
||||
public class ProcurementController {
|
||||
|
||||
// Local mapper to parse the stored line-items JSON; the saas context exposes no injectable
|
||||
// ObjectMapper bean.
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ProcurementService procurement;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
|
||||
public ProcurementController(
|
||||
ProcurementService procurement,
|
||||
TeamMembershipRepository memberRepo,
|
||||
UserRepository userRepository,
|
||||
ProcurementConfigurationProperties config) {
|
||||
this.procurement = Objects.requireNonNull(procurement);
|
||||
this.memberRepo = Objects.requireNonNull(memberRepo);
|
||||
this.userRepository = Objects.requireNonNull(userRepository);
|
||||
this.config = Objects.requireNonNull(config);
|
||||
}
|
||||
|
||||
// ---- request / response DTOs -------------------------------------------
|
||||
|
||||
public record QuoteRequest(
|
||||
long volume,
|
||||
int users,
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
String currency,
|
||||
String businessName) {
|
||||
QuoteConfig toConfig() {
|
||||
return new QuoteConfig(
|
||||
volume,
|
||||
users,
|
||||
deployment,
|
||||
termYears,
|
||||
serviceLevel,
|
||||
indemnification,
|
||||
training,
|
||||
qbr,
|
||||
currency);
|
||||
}
|
||||
}
|
||||
|
||||
public record QuoteResponse(
|
||||
Long quoteId,
|
||||
String quoteNumber,
|
||||
String status,
|
||||
String currency,
|
||||
long annualNetMinor,
|
||||
long tcvMinor,
|
||||
List<QuoteLineItem> lineItems,
|
||||
String validUntil,
|
||||
String stripeQuoteId,
|
||||
String invoiceUrl,
|
||||
QuoteConfigEcho config) {}
|
||||
|
||||
/**
|
||||
* The inputs the quote was priced from, echoed back so the builder can seed itself when the
|
||||
* buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume
|
||||
* is), so it is always 0 here; the builder treats the seeded volume as manually set.
|
||||
*/
|
||||
public record QuoteConfigEcho(
|
||||
long volume,
|
||||
int users,
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
String currency,
|
||||
String businessName) {}
|
||||
|
||||
public record SnapshotResponse(
|
||||
Long dealId,
|
||||
String stage,
|
||||
String trialStartedAt,
|
||||
String trialEndsAt,
|
||||
int trialExtensionsUsed,
|
||||
boolean licensed,
|
||||
QuoteResponse latestQuote) {}
|
||||
|
||||
// ---- endpoints ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an
|
||||
* empty snapshot ({@code dealId == null}) so the portal can render the "start" state without
|
||||
* special-casing an empty body.
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
|
||||
Long teamId = resolveTeam(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
return ResponseEntity.ok(
|
||||
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
|
||||
}
|
||||
|
||||
private static final SnapshotResponse EMPTY_SNAPSHOT =
|
||||
new SnapshotResponse(null, null, null, null, 0, false, null);
|
||||
|
||||
@PostMapping("/trial/start")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
|
||||
}
|
||||
|
||||
@PostMapping("/trial/extend")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> extendTrial(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/quote")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<QuoteResponse> buildQuote(
|
||||
@RequestBody QuoteRequest request, Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(
|
||||
toQuote(
|
||||
procurement.buildQuote(
|
||||
teamId, request.toConfig(), request.businessName())));
|
||||
}
|
||||
|
||||
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
|
||||
// draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription.
|
||||
// Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them.
|
||||
|
||||
/**
|
||||
* Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees.
|
||||
*/
|
||||
@PostMapping("/agreement")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startAgreement(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
|
||||
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
|
||||
*/
|
||||
@PostMapping("/go-live")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> goLive(Authentication auth) {
|
||||
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */
|
||||
@PostMapping("/reset")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> reset(Authentication auth) {
|
||||
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
procurement.resetDeal(teamId);
|
||||
return ResponseEntity.ok(EMPTY_SNAPSHOT);
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
|
||||
*/
|
||||
private Long resolveTeam(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return null;
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
|
||||
}
|
||||
|
||||
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
|
||||
private Long requireLeader(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return null;
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
|
||||
return rows.get(0).getTeam().getId();
|
||||
}
|
||||
|
||||
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
|
||||
QuoteResponse latest =
|
||||
procurement.quotesForDeal(deal.getDealId()).stream()
|
||||
.findFirst()
|
||||
.map(this::toQuote)
|
||||
.orElse(null);
|
||||
return new SnapshotResponse(
|
||||
deal.getDealId(),
|
||||
deal.getStage(),
|
||||
str(deal.getTrialStartedAt()),
|
||||
str(deal.getTrialEndsAt()),
|
||||
deal.getTrialExtensionsUsed(),
|
||||
deal.getLicenseRef() != null,
|
||||
latest);
|
||||
}
|
||||
|
||||
private QuoteResponse toQuote(ProcurementQuote q) {
|
||||
return new QuoteResponse(
|
||||
q.getQuoteId(),
|
||||
q.getQuoteNumber(),
|
||||
q.getStatus(),
|
||||
q.getCurrency(),
|
||||
q.getAnnualNetMinor(),
|
||||
q.getTcvMinor(),
|
||||
parseLineItems(q.getLineItemsJson()),
|
||||
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
|
||||
q.getStripeQuoteId(),
|
||||
q.getStripeInvoiceUrl(),
|
||||
new QuoteConfigEcho(
|
||||
q.getVolume(),
|
||||
0,
|
||||
q.getDeployment(),
|
||||
q.getTermYears(),
|
||||
q.getServiceLevel(),
|
||||
q.isIndemnification(),
|
||||
q.isTraining(),
|
||||
q.isQbr(),
|
||||
q.getCurrency(),
|
||||
q.getBusinessName()));
|
||||
}
|
||||
|
||||
private List<QuoteLineItem> parseLineItems(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json,
|
||||
OBJECT_MAPPER
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(List.class, QuoteLineItem.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("[procurement] failed to parse line items", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and
|
||||
* returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
|
||||
* end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
|
||||
* {@code license_ref} on the deal stay identical.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
|
||||
@Override
|
||||
public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
|
||||
String ref = "mock-trial-" + UUID.randomUUID();
|
||||
log.info(
|
||||
"[procurement][mock-license] issue trial team={} expires={} ref={}",
|
||||
teamId,
|
||||
expiresAt,
|
||||
ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
|
||||
log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
|
||||
String ref = "mock-annual-" + UUID.randomUUID();
|
||||
log.info(
|
||||
"[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
|
||||
teamId,
|
||||
deployment,
|
||||
expiresAt,
|
||||
ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void suspendLicense(String licenseRef) {
|
||||
log.info("[procurement][mock-license] suspend ref={}", licenseRef);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the
|
||||
* portal renders (trial -> 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;
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the
|
||||
* buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the
|
||||
* server-computed result (never trusted from the client). Stripe fields are populated when the
|
||||
* accepted quote is turned into a checkout.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "procurement_quote")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcurementQuote implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String STATUS_DRAFT = "draft";
|
||||
public static final String STATUS_SENT = "sent";
|
||||
public static final String STATUS_ACCEPTED = "accepted";
|
||||
public static final String STATUS_EXPIRED = "expired";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "quote_id")
|
||||
private Long quoteId;
|
||||
|
||||
@Column(name = "deal_id", nullable = false)
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "quote_number", nullable = false, length = 64)
|
||||
private String quoteNumber;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 24)
|
||||
private String status = STATUS_DRAFT;
|
||||
|
||||
@Column(name = "currency", nullable = false, length = 8)
|
||||
private String currency = "USD";
|
||||
|
||||
@Column(name = "volume", nullable = false)
|
||||
private long volume;
|
||||
|
||||
@Column(name = "seats")
|
||||
private Integer seats;
|
||||
|
||||
@Column(name = "deployment", length = 24)
|
||||
private String deployment;
|
||||
|
||||
@Column(name = "term_years", nullable = false)
|
||||
private int termYears;
|
||||
|
||||
@Column(name = "service_level", nullable = false, length = 24)
|
||||
private String serviceLevel;
|
||||
|
||||
@Column(name = "indemnification", nullable = false)
|
||||
private boolean indemnification;
|
||||
|
||||
@Column(name = "training", nullable = false)
|
||||
private boolean training;
|
||||
|
||||
@Column(name = "qbr", nullable = false)
|
||||
private boolean qbr;
|
||||
|
||||
@Column(name = "annual_net_minor", nullable = false)
|
||||
private long annualNetMinor;
|
||||
|
||||
@Column(name = "tcv_minor", nullable = false)
|
||||
private long tcvMinor;
|
||||
|
||||
@Column(name = "line_items", columnDefinition = "text")
|
||||
private String lineItemsJson;
|
||||
|
||||
// The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn.
|
||||
@Column(name = "stripe_quote_id", length = 128)
|
||||
private String stripeQuoteId;
|
||||
|
||||
// Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is
|
||||
// accepted.
|
||||
@Column(name = "stripe_invoice_url", columnDefinition = "text")
|
||||
private String stripeInvoiceUrl;
|
||||
|
||||
// Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
|
||||
@Column(name = "business_name", length = 255)
|
||||
private String businessName;
|
||||
|
||||
@Column(name = "valid_until")
|
||||
private LocalDate validUntil;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* The enterprise rate card: the inputs pricing multiplies against. In production these are read
|
||||
* from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the
|
||||
* fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source
|
||||
* of the numbers the marketing prototype encodes.
|
||||
*
|
||||
* <p>Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 =
|
||||
* +15%). Flat/one-time fees are in minor units.
|
||||
*/
|
||||
public record PricingRates(
|
||||
long perPdfMinorUnder1M,
|
||||
long perPdfMinorUnder5M,
|
||||
long perPdfMinor5MPlus,
|
||||
double priorityUplift,
|
||||
double dedicatedUplift,
|
||||
double indemnificationUplift,
|
||||
double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr
|
||||
long qbrAnnualMinor,
|
||||
long trainingOneTimeMinor) {
|
||||
|
||||
public static PricingRates defaults() {
|
||||
return new PricingRates(
|
||||
5, // $0.05 / PDF under 1M/yr
|
||||
4, // $0.04 / PDF at 1M–5M/yr
|
||||
3, // $0.03 / PDF at 5M+/yr
|
||||
0.15, // priority +15%
|
||||
0.30, // dedicated +30%
|
||||
0.05, // IP indemnification +5%
|
||||
new double[] {0.0, 0.05, 0.10, 0.12, 0.15},
|
||||
800_000, // QBRs $8,000 / yr
|
||||
750_000); // onboarding & training $7,500 one-time
|
||||
}
|
||||
|
||||
/** Volume-banded per-PDF rate for an annual volume, in minor units. */
|
||||
public long perPdfMinor(long annualVolume) {
|
||||
if (annualVolume >= 5_000_000) return perPdfMinor5MPlus;
|
||||
if (annualVolume >= 1_000_000) return perPdfMinorUnder5M;
|
||||
return perPdfMinorUnder1M;
|
||||
}
|
||||
|
||||
public double termDiscount(int termYears) {
|
||||
int idx = Math.max(1, Math.min(termYears, 5)) - 1;
|
||||
return termDiscountByYear[idx];
|
||||
}
|
||||
|
||||
public double serviceLevelUplift(String serviceLevel) {
|
||||
if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift;
|
||||
if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift;
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* The canonical enterprise pricing engine — the single server-side definition the quote builder,
|
||||
* the order form, and the Stripe checkout all derive from. A faithful port of the marketing
|
||||
* prototype's {@code quotePricing}:
|
||||
*
|
||||
* <pre>
|
||||
* annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
|
||||
* annualNet = round(annual x (1 - termDiscount)) + qbr
|
||||
* tcv = annualNet x termYears + training
|
||||
* </pre>
|
||||
*
|
||||
* The multi-year discount applies to usage + service level + indemnification, but NOT to the flat
|
||||
* QBR fee (added after), and one-time training sits outside the recurring total. All money is in
|
||||
* minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
|
||||
*/
|
||||
@Service
|
||||
public class ProcurementPricingService {
|
||||
|
||||
/** Bespoke/committed deals don't price below this ACV; the builder floors against it. */
|
||||
public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000
|
||||
|
||||
/**
|
||||
* Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
|
||||
* days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's
|
||||
* {@code users x 5 x 230 x 1.75}.
|
||||
*/
|
||||
public long estimateAnnualVolume(int users) {
|
||||
return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
|
||||
}
|
||||
|
||||
public QuoteBreakdown price(QuoteConfig cfg) {
|
||||
return price(cfg, PricingRates.defaults());
|
||||
}
|
||||
|
||||
public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
|
||||
// Never trust the client's volume: clamp to non-negative so a tampered request can't drive
|
||||
// a
|
||||
// negative amount. The rate card and formula are server-side, so the browser can't lower
|
||||
// the
|
||||
// price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed
|
||||
// floor,
|
||||
// a policy decision that is intentionally not force-applied here — see the review notes.)
|
||||
long volume = Math.max(0, cfg.volume());
|
||||
long perPdf = rates.perPdfMinor(volume);
|
||||
long usage = Math.round(volume * (double) perPdf); // base, pre-service-level
|
||||
double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel());
|
||||
long withSla = Math.round(usage * (1.0 + slaUplift));
|
||||
long withIndemnity =
|
||||
cfg.indemnification()
|
||||
? Math.round(withSla * (1.0 + rates.indemnificationUplift()))
|
||||
: withSla;
|
||||
|
||||
double termDiscount = rates.termDiscount(cfg.termYears());
|
||||
long discount = Math.round(withIndemnity * termDiscount);
|
||||
long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
|
||||
long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
|
||||
|
||||
long annualNet = (withIndemnity - discount) + qbr;
|
||||
long tcv = annualNet * cfg.termYears() + training;
|
||||
|
||||
List<QuoteLineItem> lines = new ArrayList<>();
|
||||
lines.add(
|
||||
new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"seats",
|
||||
"Unlimited users + SSO / SCIM / RBAC",
|
||||
QuoteLineItem.Kind.INCLUDED,
|
||||
0L));
|
||||
if (withSla != usage) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"service-level",
|
||||
serviceLevelLabel(cfg.serviceLevel()),
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
withSla - usage));
|
||||
}
|
||||
if (withIndemnity != withSla) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"indemnification",
|
||||
"IP indemnification",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
withIndemnity - withSla));
|
||||
}
|
||||
if (qbr > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"qbr",
|
||||
"Quarterly business reviews",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
qbr));
|
||||
}
|
||||
if (discount > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"multi-year",
|
||||
cfg.termYears() + "-year commitment",
|
||||
QuoteLineItem.Kind.DISCOUNT,
|
||||
-discount));
|
||||
}
|
||||
if (training > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"training",
|
||||
"Onboarding & training",
|
||||
QuoteLineItem.Kind.ONE_TIME,
|
||||
training));
|
||||
}
|
||||
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
|
||||
}
|
||||
|
||||
private static String serviceLevelLabel(String serviceLevel) {
|
||||
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level";
|
||||
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level";
|
||||
return "Standard service level";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
|
||||
* order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
|
||||
* after the multi-year discount; {@code tcvMinor} is total contract value across the term including
|
||||
* one-time fees. Minor units (cents).
|
||||
*/
|
||||
public record QuoteBreakdown(
|
||||
List<QuoteLineItem> lineItems, long annualNetMinor, long tcvMinor, String currency) {}
|
||||
@@ -0,0 +1,26 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps
|
||||
* (volume, commitment & service, add-ons) and is the sole input to {@link
|
||||
* ProcurementPricingService}. Amounts are never carried here — the service derives them from these
|
||||
* choices and the rate card.
|
||||
*/
|
||||
public record QuoteConfig(
|
||||
long volume, // committed PDFs per year
|
||||
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
|
||||
String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced)
|
||||
int termYears, // 1..5
|
||||
String serviceLevel, // standard | priority | dedicated
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
String currency) { // USD | EUR | GBP
|
||||
|
||||
public QuoteConfig {
|
||||
if (termYears < 1) termYears = 1;
|
||||
if (termYears > 5) termYears = 5;
|
||||
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
|
||||
if (currency == null || currency.isBlank()) currency = "USD";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents);
|
||||
* discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a
|
||||
* one-time fee vs a discount line).
|
||||
*/
|
||||
public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) {
|
||||
|
||||
public enum Kind {
|
||||
RECURRING,
|
||||
ONE_TIME,
|
||||
DISCOUNT,
|
||||
INCLUDED
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.saas.procurement.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
|
||||
public interface ProcurementDealRepository extends JpaRepository<ProcurementDeal, Long> {
|
||||
|
||||
Optional<ProcurementDeal> findByTeamId(Long teamId);
|
||||
|
||||
boolean existsByTeamId(Long teamId);
|
||||
|
||||
/** Reset: drop the team's deal (quotes + activity cascade via FK). */
|
||||
void deleteByTeamId(Long teamId);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.procurement.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
|
||||
public interface ProcurementQuoteRepository extends JpaRepository<ProcurementQuote, Long> {
|
||||
|
||||
List<ProcurementQuote> findByDealIdOrderByCreatedAtDesc(Long dealId);
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package stirling.software.saas.procurement.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
|
||||
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
|
||||
|
||||
/**
|
||||
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
|
||||
* server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the
|
||||
* portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions}
|
||||
* and this service issues the annual licence. All amounts are minor units (cents).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class ProcurementService {
|
||||
|
||||
// Local mapper for the line-items JSON snapshot; the saas context exposes no injectable
|
||||
// ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one.
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ProcurementDealRepository dealRepo;
|
||||
private final ProcurementQuoteRepository quoteRepo;
|
||||
private final ProcurementPricingService pricing;
|
||||
private final EnterpriseLicenseService licenses;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
|
||||
public ProcurementService(
|
||||
ProcurementDealRepository dealRepo,
|
||||
ProcurementQuoteRepository quoteRepo,
|
||||
ProcurementPricingService pricing,
|
||||
EnterpriseLicenseService licenses,
|
||||
ProcurementConfigurationProperties config) {
|
||||
this.dealRepo = dealRepo;
|
||||
this.quoteRepo = quoteRepo;
|
||||
this.pricing = pricing;
|
||||
this.licenses = licenses;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ProcurementDeal> getDeal(Long teamId) {
|
||||
return dealRepo.findByTeamId(teamId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProcurementQuote> quotesForDeal(Long dealId) {
|
||||
return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
|
||||
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
|
||||
* Keygen licence, and the deal row is the journey state.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal startTrial(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
|
||||
deal.setStage(ProcurementDeal.STAGE_TRIAL);
|
||||
deal.setTrialStartedAt(now);
|
||||
deal.setTrialEndsAt(ends);
|
||||
deal.setTrialExtensionsUsed(0);
|
||||
deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
|
||||
deal = dealRepo.save(deal);
|
||||
log.info(
|
||||
"[procurement] trial started team={} deal={} ends={}",
|
||||
teamId,
|
||||
deal.getDealId(),
|
||||
ends);
|
||||
return deal;
|
||||
}
|
||||
|
||||
/** Extend the current trial by the configured increment, up to the cap. */
|
||||
@Transactional
|
||||
public ProcurementDeal extendTrial(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
// Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at
|
||||
// the committed annual licence — extending would rewind its expiry.
|
||||
if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) {
|
||||
throw new IllegalStateException("Trial extension only allowed during the trial stage");
|
||||
}
|
||||
if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) {
|
||||
throw new IllegalStateException("Trial extension cap reached");
|
||||
}
|
||||
LocalDateTime base =
|
||||
deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now();
|
||||
LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays());
|
||||
deal.setTrialEndsAt(newEnd);
|
||||
deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1);
|
||||
if (deal.getLicenseRef() != null) {
|
||||
licenses.extendLicense(deal.getLicenseRef(), newEnd);
|
||||
}
|
||||
return dealRepo.save(deal);
|
||||
}
|
||||
|
||||
/** Price a quote config server-side and persist it as a draft against the team's deal. */
|
||||
@Transactional
|
||||
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
|
||||
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
|
||||
throw new IllegalStateException("Cannot rebuild a quote on a live deal");
|
||||
}
|
||||
// (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
|
||||
// so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
|
||||
deal.setStage(ProcurementDeal.STAGE_QUOTE);
|
||||
deal.setAcceptedQuoteId(null);
|
||||
deal = dealRepo.save(deal);
|
||||
|
||||
QuoteBreakdown breakdown = pricing.price(cfg);
|
||||
|
||||
ProcurementQuote quote = new ProcurementQuote();
|
||||
quote.setDealId(deal.getDealId());
|
||||
quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
|
||||
// Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
|
||||
quote.setStatus(ProcurementQuote.STATUS_DRAFT);
|
||||
quote.setCurrency(cfg.currency());
|
||||
quote.setVolume(cfg.volume());
|
||||
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
|
||||
quote.setDeployment(cfg.deployment());
|
||||
quote.setTermYears(cfg.termYears());
|
||||
quote.setServiceLevel(cfg.serviceLevel());
|
||||
quote.setIndemnification(cfg.indemnification());
|
||||
quote.setTraining(cfg.training());
|
||||
quote.setQbr(cfg.qbr());
|
||||
quote.setBusinessName(businessName);
|
||||
quote.setAnnualNetMinor(breakdown.annualNetMinor());
|
||||
quote.setTcvMinor(breakdown.tcvMinor());
|
||||
quote.setLineItemsJson(writeLineItems(breakdown));
|
||||
quote.setValidUntil(LocalDate.now().plusDays(30));
|
||||
quote = quoteRepo.save(quote);
|
||||
log.info(
|
||||
"[procurement] quote built team={} quote={} annualNet={} tcv={}",
|
||||
teamId,
|
||||
quote.getQuoteNumber(),
|
||||
quote.getAnnualNetMinor(),
|
||||
quote.getTcvMinor());
|
||||
return quote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the deal to the agreement (security) stage: the buyer has an issued quote and is
|
||||
* reviewing the enterprise agreement before it's accepted into a subscription. Requires an
|
||||
* issued quote on the deal.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal startAgreement(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
boolean hasIssuedQuote =
|
||||
quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
|
||||
.anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus()));
|
||||
if (!hasIssuedQuote) {
|
||||
throw new IllegalStateException("No issued quote for team " + teamId);
|
||||
}
|
||||
deal.setStage(ProcurementDeal.STAGE_AGREEMENT);
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId());
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the deal live: issue the annual licence and advance to the active stage. In production
|
||||
* this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this
|
||||
* method is the demo/manual stand-in until that webhook lands.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal markLive(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
int term = 1;
|
||||
String deployment = "cloud";
|
||||
if (deal.getAcceptedQuoteId() != null) {
|
||||
ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
|
||||
if (q != null) {
|
||||
term = Math.max(1, q.getTermYears());
|
||||
if (q.getDeployment() != null && !q.getDeployment().isBlank()) {
|
||||
deployment = q.getDeployment();
|
||||
}
|
||||
}
|
||||
}
|
||||
deal.setLicenseRef(
|
||||
licenses.issueAnnualLicense(
|
||||
teamId, deployment, LocalDateTime.now().plusYears(term)));
|
||||
deal.setStage(ProcurementDeal.STAGE_LIVE);
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
|
||||
* re-demos/testing.
|
||||
*/
|
||||
@Transactional
|
||||
public void resetDeal(Long teamId) {
|
||||
dealRepo.deleteByTeamId(teamId);
|
||||
log.info("[procurement] deal reset team={}", teamId);
|
||||
}
|
||||
|
||||
private String nextQuoteNumber(Long dealId) {
|
||||
int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
|
||||
String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
|
||||
return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
|
||||
}
|
||||
|
||||
private String writeLineItems(QuoteBreakdown breakdown) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("[procurement] failed to serialise line items", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Enterprise procurement: the tables that track a linked team's journey from trial to live.
|
||||
--
|
||||
-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the
|
||||
-- quotes built against it (the itemised, priced offers), and an append-only activity log for the
|
||||
-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions
|
||||
-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence
|
||||
-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService).
|
||||
--
|
||||
-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal (
|
||||
deal_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
-- one active deal per team; the journey lives on this row.
|
||||
stage VARCHAR(32) NOT NULL DEFAULT 'trial',
|
||||
-- trial | quote | security (agreement) | procurement (payment) | active (live)
|
||||
trial_started_at TIMESTAMP,
|
||||
trial_ends_at TIMESTAMP,
|
||||
trial_extensions_used INT NOT NULL DEFAULT 0,
|
||||
license_ref VARCHAR(128),
|
||||
-- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands.
|
||||
subscription_id VARCHAR(255),
|
||||
-- Stripe subscription id, mirrored into billing_subscriptions once commercial.
|
||||
accepted_quote_id BIGINT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote (
|
||||
quote_id BIGSERIAL PRIMARY KEY,
|
||||
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
|
||||
quote_number VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||
-- draft | sent | accepted | expired
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'USD',
|
||||
volume BIGINT NOT NULL,
|
||||
seats INT,
|
||||
deployment VARCHAR(24),
|
||||
term_years INT NOT NULL,
|
||||
service_level VARCHAR(24) NOT NULL,
|
||||
indemnification BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
training BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
qbr BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
annual_net_minor BIGINT NOT NULL,
|
||||
-- recurring annual fee after the multi-year discount, in minor units (cents).
|
||||
tcv_minor BIGINT NOT NULL,
|
||||
-- total contract value across the term incl. one-time fees, minor units.
|
||||
line_items TEXT,
|
||||
-- JSON snapshot of the itemised lines the order form renders.
|
||||
stripe_price_id VARCHAR(128),
|
||||
checkout_session_id VARCHAR(255),
|
||||
checkout_url TEXT,
|
||||
valid_until DATE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity (
|
||||
activity_id BIGSERIAL PRIMARY KEY,
|
||||
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
|
||||
actor_user_id BIGINT,
|
||||
-- the internal/portal user who took the action; informational (no FK).
|
||||
action VARCHAR(48) NOT NULL,
|
||||
-- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ...
|
||||
detail TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF +
|
||||
-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The
|
||||
-- Stripe operations live in Supabase edge functions; these columns hold the references they write
|
||||
-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on
|
||||
-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS business_name VARCHAR(255);
|
||||
+87
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/dist-portal
|
||||
/storybook-static
|
||||
|
||||
/editor/build
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// the decorators below transpiles to React.createElement and needs React in
|
||||
// scope. (The app + story files use the automatic runtime via the portal vite
|
||||
// config; this import is specifically for the preview config file.)
|
||||
import React, { useEffect } from "react";
|
||||
import React, { Suspense, useEffect } from "react";
|
||||
import type { Decorator, Preview } from "@storybook/react-vite";
|
||||
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
@@ -109,7 +109,9 @@ const withProviders: Decorator = (Story, context) => {
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<ThemeWatcher />
|
||||
<Story />
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
|
||||
@@ -7430,7 +7430,7 @@ upgrade = "Upgrade"
|
||||
volumeSuffix = "PDFs processed · last 30 days"
|
||||
|
||||
[portal.procurement]
|
||||
enterpriseBadge = "Enterprise"
|
||||
reset = "Reset procurement (demo)"
|
||||
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
|
||||
title = "Procurement"
|
||||
|
||||
@@ -7441,6 +7441,63 @@ request = "Request"
|
||||
sign = "Review & sign"
|
||||
upload = "Upload"
|
||||
|
||||
[portal.procurement.agreement]
|
||||
agreeCta = "Agree & subscribe"
|
||||
confirm = "I have read and agree to the Stirling Enterprise Agreement."
|
||||
eyebrow = "Agreement"
|
||||
intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription."
|
||||
title = "Review your enterprise agreement"
|
||||
|
||||
[portal.procurement.builder]
|
||||
addons = "Add-ons"
|
||||
back = "Back"
|
||||
businessName = "Business name"
|
||||
businessNamePlaceholder = "Your company"
|
||||
continue = "Continue"
|
||||
country = "Country"
|
||||
countryEuro = "Eurozone (EUR €)"
|
||||
countryUK = "United Kingdom (GBP £)"
|
||||
countryUS = "United States (USD $)"
|
||||
eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
|
||||
generate = "Generate quote"
|
||||
included = "Included"
|
||||
indemnification = "IP indemnification"
|
||||
indemnificationSub = "We defend qualifying IP claims, per the EULA"
|
||||
qbr = "Quarterly business reviews"
|
||||
qbrSub = "Your SE reviews usage and roadmap each quarter"
|
||||
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
|
||||
s1Sub = "Your team, and the PDFs you expect to run each year."
|
||||
# Step 1 — volume
|
||||
s1Title = "How much will you process?"
|
||||
s2Sub = "Longer terms discount the rate; your service level sets support."
|
||||
# Step 2 — commitment & service
|
||||
s2Title = "Commitment and service"
|
||||
s3Sub = "For the quote and the agreement it generates."
|
||||
# Step 3 — details
|
||||
s3Title = "Your details"
|
||||
serviceLevel = "Service level"
|
||||
slDedicated = "Dedicated"
|
||||
slDedicatedSub = "4 business hours · dedicated account manager · +30%"
|
||||
slPriority = "Priority"
|
||||
slPrioritySub = "Same business day · named CSM · +15%"
|
||||
slStandard = "Standard"
|
||||
slStandardSub = "Next business day · shared CSM · included"
|
||||
stepOf = "Step {{n}} of {{total}}"
|
||||
term = "Term"
|
||||
termDiscount = "{{pct}}% multi-year commitment discount applied"
|
||||
title = "Build your quote"
|
||||
training = "Onboarding & training"
|
||||
trainingSub = "Live sessions to get your team running"
|
||||
users = "Total users"
|
||||
usersPlaceholder = "e.g. 250"
|
||||
volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better."
|
||||
volManual = "Using your figure. Re-estimate from your team size any time."
|
||||
volNoUsers = "Not sure? Enter your team size and we'll estimate it."
|
||||
volume = "Annual PDF volume"
|
||||
volumePlaceholder = "e.g. 1,000,000"
|
||||
years_one = "{{count}} year"
|
||||
years_other = "{{count}} years"
|
||||
|
||||
[portal.procurement.docs]
|
||||
count_one = "{{count}} doc"
|
||||
count_other = "{{count}} docs"
|
||||
@@ -7456,6 +7513,30 @@ supportingTitle = "Supporting your evaluation"
|
||||
title = "Documents"
|
||||
upcoming = "Upcoming"
|
||||
|
||||
[portal.procurement.error]
|
||||
title = "Something went wrong"
|
||||
|
||||
[portal.procurement.hero]
|
||||
company = "Your enterprise deal"
|
||||
ctaAgreement = "Review & sign agreement"
|
||||
ctaLive = "You're live"
|
||||
ctaPayment = "Add payment"
|
||||
ctaQuote = "Review your quote"
|
||||
ctaTrial = "Build your quote"
|
||||
eyebrow = "Enterprise procurement"
|
||||
inviteTeammates = "Invite teammates"
|
||||
keyDocs = "Key documents"
|
||||
nextStep = "Next step: {{action}}"
|
||||
notStarted = "Not started"
|
||||
open = "Open procurement"
|
||||
scheduleCall = "Schedule a call"
|
||||
setup1Sub = "Invite your teammates"
|
||||
setup1Title = "Deploy the PDF Editor"
|
||||
setup2Sub = "Turn on processing across editors and other sources"
|
||||
setup2Title = "Connect the PDF Processor"
|
||||
setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them"
|
||||
setup3Title = "Add recommended policies"
|
||||
|
||||
[portal.procurement.journey]
|
||||
daysLeft_one = "{{count}} day left"
|
||||
daysLeft_other = "{{count}} days left"
|
||||
@@ -7467,15 +7548,39 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
|
||||
title = "From trial to live, one guided path"
|
||||
trialTitle = "Enterprise trial"
|
||||
|
||||
[portal.procurement.link]
|
||||
cta = "Link account"
|
||||
description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start."
|
||||
eyebrow = "Enterprise"
|
||||
title = "Link your account to begin"
|
||||
|
||||
[portal.procurement.live]
|
||||
description = "Your subscription is active and your licence is issued. Your team is provisioned and billing has started."
|
||||
eyebrow = "Live"
|
||||
title = "You're live on Stirling Enterprise"
|
||||
|
||||
[portal.procurement.locked]
|
||||
description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
|
||||
eyebrow = "Enterprise only"
|
||||
talkToSales = "Talk to sales"
|
||||
title = "The procurement track opens with Enterprise"
|
||||
|
||||
[portal.procurement.milestone]
|
||||
accept = "Accept & continue"
|
||||
description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes."
|
||||
download = "Download PDF"
|
||||
downloadError = "Could not download the quote PDF just yet — please try again in a moment."
|
||||
edit = "Edit quote"
|
||||
eyebrow = "Quote {{number}}"
|
||||
perYear = " / yr"
|
||||
preparedFor = "Prepared for {{company}}"
|
||||
tcv = "{{value}} total contract value"
|
||||
title = "Your quote is ready"
|
||||
|
||||
[portal.procurement.modal]
|
||||
cancel = "Cancel"
|
||||
chooseFile = "Choose file"
|
||||
close = "Close"
|
||||
downloadBody = "Your download will begin shortly."
|
||||
downloadCta = "Download"
|
||||
downloadTitle = "Download"
|
||||
@@ -7494,6 +7599,13 @@ uploadBody = "Send us your PO and we invoice against it on your terms. Drag in t
|
||||
uploadCta = "Upload purchase order"
|
||||
uploadTitle = "Upload your purchase order"
|
||||
|
||||
[portal.procurement.payment]
|
||||
description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed."
|
||||
downloadInvoice = "Download invoice"
|
||||
simulate = "Simulate payment received (demo)"
|
||||
title = "Subscription created"
|
||||
viewInvoice = "View & pay invoice"
|
||||
|
||||
[portal.procurement.status]
|
||||
action = "Action needed"
|
||||
available = "Available"
|
||||
@@ -7501,6 +7613,21 @@ complete = "Complete"
|
||||
pending = "Pending"
|
||||
request = "On request"
|
||||
|
||||
[portal.procurement.trial]
|
||||
body = "Extending adds 7 days and notifies your solutions engineer."
|
||||
bodyMaxed = "You have used all your extensions — talk to your solutions engineer if you need more time."
|
||||
cancel = "Cancel trial"
|
||||
extend = "Extend 7 days"
|
||||
maxed = "Maxed out"
|
||||
subtitle = "Your free trial runs through {{date}}. No card required."
|
||||
title = "Enterprise trial"
|
||||
|
||||
[portal.procurement.upsell]
|
||||
homeBadge = "Enterprise"
|
||||
homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE."
|
||||
homeCta = "Start Trial →"
|
||||
homeHeadline = "Process millions of PDFs."
|
||||
|
||||
[portal.recentActivity]
|
||||
title = "Recent activity"
|
||||
viewAll = "View all"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
DealStage,
|
||||
@@ -92,3 +93,165 @@ export async function requestDocument(
|
||||
{ method: "POST", body: { action } },
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enterprise procurement — real SaaS backend (/api/v1/procurement).
|
||||
//
|
||||
// The journey/ledger visuals above still ride the MSW mock; the commercial spine
|
||||
// below (trial, server-priced quote, accept -> Stripe checkout) is the real thing,
|
||||
// served by the saas Java backend and gated on a linked account.
|
||||
// ============================================================================
|
||||
|
||||
export type QuoteLineItemKind =
|
||||
| "RECURRING"
|
||||
| "ONE_TIME"
|
||||
| "DISCOUNT"
|
||||
| "INCLUDED";
|
||||
|
||||
export interface QuoteLineItem {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: QuoteLineItemKind;
|
||||
amountMinor: number;
|
||||
}
|
||||
|
||||
export interface QuoteResult {
|
||||
quoteId: number;
|
||||
quoteNumber: string;
|
||||
/** draft (priced, editable) | sent (issued Stripe quote — PDF + shareable) | accepted | expired. */
|
||||
status: string;
|
||||
currency: string;
|
||||
annualNetMinor: number;
|
||||
tcvMinor: number;
|
||||
lineItems: QuoteLineItem[];
|
||||
validUntil: string | null;
|
||||
/** The Stripe Quote id once issued; null while still a local draft. */
|
||||
stripeQuoteId: string | null;
|
||||
/** Hosted Stripe invoice URL, present once the quote is accepted and the subscription invoice exists. */
|
||||
invoiceUrl: string | null;
|
||||
/** The inputs this quote was priced from, so the builder can seed itself on re-edit. */
|
||||
config: QuoteConfigInput;
|
||||
}
|
||||
|
||||
/** Outcome of accepting an issued quote: Stripe creates the subscription + first invoice. */
|
||||
export interface AcceptResult {
|
||||
status: string;
|
||||
subscriptionId: string | null;
|
||||
invoiceUrl: string | null;
|
||||
invoicePdf: string | null;
|
||||
}
|
||||
|
||||
/** One shape for every state; an unstarted procurement has {@link ProcurementSnapshot.dealId} null. */
|
||||
export interface ProcurementSnapshot {
|
||||
dealId: number | null;
|
||||
stage: DealStage | null;
|
||||
trialStartedAt: string | null;
|
||||
trialEndsAt: string | null;
|
||||
trialExtensionsUsed: number;
|
||||
licensed: boolean;
|
||||
latestQuote: QuoteResult | null;
|
||||
}
|
||||
|
||||
export interface QuoteConfigInput {
|
||||
volume: number;
|
||||
users: number;
|
||||
deployment: string;
|
||||
termYears: number;
|
||||
serviceLevel: string;
|
||||
indemnification: boolean;
|
||||
training: boolean;
|
||||
qbr: boolean;
|
||||
currency: string;
|
||||
/** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */
|
||||
businessName: string;
|
||||
}
|
||||
|
||||
export function fetchSnapshot(): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>("/api/v1/procurement");
|
||||
}
|
||||
|
||||
export function startTrial(): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>(
|
||||
"/api/v1/procurement/trial/start",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function extendTrial(): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>(
|
||||
"/api/v1/procurement/trial/extend",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Advance an issued quote to the agreement (security) stage for review + agree. */
|
||||
export function startAgreement(): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>(
|
||||
"/api/v1/procurement/agreement",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Price a config server-side and persist it as a local DRAFT (no Stripe object yet). */
|
||||
export function buildQuote(cfg: QuoteConfigInput): Promise<QuoteResult> {
|
||||
return apiClient.saas.json<QuoteResult>("/api/v1/procurement/quote", {
|
||||
method: "POST",
|
||||
body: cfg,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Stripe Quote operations (Supabase edge functions) ---------------------
|
||||
// Java has no Stripe SDK, so issuing/accepting the quote and fetching its PDF run in edge functions
|
||||
// that own Stripe; they persist results back through SECURITY DEFINER RPCs. The portal invokes them
|
||||
// directly (same pattern the PAYG checkout uses).
|
||||
|
||||
async function invokeEdge<T>(fn: string, quoteId: number): Promise<T> {
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) throw new Error("No SaaS session");
|
||||
const { data, error } = await supabase.functions.invoke<T>(fn, {
|
||||
body: { quote_id: quoteId },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (data == null) throw new Error(`${fn} returned no data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Turn a draft into an issued Stripe Quote (finalized → gets a number + PDF, shareable). */
|
||||
export function issueQuote(quoteId: number): Promise<QuoteResult> {
|
||||
return invokeEdge<QuoteResult>("issue-procurement-quote", quoteId);
|
||||
}
|
||||
|
||||
/** Accept an issued quote → Stripe creates the committed subscription + first invoice. */
|
||||
export function acceptQuote(quoteId: number): Promise<AcceptResult> {
|
||||
return invokeEdge<AcceptResult>("accept-procurement-quote", quoteId);
|
||||
}
|
||||
|
||||
/** Fetch the Stripe-generated quote PDF as a blob (for download / share). */
|
||||
export async function fetchQuotePdf(quoteId: number): Promise<Blob> {
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) throw new Error("No SaaS session");
|
||||
const { data, error } = await supabase.functions.invoke<Blob>(
|
||||
"get-procurement-quote-pdf",
|
||||
{ body: { quote_id: quoteId } },
|
||||
);
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error("No PDF returned");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo/manual stand-in for the invoice.paid webhook: mark the deal live (issue licence, go active).
|
||||
*/
|
||||
export function goLive(): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>(
|
||||
"/api/v1/procurement/go-live",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Reset the team's procurement (delete the deal) and get the fresh empty snapshot. */
|
||||
export function resetProcurement(): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>("/api/v1/procurement/reset", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
UsageIcon,
|
||||
LinkIcon,
|
||||
DocsIcon,
|
||||
ProcurementIcon,
|
||||
SettingsIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@portal/components/icons";
|
||||
@@ -136,15 +135,10 @@ export function Sidebar() {
|
||||
const { activeView, setActiveView } = useView();
|
||||
const { theme } = useTheme();
|
||||
const { openSettings } = useUI();
|
||||
const { tier } = useTier();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Procurement is the enterprise buyer's commercial journey — surfaced only to
|
||||
// enterprise tenants (it has no free/pro equivalent).
|
||||
const platformGroup: NavEntry[] =
|
||||
tier === "enterprise"
|
||||
? [{ id: "procurement", icon: <ProcurementIcon /> }, ...GROUP_PLATFORM]
|
||||
: GROUP_PLATFORM;
|
||||
// Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into
|
||||
// a takeover modal (matching the marketing prototype).
|
||||
|
||||
function renderGroup(entries: NavEntry[]) {
|
||||
return entries.map((entry) => (
|
||||
@@ -227,7 +221,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
<div className="portal-sidebar__divider" aria-hidden />
|
||||
<div className="portal-sidebar__group">
|
||||
{renderGroup(platformGroup)}
|
||||
{renderGroup(GROUP_PLATFORM)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
|
||||
interface Props {
|
||||
/** Render without the Card wrapper, to embed inside another card's column. */
|
||||
@@ -8,10 +9,12 @@ interface Props {
|
||||
|
||||
/**
|
||||
* Volume-discount / Enterprise upsell, shared by the free and subscribed billing
|
||||
* views. The CTA is intentionally inert until the sales/quote URL is confirmed.
|
||||
* views. The CTA opens the procurement journey (/procurement auto-opens the quote
|
||||
* builder in the takeover modal).
|
||||
*/
|
||||
export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const body = (
|
||||
<>
|
||||
<span className="portal-billing__eyebrow">
|
||||
@@ -32,8 +35,11 @@ export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Destination wired when the enterprise/sales URL is confirmed. */}
|
||||
<Button variant="gradient" size="sm" disabled>
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
onClick={() => setActiveView("procurement")}
|
||||
>
|
||||
{t(
|
||||
"portal.billing.enterpriseUpsell.cta",
|
||||
"Build your Enterprise quote",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
|
||||
import type { ProcurementSnapshot } from "@portal/api/procurement";
|
||||
|
||||
const base: ProcurementSnapshot = {
|
||||
dealId: 1,
|
||||
stage: "trial",
|
||||
trialStartedAt: "2026-06-25T00:00:00Z",
|
||||
trialEndsAt: "2026-07-09T00:00:00Z",
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
latestQuote: null,
|
||||
};
|
||||
|
||||
/** The Home deal-status hero across the commercial stages; the CTA expands the takeover modal. */
|
||||
const meta: Meta<typeof DealStatusHero> = {
|
||||
title: "Portal/Procurement/DealStatusHero",
|
||||
component: DealStatusHero,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
onExpand: () => {},
|
||||
onKeyDocs: () => {},
|
||||
onInvite: () => {},
|
||||
onSchedule: () => {},
|
||||
onManageTrial: () => {},
|
||||
onNavigate: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DealStatusHero>;
|
||||
|
||||
export const Trial: Story = { args: { snapshot: base } };
|
||||
export const Quote: Story = {
|
||||
args: { snapshot: { ...base, stage: "quote", trialEndsAt: null } },
|
||||
};
|
||||
export const Agreement: Story = {
|
||||
args: { snapshot: { ...base, stage: "security", trialEndsAt: null } },
|
||||
};
|
||||
export const Payment: Story = {
|
||||
args: { snapshot: { ...base, stage: "procurement", trialEndsAt: null } },
|
||||
};
|
||||
export const Live: Story = {
|
||||
args: {
|
||||
snapshot: { ...base, stage: "active", licensed: true, trialEndsAt: null },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { ViewId } from "@portal/contexts/ViewContext";
|
||||
import { JOURNEY, type ProcurementSnapshot } from "@portal/api/procurement";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the
|
||||
* deal stage: quick-action chips (trial countdown → manage, key documents, invite teammates,
|
||||
* schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that
|
||||
* expands the flow into the takeover modal. Matches the marketing prototype.
|
||||
*/
|
||||
export function DealStatusHero({
|
||||
snapshot,
|
||||
busy = false,
|
||||
onExpand,
|
||||
onKeyDocs,
|
||||
onInvite,
|
||||
onSchedule,
|
||||
onManageTrial,
|
||||
onNavigate,
|
||||
}: {
|
||||
snapshot: ProcurementSnapshot;
|
||||
busy?: boolean;
|
||||
onExpand: () => void;
|
||||
onKeyDocs: () => void;
|
||||
onInvite: () => void;
|
||||
onSchedule: () => void;
|
||||
onManageTrial: () => void;
|
||||
onNavigate: (view: ViewId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stage = snapshot.stage ?? "trial";
|
||||
const inTrial = stage === "trial";
|
||||
const cta =
|
||||
stage === "trial"
|
||||
? t("portal.procurement.hero.ctaTrial")
|
||||
: stage === "quote"
|
||||
? t("portal.procurement.hero.ctaQuote")
|
||||
: stage === "security"
|
||||
? t("portal.procurement.hero.ctaAgreement")
|
||||
: stage === "procurement"
|
||||
? t("portal.procurement.hero.ctaPayment")
|
||||
: t("portal.procurement.hero.ctaLive");
|
||||
|
||||
const setupSteps: { title: string; sub: string; view: ViewId }[] = [
|
||||
{
|
||||
title: t("portal.procurement.hero.setup1Title"),
|
||||
sub: t("portal.procurement.hero.setup1Sub"),
|
||||
view: "users",
|
||||
},
|
||||
{
|
||||
title: t("portal.procurement.hero.setup2Title"),
|
||||
sub: t("portal.procurement.hero.setup2Sub"),
|
||||
view: "sources",
|
||||
},
|
||||
{
|
||||
title: t("portal.procurement.hero.setup3Title"),
|
||||
sub: t("portal.procurement.hero.setup3Sub"),
|
||||
view: "policies",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="portal-hero">
|
||||
<div className="portal-hero__top">
|
||||
<div>
|
||||
<span className="portal-hero__eyebrow">
|
||||
{t("portal.procurement.hero.eyebrow")}
|
||||
</span>
|
||||
<span className="portal-hero__company">
|
||||
{t("portal.procurement.hero.company")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-hero__chips">
|
||||
{inTrial && snapshot.trialEndsAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onManageTrial}
|
||||
>
|
||||
{t("portal.procurement.journey.daysLeft", {
|
||||
count: daysLeft(snapshot.trialEndsAt),
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
{stage !== "active" && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onKeyDocs}
|
||||
>
|
||||
{t("portal.procurement.hero.keyDocs")}
|
||||
</button>
|
||||
)}
|
||||
{stage !== "active" && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onInvite}
|
||||
>
|
||||
{t("portal.procurement.hero.inviteTeammates")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onSchedule}
|
||||
>
|
||||
{t("portal.procurement.hero.scheduleCall")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-hero__stepper">
|
||||
<StageStepper journey={JOURNEY} currentStage={stage} />
|
||||
</div>
|
||||
|
||||
{inTrial && (
|
||||
<ul className="portal-hero__checklist">
|
||||
{setupSteps.map((s) => (
|
||||
<li key={s.title}>
|
||||
<button type="button" onClick={() => onNavigate(s.view)}>
|
||||
<span className="portal-hero__check-dot" aria-hidden />
|
||||
<span className="portal-hero__check-text">
|
||||
<span className="portal-hero__check-title">{s.title}</span>
|
||||
<span className="portal-hero__check-sub">{s.sub}</span>
|
||||
</span>
|
||||
<span className="portal-hero__check-pill">
|
||||
{t("portal.procurement.hero.notStarted")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="portal-hero__next">
|
||||
<span className="portal-hero__next-label">
|
||||
<span className="portal-hero__next-dot" />
|
||||
{t("portal.procurement.hero.nextStep", { action: cta })}
|
||||
</span>
|
||||
<div className="portal-hero__next-actions">
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
loading={busy}
|
||||
onClick={onExpand}
|
||||
>
|
||||
{stage === "active" ? t("portal.procurement.hero.open") : cta}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function daysLeft(iso: string): number {
|
||||
const end = new Date(iso).getTime();
|
||||
return Math.max(0, Math.ceil((end - Date.now()) / 86_400_000));
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import type { QuoteResult } from "@portal/api/procurement";
|
||||
import { money } from "@portal/components/procurement/format";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The agreement (security) step: a single combined Stirling Enterprise Agreement — Master Service
|
||||
* Agreement + Order Form (from the issued quote) + EULA + Data Processing Agreement — that the buyer
|
||||
* reviews and agrees to before it's accepted into a subscription. No e-signature for now: an explicit
|
||||
* "I agree" click stands in (the terms reference the accepted quote). Document body is static legal
|
||||
* copy; the surrounding UI is translated.
|
||||
*/
|
||||
export function ProcurementAgreement({
|
||||
quote,
|
||||
busy,
|
||||
onAgree,
|
||||
}: {
|
||||
quote: QuoteResult;
|
||||
busy: boolean;
|
||||
onAgree: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const annual = money(quote.annualNetMinor, quote.currency);
|
||||
const tcv = money(quote.tcvMinor, quote.currency);
|
||||
const years = quote.config.termYears;
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.agreement.eyebrow")}
|
||||
</span>
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.agreement.title")}
|
||||
</h3>
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.agreement.intro")}
|
||||
</p>
|
||||
|
||||
<div className="portal-agreement__doc">
|
||||
<h4>1. Master Service Agreement</h4>
|
||||
<p>
|
||||
This Stirling Enterprise Agreement ("Agreement") is entered into
|
||||
between Stirling PDF Inc. ("Stirling") and the customer identified on
|
||||
the Order Form ("Customer"). It governs Customer's access to and use
|
||||
of the Stirling enterprise platform and related services (the
|
||||
"Service"). Stirling will provide the Service with commercially
|
||||
reasonable skill and care and in accordance with the service levels
|
||||
set out in the Order Form.
|
||||
</p>
|
||||
|
||||
<h4>2. Order Form</h4>
|
||||
<p>
|
||||
Quote <strong>{quote.quoteNumber}</strong> forms the Order Form for
|
||||
this Agreement. Customer commits to a {years}-year term at{" "}
|
||||
<strong>{annual}</strong> per year (total contract value{" "}
|
||||
<strong>{tcv}</strong>), billed annually in advance by invoice. Fees
|
||||
are exclusive of taxes. The committed volume, service level, and
|
||||
add-ons are itemised below:
|
||||
</p>
|
||||
<ul className="portal-qb__lines portal-agreement__lines">
|
||||
{quote.lineItems.map((li) => (
|
||||
<li key={li.key} data-kind={li.kind}>
|
||||
<span>{li.label}</span>
|
||||
<span>
|
||||
{li.kind === "INCLUDED"
|
||||
? t("portal.procurement.builder.included")
|
||||
: money(li.amountMinor, quote.currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4>3. End-User License Agreement</h4>
|
||||
<p>
|
||||
Subject to the terms of this Agreement, Stirling grants Customer a
|
||||
non-exclusive, non-transferable right to use the Service for its
|
||||
internal business purposes during the term. Customer is responsible
|
||||
for its users' compliance and for the content it processes. The
|
||||
Service, and all intellectual property in it, remains Stirling's.
|
||||
</p>
|
||||
|
||||
<h4>4. Data Processing Agreement</h4>
|
||||
<p>
|
||||
Where Stirling processes personal data on Customer's behalf, it does
|
||||
so only on Customer's documented instructions and applies appropriate
|
||||
technical and organisational measures. Sub-processors, international
|
||||
transfers, and security commitments are as described in Stirling's
|
||||
Data Processing Agreement and Trust Center, incorporated here by
|
||||
reference.
|
||||
</p>
|
||||
|
||||
<h4>5. Acceptance</h4>
|
||||
<p>
|
||||
By agreeing below, Customer accepts this Agreement and the Order Form.
|
||||
On acceptance, Stirling will issue the committed annual subscription
|
||||
and its first invoice. This preview stands in for e-signature during
|
||||
the pilot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="portal-qb__eula portal-agreement__accept">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>
|
||||
<span>{t("portal.procurement.agreement.confirm")}</span>
|
||||
</label>
|
||||
|
||||
<div className="portal-proc__payment-actions">
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
loading={busy}
|
||||
disabled={!checked}
|
||||
onClick={onAgree}
|
||||
>
|
||||
{t("portal.procurement.agreement.agreeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { ProcurementSnapshot } from "@portal/api/procurement";
|
||||
import { useFocusTrap } from "@portal/components/procurement/ProcurementModal";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule
|
||||
* a call, and trial management. Content is mocked for the pilot (static demo data); the shells and
|
||||
* wiring are real so the hero behaves like the marketing prototype.
|
||||
*/
|
||||
|
||||
function SideModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="portal-sidemodal"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
ref={trapRef}
|
||||
className="portal-sidemodal__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-procmodal__close"
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.procurement.modal.close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="portal-sidemodal__header">
|
||||
<h3 className="portal-sidemodal__title">{title}</h3>
|
||||
{subtitle && <p className="portal-sidemodal__sub">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="portal-sidemodal__body">{children}</div>
|
||||
{footer && <div className="portal-sidemodal__footer">{footer}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Key documents ────────────────────────────────────────────────────────────
|
||||
type DocStatus = "available" | "action" | "request";
|
||||
interface DocRow {
|
||||
name: string;
|
||||
sub: string;
|
||||
status: DocStatus;
|
||||
fee?: number;
|
||||
}
|
||||
const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [
|
||||
{
|
||||
group: "Your deal",
|
||||
docs: [
|
||||
{
|
||||
name: "Formal quote",
|
||||
sub: "Built to your volume, term, and service level",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Master Services Agreement",
|
||||
sub: "One signature — MSA, order form, EULA, and DPA combined",
|
||||
status: "action",
|
||||
},
|
||||
{
|
||||
name: "Bank transfer instructions",
|
||||
sub: "Wire details for your AP team",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Purchase order",
|
||||
sub: "Issuing a PO? Upload it and we invoice against it",
|
||||
status: "request",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Supporting your evaluation",
|
||||
docs: [
|
||||
{
|
||||
name: "SOC 2 Type II report",
|
||||
sub: "Audited · NDA-gated",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Custom security review",
|
||||
sub: "We complete your questionnaire and join your review call",
|
||||
status: "request",
|
||||
fee: 5000,
|
||||
},
|
||||
{
|
||||
name: "Business Associate Agreement",
|
||||
sub: "HIPAA · available on request",
|
||||
status: "request",
|
||||
fee: 2500,
|
||||
},
|
||||
{ name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" },
|
||||
{
|
||||
name: "Certificate of Insurance",
|
||||
sub: "Cyber + E&O · current policy",
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const STATUS_LABEL: Record<DocStatus, string> = {
|
||||
available: "Download",
|
||||
action: "Action needed",
|
||||
request: "Request",
|
||||
};
|
||||
|
||||
export function KeyDocumentsModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Key documents"
|
||||
subtitle="Everything for each stage of your rollout, in one place."
|
||||
>
|
||||
{STAGE_DOCS.map((g) => (
|
||||
<div key={g.group} className="portal-docs__group">
|
||||
<div className="portal-docs__group-title">{g.group}</div>
|
||||
<ul className="portal-docs__list">
|
||||
{g.docs.map((d) => (
|
||||
<li key={d.name} className="portal-docs__row">
|
||||
<div className="portal-docs__row-text">
|
||||
<span className="portal-docs__row-name">{d.name}</span>
|
||||
<span className="portal-docs__row-sub">
|
||||
{d.sub}
|
||||
{d.fee ? ` · one-time $${d.fee.toLocaleString()}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="portal-docs__row-action"
|
||||
data-status={d.status}
|
||||
>
|
||||
{STATUS_LABEL[d.status]}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</SideModal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Schedule a call ──────────────────────────────────────────────────────────
|
||||
const SLOTS = [
|
||||
"Tomorrow · 10:00",
|
||||
"Tomorrow · 15:30",
|
||||
"Thursday · 11:00",
|
||||
"Friday · 09:30",
|
||||
];
|
||||
|
||||
export function ScheduleCallModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Schedule a call"
|
||||
subtitle="Your solutions engineer will walk your team through the rollout."
|
||||
>
|
||||
<div className="portal-se">
|
||||
<span className="portal-se__avatar" aria-hidden>
|
||||
SE
|
||||
</span>
|
||||
<div>
|
||||
<div className="portal-se__name">Your solutions engineer</div>
|
||||
<div className="portal-se__role">
|
||||
Dedicated to your evaluation and rollout
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-slots">
|
||||
{SLOTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className="portal-slots__slot"
|
||||
onClick={onClose}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SideModal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trial management ─────────────────────────────────────────────────────────
|
||||
export function TrialManageModal({
|
||||
open,
|
||||
onClose,
|
||||
snapshot,
|
||||
busy,
|
||||
onExtend,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
snapshot: ProcurementSnapshot;
|
||||
busy: boolean;
|
||||
onExtend: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ends = snapshot.trialEndsAt
|
||||
? new Date(snapshot.trialEndsAt).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "";
|
||||
const maxed = snapshot.trialExtensionsUsed >= 2;
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("portal.procurement.trial.title")}
|
||||
subtitle={
|
||||
ends
|
||||
? t("portal.procurement.trial.subtitle", { date: ends })
|
||||
: undefined
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-sidemodal__ghost"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("portal.procurement.trial.cancel")}
|
||||
</button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
loading={busy}
|
||||
disabled={maxed}
|
||||
onClick={onExtend}
|
||||
>
|
||||
{maxed
|
||||
? t("portal.procurement.trial.maxed")
|
||||
: t("portal.procurement.trial.extend")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="portal-sidemodal__text">
|
||||
{maxed
|
||||
? t("portal.procurement.trial.bodyMaxed")
|
||||
: t("portal.procurement.trial.body")}
|
||||
</p>
|
||||
</SideModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
|
||||
|
||||
/**
|
||||
* The end-to-end procurement experience (Home hero + takeover modal), driven by the `procurementSaas`
|
||||
* MSW handlers: start trial → build quote → generate (issue Stripe Quote) → milestone (download PDF /
|
||||
* accept). `autoOpen` opens the modal so the flow is immediately clickable.
|
||||
*/
|
||||
const meta: Meta<typeof ProcurementHome> = {
|
||||
title: "Portal/Procurement/ProcurementHome",
|
||||
component: ProcurementHome,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ProcurementHome>;
|
||||
|
||||
export const Default: Story = { args: { autoOpen: true } };
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Card, EmptyState, Skeleton } from "@app/ui";
|
||||
import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
acceptQuote,
|
||||
extendTrial,
|
||||
fetchQuotePdf,
|
||||
fetchSnapshot,
|
||||
goLive,
|
||||
issueQuote,
|
||||
JOURNEY,
|
||||
resetProcurement,
|
||||
startAgreement,
|
||||
startTrial,
|
||||
type ProcurementSnapshot,
|
||||
type QuoteResult,
|
||||
} from "@portal/api/procurement";
|
||||
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
|
||||
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
|
||||
import {
|
||||
KeyDocumentsModal,
|
||||
ScheduleCallModal,
|
||||
TrialManageModal,
|
||||
} from "@portal/components/procurement/ProcurementExtras";
|
||||
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
|
||||
import {
|
||||
LiveStageCard,
|
||||
PaymentStageCard,
|
||||
QuoteMilestoneCard,
|
||||
} from "@portal/components/procurement/ProcurementStages";
|
||||
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The procurement experience on Home: a compact deal-status hero once a trial is running (or the
|
||||
* enterprise upsell on-ramp before it), both expanding into the full-screen takeover modal that
|
||||
* holds the journey — build + issue a quote (a Stripe Quote with a real PDF, the milestone the buyer
|
||||
* can share and return to), review + agree to the enterprise agreement, then accept into a committed
|
||||
* subscription. Starting a trial is a single click (no "start a trial" prompt); the deadline + next
|
||||
* steps then show on the hero. Rendered on Home and at /procurement (autoOpen). Gated on a link.
|
||||
*/
|
||||
export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { isLinked } = useLink();
|
||||
const { openLinkModal } = useUI();
|
||||
const { setActiveView } = useView();
|
||||
|
||||
const state = useAsync<ProcurementSnapshot | null>(
|
||||
() => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
|
||||
[isLinked],
|
||||
);
|
||||
const [snap, setSnap] = useState<ProcurementSnapshot | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [extra, setExtra] = useState<null | "docs" | "schedule" | "trial">(
|
||||
null,
|
||||
);
|
||||
|
||||
const data = snap ?? (state.loading ? null : state.data);
|
||||
const started = data?.dealId != null;
|
||||
const stage = data?.stage;
|
||||
const latest = data?.latestQuote ?? null;
|
||||
const isIssued = latest?.status === "sent" || latest?.status === "open";
|
||||
// No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds.
|
||||
const isDraft =
|
||||
!latest ||
|
||||
["draft", "expired", "canceled", "cancelled"].includes(latest.status);
|
||||
|
||||
async function run(fn: () => Promise<unknown>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
setSnap(await fetchSnapshot());
|
||||
} catch (e) {
|
||||
console.error("[procurement] action failed", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onStartTrial = () => run(startTrial);
|
||||
const onExtendTrial = () => run(extendTrial);
|
||||
const onReset = () =>
|
||||
run(async () => {
|
||||
await resetProcurement();
|
||||
setEditing(false);
|
||||
setInvoicePdf(null);
|
||||
});
|
||||
const onGenerate = (draft: QuoteResult) =>
|
||||
run(async () => {
|
||||
await issueQuote(draft.quoteId);
|
||||
setEditing(false);
|
||||
});
|
||||
// Milestone → agreement (security) stage; then agreeing accepts into a subscription.
|
||||
const onAcceptQuote = () => run(startAgreement);
|
||||
const onAgree = () =>
|
||||
run(async () => {
|
||||
if (!latest) return;
|
||||
const res = await acceptQuote(latest.quoteId);
|
||||
setInvoicePdf(res.invoicePdf);
|
||||
});
|
||||
|
||||
async function onDownloadPdf() {
|
||||
if (!latest) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const blob = await fetchQuotePdf(latest.quoteId);
|
||||
// A same-gesture <a download> click is reliable; window.open after an await is often
|
||||
// popup-blocked (which is what made this take "a few goes").
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${latest.quoteNumber || "quote"}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (e) {
|
||||
console.error("[procurement] quote PDF download failed", e);
|
||||
setError(t("portal.procurement.milestone.downloadError"));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
|
||||
// yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
|
||||
useEffect(() => {
|
||||
if (autoOpen && started) setOpen(true);
|
||||
}, [autoOpen, started]);
|
||||
|
||||
const banner =
|
||||
isLinked && started && data ? (
|
||||
<DealStatusHero
|
||||
snapshot={data}
|
||||
busy={busy}
|
||||
onExpand={() => setOpen(true)}
|
||||
onKeyDocs={() => setExtra("docs")}
|
||||
onInvite={() => setActiveView("users")}
|
||||
onSchedule={() => setExtra("schedule")}
|
||||
onManageTrial={() => setExtra("trial")}
|
||||
onNavigate={setActiveView}
|
||||
/>
|
||||
) : (
|
||||
<Card className="portal-proc__upsell">
|
||||
<div className="portal-proc__upsell-text">
|
||||
<span className="portal-proc__upsell-badge">
|
||||
{t("portal.procurement.upsell.homeBadge")}
|
||||
</span>
|
||||
<p className="portal-proc__upsell-copy">
|
||||
<strong>{t("portal.procurement.upsell.homeHeadline")} </strong>
|
||||
{t("portal.procurement.upsell.homeBody")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
accent="blue"
|
||||
loading={busy}
|
||||
disabled={!isLinked}
|
||||
onClick={onStartTrial}
|
||||
>
|
||||
{t("portal.procurement.upsell.homeCta")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{banner}
|
||||
<ProcurementModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("portal.procurement.title")}
|
||||
subtitle={t("portal.procurement.subtitle")}
|
||||
>
|
||||
{error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("portal.procurement.error.title")}
|
||||
onDismiss={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{!isLinked && (
|
||||
<EmptyState
|
||||
eyebrow={t("portal.procurement.link.eyebrow")}
|
||||
title={t("portal.procurement.link.title")}
|
||||
description={t("portal.procurement.link.description")}
|
||||
actions={
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
onClick={() => openLinkModal()}
|
||||
>
|
||||
{t("portal.procurement.link.cta")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLinked && (state.loading || !started) && <Skeleton height="10rem" />}
|
||||
|
||||
{isLinked && started && (
|
||||
<>
|
||||
<div className="portal-proc__modal-stepper">
|
||||
<StageStepper journey={JOURNEY} currentStage={stage!} />
|
||||
</div>
|
||||
|
||||
{(editing ||
|
||||
(isDraft && (stage === "trial" || stage === "quote"))) && (
|
||||
<QuoteBuilder
|
||||
deployment="cloud"
|
||||
initial={latest?.config}
|
||||
onGenerate={onGenerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && isIssued && stage === "quote" && latest && (
|
||||
<QuoteMilestoneCard
|
||||
quote={latest}
|
||||
busy={busy}
|
||||
downloading={downloading}
|
||||
onAccept={onAcceptQuote}
|
||||
onDownload={onDownloadPdf}
|
||||
onEdit={() => setEditing(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "security" && latest && (
|
||||
<ProcurementAgreement
|
||||
quote={latest}
|
||||
busy={busy}
|
||||
onAgree={onAgree}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "procurement" && latest && (
|
||||
<PaymentStageCard
|
||||
invoiceUrl={latest.invoiceUrl}
|
||||
invoicePdf={invoicePdf}
|
||||
busy={busy}
|
||||
onSimulate={() => run(goLive)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "active" && <LiveStageCard />}
|
||||
|
||||
<div className="portal-proc__reset">
|
||||
<button type="button" onClick={onReset} disabled={busy}>
|
||||
{t("portal.procurement.reset")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ProcurementModal>
|
||||
|
||||
<KeyDocumentsModal
|
||||
open={extra === "docs"}
|
||||
onClose={() => setExtra(null)}
|
||||
/>
|
||||
<ScheduleCallModal
|
||||
open={extra === "schedule"}
|
||||
onClose={() => setExtra(null)}
|
||||
/>
|
||||
{data && (
|
||||
<TrialManageModal
|
||||
open={extra === "trial"}
|
||||
onClose={() => setExtra(null)}
|
||||
snapshot={data}
|
||||
busy={busy}
|
||||
onExtend={async () => {
|
||||
await onExtendTrial();
|
||||
setExtra(null);
|
||||
}}
|
||||
onCancel={async () => {
|
||||
await onReset();
|
||||
setExtra(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
|
||||
|
||||
/**
|
||||
* The full-screen takeover modal shell. Rendered always-open here to verify the panel has a solid
|
||||
* (not see-through) surface over the dimmed, blurred backdrop.
|
||||
*/
|
||||
const meta: Meta<typeof ProcurementModal> = {
|
||||
title: "Portal/Procurement/ProcurementModal",
|
||||
component: ProcurementModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
open: true,
|
||||
onClose: () => {},
|
||||
title: "Enterprise procurement",
|
||||
subtitle: "Get your team evaluated, contracted, and onboarded.",
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ProcurementModal>;
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<Card padding="loose">
|
||||
<h3 style={{ margin: 0 }}>Ready for payment</h3>
|
||||
<p style={{ color: "var(--color-text-3)" }}>
|
||||
Your quote is accepted. Continue to checkout to pay your committed
|
||||
contract and go live.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "0.6rem" }}>
|
||||
<Button variant="gradient" accent="purple">
|
||||
Continue to checkout
|
||||
</Button>
|
||||
<Button variant="outline">Edit quote</Button>
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
|
||||
export function useFocusTrap(open: boolean) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const panel = ref.current;
|
||||
if (!panel) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const focusables = () =>
|
||||
Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => !el.hasAttribute("disabled"));
|
||||
(focusables()[0] ?? panel).focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const items = focusables();
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
panel.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
panel.removeEventListener("keydown", onKey);
|
||||
prev?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen takeover modal for the procurement flow, copying the prototype's modal design
|
||||
* (portaled to body, dimmed + blurred backdrop, rounded panel, close button). The Home deal-status
|
||||
* hero expands into this.
|
||||
*/
|
||||
export function ProcurementModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="portal-procmodal"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
ref={trapRef}
|
||||
className="portal-procmodal__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-procmodal__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="portal-procmodal__header">
|
||||
<h2 className="portal-procmodal__title">{title}</h2>
|
||||
{subtitle && <p className="portal-procmodal__sub">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="portal-procmodal__body">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import type { QuoteResult } from "@portal/api/procurement";
|
||||
import { money } from "@portal/components/procurement/format";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The stage-specific cards shown inside the procurement takeover modal once a quote exists: the
|
||||
* issued-quote milestone, the subscription-created payment step, and the live confirmation. Each is
|
||||
* a pure presentational view driven by props; ProcurementHome owns the state and the actions.
|
||||
*/
|
||||
|
||||
/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */
|
||||
export function QuoteMilestoneCard({
|
||||
quote,
|
||||
busy,
|
||||
downloading,
|
||||
onAccept,
|
||||
onDownload,
|
||||
onEdit,
|
||||
}: {
|
||||
quote: QuoteResult;
|
||||
busy: boolean;
|
||||
downloading: boolean;
|
||||
onAccept: () => void;
|
||||
onDownload: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.milestone.eyebrow", {
|
||||
number: quote.quoteNumber,
|
||||
})}
|
||||
</span>
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.milestone.title")}
|
||||
</h3>
|
||||
{quote.config.businessName && (
|
||||
<p className="portal-proc__milestone-for">
|
||||
{t("portal.procurement.milestone.preparedFor", {
|
||||
company: quote.config.businessName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.milestone.description")}
|
||||
</p>
|
||||
<ul className="portal-qb__lines portal-proc__milestone-lines">
|
||||
{quote.lineItems.map((li) => (
|
||||
<li key={li.key} data-kind={li.kind}>
|
||||
<span>{li.label}</span>
|
||||
<span>
|
||||
{li.kind === "INCLUDED"
|
||||
? t("portal.procurement.builder.included")
|
||||
: money(li.amountMinor, quote.currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="portal-proc__milestone-totals">
|
||||
<span className="portal-proc__milestone-annual">
|
||||
{money(quote.annualNetMinor, quote.currency)}
|
||||
<small>{t("portal.procurement.milestone.perYear")}</small>
|
||||
</span>
|
||||
<span className="portal-proc__milestone-tcv">
|
||||
{t("portal.procurement.milestone.tcv", {
|
||||
value: money(quote.tcvMinor, quote.currency),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-proc__payment-actions">
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
loading={busy}
|
||||
onClick={onAccept}
|
||||
>
|
||||
{t("portal.procurement.milestone.accept")}
|
||||
</Button>
|
||||
<Button variant="outline" loading={downloading} onClick={onDownload}>
|
||||
{t("portal.procurement.milestone.download")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onEdit}>
|
||||
{t("portal.procurement.milestone.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */
|
||||
export function PaymentStageCard({
|
||||
invoiceUrl,
|
||||
invoicePdf,
|
||||
busy,
|
||||
onSimulate,
|
||||
}: {
|
||||
invoiceUrl?: string | null;
|
||||
invoicePdf?: string | null;
|
||||
busy: boolean;
|
||||
onSimulate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.payment.title")}
|
||||
</h3>
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.payment.description")}
|
||||
</p>
|
||||
{(invoiceUrl || invoicePdf) && (
|
||||
<div className="portal-proc__payment-actions">
|
||||
{invoiceUrl && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
onClick={() => window.open(invoiceUrl, "_blank", "noopener")}
|
||||
>
|
||||
{t("portal.procurement.payment.viewInvoice")}
|
||||
</Button>
|
||||
)}
|
||||
{invoicePdf && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(invoicePdf, "_blank", "noopener")}
|
||||
>
|
||||
{t("portal.procurement.payment.downloadInvoice")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-proc__reset">
|
||||
<button type="button" onClick={onSimulate} disabled={busy}>
|
||||
{t("portal.procurement.payment.simulate")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** The live confirmation once the deal is active. */
|
||||
export function LiveStageCard() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.live.eyebrow")}
|
||||
</span>
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.live.title")}
|
||||
</h3>
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.live.description")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The enterprise quote builder. The "Review quote" step calls the SaaS backend, answered here by
|
||||
* the `procurementSaas` MSW handler, so all four steps (incl. the itemised quote paper) work in
|
||||
* Storybook. Click through Volume → Commitment & service → Details → Quote.
|
||||
*/
|
||||
const meta: Meta<typeof QuoteBuilder> = {
|
||||
title: "Portal/Procurement/QuoteBuilder",
|
||||
component: QuoteBuilder,
|
||||
parameters: { layout: "padded" },
|
||||
args: { deployment: "cloud", onGenerate: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof QuoteBuilder>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import {
|
||||
DocumentsIcon,
|
||||
PoliciesIcon,
|
||||
UsersIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { money } from "@portal/components/procurement/format";
|
||||
import {
|
||||
buildQuote,
|
||||
type QuoteConfigInput,
|
||||
type QuoteResult,
|
||||
} from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const STEPS = ["volume", "plan", "details"] as const;
|
||||
const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years
|
||||
const SLA_UPLIFT: Record<string, number> = {
|
||||
standard: 0,
|
||||
priority: 0.15,
|
||||
dedicated: 0.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* The enterprise quote builder — volume → commitment & service → details. A client-side preview
|
||||
* drives the live footer total; the backend is authoritative. Completing the form generates the
|
||||
* quote directly (build + issue in one step) — the issued quote is then shown as the milestone, so
|
||||
* there's no redundant in-builder preview.
|
||||
*/
|
||||
export function QuoteBuilder({
|
||||
deployment,
|
||||
initial,
|
||||
onGenerate,
|
||||
}: {
|
||||
deployment: string;
|
||||
/** Seed the builder from an existing quote's config (re-editing a quote). */
|
||||
initial?: QuoteConfigInput;
|
||||
/** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */
|
||||
onGenerate: (quote: QuoteResult) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [cfg, setCfg] = useState<QuoteConfigInput>(
|
||||
initial ?? {
|
||||
volume: 1_000_000,
|
||||
users: 0,
|
||||
deployment,
|
||||
termYears: 3,
|
||||
serviceLevel: "priority",
|
||||
indemnification: false,
|
||||
training: false,
|
||||
qbr: false,
|
||||
currency: "USD",
|
||||
businessName: "",
|
||||
},
|
||||
);
|
||||
// A seeded quote carries a volume but no user count, so treat it as manually set.
|
||||
const [manualVolume, setManualVolume] = useState(initial != null);
|
||||
const [eula, setEula] = useState(initial != null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function set<K extends keyof QuoteConfigInput>(k: K, v: QuoteConfigInput[K]) {
|
||||
setCfg((c) => ({ ...c, [k]: v }));
|
||||
}
|
||||
|
||||
// Re-editing an existing quote: everything is seeded, so jump to the last step (details) with the
|
||||
// agreement pre-accepted — one click re-generates, or Back to change a field. No walking from step 1.
|
||||
// Mount-only: seed the step from `initial` once (deliberately no deps).
|
||||
useEffect(() => {
|
||||
if (initial) setStep(STEPS.length - 1);
|
||||
}, []);
|
||||
|
||||
const preview = previewAnnualMinor(cfg);
|
||||
const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0);
|
||||
|
||||
// Fully filled → price + hand the draft to the parent to issue as a Stripe Quote (which then shows
|
||||
// as the milestone). No separate in-builder preview step.
|
||||
async function generate() {
|
||||
setBusy(true);
|
||||
try {
|
||||
onGenerate(await buildQuote(cfg));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-qb">
|
||||
<div className="portal-qb__head">
|
||||
<h3 className="portal-qb__title">
|
||||
{t("portal.procurement.builder.title")}
|
||||
</h3>
|
||||
<span className="portal-qb__stepchip">
|
||||
{t("portal.procurement.builder.stepOf", {
|
||||
n: step + 1,
|
||||
total: STEPS.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-qb__progress">
|
||||
{STEPS.map((s, i) => (
|
||||
<span key={s} data-on={i <= step || undefined} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="portal-qb__body">
|
||||
{step === 0 && (
|
||||
<Step
|
||||
icon={<DocumentsIcon size={22} />}
|
||||
title={t("portal.procurement.builder.s1Title")}
|
||||
sub={t("portal.procurement.builder.s1Sub")}
|
||||
>
|
||||
<div className="portal-qb__row">
|
||||
<Field label={t("portal.procurement.builder.users")}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("portal.procurement.builder.usersPlaceholder")}
|
||||
value={cfg.users || ""}
|
||||
onChange={(e) => {
|
||||
const users = Number(e.target.value);
|
||||
set("users", users);
|
||||
if (!manualVolume) set("volume", estimateVolume(users));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.volume")}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.volumePlaceholder",
|
||||
)}
|
||||
value={cfg.volume || ""}
|
||||
onChange={(e) => {
|
||||
setManualVolume(true);
|
||||
set("volume", Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<p className="portal-qb__hint">
|
||||
{cfg.users > 0 && !manualVolume
|
||||
? t("portal.procurement.builder.volEstimated", {
|
||||
count: cfg.users,
|
||||
})
|
||||
: cfg.users > 0
|
||||
? t("portal.procurement.builder.volManual")
|
||||
: t("portal.procurement.builder.volNoUsers")}
|
||||
</p>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Step
|
||||
icon={<PoliciesIcon size={22} />}
|
||||
title={t("portal.procurement.builder.s2Title")}
|
||||
sub={t("portal.procurement.builder.s2Sub")}
|
||||
>
|
||||
<Field label={t("portal.procurement.builder.term")}>
|
||||
<div className="portal-qb__pills">
|
||||
{[1, 2, 3, 4, 5].map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
data-on={cfg.termYears === y || undefined}
|
||||
onClick={() => set("termYears", y)}
|
||||
>
|
||||
{t("portal.procurement.builder.years", { count: y })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{TERM_DISCOUNT[cfg.termYears - 1] > 0 && (
|
||||
<p className="portal-qb__discount">
|
||||
{t("portal.procurement.builder.termDiscount", {
|
||||
pct: Math.round(TERM_DISCOUNT[cfg.termYears - 1] * 100),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label={t("portal.procurement.builder.serviceLevel")}>
|
||||
<div className="portal-qb__opts">
|
||||
<OptCard
|
||||
on={cfg.serviceLevel === "standard"}
|
||||
title={t("portal.procurement.builder.slStandard")}
|
||||
sub={t("portal.procurement.builder.slStandardSub")}
|
||||
onClick={() => set("serviceLevel", "standard")}
|
||||
/>
|
||||
<OptCard
|
||||
on={cfg.serviceLevel === "priority"}
|
||||
title={t("portal.procurement.builder.slPriority")}
|
||||
sub={t("portal.procurement.builder.slPrioritySub")}
|
||||
onClick={() => set("serviceLevel", "priority")}
|
||||
/>
|
||||
<OptCard
|
||||
on={cfg.serviceLevel === "dedicated"}
|
||||
title={t("portal.procurement.builder.slDedicated")}
|
||||
sub={t("portal.procurement.builder.slDedicatedSub")}
|
||||
onClick={() => set("serviceLevel", "dedicated")}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label={t("portal.procurement.builder.addons")}>
|
||||
<div className="portal-qb__addons">
|
||||
<AddOn
|
||||
on={cfg.indemnification}
|
||||
title={t("portal.procurement.builder.indemnification")}
|
||||
sub={t("portal.procurement.builder.indemnificationSub")}
|
||||
onClick={() => set("indemnification", !cfg.indemnification)}
|
||||
/>
|
||||
<AddOn
|
||||
on={cfg.training}
|
||||
title={t("portal.procurement.builder.training")}
|
||||
sub={t("portal.procurement.builder.trainingSub")}
|
||||
onClick={() => set("training", !cfg.training)}
|
||||
/>
|
||||
<AddOn
|
||||
on={cfg.qbr}
|
||||
title={t("portal.procurement.builder.qbr")}
|
||||
sub={t("portal.procurement.builder.qbrSub")}
|
||||
onClick={() => set("qbr", !cfg.qbr)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Step
|
||||
icon={<UsersIcon size={22} />}
|
||||
title={t("portal.procurement.builder.s3Title")}
|
||||
sub={t("portal.procurement.builder.s3Sub")}
|
||||
>
|
||||
<Field label={t("portal.procurement.builder.businessName")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.businessNamePlaceholder",
|
||||
)}
|
||||
value={cfg.businessName}
|
||||
onChange={(e) => set("businessName", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="portal-qb__row">
|
||||
<Field label={t("portal.procurement.builder.country")}>
|
||||
<select
|
||||
value={cfg.currency}
|
||||
onChange={(e) => set("currency", e.target.value)}
|
||||
>
|
||||
<option value="USD">
|
||||
{t("portal.procurement.builder.countryUS")}
|
||||
</option>
|
||||
<option value="GBP">
|
||||
{t("portal.procurement.builder.countryUK")}
|
||||
</option>
|
||||
<option value="EUR">
|
||||
{t("portal.procurement.builder.countryEuro")}
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<label className="portal-qb__eula">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eula}
|
||||
onChange={(e) => setEula(e.target.checked)}
|
||||
/>
|
||||
<span>{t("portal.procurement.builder.eula")}</span>
|
||||
</label>
|
||||
</Step>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-qb__foot">
|
||||
<span className="portal-qb__running">
|
||||
{t("portal.procurement.builder.running", {
|
||||
annual: money(preview, cfg.currency),
|
||||
years: cfg.termYears,
|
||||
tcv: money(tcvPreview, cfg.currency),
|
||||
})}
|
||||
</span>
|
||||
<div className="portal-qb__foot-btns">
|
||||
{step > 0 && (
|
||||
<Button variant="outline" onClick={() => setStep(step - 1)}>
|
||||
{t("portal.procurement.builder.back")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 0 && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
disabled={cfg.volume <= 0}
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
{t("portal.procurement.builder.continue")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
onClick={() => setStep(2)}
|
||||
>
|
||||
{t("portal.procurement.builder.continue")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
loading={busy}
|
||||
disabled={!eula}
|
||||
onClick={generate}
|
||||
>
|
||||
{t("portal.procurement.builder.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({
|
||||
icon,
|
||||
title,
|
||||
sub,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
sub: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="portal-qb__intro">
|
||||
<span className="portal-qb__intro-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<div className="portal-qb__intro-title">{title}</div>
|
||||
<div className="portal-qb__intro-sub">{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function OptCard({
|
||||
on,
|
||||
title,
|
||||
sub,
|
||||
onClick,
|
||||
}: {
|
||||
on: boolean;
|
||||
title: string;
|
||||
sub: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-qb__opt"
|
||||
data-on={on || undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="portal-qb__opt-title">{title}</span>
|
||||
<span className="portal-qb__opt-sub">{sub}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AddOn({
|
||||
on,
|
||||
title,
|
||||
sub,
|
||||
onClick,
|
||||
}: {
|
||||
on: boolean;
|
||||
title: string;
|
||||
sub: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-qb__addon"
|
||||
data-on={on || undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="portal-qb__addon-box" aria-hidden>
|
||||
{on ? "✓" : ""}
|
||||
</span>
|
||||
<span>
|
||||
<span className="portal-qb__addon-title">{title}</span>
|
||||
<span className="portal-qb__addon-sub">{sub}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function estimateVolume(users: number): number {
|
||||
const raw = Math.max(0, users) * 5 * 230 * 1.75;
|
||||
const stepSize = raw >= 1_000_000 ? 50_000 : raw >= 100_000 ? 25_000 : 5_000;
|
||||
return Math.round(raw / stepSize) * stepSize;
|
||||
}
|
||||
|
||||
function previewAnnualMinor(cfg: QuoteConfigInput): number {
|
||||
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
|
||||
const usage = Math.round(cfg.volume * perPdf);
|
||||
const withSla = Math.round(usage * (1 + (SLA_UPLIFT[cfg.serviceLevel] ?? 0)));
|
||||
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
|
||||
const disc = Math.round(
|
||||
withInd * TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
|
||||
);
|
||||
return withInd - disc + (cfg.qbr ? 800_000 : 0);
|
||||
}
|
||||
@@ -9,6 +9,15 @@ export const USD = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
/** Format a minor-unit (cents) amount in the given currency, whole units (no decimals). */
|
||||
export function money(minor: number, currency: string): string {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currency || "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(minor / 100);
|
||||
}
|
||||
|
||||
/** Document status → badge tone. Action items lean amber to pull the eye. */
|
||||
export const STATUS_TONE: Record<DocStatus, StatusTone> = {
|
||||
available: "success",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
|
||||
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
|
||||
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
|
||||
import { procurementHandlers } from "@portal/mocks/handlers/procurement";
|
||||
import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas";
|
||||
import { docsHandlers } from "@portal/mocks/handlers/docs";
|
||||
import { settingsHandlers } from "@portal/mocks/handlers/settings";
|
||||
import { usersHandlers } from "@portal/mocks/handlers/users";
|
||||
@@ -30,6 +31,7 @@ export const handlers = [
|
||||
...infrastructureHandlers,
|
||||
...docsHandlers,
|
||||
...procurementHandlers,
|
||||
...procurementSaasHandlers,
|
||||
...settingsHandlers,
|
||||
...usersHandlers,
|
||||
...agentsHandlers,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
/**
|
||||
* MSW mock for the real SaaS procurement endpoints (`apiClient.saas` → VITE_SAAS_API_URL, which is
|
||||
* `http://saas.mock` in dev/Storybook). Lets the trial → quote → accept flow run without the Java
|
||||
* backend (Storybook + mocks-on dev). Pricing mirrors ProcurementPricingService.
|
||||
*/
|
||||
const SAAS = "http://saas.mock";
|
||||
|
||||
const EMPTY = {
|
||||
dealId: null,
|
||||
stage: null,
|
||||
trialStartedAt: null,
|
||||
trialEndsAt: null,
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
latestQuote: null,
|
||||
};
|
||||
|
||||
interface Cfg {
|
||||
volume: number;
|
||||
serviceLevel: string;
|
||||
termYears: number;
|
||||
indemnification: boolean;
|
||||
training: boolean;
|
||||
qbr: boolean;
|
||||
currency: string;
|
||||
businessName?: string;
|
||||
}
|
||||
|
||||
let deal: typeof EMPTY | (Record<string, unknown> & { latestQuote: unknown }) =
|
||||
EMPTY;
|
||||
let seq = 0;
|
||||
|
||||
const SLA: Record<string, number> = {
|
||||
standard: 0,
|
||||
priority: 0.15,
|
||||
dedicated: 0.3,
|
||||
};
|
||||
const TERM = [0, 0.05, 0.1, 0.12, 0.15];
|
||||
|
||||
function priceQuote(cfg: Cfg) {
|
||||
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
|
||||
const usage = Math.round(cfg.volume * perPdf);
|
||||
const withSla = Math.round(usage * (1 + (SLA[cfg.serviceLevel] ?? 0)));
|
||||
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
|
||||
const disc = Math.round(
|
||||
withInd * TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
|
||||
);
|
||||
const qbr = cfg.qbr ? 800_000 : 0;
|
||||
const training = cfg.training ? 750_000 : 0;
|
||||
const annualNetMinor = withInd - disc + qbr;
|
||||
const tcvMinor = annualNetMinor * cfg.termYears + training;
|
||||
|
||||
type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED";
|
||||
const lines: {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: Kind;
|
||||
amountMinor: number;
|
||||
}[] = [
|
||||
{
|
||||
key: "usage",
|
||||
label: "PDF processing",
|
||||
kind: "RECURRING",
|
||||
amountMinor: usage,
|
||||
},
|
||||
{
|
||||
key: "seats",
|
||||
label: "Unlimited users + SSO / SCIM / RBAC",
|
||||
kind: "INCLUDED",
|
||||
amountMinor: 0,
|
||||
},
|
||||
];
|
||||
if (withSla !== usage)
|
||||
lines.push({
|
||||
key: "service-level",
|
||||
label:
|
||||
cfg.serviceLevel === "dedicated"
|
||||
? "Dedicated service level"
|
||||
: "Priority service level",
|
||||
kind: "RECURRING",
|
||||
amountMinor: withSla - usage,
|
||||
});
|
||||
if (withInd !== withSla)
|
||||
lines.push({
|
||||
key: "indemnification",
|
||||
label: "IP indemnification",
|
||||
kind: "RECURRING",
|
||||
amountMinor: withInd - withSla,
|
||||
});
|
||||
if (qbr > 0)
|
||||
lines.push({
|
||||
key: "qbr",
|
||||
label: "Quarterly business reviews",
|
||||
kind: "RECURRING",
|
||||
amountMinor: qbr,
|
||||
});
|
||||
if (disc > 0)
|
||||
lines.push({
|
||||
key: "multi-year",
|
||||
label: `${cfg.termYears}-year commitment`,
|
||||
kind: "DISCOUNT",
|
||||
amountMinor: -disc,
|
||||
});
|
||||
if (training > 0)
|
||||
lines.push({
|
||||
key: "training",
|
||||
label: "Onboarding & training",
|
||||
kind: "ONE_TIME",
|
||||
amountMinor: training,
|
||||
});
|
||||
|
||||
seq += 1;
|
||||
return {
|
||||
quoteId: seq,
|
||||
quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`,
|
||||
status: "draft",
|
||||
currency: cfg.currency || "USD",
|
||||
annualNetMinor,
|
||||
tcvMinor,
|
||||
lineItems: lines,
|
||||
validUntil: "2026-07-31",
|
||||
stripeQuoteId: null,
|
||||
invoiceUrl: null,
|
||||
config: {
|
||||
volume: cfg.volume,
|
||||
users: 0,
|
||||
deployment: "cloud",
|
||||
termYears: cfg.termYears,
|
||||
serviceLevel: cfg.serviceLevel,
|
||||
indemnification: cfg.indemnification,
|
||||
training: cfg.training,
|
||||
qbr: cfg.qbr,
|
||||
currency: cfg.currency || "USD",
|
||||
businessName: cfg.businessName ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resetProcurementSaasStore() {
|
||||
deal = EMPTY;
|
||||
seq = 0;
|
||||
}
|
||||
|
||||
export const procurementSaasHandlers = [
|
||||
http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)),
|
||||
http.post(`${SAAS}/api/v1/procurement/trial/start`, () => {
|
||||
const now = Date.now();
|
||||
deal = {
|
||||
dealId: 1,
|
||||
stage: "trial",
|
||||
trialStartedAt: new Date(now).toISOString(),
|
||||
trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(),
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: true,
|
||||
latestQuote: null,
|
||||
};
|
||||
return HttpResponse.json(deal);
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/procurement/quote`, async ({ request }) => {
|
||||
const cfg = (await request.json()) as Cfg;
|
||||
const quote = priceQuote(cfg);
|
||||
const d = (
|
||||
deal.dealId ? deal : { dealId: 1, trialExtensionsUsed: 0 }
|
||||
) as Record<string, unknown>;
|
||||
deal = {
|
||||
...d,
|
||||
stage: "quote",
|
||||
licensed: true,
|
||||
latestQuote: quote,
|
||||
} as never;
|
||||
return HttpResponse.json(quote);
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/procurement/trial/extend`, () => {
|
||||
const d = deal as Record<string, unknown>;
|
||||
if (d.dealId) {
|
||||
const base = d.trialEndsAt
|
||||
? Date.parse(d.trialEndsAt as string)
|
||||
: Date.now();
|
||||
d.trialEndsAt = new Date(base + 7 * 86_400_000).toISOString();
|
||||
d.trialExtensionsUsed = ((d.trialExtensionsUsed as number) ?? 0) + 1;
|
||||
}
|
||||
return HttpResponse.json(deal);
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/procurement/agreement`, () => {
|
||||
(deal as Record<string, unknown>).stage = "security";
|
||||
return HttpResponse.json(deal);
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/procurement/go-live`, () => {
|
||||
const d = deal as Record<string, unknown>;
|
||||
if (d.dealId) {
|
||||
d.stage = "active";
|
||||
d.licensed = true;
|
||||
}
|
||||
return HttpResponse.json(deal);
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/procurement/reset`, () => {
|
||||
resetProcurementSaasStore();
|
||||
return HttpResponse.json(EMPTY);
|
||||
}),
|
||||
|
||||
// Stripe Quote edge functions (supabase.functions.invoke → ${url}/functions/v1/{name}).
|
||||
http.post(`${SAAS}/functions/v1/issue-procurement-quote`, () => {
|
||||
const q = (deal as { latestQuote: Record<string, unknown> | null })
|
||||
.latestQuote;
|
||||
if (q) {
|
||||
q.status = "sent";
|
||||
q.stripeQuoteId = `qt_mock_${q.quoteId}`;
|
||||
}
|
||||
return HttpResponse.json(q);
|
||||
}),
|
||||
http.post(`${SAAS}/functions/v1/accept-procurement-quote`, () => {
|
||||
const q = (deal as { latestQuote: Record<string, unknown> | null })
|
||||
.latestQuote;
|
||||
const invoiceUrl = "https://invoice.stripe.com/i/mock_procurement";
|
||||
if (q) {
|
||||
q.status = "accepted";
|
||||
q.invoiceUrl = invoiceUrl;
|
||||
(deal as Record<string, unknown>).stage = "procurement";
|
||||
}
|
||||
return HttpResponse.json({
|
||||
status: "accepted",
|
||||
subscriptionId: "sub_mock_procurement",
|
||||
invoiceUrl,
|
||||
invoicePdf: "https://invoice.stripe.com/i/mock_procurement/pdf",
|
||||
});
|
||||
}),
|
||||
http.post(`${SAAS}/functions/v1/get-procurement-quote-pdf`, () => {
|
||||
// A minimal valid PDF so the download opens something in Storybook / mock dev.
|
||||
const pdf = `%PDF-1.1
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 200]>>endobj
|
||||
trailer<</Root 1 0 R>>
|
||||
%%EOF`;
|
||||
return new HttpResponse(pdf, {
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -28,6 +28,7 @@ import { UsageAreaChart } from "@portal/components/UsageAreaChart";
|
||||
import { RecentActivity } from "@portal/components/RecentActivity";
|
||||
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
|
||||
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
|
||||
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
|
||||
import { PolicySummary } from "@portal/components/PolicySummary";
|
||||
import { PipelineForkWizard } from "@portal/components/PipelineForkWizard";
|
||||
import "@portal/views/Home.css";
|
||||
@@ -538,6 +539,8 @@ export function Home() {
|
||||
<WelcomeCarousel onTryOp={() => setRunnerOpen(true)} />
|
||||
<ProcessingStatusStrip />
|
||||
|
||||
<ProcurementHome />
|
||||
|
||||
{tier === "free" && (
|
||||
<>
|
||||
<TierKpiStrip />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Skeleton, StatusBadge } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
advanceStage,
|
||||
fetchProcurement,
|
||||
type DealStage,
|
||||
type LedgerDoc,
|
||||
type ProcurementResponse,
|
||||
} from "@portal/api/procurement";
|
||||
import { DealJourney } from "@portal/components/procurement/DealJourney";
|
||||
import { DocumentLedger } from "@portal/components/procurement/DocumentLedger";
|
||||
import { ActionModal } from "@portal/components/procurement/ActionModal";
|
||||
import { LockedState } from "@portal/components/procurement/LockedState";
|
||||
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* Procurement: the enterprise commercial journey (trial to live) plus the
|
||||
* document ledger. Enterprise-only: free/pro buyers see a locked upgrade state.
|
||||
* /procurement — procurement is no longer a nav tab; it lives on Home as the deal-status hero.
|
||||
* This route is kept for deep links (and the Usage "Build your quote" CTA): it renders the same
|
||||
* surface, opening the takeover modal once a deal is underway.
|
||||
*/
|
||||
export function Procurement() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [activeDoc, setActiveDoc] = useState<LedgerDoc | null>(null);
|
||||
const [advancing, setAdvancing] = useState(false);
|
||||
|
||||
const state = useAsync<ProcurementResponse>(
|
||||
() => fetchProcurement(tier),
|
||||
[tier],
|
||||
);
|
||||
|
||||
// Write actions return the new canonical state; we hold it here so the
|
||||
// journey reflects every action immediately. Cleared when the tier (and so
|
||||
// the deal) changes, falling back to whatever the GET loaded.
|
||||
const [applied, setApplied] = useState<ProcurementResponse | null>(null);
|
||||
useEffect(() => setApplied(null), [tier]);
|
||||
const data = applied ?? (state.loading ? null : state.data);
|
||||
|
||||
async function onAdvance(stage: DealStage) {
|
||||
setAdvancing(true);
|
||||
try {
|
||||
setApplied(await advanceStage(stage));
|
||||
} finally {
|
||||
setAdvancing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-proc">
|
||||
<header className="portal-proc__header">
|
||||
<div>
|
||||
<h1 className="portal-proc__title">
|
||||
{t("portal.procurement.title")}
|
||||
</h1>
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone="purple" size="md">
|
||||
{t("portal.procurement.enterpriseBadge")}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{state.loading && (
|
||||
<Card padding="loose">
|
||||
<Skeleton width="12rem" height="1.25rem" />
|
||||
<Skeleton height="8rem" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && !data.unlocked && (
|
||||
<LockedState
|
||||
journey={data.journey}
|
||||
onTalkToSales={() => {
|
||||
// TODO(backend): POST /v1/procurement/sales-contact, for now this
|
||||
// is the sidebar's upgrade path; hand off to the account team.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && data.unlocked && data.deal && (
|
||||
<>
|
||||
<DealJourney
|
||||
deal={data.deal}
|
||||
journey={data.journey}
|
||||
onAdvance={onAdvance}
|
||||
advancing={advancing}
|
||||
/>
|
||||
<DocumentLedger
|
||||
groups={data.ledger}
|
||||
supporting={data.supporting}
|
||||
journey={data.journey}
|
||||
currentStage={data.deal.currentStage}
|
||||
onAction={setActiveDoc}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ActionModal
|
||||
doc={activeDoc}
|
||||
onClose={() => setActiveDoc(null)}
|
||||
onDone={(next) => {
|
||||
setApplied(next);
|
||||
setActiveDoc(null);
|
||||
}}
|
||||
/>
|
||||
<ProcurementHome autoOpen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user