Skip to content

Commit 7bd3826

Browse files
authored
Portal procurement: real pricing/trial/quote spine + linked-gated checkout (vertical slice) (Stirling-Tools#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).
1 parent 17aa718 commit 7bd3826

42 files changed

Lines changed: 4603 additions & 118 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@
1818
"stirling.software.saas.repository",
1919
"stirling.software.saas.billing.repository",
2020
"stirling.software.saas.ai.repository",
21-
"stirling.software.saas.payg.repository"
21+
"stirling.software.saas.payg.repository",
22+
"stirling.software.saas.procurement.repository"
2223
})
2324
@EntityScan({
2425
"stirling.software.saas.accountlink",
2526
"stirling.software.saas.model",
2627
"stirling.software.saas.billing.model",
2728
"stirling.software.saas.ai.model",
28-
"stirling.software.saas.payg"
29+
"stirling.software.saas.payg",
30+
"stirling.software.saas.procurement.model"
2931
})
3032
public class SaasJpaConfig {}
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
package stirling.software.saas.procurement.api;
2+
3+
import java.util.List;
4+
import java.util.Objects;
5+
6+
import org.springframework.context.annotation.Profile;
7+
import org.springframework.http.HttpStatus;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.security.access.prepost.PreAuthorize;
10+
import org.springframework.security.core.Authentication;
11+
import org.springframework.web.bind.annotation.GetMapping;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestBody;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
import com.fasterxml.jackson.databind.ObjectMapper;
18+
19+
import io.swagger.v3.oas.annotations.Hidden;
20+
21+
import lombok.extern.slf4j.Slf4j;
22+
23+
import stirling.software.common.model.enumeration.TeamRole;
24+
import stirling.software.proprietary.security.database.repository.UserRepository;
25+
import stirling.software.proprietary.security.model.User;
26+
import stirling.software.saas.model.TeamMembership;
27+
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
28+
import stirling.software.saas.procurement.model.ProcurementDeal;
29+
import stirling.software.saas.procurement.model.ProcurementQuote;
30+
import stirling.software.saas.procurement.pricing.QuoteConfig;
31+
import stirling.software.saas.procurement.pricing.QuoteLineItem;
32+
import stirling.software.saas.procurement.service.ProcurementService;
33+
import stirling.software.saas.repository.TeamMembershipRepository;
34+
import stirling.software.saas.util.AuthenticationUtils;
35+
36+
/**
37+
* The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
38+
* (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
39+
* Supabase edge function the portal calls with the accepted quote — this controller never touches
40+
* Stripe. The caller's team is resolved from the authenticated principal; a team id is never
41+
* trusted from the request. Mutations require the team leader.
42+
*/
43+
@Slf4j
44+
@Hidden
45+
@RestController
46+
@RequestMapping("/api/v1/procurement")
47+
@Profile("saas")
48+
public class ProcurementController {
49+
50+
// Local mapper to parse the stored line-items JSON; the saas context exposes no injectable
51+
// ObjectMapper bean.
52+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
53+
54+
private final ProcurementService procurement;
55+
private final TeamMembershipRepository memberRepo;
56+
private final UserRepository userRepository;
57+
private final ProcurementConfigurationProperties config;
58+
59+
public ProcurementController(
60+
ProcurementService procurement,
61+
TeamMembershipRepository memberRepo,
62+
UserRepository userRepository,
63+
ProcurementConfigurationProperties config) {
64+
this.procurement = Objects.requireNonNull(procurement);
65+
this.memberRepo = Objects.requireNonNull(memberRepo);
66+
this.userRepository = Objects.requireNonNull(userRepository);
67+
this.config = Objects.requireNonNull(config);
68+
}
69+
70+
// ---- request / response DTOs -------------------------------------------
71+
72+
public record QuoteRequest(
73+
long volume,
74+
int users,
75+
String deployment,
76+
int termYears,
77+
String serviceLevel,
78+
boolean indemnification,
79+
boolean training,
80+
boolean qbr,
81+
String currency,
82+
String businessName) {
83+
QuoteConfig toConfig() {
84+
return new QuoteConfig(
85+
volume,
86+
users,
87+
deployment,
88+
termYears,
89+
serviceLevel,
90+
indemnification,
91+
training,
92+
qbr,
93+
currency);
94+
}
95+
}
96+
97+
public record QuoteResponse(
98+
Long quoteId,
99+
String quoteNumber,
100+
String status,
101+
String currency,
102+
long annualNetMinor,
103+
long tcvMinor,
104+
List<QuoteLineItem> lineItems,
105+
String validUntil,
106+
String stripeQuoteId,
107+
String invoiceUrl,
108+
QuoteConfigEcho config) {}
109+
110+
/**
111+
* The inputs the quote was priced from, echoed back so the builder can seed itself when the
112+
* buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume
113+
* is), so it is always 0 here; the builder treats the seeded volume as manually set.
114+
*/
115+
public record QuoteConfigEcho(
116+
long volume,
117+
int users,
118+
String deployment,
119+
int termYears,
120+
String serviceLevel,
121+
boolean indemnification,
122+
boolean training,
123+
boolean qbr,
124+
String currency,
125+
String businessName) {}
126+
127+
public record SnapshotResponse(
128+
Long dealId,
129+
String stage,
130+
String trialStartedAt,
131+
String trialEndsAt,
132+
int trialExtensionsUsed,
133+
boolean licensed,
134+
QuoteResponse latestQuote) {}
135+
136+
// ---- endpoints ----------------------------------------------------------
137+
138+
/**
139+
* The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an
140+
* empty snapshot ({@code dealId == null}) so the portal can render the "start" state without
141+
* special-casing an empty body.
142+
*/
143+
@GetMapping
144+
@PreAuthorize("isAuthenticated()")
145+
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
146+
Long teamId = resolveTeam(auth);
147+
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
148+
return ResponseEntity.ok(
149+
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
150+
}
151+
152+
private static final SnapshotResponse EMPTY_SNAPSHOT =
153+
new SnapshotResponse(null, null, null, null, 0, false, null);
154+
155+
@PostMapping("/trial/start")
156+
@PreAuthorize("isAuthenticated()")
157+
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
158+
Long teamId = requireLeader(auth);
159+
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
160+
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
161+
}
162+
163+
@PostMapping("/trial/extend")
164+
@PreAuthorize("isAuthenticated()")
165+
public ResponseEntity<SnapshotResponse> extendTrial(Authentication auth) {
166+
Long teamId = requireLeader(auth);
167+
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
168+
try {
169+
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
170+
} catch (IllegalStateException e) {
171+
return ResponseEntity.status(HttpStatus.CONFLICT).build();
172+
}
173+
}
174+
175+
@PostMapping("/quote")
176+
@PreAuthorize("isAuthenticated()")
177+
public ResponseEntity<QuoteResponse> buildQuote(
178+
@RequestBody QuoteRequest request, Authentication auth) {
179+
Long teamId = requireLeader(auth);
180+
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
181+
return ResponseEntity.ok(
182+
toQuote(
183+
procurement.buildQuote(
184+
teamId, request.toConfig(), request.businessName())));
185+
}
186+
187+
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
188+
// draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription.
189+
// Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them.
190+
191+
/**
192+
* Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees.
193+
*/
194+
@PostMapping("/agreement")
195+
@PreAuthorize("isAuthenticated()")
196+
public ResponseEntity<SnapshotResponse> startAgreement(Authentication auth) {
197+
Long teamId = requireLeader(auth);
198+
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
199+
try {
200+
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
201+
} catch (IllegalStateException e) {
202+
return ResponseEntity.status(HttpStatus.CONFLICT).build();
203+
}
204+
}
205+
206+
/**
207+
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
208+
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
209+
*/
210+
@PostMapping("/go-live")
211+
@PreAuthorize("isAuthenticated()")
212+
public ResponseEntity<SnapshotResponse> goLive(Authentication auth) {
213+
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
214+
Long teamId = requireLeader(auth);
215+
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
216+
try {
217+
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
218+
} catch (IllegalStateException e) {
219+
return ResponseEntity.status(HttpStatus.CONFLICT).build();
220+
}
221+
}
222+
223+
/** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */
224+
@PostMapping("/reset")
225+
@PreAuthorize("isAuthenticated()")
226+
public ResponseEntity<SnapshotResponse> reset(Authentication auth) {
227+
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
228+
Long teamId = requireLeader(auth);
229+
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
230+
procurement.resetDeal(teamId);
231+
return ResponseEntity.ok(EMPTY_SNAPSHOT);
232+
}
233+
234+
// ---- helpers ------------------------------------------------------------
235+
236+
/**
237+
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
238+
*/
239+
private Long resolveTeam(Authentication auth) {
240+
User user;
241+
try {
242+
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
243+
} catch (SecurityException e) {
244+
return null;
245+
}
246+
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
247+
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
248+
}
249+
250+
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
251+
private Long requireLeader(Authentication auth) {
252+
User user;
253+
try {
254+
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
255+
} catch (SecurityException e) {
256+
return null;
257+
}
258+
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
259+
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
260+
return rows.get(0).getTeam().getId();
261+
}
262+
263+
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
264+
QuoteResponse latest =
265+
procurement.quotesForDeal(deal.getDealId()).stream()
266+
.findFirst()
267+
.map(this::toQuote)
268+
.orElse(null);
269+
return new SnapshotResponse(
270+
deal.getDealId(),
271+
deal.getStage(),
272+
str(deal.getTrialStartedAt()),
273+
str(deal.getTrialEndsAt()),
274+
deal.getTrialExtensionsUsed(),
275+
deal.getLicenseRef() != null,
276+
latest);
277+
}
278+
279+
private QuoteResponse toQuote(ProcurementQuote q) {
280+
return new QuoteResponse(
281+
q.getQuoteId(),
282+
q.getQuoteNumber(),
283+
q.getStatus(),
284+
q.getCurrency(),
285+
q.getAnnualNetMinor(),
286+
q.getTcvMinor(),
287+
parseLineItems(q.getLineItemsJson()),
288+
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
289+
q.getStripeQuoteId(),
290+
q.getStripeInvoiceUrl(),
291+
new QuoteConfigEcho(
292+
q.getVolume(),
293+
0,
294+
q.getDeployment(),
295+
q.getTermYears(),
296+
q.getServiceLevel(),
297+
q.isIndemnification(),
298+
q.isTraining(),
299+
q.isQbr(),
300+
q.getCurrency(),
301+
q.getBusinessName()));
302+
}
303+
304+
private List<QuoteLineItem> parseLineItems(String json) {
305+
if (json == null || json.isBlank()) return List.of();
306+
try {
307+
return OBJECT_MAPPER.readValue(
308+
json,
309+
OBJECT_MAPPER
310+
.getTypeFactory()
311+
.constructCollectionType(List.class, QuoteLineItem.class));
312+
} catch (Exception e) {
313+
log.warn("[procurement] failed to parse line items", e);
314+
return List.of();
315+
}
316+
}
317+
318+
private static String str(Object o) {
319+
return o == null ? null : o.toString();
320+
}
321+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package stirling.software.saas.procurement.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.context.annotation.Profile;
5+
import org.springframework.stereotype.Component;
6+
7+
import lombok.Getter;
8+
import lombok.Setter;
9+
10+
/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */
11+
@Getter
12+
@Setter
13+
@Component
14+
@Profile("saas")
15+
@ConfigurationProperties(prefix = "stirling.procurement")
16+
public class ProcurementConfigurationProperties {
17+
18+
/** Free trial length, in days (no card). */
19+
private int trialDurationDays = 14;
20+
21+
/** Days added per trial extension. */
22+
private int trialExtensionDays = 7;
23+
24+
/** Maximum number of trial extensions a buyer may take. */
25+
private int maxTrialExtensions = 2;
26+
27+
/**
28+
* Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement
29+
* or mark it live without payment. Off by default; turn on ONLY in demo/dev environments —
30+
* /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
31+
*/
32+
private boolean demoControlsEnabled = false;
33+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package stirling.software.saas.procurement.license;
2+
3+
import java.time.LocalDateTime;
4+
5+
/**
6+
* Issues and modifies the customer-facing entitlement that actually unlocks the product for an
7+
* enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam
8+
* the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records
9+
* intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only
10+
* verifies this instance's own licence.
11+
*/
12+
public interface EnterpriseLicenseService {
13+
14+
/** Issue a time-boxed trial licence for the team; returns the licence reference. */
15+
String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
16+
17+
/** Move a licence's expiry out (trial extension). */
18+
void extendLicense(String licenseRef, LocalDateTime newExpiry);
19+
20+
/** Issue/upgrade to a committed annual licence with the quote's entitlements. */
21+
String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
22+
23+
/** Suspend a licence (e.g. payment failed, deal lost). */
24+
void suspendLicense(String licenseRef);
25+
}

0 commit comments

Comments
 (0)