Procurement: render the real Enterprise Agreement + capture a signature
Replaces the placeholder agreement stub with the full Stirling Enterprise
Agreement (MSA + Order Form + DPA, one signature), served from a new
versioned legal-document registry, and captures a real signature record.
Versioned legal registry (backend-owned)
- app/saas/.../resources/legal/manifest.json + legal/<id>/<version>/*.md for
the agreement (MSA + DPA), EULA, SLA exhibit and subprocessors, cleaned of
internal counsel markers. Publishing a new version = drop a markdown file +
bump the manifest; no code change.
- LegalDocumentRegistry loads the manifest and fills {{token}} slots.
Agreement rendering + signing
- AgreementAssembler builds MSA + a generated Order Form (Part B, from the
quote) + DPA, filling every token; provider signatory is Matt Joseph, CEO.
- GET /procurement/agreement/document serves the filled markdown; the portal
renders it (react-markdown) with a scroll-to-sign gate and typed legal
name / signatory / title / authority.
- POST /procurement/agreement/sign records an immutable signature pinned to
the document id + version + a SHA-256 content hash + the variable snapshot,
then the flow accepts the quote as before.
Signed PDF
- AgreementPdfRenderer dogfoods Stirling's own Markdown->HTML->PDF path
(commonmark + common FileToPdf/WeasyPrint); resilient — the signature is
recorded even if the render runtime is unavailable, and the PDF is served
from GET /procurement/agreement/signature/pdf when stored.
Storage: procurement_agreement_signature (V38 + Supabase twin). Read-only
pricing accessors added for the Order Form (effective rate/PDF, term
discount %); no billing behaviour change. Pricing reconciliation (25 MB
data-processing model) is intentionally out of scope. Legal text is DRAFT,
attorney-review-required — surfaced with a draft badge.
This commit is contained in:
@@ -32,6 +32,11 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation project(':proprietary')
|
||||
|
||||
// Markdown -> HTML for rendering versioned legal documents (agreement) to PDF via the
|
||||
// shared FileToPdf/WeasyPrint path in :common. Same library the core Markdown-to-PDF tool uses.
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion"
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One legal document's registry entry, as declared in {@code legal/manifest.json}. Immutable
|
||||
* snapshot loaded at startup by {@link LegalDocumentRegistry}.
|
||||
*
|
||||
* <p>{@code parts} lists the pieces, in render order, that make up the document. A plain entry
|
||||
* (e.g. {@code "msa.md"}) is a static markdown file under {@code legal/<id>/<version>/}; an entry
|
||||
* prefixed with {@code "@"} (e.g. {@code "@order-form"}) is a dynamic section that a document
|
||||
* assembler generates at render time.
|
||||
*/
|
||||
public record LegalDocumentMeta(
|
||||
String id,
|
||||
String label,
|
||||
String displayName,
|
||||
String version,
|
||||
String effectiveDate,
|
||||
String status,
|
||||
List<String> parts) {
|
||||
|
||||
/** Fully-qualified version label shown to users and stored on signatures, e.g. "SEA v0.9.1". */
|
||||
public String versionLabel() {
|
||||
return label + " v" + version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Loads the versioned legal-document registry from {@code legal/manifest.json} on startup and
|
||||
* serves document metadata + rendered markdown from the classpath.
|
||||
*
|
||||
* <p>Publishing a new version of any document is a content-only change: drop the markdown under
|
||||
* {@code legal/<id>/<newVersion>/} and bump that document's {@code version} in the manifest — no
|
||||
* code change. Signatures pin the exact {@code {id, version, contentHash}} they were signed against
|
||||
* (see the procurement agreement flow), so historical documents stay reproducible.
|
||||
*
|
||||
* <p>Token slots of the form <code>{{name}}</code> in the markdown are filled at render time. This
|
||||
* registry fills the document-level common tokens ({@code version}, {@code version_date}, {@code
|
||||
* subprocessor_url}, {@code eula_url}); callers that need per-quote tokens (the enterprise
|
||||
* agreement's Order Form) fill the rest.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class LegalDocumentRegistry {
|
||||
|
||||
private static final String MANIFEST = "legal/manifest.json";
|
||||
private static final Pattern TOKEN = Pattern.compile("\\{\\{\\s*([a-zA-Z0-9_]+)\\s*}}");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final Map<String, LegalDocumentMeta> documents = new LinkedHashMap<>();
|
||||
private String subprocessorUrl = "";
|
||||
private String eulaUrl = "";
|
||||
|
||||
@PostConstruct
|
||||
void load() throws IOException {
|
||||
JsonNode root;
|
||||
try (InputStream in = new ClassPathResource(MANIFEST).getInputStream()) {
|
||||
root = objectMapper.readTree(in);
|
||||
}
|
||||
subprocessorUrl = root.path("subprocessorUrl").asText("");
|
||||
eulaUrl = root.path("eulaUrl").asText("");
|
||||
JsonNode docs = root.path("documents");
|
||||
docs.fieldNames()
|
||||
.forEachRemaining(
|
||||
id -> {
|
||||
JsonNode d = docs.get(id);
|
||||
List<String> parts =
|
||||
objectMapper.convertValue(
|
||||
d.path("parts"),
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(
|
||||
List.class, String.class));
|
||||
documents.put(
|
||||
id,
|
||||
new LegalDocumentMeta(
|
||||
id,
|
||||
d.path("label").asText(id),
|
||||
d.path("displayName").asText(id),
|
||||
d.path("version").asText("0"),
|
||||
d.path("effectiveDate").asText(""),
|
||||
d.path("status").asText("draft"),
|
||||
parts == null ? List.of() : parts));
|
||||
});
|
||||
log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST);
|
||||
}
|
||||
|
||||
public Optional<LegalDocumentMeta> meta(String docId) {
|
||||
return Optional.ofNullable(documents.get(docId));
|
||||
}
|
||||
|
||||
public String subprocessorUrl() {
|
||||
return subprocessorUrl;
|
||||
}
|
||||
|
||||
public String eulaUrl() {
|
||||
return eulaUrl;
|
||||
}
|
||||
|
||||
/** Document-level tokens available to every document (before any per-quote tokens). */
|
||||
public Map<String, String> commonTokens(LegalDocumentMeta meta) {
|
||||
Map<String, String> t = new LinkedHashMap<>();
|
||||
t.put("version", meta.version());
|
||||
t.put("version_date", meta.effectiveDate());
|
||||
t.put("subprocessor_url", subprocessorUrl);
|
||||
t.put("eula_url", eulaUrl);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Read one static markdown part of a document from the classpath. */
|
||||
public String readPart(LegalDocumentMeta meta, String partFile) {
|
||||
String path = "legal/" + meta.id() + "/" + meta.version() + "/" + partFile;
|
||||
try (InputStream in = new ClassPathResource(path).getInputStream()) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Missing legal document part: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The concatenated static parts of a document (dynamic {@code @}-parts skipped), with only the
|
||||
* common tokens filled. Use for fully-static documents (EULA, SLA, subprocessors).
|
||||
*/
|
||||
public String staticMarkdown(String docId) {
|
||||
LegalDocumentMeta meta =
|
||||
meta(docId)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("Unknown document: " + docId));
|
||||
Map<String, String> tokens = commonTokens(meta);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String part : meta.parts()) {
|
||||
if (part.startsWith("@")) continue; // dynamic section — not part of the static body
|
||||
if (sb.length() > 0) sb.append("\n\n");
|
||||
sb.append(fill(readPart(meta, part), tokens));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Replace {@code {{token}}} slots; unknown tokens are left intact so gaps are visible. */
|
||||
public static String fill(String markdown, Map<String, String> tokens) {
|
||||
Matcher m = TOKEN.matcher(markdown);
|
||||
StringBuilder out = new StringBuilder();
|
||||
while (m.find()) {
|
||||
String key = m.group(1);
|
||||
String value = tokens.get(key);
|
||||
m.appendReplacement(
|
||||
out,
|
||||
value == null
|
||||
? Matcher.quoteReplacement(m.group(0))
|
||||
: Matcher.quoteReplacement(value));
|
||||
}
|
||||
m.appendTail(out);
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
+115
@@ -22,6 +22,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
@@ -30,6 +32,8 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.legal.AgreementSigning;
|
||||
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.model.QuoteDetails;
|
||||
@@ -192,6 +196,25 @@ public class ProcurementController {
|
||||
String licenseKey,
|
||||
QuoteResponse latestQuote) {}
|
||||
|
||||
/** The filled agreement for review: registry metadata + the rendered markdown body. */
|
||||
public record AgreementDocumentResponse(
|
||||
String docId,
|
||||
String version,
|
||||
String versionLabel,
|
||||
String displayName,
|
||||
String effectiveDate,
|
||||
String status,
|
||||
String markdown) {}
|
||||
|
||||
/** Buyer-supplied signing inputs from the agreement stage. */
|
||||
public record SignAgreementRequest(
|
||||
String customerLegalName,
|
||||
String signatoryName,
|
||||
String signatoryTitle,
|
||||
boolean authorityConfirmed) {}
|
||||
|
||||
public record SignAgreementResponse(Long signatureId, String versionLabel, boolean pdfStored) {}
|
||||
|
||||
// ---- endpoints ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -293,6 +316,89 @@ public class ProcurementController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The filled Stirling Enterprise Agreement (MSA + Order Form + DPA) for the team's current
|
||||
* quote, as markdown, for the buyer to review before signing. 404 when there's no quote yet.
|
||||
*/
|
||||
@GetMapping("/agreement/document")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<AgreementDocumentResponse> agreementDocument(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return procurement
|
||||
.agreementDocument(teamId)
|
||||
.<ResponseEntity<AgreementDocumentResponse>>map(
|
||||
a ->
|
||||
ResponseEntity.ok(
|
||||
new AgreementDocumentResponse(
|
||||
a.docId(),
|
||||
a.version(),
|
||||
a.versionLabel(),
|
||||
a.displayName(),
|
||||
a.effectiveDate(),
|
||||
a.status(),
|
||||
a.markdown())))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a signed agreement: capture the typed legal name / signatory / title / authority, pin
|
||||
* the exact document version + content hash + variable snapshot, and store the rendered PDF
|
||||
* (best-effort). The caller then proceeds to accept the quote as before.
|
||||
*/
|
||||
@PostMapping("/agreement/sign")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SignAgreementResponse> signAgreement(
|
||||
@RequestBody SignAgreementRequest request,
|
||||
Authentication auth,
|
||||
HttpServletRequest http) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
if (request == null
|
||||
|| request.signatoryName() == null
|
||||
|| request.signatoryName().isBlank()
|
||||
|| !request.authorityConfirmed()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
try {
|
||||
ProcurementAgreementSignature sig =
|
||||
procurement.signAgreement(
|
||||
teamId,
|
||||
new AgreementSigning(
|
||||
request.customerLegalName(),
|
||||
request.signatoryName(),
|
||||
request.signatoryTitle(),
|
||||
request.authorityConfirmed()),
|
||||
clientIp(http));
|
||||
return ResponseEntity.ok(
|
||||
new SignAgreementResponse(
|
||||
sig.getSignatureId(), sig.getDocumentLabel(), sig.getPdf() != null));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Download the stored signed-agreement PDF for the team. 404 if none was rendered/stored. */
|
||||
@GetMapping("/agreement/signature/pdf")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<byte[]> signaturePdf(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return procurement
|
||||
.latestSignature(teamId)
|
||||
.filter(s -> s.getPdf() != null)
|
||||
.<ResponseEntity<byte[]>>map(
|
||||
s ->
|
||||
ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment;"
|
||||
+ " filename=\"stirling-enterprise-agreement.pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(s.getPdf()))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term, valid
|
||||
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
|
||||
@@ -360,6 +466,15 @@ public class ProcurementController {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** Best-effort client IP for the signature record: first X-Forwarded-For hop, else the peer. */
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
|
||||
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.legal.LegalDocumentMeta;
|
||||
import stirling.software.saas.legal.LegalDocumentRegistry;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem;
|
||||
|
||||
/**
|
||||
* Builds the full Stirling Enterprise Agreement for a specific quote: the static MSA (Part A) and
|
||||
* DPA (Part C) from the {@link LegalDocumentRegistry}, with the dynamic Order Form (Part B)
|
||||
* generated from the quote and slotted where the manifest's {@code @order-form} part sits.
|
||||
*
|
||||
* <p>Only the Order Form varies per deal; the MSA and DPA bodies are rendered verbatim with token
|
||||
* substitution. The set of values used is returned as {@code variablesJson} so a signature can pin
|
||||
* exactly what was rendered.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgreementAssembler {
|
||||
|
||||
public static final String DOC_ID = "enterprise-agreement";
|
||||
|
||||
private static final DateTimeFormatter DATE =
|
||||
DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.US);
|
||||
private static final String BLANK = "\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_";
|
||||
|
||||
private final LegalDocumentRegistry registry;
|
||||
private final ProcurementPricingService pricing;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Render the agreement for a quote. {@code signing} is null for a preview (before signing) —
|
||||
* the effective date and signature block then read as blanks / "On signature".
|
||||
*/
|
||||
public AssembledAgreement assemble(ProcurementQuote quote, AgreementSigning signing) {
|
||||
LegalDocumentMeta meta =
|
||||
registry.meta(DOC_ID)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"Enterprise agreement not registered"));
|
||||
|
||||
Map<String, String> tokens = tokens(quote, signing, meta);
|
||||
|
||||
StringBuilder md = new StringBuilder();
|
||||
for (String part : meta.parts()) {
|
||||
if (md.length() > 0) md.append("\n\n");
|
||||
if ("@order-form".equals(part)) {
|
||||
md.append(LegalDocumentRegistry.fill(orderForm(quote, tokens), tokens));
|
||||
} else {
|
||||
md.append(LegalDocumentRegistry.fill(registry.readPart(meta, part), tokens));
|
||||
}
|
||||
}
|
||||
|
||||
String variablesJson;
|
||||
try {
|
||||
variablesJson = objectMapper.writeValueAsString(tokens);
|
||||
} catch (Exception e) {
|
||||
variablesJson = "{}";
|
||||
}
|
||||
|
||||
return new AssembledAgreement(
|
||||
meta.id(),
|
||||
meta.version(),
|
||||
meta.versionLabel(),
|
||||
meta.displayName(),
|
||||
meta.effectiveDate(),
|
||||
meta.status(),
|
||||
md.toString(),
|
||||
variablesJson);
|
||||
}
|
||||
|
||||
private Map<String, String> tokens(
|
||||
ProcurementQuote quote, AgreementSigning signing, LegalDocumentMeta meta) {
|
||||
QuoteConfig cfg = toConfig(quote);
|
||||
boolean signed = signing != null;
|
||||
|
||||
String legalName =
|
||||
signed && notBlank(signing.customerLegalName())
|
||||
? signing.customerLegalName().trim()
|
||||
: (notBlank(quote.getBusinessName())
|
||||
? quote.getBusinessName().trim()
|
||||
: "Customer");
|
||||
|
||||
Map<String, String> t = new LinkedHashMap<>(registry.commonTokens(meta));
|
||||
t.put("effective_date", signed ? LocalDate.now().format(DATE) : "On signature");
|
||||
t.put("customer_legal_name", legalName);
|
||||
t.put("quote_ref", nz(quote.getQuoteNumber()));
|
||||
t.put("deployment", ProcurementPricingService.deploymentName(quote.getDeployment()));
|
||||
t.put("committed_pdfs_yr", String.format(Locale.US, "%,d", Math.max(0, quote.getVolume())));
|
||||
t.put("posture", ProcurementPricingService.postureName(quote.getIntensity()));
|
||||
t.put("processes_per_pdf", String.valueOf(Math.max(1, quote.getIntensity())));
|
||||
t.put("rate_per_pdf", String.format(Locale.US, "$%.4f", pricing.effectiveRatePerPdf(cfg)));
|
||||
t.put("term_years", String.valueOf(quote.getTermYears()));
|
||||
t.put("term_discount_pct", pricing.termDiscountPct(quote.getTermYears()) + "%");
|
||||
t.put("sla_tier", slaTier(quote.getServiceLevel()));
|
||||
t.put("annual_fee_y1", money(quote.getAnnualNetMinor()));
|
||||
t.put("elected_or_not", quote.isIndemnification() ? "Elected" : "Not elected");
|
||||
t.put(
|
||||
"customer_signatory",
|
||||
signed && notBlank(signing.signatoryName())
|
||||
? signing.signatoryName().trim()
|
||||
: BLANK);
|
||||
t.put(
|
||||
"customer_signatory_title",
|
||||
signed && notBlank(signing.signatoryTitle())
|
||||
? signing.signatoryTitle().trim()
|
||||
: BLANK);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Part B — the Order Form. Generated from the quote; the only per-deal section. */
|
||||
private String orderForm(ProcurementQuote quote, Map<String, String> t) {
|
||||
String date = t.get("effective_date");
|
||||
String signatory = t.get("customer_signatory");
|
||||
String signatoryTitle = t.get("customer_signatory_title");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("## Part B — Order Form · {{quote_ref}}\n\n");
|
||||
sb.append("| Term | Value |\n| --- | --- |\n");
|
||||
row(sb, "Customer", "{{customer_legal_name}}");
|
||||
row(sb, "Subscription", "Enterprise · {{deployment}}");
|
||||
row(sb, "Committed Volume", "{{committed_pdfs_yr}} PDFs / year at the {{posture}} posture");
|
||||
row(sb, "Committed rate", "{{rate_per_pdf}} per PDF");
|
||||
row(sb, "Service level", "{{sla_tier}} (per SLA Exhibit)");
|
||||
row(
|
||||
sb,
|
||||
"Term",
|
||||
"{{term_years}} year(s) · term discount {{term_discount_pct}} on committed processing");
|
||||
row(sb, "Itemized services", itemizedServices(quote));
|
||||
row(sb, "Annual Fee (year 1)", "{{annual_fee_y1}}");
|
||||
row(sb, "Escalator", "+3% at each anniversary during the Term");
|
||||
row(sb, "Payment", "Annual in advance · net 30 · ACH, wire, or check");
|
||||
row(sb, "Overage", "Committed rate, billed quarterly in arrears");
|
||||
row(
|
||||
sb,
|
||||
"Data schedule",
|
||||
"First 25 MB per file included; each additional 25 MB or part thereof (decimal MB,"
|
||||
+ " rounded up per file, measured once at ingestion) draws down 1 PDF Process."
|
||||
+ " Frozen for the Term (MSA §3.5).");
|
||||
row(
|
||||
sb,
|
||||
"Drawdown schedule",
|
||||
"{{posture}}: {{processes_per_pdf}} PDF Processes per PDF (MSA §3.3, frozen for the Term)");
|
||||
row(
|
||||
sb,
|
||||
"Enhanced IP Protection",
|
||||
"{{elected_or_not}} — extends §7.3 to patent claims at the §8.2 super-cap");
|
||||
row(sb, "Standard terms", "SSO, SCIM, RBAC, and audit logs included.");
|
||||
|
||||
sb.append(
|
||||
"\n**Itemized services menu (included as elected):** Self-hosted deployment $12,000/yr"
|
||||
+ " · Air-gapped deployment $36,000/yr · Dedicated SE/CSM $30,000/yr · Enhanced IP"
|
||||
+ " Protection (patent coverage, Section 7.3) 5% of committed processing fees ·"
|
||||
+ " Onboarding & training $7,500 one-time · Quarterly business reviews $8,000/yr."
|
||||
+ " Baseline IP indemnification (copyright, trademark, trade secret) is included at"
|
||||
+ " no charge.\n\n");
|
||||
sb.append(
|
||||
"**Signatures.** By signing, each signatory represents they have authority to bind"
|
||||
+ " their Party. Signatures delivered electronically or in counterparts are"
|
||||
+ " effective as originals.\n\n");
|
||||
sb.append("| Provider | Customer |\n| --- | --- |\n");
|
||||
sb.append("| Stirling PDF, Inc. | {{customer_legal_name}} |\n");
|
||||
sb.append("| Name: Matt Joseph | Name: ").append(signatory).append(" |\n");
|
||||
sb.append("| Title: CEO | Title: ").append(signatoryTitle).append(" |\n");
|
||||
sb.append("| Date: ").append(date).append(" | Date: ").append(date).append(" |\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The elected add-on lines, taken from the quote's stored breakdown (excludes the base meter).
|
||||
*/
|
||||
private String itemizedServices(ProcurementQuote quote) {
|
||||
List<QuoteLineItem> lines = parseLineItems(quote.getLineItemsJson());
|
||||
List<String> elected = new ArrayList<>();
|
||||
for (QuoteLineItem li : lines) {
|
||||
if (li.key().equals("usage")
|
||||
|| li.key().equals("seats")
|
||||
|| li.key().equals("multi-year")) {
|
||||
continue;
|
||||
}
|
||||
String suffix = li.kind() == QuoteLineItem.Kind.ONE_TIME ? " (one-time)" : "/yr";
|
||||
elected.add(li.label() + " " + money(li.amountMinor()) + suffix);
|
||||
}
|
||||
return elected.isEmpty() ? "None elected" : String.join(" · ", elected);
|
||||
}
|
||||
|
||||
private List<QuoteLineItem> parseLineItems(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(
|
||||
json,
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(List.class, QuoteLineItem.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("[legal] could not parse quote line items for the order form", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static QuoteConfig toConfig(ProcurementQuote q) {
|
||||
int users = q.getSeats() == null ? 0 : q.getSeats();
|
||||
return new QuoteConfig(
|
||||
q.getVolume(),
|
||||
users,
|
||||
q.getIntensity(),
|
||||
q.getSizeMult(),
|
||||
q.getDeployment(),
|
||||
q.getTermYears(),
|
||||
q.getServiceLevel(),
|
||||
q.isIndemnification(),
|
||||
q.isTraining(),
|
||||
q.isQbr(),
|
||||
q.getCurrency());
|
||||
}
|
||||
|
||||
private static void row(StringBuilder sb, String term, String value) {
|
||||
sb.append("| ").append(term).append(" | ").append(value).append(" |\n");
|
||||
}
|
||||
|
||||
private static String slaTier(String serviceLevel) {
|
||||
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated";
|
||||
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority";
|
||||
return "Standard";
|
||||
}
|
||||
|
||||
/** Minor units (cents) → whole-dollar display; the quote figures are whole dollars. */
|
||||
private static String money(long minor) {
|
||||
return String.format(Locale.US, "$%,d", minor / 100L);
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
private static String nz(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import org.commonmark.Extension;
|
||||
import org.commonmark.ext.gfm.tables.TablesExtension;
|
||||
import org.commonmark.node.Node;
|
||||
import org.commonmark.parser.Parser;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.FileToPdf;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Renders an assembled agreement's markdown to a PDF, dogfooding Stirling's own conversion path:
|
||||
* commonmark (markdown → HTML) then {@link FileToPdf#convertHtmlToPdf} (HTML → PDF via WeasyPrint),
|
||||
* the same pipeline as the product's Markdown-to-PDF tool.
|
||||
*
|
||||
* <p>The signed PDF is a stored artifact, but it must never block signing: {@link #tryRender}
|
||||
* returns {@code null} if the conversion runtime (WeasyPrint) is unavailable, so the signature is
|
||||
* still recorded and the buyer keeps the on-the-fly download.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgreementPdfRenderer {
|
||||
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final CustomHtmlSanitizer customHtmlSanitizer;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private static final List<Extension> EXTENSIONS = List.of(TablesExtension.create());
|
||||
|
||||
/** Render to PDF, or return null if the conversion runtime isn't available. */
|
||||
public byte[] tryRender(String markdown) {
|
||||
try {
|
||||
return render(markdown);
|
||||
} catch (Exception e) {
|
||||
org.slf4j.LoggerFactory.getLogger(AgreementPdfRenderer.class)
|
||||
.warn(
|
||||
"[legal] agreement PDF render unavailable; recording signature without a"
|
||||
+ " stored PDF: {}",
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] render(String markdown) throws Exception {
|
||||
Parser parser = Parser.builder().extensions(EXTENSIONS).build();
|
||||
Node document = parser.parse(markdown);
|
||||
HtmlRenderer renderer = HtmlRenderer.builder().extensions(EXTENSIONS).build();
|
||||
String html = renderer.render(document);
|
||||
|
||||
byte[] pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
html.getBytes(StandardCharsets.UTF_8),
|
||||
"agreement.html",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
return pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
/**
|
||||
* The buyer-supplied inputs captured at the moment of signing the enterprise agreement: the legal
|
||||
* entity name, the signatory's typed name and title, and their representation of authority to bind.
|
||||
* Null when the agreement is rendered for preview (before signing).
|
||||
*/
|
||||
public record AgreementSigning(
|
||||
String customerLegalName,
|
||||
String signatoryName,
|
||||
String signatoryTitle,
|
||||
boolean authorityConfirmed) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
/**
|
||||
* A rendered enterprise agreement: the full markdown the buyer sees (MSA + Order Form + DPA, tokens
|
||||
* filled), plus the registry metadata that pins it. {@code variablesJson} is the exact set of
|
||||
* Order-Form values as rendered, stored alongside a signature so the document is reproducible.
|
||||
*/
|
||||
public record AssembledAgreement(
|
||||
String docId,
|
||||
String version,
|
||||
String versionLabel,
|
||||
String displayName,
|
||||
String effectiveDate,
|
||||
String status,
|
||||
String markdown,
|
||||
String variablesJson) {}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
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 lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* An immutable record of a signed enterprise agreement. Each signature pins the exact legal
|
||||
* document it was signed against — {@code documentId} + {@code documentVersion} + a SHA-256 {@code
|
||||
* contentHash} of the rendered markdown — plus the Order-Form variable snapshot and the typed
|
||||
* signatory details, so the agreement stays reproducible even after the templates version up. The
|
||||
* rendered PDF is stored when the conversion runtime is available.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "procurement_agreement_signature")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcurementAgreementSignature implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "signature_id")
|
||||
private Long signatureId;
|
||||
|
||||
@Column(name = "deal_id", nullable = false)
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "quote_id", nullable = false)
|
||||
private Long quoteId;
|
||||
|
||||
// Which legal document, and which version of it, was signed.
|
||||
@Column(name = "document_id", nullable = false, length = 64)
|
||||
private String documentId;
|
||||
|
||||
@Column(name = "document_version", nullable = false, length = 32)
|
||||
private String documentVersion;
|
||||
|
||||
@Column(name = "document_label", length = 64)
|
||||
private String documentLabel;
|
||||
|
||||
// SHA-256 (hex) of the exact rendered agreement markdown the buyer accepted.
|
||||
@Column(name = "content_hash", nullable = false, length = 64)
|
||||
private String contentHash;
|
||||
|
||||
// The Order-Form variable values as rendered, so the document can be reproduced.
|
||||
@Column(name = "variables_json", columnDefinition = "text")
|
||||
private String variablesJson;
|
||||
|
||||
@Column(name = "customer_legal_name", length = 255)
|
||||
private String customerLegalName;
|
||||
|
||||
@Column(name = "signatory_name", nullable = false, length = 255)
|
||||
private String signatoryName;
|
||||
|
||||
@Column(name = "signatory_title", length = 255)
|
||||
private String signatoryTitle;
|
||||
|
||||
@Column(name = "authority_confirmed", nullable = false)
|
||||
private boolean authorityConfirmed;
|
||||
|
||||
@Column(name = "signer_ip", length = 64)
|
||||
private String signerIp;
|
||||
|
||||
// The rendered PDF artifact; null when the conversion runtime was unavailable at signing.
|
||||
@Column(name = "pdf")
|
||||
private byte[] pdf;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "signed_at", nullable = false, updatable = false)
|
||||
private LocalDateTime signedAt;
|
||||
}
|
||||
+43
-6
@@ -60,12 +60,7 @@ public class ProcurementPricingService {
|
||||
rates.discountPerDoubling()
|
||||
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
|
||||
: 0.0;
|
||||
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
|
||||
// File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds
|
||||
// into the per-run rate after the floor, so it flows through the meter, TCV and renewal.
|
||||
// QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a
|
||||
// cheaper factor in.
|
||||
rate *= cfg.sizeMult();
|
||||
double rate = perRunRate(cfg, rates);
|
||||
double termDisc = rates.termDiscount(cfg.termYears());
|
||||
|
||||
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
|
||||
@@ -160,6 +155,48 @@ public class ProcurementPricingService {
|
||||
return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency());
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-run rate after the committed-volume curve, the half-cent floor, and the file-size
|
||||
* multiplier — the same value {@link #price} meters against. Extracted so read-only callers
|
||||
* (the Order Form) can quote it without re-deriving the curve.
|
||||
*/
|
||||
private static double perRunRate(QuoteConfig cfg, PricingRates rates) {
|
||||
long runVol = Math.max(0, cfg.volume()) * (long) Math.max(1, cfg.intensity());
|
||||
double volDisc =
|
||||
runVol > RUN_CURVE_KNEE
|
||||
? Math.min(
|
||||
0.5,
|
||||
rates.discountPerDoubling()
|
||||
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
|
||||
: 0.0;
|
||||
return Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc))
|
||||
* cfg.sizeMult();
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective per-PDF rate at the chosen posture, in dollars (4-decimal quote figure). This
|
||||
* is what the Order Form and quote copy speak in — never the per-run rate. Read-only; does not
|
||||
* affect billing.
|
||||
*/
|
||||
public double effectiveRatePerPdf(QuoteConfig cfg) {
|
||||
return perRunRate(cfg, PricingRates.defaults()) * Math.max(1, cfg.intensity());
|
||||
}
|
||||
|
||||
/** The multi-year term discount as a whole-percent figure for the Order Form (0.05 → 5). */
|
||||
public int termDiscountPct(int termYears) {
|
||||
return (int) Math.round(PricingRates.defaults().termDiscount(termYears) * 100.0);
|
||||
}
|
||||
|
||||
/** Buyer-facing posture name (Essentials / Governed / Regulated) for the given intensity. */
|
||||
public static String postureName(int intensity) {
|
||||
return postureLabel(intensity);
|
||||
}
|
||||
|
||||
/** Buyer-facing deployment name (Stirling Cloud / Self-hosted / Air-gapped). */
|
||||
public static String deploymentName(String deployment) {
|
||||
return deploymentLabel(deployment);
|
||||
}
|
||||
|
||||
/** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */
|
||||
public double cpiEscalator() {
|
||||
return PricingRates.defaults().cpiEscalator();
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.saas.procurement.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
|
||||
|
||||
public interface ProcurementAgreementSignatureRepository
|
||||
extends JpaRepository<ProcurementAgreementSignature, Long> {
|
||||
|
||||
Optional<ProcurementAgreementSignature> findFirstByDealIdOrderBySignedAtDesc(Long dealId);
|
||||
|
||||
Optional<ProcurementAgreementSignature> findFirstByQuoteIdOrderBySignedAtDesc(Long quoteId);
|
||||
}
|
||||
+108
-1
@@ -20,14 +20,20 @@ import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.legal.AgreementAssembler;
|
||||
import stirling.software.saas.procurement.legal.AgreementPdfRenderer;
|
||||
import stirling.software.saas.procurement.legal.AgreementSigning;
|
||||
import stirling.software.saas.procurement.legal.AssembledAgreement;
|
||||
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
|
||||
import stirling.software.saas.procurement.license.LicenseEntitlements;
|
||||
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.model.QuoteDetails;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.repository.ProcurementAgreementSignatureRepository;
|
||||
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
|
||||
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
|
||||
|
||||
@@ -52,6 +58,9 @@ public class ProcurementService {
|
||||
private final EnterpriseLicenseService licenses;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final AgreementAssembler agreementAssembler;
|
||||
private final AgreementPdfRenderer agreementPdfRenderer;
|
||||
private final ProcurementAgreementSignatureRepository signatureRepo;
|
||||
|
||||
public ProcurementService(
|
||||
ProcurementDealRepository dealRepo,
|
||||
@@ -59,13 +68,19 @@ public class ProcurementService {
|
||||
ProcurementPricingService pricing,
|
||||
EnterpriseLicenseService licenses,
|
||||
ProcurementConfigurationProperties config,
|
||||
TeamMembershipRepository memberRepo) {
|
||||
TeamMembershipRepository memberRepo,
|
||||
AgreementAssembler agreementAssembler,
|
||||
AgreementPdfRenderer agreementPdfRenderer,
|
||||
ProcurementAgreementSignatureRepository signatureRepo) {
|
||||
this.dealRepo = dealRepo;
|
||||
this.quoteRepo = quoteRepo;
|
||||
this.pricing = pricing;
|
||||
this.licenses = licenses;
|
||||
this.config = config;
|
||||
this.memberRepo = memberRepo;
|
||||
this.agreementAssembler = agreementAssembler;
|
||||
this.agreementPdfRenderer = agreementPdfRenderer;
|
||||
this.signatureRepo = signatureRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,6 +256,98 @@ public class ProcurementService {
|
||||
return deal;
|
||||
}
|
||||
|
||||
/** The quote a team is currently transacting on: its accepted quote, else the most recent. */
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ProcurementQuote> currentQuote(Long teamId) {
|
||||
return dealRepo.findByTeamId(teamId)
|
||||
.flatMap(
|
||||
deal -> {
|
||||
if (deal.getAcceptedQuoteId() != null) {
|
||||
Optional<ProcurementQuote> accepted =
|
||||
quoteRepo.findById(deal.getAcceptedQuoteId());
|
||||
if (accepted.isPresent()) return accepted;
|
||||
}
|
||||
return quoteRepo
|
||||
.findByDealIdOrderByCreatedAtDesc(deal.getDealId())
|
||||
.stream()
|
||||
.findFirst();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The filled enterprise agreement for a team's current quote, rendered for review (unsigned).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<AssembledAgreement> agreementDocument(Long teamId) {
|
||||
return currentQuote(teamId).map(q -> agreementAssembler.assemble(q, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a signed enterprise agreement: assemble the final document, hash it, render + store
|
||||
* the PDF (best-effort), and persist an immutable signature pinned to the exact document
|
||||
* version. Does not itself accept the quote into a subscription — the caller proceeds to accept
|
||||
* as before.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementAgreementSignature signAgreement(
|
||||
Long teamId, AgreementSigning signing, String signerIp) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
ProcurementQuote quote =
|
||||
currentQuote(teamId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No quote to sign for team " + teamId));
|
||||
|
||||
AssembledAgreement assembled = agreementAssembler.assemble(quote, signing);
|
||||
|
||||
ProcurementAgreementSignature sig = new ProcurementAgreementSignature();
|
||||
sig.setDealId(deal.getDealId());
|
||||
sig.setQuoteId(quote.getQuoteId());
|
||||
sig.setDocumentId(assembled.docId());
|
||||
sig.setDocumentVersion(assembled.version());
|
||||
sig.setDocumentLabel(assembled.versionLabel());
|
||||
sig.setContentHash(sha256(assembled.markdown()));
|
||||
sig.setVariablesJson(assembled.variablesJson());
|
||||
sig.setCustomerLegalName(signing.customerLegalName());
|
||||
sig.setSignatoryName(signing.signatoryName());
|
||||
sig.setSignatoryTitle(signing.signatoryTitle());
|
||||
sig.setAuthorityConfirmed(signing.authorityConfirmed());
|
||||
sig.setSignerIp(signerIp);
|
||||
sig.setPdf(agreementPdfRenderer.tryRender(assembled.markdown()));
|
||||
sig = signatureRepo.save(sig);
|
||||
log.info(
|
||||
"[procurement] agreement signed team={} quote={} doc={} pdf={}",
|
||||
teamId,
|
||||
quote.getQuoteId(),
|
||||
assembled.versionLabel(),
|
||||
sig.getPdf() != null);
|
||||
return sig;
|
||||
}
|
||||
|
||||
/** The latest recorded signature for a team's deal, if any (for the signed-PDF download). */
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ProcurementAgreementSignature> latestSignature(Long teamId) {
|
||||
return dealRepo.findByTeamId(teamId)
|
||||
.flatMap(
|
||||
deal ->
|
||||
signatureRepo.findFirstByDealIdOrderBySignedAtDesc(
|
||||
deal.getDealId()));
|
||||
}
|
||||
|
||||
private static String sha256(String s) {
|
||||
try {
|
||||
byte[] digest =
|
||||
java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
return java.util.HexFormat.of().formatHex(digest);
|
||||
} catch (java.security.NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term (valid
|
||||
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
-- Signed enterprise agreements. Each row is an immutable record of one signing: which legal
|
||||
-- document + version was signed, a SHA-256 hash of the exact rendered markdown, the Order-Form
|
||||
-- variable snapshot, the typed signatory details, and the rendered PDF (when the conversion
|
||||
-- runtime was available). Pinning {document_id, document_version, content_hash} keeps the signed
|
||||
-- agreement reproducible even after the templates version up. Written/read by the Java backend
|
||||
-- via JPA. Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_agreement_signature (
|
||||
signature_id BIGSERIAL PRIMARY KEY,
|
||||
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
|
||||
quote_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_quote(quote_id) ON DELETE CASCADE,
|
||||
document_id VARCHAR(64) NOT NULL,
|
||||
document_version VARCHAR(32) NOT NULL,
|
||||
document_label VARCHAR(64),
|
||||
content_hash VARCHAR(64) NOT NULL,
|
||||
variables_json TEXT,
|
||||
customer_legal_name VARCHAR(255),
|
||||
signatory_name VARCHAR(255) NOT NULL,
|
||||
signatory_title VARCHAR(255),
|
||||
authority_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
signer_ip VARCHAR(64),
|
||||
pdf BYTEA,
|
||||
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_signature_deal ON stirling_pdf.procurement_agreement_signature (deal_id, signed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_signature_quote ON stirling_pdf.procurement_agreement_signature (quote_id, signed_at DESC);
|
||||
@@ -0,0 +1,53 @@
|
||||
## Part C — Data Processing Addendum
|
||||
|
||||
This DPA forms part of the Agreement and applies where Provider processes Personal Data on Customer's behalf.
|
||||
|
||||
### C1. Roles; scope; instructions
|
||||
|
||||
Customer is the controller (or a processor on behalf of its own controllers); Provider is a processor (or subprocessor, as applicable). Provider processes Personal Data only on Customer's documented instructions — including processing initiated by Customer's users, policies, pipelines, and API calls — unless required by law (in which case Provider informs Customer unless legally prohibited). Provider will inform Customer without undue delay if, in Provider's opinion, an instruction infringes the GDPR, UK GDPR, or other applicable data-protection law. Customer is responsible for the lawfulness of the Personal Data it submits and the instructions it gives; Customer's rights under this DPA include instruction, audit (C9), objection to subprocessors (C5), assistance (C6), and return or deletion of data (C10).
|
||||
|
||||
### C2. Details of processing
|
||||
|
||||
**Subject matter/nature:** PDF processing and governance (classification, redaction, routing, retention, conversion, signing, extraction, AI-assisted analysis). **Duration:** the Term plus the deletion period. **Categories of data:** any Personal Data contained in Customer files and metadata (names, contact details, identifiers, financial or health data if present in Customer files), account data of Customer users. **Data subjects:** Customer's employees, users, customers, and other persons appearing in Customer files. **Sensitive data:** may be present in Customer files at Customer's discretion; Customer is responsible for the lawful basis.
|
||||
|
||||
### C3. Confidentiality; personnel
|
||||
|
||||
Provider ensures persons authorized to process Personal Data are bound by confidentiality and receive security training. Zero-standing-access applies: content access is just-in-time, logged, and audited (MSA Section 4.2).
|
||||
|
||||
### C4. Security measures (Annex II summary)
|
||||
|
||||
Encryption in transit (TLS 1.2+) and at rest (AES-256); zero-standing-access with audited JIT elevation; role-based access control; SSO/SCIM; tenant isolation; vulnerability management and penetration testing; audit logging of processing events (including file name, hash, size, and operations); backup and recovery. For Self-hosted and Air-gapped deployments, Customer operates the runtime environment and is responsible for infrastructure-level controls; Provider's measures apply to license/metering services and support access.
|
||||
|
||||
### C5. Subprocessors
|
||||
|
||||
Customer generally authorizes the subprocessors listed at {{subprocessor_url}}: cloud infrastructure (Amazon Web Services); payment processing (Stripe, acting as an independent controller for payment data); email delivery (Google); account infrastructure (Supabase); product telemetry (PostHog, EU-hosted; pseudonymous usage events, never file content); and AI model providers — **Anthropic** (Claude models, which receive prompts and queries only) and **Voyage AI** (embedding models, which receive extracted text excerpts solely to generate embeddings where Customer enables Ingestion/RAG features). **Customer files are never transmitted to any AI provider,** and neither AI provider trains on Customer data. Provider gives thirty (30) days' notice of new subprocessors; Customer may object on reasonable data-protection grounds, and if unresolved, may terminate the affected Services with a pro-rata refund. Provider imposes data-protection obligations on each subprocessor by written contract that are at least as protective as this DPA, and remains fully responsible to Customer for each subprocessor's performance.
|
||||
|
||||
### C6. Data subject requests; assistance
|
||||
|
||||
Taking into account the nature of the processing, Provider provides reasonable assistance (including through the Processor's search, redaction, and audit tools) for Customer's obligations under GDPR Articles 32–36: security of processing, breach notification to authorities and data subjects, data protection impact assessments, and prior consultations with supervisory authorities, as well as responses to data subject requests. Provider forwards requests received directly to Customer and does not respond except as legally required. Provider makes available to Customer all information necessary to demonstrate compliance with this DPA and allows for and contributes to audits, including inspections, per Section C9.
|
||||
|
||||
### C7. Breach notification
|
||||
|
||||
Per MSA Section 5.3: without undue delay after becoming aware of a Personal Data Breach, and in any event within forty-eight (48) hours of awareness, with information provided in phases as available — including the nature of the breach, categories and approximate volumes affected, likely consequences, and measures taken or proposed.
|
||||
|
||||
### C8. International transfers
|
||||
|
||||
Where Personal Data subject to GDPR/UK GDPR is transferred to countries without adequacy, the Parties incorporate the EU Standard Contractual Clauses (Commission Decision 2021/914): **Module 2** (controller-to-processor) where Customer is a controller, and **Module 3** (processor-to-processor) where Customer acts as a processor, with the following selections — Clause 7 (docking): included; Clause 9(a): Option 2 (general written authorization, 30 days' notice per C5); Clause 11(a) optional language: not used; Clause 17: the law of Ireland; Clause 18: the courts of Ireland; competent supervisory authority: the Irish Data Protection Commission (per Annex I.C). Annex I (parties, description of transfer: as per Section C2), Annex II (technical and organizational measures: as per Section C4), and Annex III (subprocessors: as per Section C5 and {{subprocessor_url}}) are completed by reference to this DPA. For UK transfers, the UK International Data Transfer Addendum applies with its Tables completed by reference to the foregoing. Provider is not certified under the EU-U.S. Data Privacy Framework; the SCCs are the transfer mechanism.
|
||||
|
||||
**Note:** Provider does not currently offer contractual EU data residency for Stirling Cloud; residency is achieved via Self-hosted or Air-gapped deployment.
|
||||
|
||||
### C9. Audits
|
||||
|
||||
Provider's security reports and documentation (Section 5.1) are the ordinary means of demonstrating compliance. Customer may additionally audit — by itself or a mandated auditor — once per year on thirty (30) days' notice, and at any time where: (a) a security incident affecting Customer Personal Data has occurred; (b) provided documentation reveals a material deficiency; (c) a competent supervisory authority requires it; or (d) Customer reasonably suspects material noncompliance with this DPA. Audits are conducted during business hours, under confidentiality, at Customer's cost, with reasonable notice, without unreasonable interference with Provider's operations, and without access to other customers' data.
|
||||
|
||||
### C10. Return & deletion
|
||||
|
||||
On termination, at Customer's choice, Provider returns Customer file content and Personal Data (export of Customer files and the governed-record metadata) and/or deletes them — from live systems within thirty (30) days and from backups within ninety (90) days — except as retention is required by law, and certifies deletion on request. Where Customer uses HYOK, key destruction by Customer renders content cryptographically inaccessible immediately.
|
||||
|
||||
### C11. CCPA/CPRA
|
||||
|
||||
Provider is a "service provider" under the CCPA/CPRA. Provider: (a) processes Personal Information only for the business purposes specified in this Agreement — providing, securing, metering, and supporting the Services described in Section C2; (b) shall not sell or share Personal Information; (c) shall not retain, use, or disclose it for any purpose other than those business purposes, or outside the direct business relationship between the Parties; (d) shall not combine it with Personal Information received from other sources, except as permitted by CCPA regulations for the business purposes; (e) provides the same level of privacy protection required of businesses by the CCPA; (f) will notify Customer if it determines it can no longer meet its CCPA obligations; (g) grants Customer the right, upon reasonable notice, to take reasonable and appropriate steps to ensure Provider's use of Personal Information is consistent with Customer's obligations, and to stop and remediate any unauthorized use; and (h) flows these requirements down to its subprocessors per Section C5. Provider certifies that it understands these restrictions and will comply with them.
|
||||
|
||||
### C12. Liability
|
||||
|
||||
Liability under this DPA is subject to the MSA's limitations (Section 8).
|
||||
@@ -0,0 +1,119 @@
|
||||
# Stirling Enterprise Agreement
|
||||
|
||||
One signature executes all three parts of this Agreement: the Master Services Agreement (Part A), the Order Form (Part B), and the Data Processing Addendum (Part C). The Stirling EULA & Commercial Terms is incorporated by reference to the extent stated in Section 9.1.
|
||||
|
||||
## Part A — Master Services Agreement
|
||||
|
||||
This Master Services Agreement (the "Agreement") is entered into as of {{effective_date}} (the "Effective Date") by and between **Stirling PDF, Inc.**, a Delaware corporation with offices at 548 Market Street PMB 887643, San Francisco, CA 94104 ("Provider"), and **{{customer_legal_name}}** ("Customer"). Each a "Party," together the "Parties."
|
||||
|
||||
### 1. Services & License
|
||||
|
||||
1.1 **The Services.** Provider will provide the Stirling PDF Processor (the "Processor") — the hosted or customer-deployed platform for distributing PDF editors and governing PDF processing, including policies, pipelines, the Stirling Agent, API access, and the administrative console — and the Stirling PDF Editor (the "Editor"), as described in the Order Form.
|
||||
|
||||
1.2 **License grants.** Provider grants Customer, for the Term: (a) a non-exclusive, non-transferable right to access and use the Processor for Customer's internal business operations, up to the Committed Volume; and (b) a non-exclusive right to deploy and distribute the Editor to Customer's authorized users without limit on user count. Open-source components of the Editor remain governed by their own licenses, which control for those components.
|
||||
|
||||
1.3 **Deployment.** The Services are delivered via the deployment stated in the Order Form (Stirling Cloud, Self-hosted, or Air-gapped). Self-hosted deployments validate their license and report metering data online; Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically as described in the Documentation. Customer shall not disable, circumvent, or falsify license validation or usage metering. The metered rate does not vary by deployment; deployment-specific services are priced as line items in the Order Form.
|
||||
|
||||
1.4 **Restrictions.** Customer shall not: resell or provide the Services to third parties as a service bureau; reverse engineer non-open-source components; use the Services to violate law; or exceed the scope of the Order Form other than through Overage (Section 3.4).
|
||||
|
||||
### 2. Term & Renewal
|
||||
|
||||
2.1 **Initial Term.** {{term_years}} year(s) from the Effective Date.
|
||||
|
||||
2.2 **Renewal.** The Agreement auto-renews for successive periods equal to the Initial Term unless either Party gives sixty (60) days' written notice of non-renewal before the end of the then-current term. The Annual Fee for each renewal year equals the immediately preceding year's Annual Fee increased by three percent (3%) — the same formula as Section 2.3. Itemized services escalate at the same rate unless restated in a superseding Order Form.
|
||||
|
||||
2.3 **In-term escalator.** The Annual Fee (including itemized services) increases by a fixed three percent (3%) at each anniversary of the Effective Date during the Term.
|
||||
|
||||
### 3. Fees & Payment
|
||||
|
||||
3.1 **Annual Fee.** Customer shall pay the Annual Fee stated in the Order Form, calculated as the Committed Volume ({{committed_pdfs_yr}} PDFs per year) at {{rate_per_pdf}} per PDF at the {{posture}} governance posture, plus the itemized services in the Order Form, less the term discount stated there.
|
||||
|
||||
3.2 **Invoicing.** Fees are invoiced annually in advance, due net thirty (30) days. Late amounts accrue interest at 1.5% per month or the maximum permitted by law, whichever is less. Fees are exclusive of taxes; Customer is responsible for all taxes other than Provider's income taxes.
|
||||
|
||||
3.3 **Committed Volume; measurement.** The Committed Volume is denominated in PDFs processed per year at the stated posture, and converts to a drawdown allowance in PDF Processes at the fixed conversion schedule below, which is frozen for the Term:
|
||||
|
||||
| Posture | PDF Processes per PDF |
|
||||
| --- | --- |
|
||||
| Essentials | 2 |
|
||||
| Governed | 4 |
|
||||
| Regulated | 7 |
|
||||
|
||||
A **"PDF Process"** is one policy execution, one pipeline run, or one Stirling Agent returned artifact, applied to one file, plus Data Processing increments under Section 3.5. For clarity: a pipeline run counts as one PDF Process regardless of the number of operations in its chain; a Stirling Agent artifact counts as one regardless of the number of messages that produced it; failed processes (those that do not complete) are not counted; reprocessing the same file and duplicate submissions are counted; counts are whole numbers (no rounding). The Processor's audit log records each PDF Process and is the system of record, subject to Section 3.7. Provider will make a per-file usage statement (file identifier, size, processes, drawdown) available for audit.
|
||||
|
||||
**Worked example.** At the Governed posture, a commitment of 90,000,000 PDFs/year provides a drawdown allowance of 360,000,000 PDF Processes. A 60 MB file that runs the four Governed policies draws down 4 PDF Processes plus 2 Data Processing increments (Section 3.5) = 6 PDF Processes. The allowance is a purchased quantity, not a feature limit: Customer may run any number of policies or pipelines; actual consumption simply draws the allowance down faster, and consumption beyond it bills as Overage (Section 3.4).
|
||||
|
||||
3.4 **Overage.** Consumption beyond the Committed Volume in a contract year is billed quarterly in arrears at the committed rate stated in the Order Form. Overage does not increase subsequent years' Committed Volume.
|
||||
|
||||
3.5 **Data Processing.** Each file includes its first twenty-five (25) megabytes (decimal, 1 MB = 1,000,000 bytes) at no additional drawdown. Each additional twenty-five (25) megabytes or part thereof (rounded up per file) draws down one (1) additional PDF Process. File size is measured once per file at ingestion, on the file as submitted. This schedule is stated here in full, is frozen for the Term, and is not subject to alteration through the Documentation.
|
||||
|
||||
3.6 **No refunds.** Except as expressly stated (Sections 7.1, 7.3, 10.3, and DPA Section C5), fees are non-refundable and Committed Volume does not roll over between contract years.
|
||||
|
||||
3.7 **Billing disputes.** Customer may dispute any invoice or metering record in good faith within sixty (60) days of the invoice date. Provider will investigate promptly, provide the relevant audit-log extracts and usage statements, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; Customer may rebut it with reasonable evidence. Undisputed amounts remain payable when due.
|
||||
|
||||
### 4. Data Protection
|
||||
|
||||
4.1 The Data Processing Addendum at Part C (the "DPA") is incorporated into this Agreement and governs Provider's processing of Customer Personal Data, in compliance with the GDPR, UK GDPR, and CCPA/CPRA to the extent applicable.
|
||||
|
||||
4.2 **Zero-standing-access.** Customer file content is encrypted in transit and at rest. Provider personnel have no standing access to Customer file content; access is granted just-in-time under audited elevation, solely as necessary to provide the Services or as instructed by Customer. Document metadata is maintained to operate the governed record. Where the Order Form includes BYOK or HYOK key management, the key terms in the Documentation apply.
|
||||
|
||||
### 5. Security & Availability
|
||||
|
||||
5.1 **Security program.** Provider maintains a written information security program including access controls, encryption (TLS 1.2+ in transit, AES-256 at rest), audit logging, vulnerability management, and personnel security. Provider will provide its available security documentation (including its security program overview and penetration-test attestation) upon request under confidentiality.
|
||||
|
||||
5.2 **Availability.** For Stirling Cloud deployments, Provider targets 99.9% monthly uptime, excluding scheduled maintenance announced at least 48 hours in advance. The uptime figure is a target, not a credited commitment, and no service credits apply. Support response commitments for the {{sla_tier}} tier are set out in the SLA Exhibit referenced by the Order Form.
|
||||
|
||||
5.3 **Breach notice.** Provider will notify Customer without undue delay after becoming aware of a Personal Data Breach affecting Customer Personal Data, and in any event within forty-eight (48) hours of awareness. Provider may provide information in phases as it becomes available and will supplement its notice as investigation proceeds.
|
||||
|
||||
5.4 **Updates.** Provider will make security patches and product upgrades available to Customer at no additional charge for supported versions.
|
||||
|
||||
### 6. Confidentiality
|
||||
|
||||
6.1 Each Party shall protect the other's Confidential Information with at least the care it uses for its own similar information and no less than reasonable care, use it solely to perform under this Agreement, and disclose it only to personnel and advisors with a need to know who are bound by confidentiality obligations at least as protective. Confidential Information excludes information that is public without breach, independently developed, or rightfully received from a third party.
|
||||
|
||||
6.2 Compelled disclosure is permitted with prompt notice (where lawful) and reasonable cooperation to seek protective treatment.
|
||||
|
||||
6.3 Obligations survive three (3) years after termination; trade secrets survive as long as they remain trade secrets.
|
||||
|
||||
### 7. Warranties & Indemnification
|
||||
|
||||
7.1 **Performance warranty.** Provider warrants the Services will perform materially in accordance with the Documentation. Customer's exclusive remedy for breach is re-performance or, if Provider cannot re-perform within thirty (30) days, termination of the affected Services and a pro-rata refund of prepaid, unused fees for those Services.
|
||||
|
||||
7.2 **Mutual warranties.** Each Party warrants it has the authority to enter this Agreement and will comply with applicable law in its performance.
|
||||
|
||||
7.3 **IP indemnification.** Provider shall defend Customer against third-party claims that the Services, as provided and used per this Agreement, infringe a copyright or trademark or misappropriate a trade secret, and shall indemnify Customer for resulting damages finally awarded or agreed in settlement. Where **Enhanced IP Protection** is elected on the Order Form, this obligation extends to patent claims and carries the enhanced cap stated in Section 8.2. Exclusions: combinations with non-Provider materials, Customer content, modifications not made by Provider, and use after notice to stop. Provider may procure rights, modify, or replace the Services; if none is practicable, Provider may terminate the affected Services and refund prepaid, unused fees. This section states Customer's exclusive remedy for IP claims.
|
||||
|
||||
7.4 **Customer indemnification.** Customer shall defend and indemnify Provider against third-party claims arising from Customer content, Customer's breach of Section 1.4, or Customer's violation of law.
|
||||
|
||||
7.5 **Disclaimer.** EXCEPT AS EXPRESSLY STATED, THE SERVICES ARE PROVIDED WITHOUT OTHER WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. AI-ASSISTED OUTPUTS (INCLUDING CLASSIFICATION, EXTRACTION, AND AGENT ARTIFACTS) ARE PROBABILISTIC; CUSTOMER IS RESPONSIBLE FOR HUMAN REVIEW WHERE OUTPUTS HAVE LEGAL OR REGULATORY EFFECT.
|
||||
|
||||
### 8. Limitation of Liability
|
||||
|
||||
8.1 NEITHER PARTY IS LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR LOST PROFITS OR REVENUE.
|
||||
|
||||
8.2 **General cap.** EACH PARTY'S AGGREGATE LIABILITY IS CAPPED AT THE FEES PAID OR PAYABLE UNDER THIS AGREEMENT IN THE TWELVE (12) MONTHS PRECEDING THE FIRST EVENT GIVING RISE TO LIABILITY. **Super-cap:** for breaches of Section 6 (Confidentiality), breaches of the DPA or Section 4–5 security obligations, and IP indemnification under Section 7.3, the cap is TWO TIMES (2x) such fees. **Uncapped:** fraud, willful misconduct, Customer's payment obligations, and Customer's indemnification under Section 7.4 for claims arising from Customer's willful violation of law.
|
||||
|
||||
8.3 All claims arising from the same event or series of connected events count as a single claim for the purposes of the caps in Section 8.2.
|
||||
|
||||
### 9. General
|
||||
|
||||
9.1 **Entire agreement; precedence.** This Agreement (Parts A–C, the Order Form, the SLA Exhibit, and the Standard Contractual Clauses where applicable) is the entire agreement and supersedes prior proposals and quotes, including {{quote_ref}}. Only the following provisions of the Stirling EULA & Commercial Terms are incorporated: Section 3 (Definitions), Section 8 (AI features), Section 10 (Self-hosted and desktop software), and Section 11 (Fair use). The website Terms of Service do not apply to this Agreement; without limitation, their arbitration and class-waiver provisions, online auto-renewal rules, unilateral-amendment provision, self-serve pricing, and clickwrap acceptance mechanism are expressly excluded. Precedence: Order Form → Standard Contractual Clauses (for international transfers) → DPA → MSA → SLA Exhibit → incorporated EULA sections → Documentation.
|
||||
|
||||
9.2 **Governing law; venue.** Delaware law, excluding conflicts rules. Exclusive jurisdiction and venue in the state or federal courts located in San Francisco County, California, and the Parties consent to personal jurisdiction there.
|
||||
|
||||
9.3 **Assignment.** Neither Party may assign without the other's consent, except to a successor in a merger, acquisition, or sale of substantially all assets, with notice.
|
||||
|
||||
9.4 **Notices.** Written notices to the addresses on the Order Form; email permitted with confirmation of receipt.
|
||||
|
||||
9.5 **Force majeure; independent contractors; waiver; severability.** Standard terms apply: neither Party is liable for delay caused by events beyond reasonable control; the Parties are independent contractors; failure to enforce is not waiver; unenforceable provisions are severed with the remainder in effect.
|
||||
|
||||
9.6 **Publicity.** Neither Party may use the other's name or marks publicly without prior written consent, except Provider may identify Customer as a customer with Customer's prior approval of the specific use.
|
||||
|
||||
9.7 **Suspension.** Provider may suspend the Services for material breach that threatens the security or integrity of the Services, with notice and opportunity to cure where practicable. Undisputed unpaid fees more than thirty (30) days late are grounds for suspension after ten (10) days' notice.
|
||||
|
||||
### 10. Termination
|
||||
|
||||
10.1 Either Party may terminate for material breach uncured thirty (30) days after written notice, or immediately upon the other's insolvency.
|
||||
|
||||
10.2 On termination: Customer's access ends (self-hosted licenses expire per the license mechanism); each Party returns or destroys the other's Confidential Information; the DPA's deletion terms govern Customer Personal Data; Sections 3 (accrued fees), 6, 7, 8, 9, and 10 survive.
|
||||
|
||||
10.3 If Customer terminates for Provider's uncured material breach, Provider refunds prepaid fees for the unused remainder of the then-current contract year.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Stirling EULA & Commercial Terms
|
||||
|
||||
**Effective:** {{version_date}} · **Version:** {{version}}
|
||||
|
||||
## 1. Agreement; precedence
|
||||
|
||||
These EULA & Commercial Terms ("EULA") supplement the Stirling Terms of Service (stirling.com/legal/terms-of-service). If they conflict, this EULA controls for the software and the commercial terms below. Open-source components are governed by their own licenses, which control for those components. By clicking accept, creating a workspace, or using the software, you agree on behalf of yourself and, if applicable, the organization you represent ("you").
|
||||
|
||||
## 2. The products
|
||||
|
||||
**The Stirling PDF Editor** is free: manual editing, the tool catalog, and team administration (including SSO) carry no subscription fee or per-seat charge, whether used in the browser, as a desktop application, or self-hosted. Usage limits on automated processing, fair-use rules (Section 11), support levels, and the feature set may change over time, and third-party costs (such as your own hosting) are yours. The Editor's open-source components remain available under their own licenses independently of this EULA. **The Stirling PDF Processor** is the paid platform that processes PDFs automatically — policies, pipelines, the Stirling Agent, and API processing — billed on the meter below.
|
||||
|
||||
## 3. Definitions
|
||||
|
||||
**"PDF Process"** — one policy execution, one pipeline run (regardless of the number of operations in its chain), or one Stirling Agent returned artifact (a processed file or summary, regardless of the number of messages that produced it), applied to one file. **"Data Processing"** — the data-volume component of the meter: files carry their first 25 MB included per file; volume past that is billed per Section 4. **"file"** — a document processed by the Processor. Chatting with the Stirling Agent is free; only returned artifacts meter.
|
||||
|
||||
## 4. Metered billing (pay as you go)
|
||||
|
||||
4.1 **Rates.** 1¢ per PDF Process, plus Data Processing at 1¢ per 25 MB increment past the first 25 MB of each file. Megabytes are decimal (1 MB = 1,000,000 bytes); size is measured once per file as submitted; increments round up ("part thereof" counts — a 26 MB file incurs one Data Processing increment, a 60 MB file incurs two). Rates may change on thirty (30) days' notice; changes apply prospectively. **Example:** two policies on a 3 MB contract = 2¢; two policies on a 60 MB scan set = 2¢ + 2¢ data = 4¢.
|
||||
|
||||
4.2 **Free allotment.** New workspaces receive a one-time allotment of 500 PDF Processes. A file processed by two processes consumes two of the 500. When the allotment is exhausted, processing pauses until the Processor is switched on.
|
||||
|
||||
4.3 **Invoices.** Usage is invoiced monthly on the 1st for the prior cycle, charged to your payment method on file (card or ACH debit). You authorize these charges.
|
||||
|
||||
4.4 **Threshold charges.** We may charge your payment method when accrued usage reaches a threshold, rather than only monthly. The applicable threshold is displayed in Usage & Billing before it applies, changes only prospectively with notice in the product, and threshold charges never increase your total usage charges — they only change when accrued usage is collected. Each threshold charge is itemized on your usage statement.
|
||||
|
||||
4.5 **Usage records.** The Processor's audit log is the system of record, subject to Section 4.6. Your Usage & Billing page shows consumption, and a per-file usage statement (name, size, processes, charge) is available for download.
|
||||
|
||||
4.6 **Billing disputes.** You may dispute a charge or metering record in good faith within sixty (60) days of the invoice or charge date. We will investigate, provide the relevant usage-statement detail, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; reasonable contrary evidence will be considered. Undisputed amounts remain payable.
|
||||
|
||||
## 5. Spend limits
|
||||
|
||||
5.1 You may set a monthly spend limit. By default, processing pauses when usage reaches the limit; queued documents resume when you raise the limit or the cycle resets. Nothing already processed is lost.
|
||||
|
||||
5.2 If you enable **keep-processing** ("Keep processing if you hit your limit"), usage past the limit continues to accrue and be billed per Section 4; the limit then functions as a notification threshold. You can change the limit or the toggle at any time in Usage & Billing.
|
||||
|
||||
## 6. Cancellation; downgrade
|
||||
|
||||
You may revert to the free Editor plan at any time from Usage & Billing. Accrued usage remains payable. Your policies, configuration, and history are retained per the Terms of Service data-retention practices.
|
||||
|
||||
## 7. Prepaid capacity (self-serve annual)
|
||||
|
||||
7.1 **Offer.** You may prepay twelve (12) months of processing capacity for the price of ten (10) (the "12-for-10 rate"), sized at purchase. Payment by card, or by bank transfer against a generated invoice (net 30); prepaid capacity activates when payment clears.
|
||||
|
||||
7.2 **No renewal of prepaid capacity; automatic transition to pay-as-you-go.** Prepaid capacity does not renew for another prepaid term. At purchase, you affirmatively consent to the following transition, which is disclosed before you pay: when the term ends, metered billing (Section 4) applies automatically at then-current rates so processing does not pause. We remind you thirty (30) days before term end; the reminder states the metered rates that will apply and how to cancel or revert to the free Editor plan (one click in Usage & Billing).
|
||||
|
||||
7.3 **Consumption; overage; expiry.** Capacity draws down in PDF Processes. If you exhaust capacity mid-term, you may top up at the same 12-for-10 rate, or metered billing applies at list rates (with a card on file) or processing pauses (without one). Unused capacity expires at term end and is not refunded and does not roll over.
|
||||
|
||||
7.4 **Cap.** Self-serve prepaid capacity is limited to 1,000,000 PDF Processes per year; larger commitments are available under a Stirling Enterprise Agreement.
|
||||
|
||||
## 8. AI features
|
||||
|
||||
The classification, extraction, redaction-assist, and Stirling Agent features use machine-learning models from the providers listed at {{subprocessor_url}}. Currently: **Anthropic** (Claude models), which receives prompts and queries only; and **Voyage AI** (embedding models), which receives extracted text excerpts solely to generate embeddings when you enable Ingestion/RAG features. **Your files are never transmitted to any AI provider.** AI charges are included in the price of whatever runs — there is no separate AI surcharge. Your content is not used to train models, by us or by these providers (verified against our signed provider agreements). AI outputs are probabilistic; review outputs before relying on them where accuracy has legal effect.
|
||||
|
||||
## 9. Evaluations and trials
|
||||
|
||||
Enterprise trials run fourteen (14) days, require no payment method, and are provided for evaluation only, AS IS, without service level commitments. Either party may end an evaluation at any time; on expiry your workspace continues on the free Editor plan.
|
||||
|
||||
## 10. Self-hosted and desktop software
|
||||
|
||||
10.1 **License.** We grant you a non-exclusive, non-transferable license to install and run the Editor and, with an active plan, the self-hosted Processor, for your internal business use. Open-source components remain under their own licenses.
|
||||
|
||||
10.2 **License validation and metering.** Self-hosted Processor deployments validate their license online and transmit usage metering data (process counts, file sizes, and file hashes for billing integrity, and diagnostic data — never file content or file names) to Stirling. File names used for unique PDF identification remain on your server and are not transmitted. Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically. You will not disable, circumvent, or falsify validation or metering. **The meter is the same regardless of where the software runs.**
|
||||
|
||||
10.3 **Updates.** Security patches and upgrades are made available for supported versions; some updates may install automatically per Terms of Service §5.
|
||||
|
||||
10.4 **Authorized users and administration.** "Authorized Users" are your employees, and the employees of your affiliates and contractors working on your behalf, whom you provision through your workspace. You are responsible for your users' credentials, your administrators' actions, and your users' compliance with this EULA. One workspace serves one legal entity and its affiliates; serving unrelated third parties requires a separate agreement. You may not redistribute the Processor or offer it as a hosted service to others. On termination or downgrade, self-hosted Processor licenses expire per the license mechanism; installed Editor copies remain usable under the free plan. We may verify license compliance through the validation mechanism in Section 10.2.
|
||||
|
||||
## 11. Fair use
|
||||
|
||||
Free-tier and flat-price features are subject to fair use: we may throttle or decline usage patterns that abuse free processing (for example, automation disguised as manual editing) after notice where practicable.
|
||||
|
||||
## 12. Changes to this EULA
|
||||
|
||||
We may update this EULA. Material changes take effect thirty (30) days after notice. Changes that materially increase your price or reduce your rights take effect at your next billing cycle or prepaid term start, or upon your affirmative acceptance — whichever comes first — except changes strictly necessary for legal compliance or security, which may take effect sooner with notice. Continued use after the effective date is acceptance. Version history is available at {{eula_url}}.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"subprocessorUrl": "https://www.stirlingpdf.com/legal/subprocessors",
|
||||
"eulaUrl": "https://www.stirlingpdf.com/legal/eula",
|
||||
"documents": {
|
||||
"enterprise-agreement": {
|
||||
"label": "SEA",
|
||||
"displayName": "Stirling Enterprise Agreement",
|
||||
"version": "0.9.1",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["msa.md", "@order-form", "dpa.md"]
|
||||
},
|
||||
"eula": {
|
||||
"label": "EULA",
|
||||
"displayName": "Stirling EULA & Commercial Terms",
|
||||
"version": "1.0.0",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["eula.md"]
|
||||
},
|
||||
"sla": {
|
||||
"label": "SLA",
|
||||
"displayName": "Stirling SLA Exhibit",
|
||||
"version": "1.0.0",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["sla.md"]
|
||||
},
|
||||
"subprocessors": {
|
||||
"label": "SUBP",
|
||||
"displayName": "Stirling Subprocessors",
|
||||
"version": "1.0.0",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["subprocessors.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# SLA Exhibit — Stirling Enterprise Agreement
|
||||
|
||||
Referenced by the Order Form's service-level row and MSA Section 5.2. One document, three tiers; the Order Form's tier selection determines the applicable column. Uptime is a target, not a credited commitment: no service credits apply at any tier. Tiers differentiate support response, channels, and people.
|
||||
|
||||
## 1. Availability
|
||||
|
||||
For Stirling Cloud deployments, Provider targets **99.9% monthly uptime**, measured at the API and console endpoints, excluding scheduled maintenance announced at least 48 hours in advance and events beyond Provider's reasonable control. Current and historical status is published at the system status page. No service credits apply; persistent material failure to meet the target is addressed through MSA §7.1 (performance warranty and remedies) and §10 (termination for material breach).
|
||||
|
||||
Self-hosted and Air-gapped deployments: availability of the runtime is Customer's responsibility; this Section applies to Provider's license, metering, and update services.
|
||||
|
||||
## 2. Support tiers
|
||||
|
||||
| | **Standard** | **Priority** | **Dedicated** |
|
||||
| --- | --- | --- | --- |
|
||||
| Included with | Every Enterprise Agreement | Every Enterprise Agreement | The Dedicated SE/CSM line item ($30,000/yr) |
|
||||
| Hours | Business hours (Mon–Fri, 9:00–18:00 US Eastern, excl. US holidays) | Business hours + extended (7:00–21:00 US Eastern) | 24×7 for Severity 1 |
|
||||
| Channels | Email, in-product | Email, in-product, private Slack/Teams channel | All Priority channels + named Solutions Engineer and CSM |
|
||||
| Severity 1 first response (production down / processing halted org-wide) | 8 business hours | 4 hours | 1 hour, 24×7 |
|
||||
| Severity 2 (major feature degraded, no workaround) | Next business day | 8 business hours | 4 hours |
|
||||
| Severity 3 (minor defect, workaround exists) | 3 business days | 2 business days | Next business day |
|
||||
| Severity 4 (question, cosmetic) | 5 business days | 3 business days | 2 business days |
|
||||
| Escalation path | Support queue | Support lead | Named SE → CSM → Provider executive |
|
||||
| Business reviews | — | — | Quarterly, where the QBR line item is elected |
|
||||
|
||||
First response = a qualified human engaging with the issue, not an acknowledgment autoresponder. Resolution times are not committed; Provider works Severity 1 issues continuously within the tier's hours until resolved or downgraded.
|
||||
|
||||
## 3. Severity is set by Customer, subject to reasonable reclassification
|
||||
|
||||
Customer designates severity at filing; Provider may reclassify with explanation. Severity 1 requires production impact in a live (non-evaluation) environment.
|
||||
|
||||
## 4. Maintenance and updates
|
||||
|
||||
Scheduled maintenance is announced at least 48 hours ahead and targeted at low-usage windows. Security patches for supported versions ship to all tiers at no charge (MSA §5.4). Trials and evaluations are provided AS IS and are outside this Exhibit (EULA §9).
|
||||
|
||||
## 5. Exclusions
|
||||
|
||||
This Exhibit does not apply to: issues caused by Customer's environment, modifications, or third-party systems; usage exceeding the fair-use provisions; Preview/Beta features; or Force Majeure events (MSA §9.5).
|
||||
@@ -0,0 +1,19 @@
|
||||
# Stirling PDF — Subprocessors
|
||||
|
||||
Referenced by DPA §C5 and Annex III, and by EULA §8. Changes to this list carry 30 days' notice per DPA §C5. Last updated: {{version_date}}.
|
||||
|
||||
Stirling PDF, Inc. uses the following subprocessors to provide the Services. Customer files are processed within Stirling's own infrastructure; **no customer files are transmitted to any AI provider.**
|
||||
|
||||
| Subprocessor | Purpose | Data processed | Location |
|
||||
| --- | --- | --- | --- |
|
||||
| **Amazon Web Services (AWS)** | Cloud infrastructure and storage for Stirling Cloud | Customer files (encrypted at rest), account and usage data | United States (EU region availability per deployment — see DPA §C8 note) |
|
||||
| **Stripe** | Payment processing | Billing contact and transaction data. Payment card details go directly to Stripe, which acts as an independent controller for them | United States |
|
||||
| **Supabase** | Account and workspace data infrastructure | Account, workspace, and configuration data | United States |
|
||||
| **Google** | Transactional and operational email delivery | Names, email addresses, message content of service emails | United States |
|
||||
| **Anthropic** | AI models (Claude) powering the Stirling Agent and AI-assisted features | Prompts and queries only — never customer files | United States |
|
||||
| **Voyage AI** | Embedding models for Ingestion/RAG features | Extracted text excerpts, only where the customer enables Ingestion/RAG, solely to generate embeddings — never customer files | United States |
|
||||
| **PostHog** | Product telemetry and usage analytics | Pseudonymous usage events and diagnostic data — never file content | European Union (EU-hosted) |
|
||||
|
||||
Neither AI provider uses customer data for model training (contractually confirmed).
|
||||
|
||||
Self-hosted and air-gapped deployments: customer files remain in the customer's environment; Stirling receives license-validation and metering data only (process counts, file sizes, file hashes — never file names or content).
|
||||
@@ -7696,10 +7696,22 @@ upload = "Upload"
|
||||
|
||||
[portal.procurement.agreement]
|
||||
agreeCta = "Agree & subscribe"
|
||||
confirm = "I have read and agree to the Stirling Enterprise Agreement."
|
||||
confirm = "I have read and agree to this Agreement, and I represent that I am authorized to sign it on behalf of the organization named above."
|
||||
draftBadge = "Draft — for review"
|
||||
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."
|
||||
legalName = "Legal entity name"
|
||||
legalNamePlaceholder = "The legal entity that will sign"
|
||||
loadError = "Could not load the agreement. Please try again."
|
||||
loading = "Loading the agreement…"
|
||||
scrollHint = "Scroll through the full agreement to enable signing."
|
||||
signatory = "Signatory name"
|
||||
signatoryPlaceholder = "Full name"
|
||||
signatoryTitle = "Signatory title"
|
||||
signatoryTitlePlaceholder = "e.g. General Counsel"
|
||||
signError = "Could not record your signature. Please try again."
|
||||
title = "Review your enterprise agreement"
|
||||
version = "{{label}} · review the full terms below, then sign."
|
||||
|
||||
[portal.procurement.builder]
|
||||
addons = "Add-ons"
|
||||
@@ -7719,7 +7731,6 @@ contactNamePlaceholder = "Jane Doe"
|
||||
continue = "Continue"
|
||||
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"
|
||||
pdfSize = "PDF size"
|
||||
|
||||
@@ -418,6 +418,51 @@ export function buildQuote(cfg: QuoteConfigInput): Promise<QuoteResult> {
|
||||
});
|
||||
}
|
||||
|
||||
/** The filled enterprise agreement (MSA + Order Form + DPA) for the current quote, as markdown. */
|
||||
export interface AgreementDocument {
|
||||
docId: string;
|
||||
version: string;
|
||||
/** e.g. "SEA v0.9.1" — the exact document version a signature will be pinned to. */
|
||||
versionLabel: string;
|
||||
displayName: string;
|
||||
effectiveDate: string;
|
||||
/** "draft" until counsel clears it — the UI badges drafts. */
|
||||
status: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
/** Buyer-supplied signing inputs captured on the agreement stage. */
|
||||
export interface SignAgreementInput {
|
||||
customerLegalName: string;
|
||||
signatoryName: string;
|
||||
signatoryTitle: string;
|
||||
authorityConfirmed: boolean;
|
||||
}
|
||||
|
||||
export interface SignAgreementResult {
|
||||
signatureId: number;
|
||||
versionLabel: string;
|
||||
/** Whether the signed PDF was rendered + stored (false when the render runtime was unavailable). */
|
||||
pdfStored: boolean;
|
||||
}
|
||||
|
||||
/** Fetch the filled agreement to review before signing. */
|
||||
export function fetchAgreementDocument(): Promise<AgreementDocument> {
|
||||
return apiClient.saas.json<AgreementDocument>(
|
||||
"/api/v1/procurement/agreement/document",
|
||||
);
|
||||
}
|
||||
|
||||
/** Record the signed agreement (pins version + hash + variable snapshot + signatory + PDF). */
|
||||
export function recordAgreementSignature(
|
||||
input: SignAgreementInput,
|
||||
): Promise<SignAgreementResult> {
|
||||
return apiClient.saas.json<SignAgreementResult>(
|
||||
"/api/v1/procurement/agreement/sign",
|
||||
{ method: "POST", body: input },
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import type { QuoteResult } from "@portal/api/procurement";
|
||||
import { money } from "@portal/components/procurement/format";
|
||||
import {
|
||||
fetchAgreementDocument,
|
||||
recordAgreementSignature,
|
||||
type QuoteResult,
|
||||
} from "@portal/api/procurement";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
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.
|
||||
* The agreement (security) step: the buyer reviews the full Stirling Enterprise Agreement — Master
|
||||
* Services Agreement + Order Form (from the quote) + Data Processing Addendum, one signature — then
|
||||
* signs it. The document body is served by the backend from the versioned legal registry (static
|
||||
* legal copy, English only); this component renders it, gates signing behind a scroll-through, and
|
||||
* captures the typed legal name, signatory, title, and authority. On sign it records the signature
|
||||
* (pinned to the exact document version + a hash) and then accepts the quote into a subscription.
|
||||
*/
|
||||
export function ProcurementAgreement({
|
||||
quote,
|
||||
@@ -23,120 +30,155 @@ export function ProcurementAgreement({
|
||||
quote: QuoteResult;
|
||||
busy: boolean;
|
||||
downloading: boolean;
|
||||
/** Accept the quote straight into a committed subscription (this is also the agreement). */
|
||||
/** Accept the quote straight into a committed subscription (runs after the signature is saved). */
|
||||
onAgree: () => void;
|
||||
onDownload: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const annual = money(quote.annualNetMinor, quote.currency);
|
||||
const tcv = money(quote.tcvMinor, quote.currency);
|
||||
const renewal = money(quote.renewalAnnualNetMinor, quote.currency);
|
||||
const years = quote.config.termYears;
|
||||
const { data: doc, loading } = useAsync(fetchAgreementDocument, []);
|
||||
|
||||
const [legalName, setLegalName] = useState(quote.config.businessName ?? "");
|
||||
const [signatory, setSignatory] = useState(quote.config.contactName ?? "");
|
||||
const [title, setTitle] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [scrolledToEnd, setScrolledToEnd] = useState(false);
|
||||
const [signing, setSigning] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const docRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = docRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24) {
|
||||
setScrolledToEnd(true);
|
||||
}
|
||||
};
|
||||
|
||||
const ready =
|
||||
scrolledToEnd &&
|
||||
confirmed &&
|
||||
legalName.trim().length > 0 &&
|
||||
signatory.trim().length > 0;
|
||||
|
||||
const sign = async () => {
|
||||
setError(false);
|
||||
setSigning(true);
|
||||
try {
|
||||
await recordAgreementSignature({
|
||||
customerLegalName: legalName.trim(),
|
||||
signatoryName: signatory.trim(),
|
||||
signatoryTitle: title.trim(),
|
||||
authorityConfirmed: confirmed,
|
||||
});
|
||||
onAgree(); // proceed into the committed subscription
|
||||
} catch {
|
||||
setError(true);
|
||||
setSigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.agreement.eyebrow")}
|
||||
{doc && doc.status !== "final" && (
|
||||
<span className="portal-agreement__draft">
|
||||
{t("portal.procurement.agreement.draftBadge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.agreement.title")}
|
||||
</h3>
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.agreement.intro")}
|
||||
{doc
|
||||
? t("portal.procurement.agreement.version", {
|
||||
label: doc.versionLabel,
|
||||
})
|
||||
: 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>
|
||||
<div
|
||||
className="portal-agreement__doc portal-agreement__scroll"
|
||||
ref={docRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{loading && <p>{t("portal.procurement.agreement.loading")}</p>}
|
||||
{!loading && !doc && (
|
||||
<p>{t("portal.procurement.agreement.loadError")}</p>
|
||||
)}
|
||||
{doc && (
|
||||
<div className="portal-agreement__md">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{doc.markdown}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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:
|
||||
{!scrolledToEnd && doc && (
|
||||
<p className="portal-qb__hint">
|
||||
{t("portal.procurement.agreement.scrollHint")}
|
||||
</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. Term, renewal and annual fee adjustment</h4>
|
||||
<p>
|
||||
This Agreement runs for the committed {years}-year term set out in the
|
||||
Order Form. It then renews automatically for successive one-year terms
|
||||
unless either party gives written notice of non-renewal at least 30
|
||||
days before the end of the then-current term. On each renewal the
|
||||
annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment.
|
||||
Based on this quote, the first renewal year would be approximately{" "}
|
||||
<strong>{renewal}</strong> per year; the committed term above is
|
||||
billed at the rate in the Order Form and is not affected.
|
||||
</p>
|
||||
|
||||
<h4>4. End-User License Agreement</h4>
|
||||
<p>
|
||||
Subject to the terms of this Agreement, Stirling grants Customer a
|
||||
non-exclusive, non-transferable right to use the Service for its
|
||||
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>5. Data Processing Agreement</h4>
|
||||
<p>
|
||||
Where Stirling processes personal data on Customer's behalf, it does
|
||||
so only on Customer's documented instructions and applies appropriate
|
||||
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>6. 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 className="portal-qb__row portal-agreement__signfields">
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.agreement.legalName")}
|
||||
</span>
|
||||
<input
|
||||
value={legalName}
|
||||
placeholder={t("portal.procurement.agreement.legalNamePlaceholder")}
|
||||
onChange={(e) => setLegalName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.agreement.signatory")}
|
||||
</span>
|
||||
<input
|
||||
value={signatory}
|
||||
placeholder={t("portal.procurement.agreement.signatoryPlaceholder")}
|
||||
onChange={(e) => setSignatory(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.agreement.signatoryTitle")}
|
||||
</span>
|
||||
<input
|
||||
value={title}
|
||||
placeholder={t(
|
||||
"portal.procurement.agreement.signatoryTitlePlaceholder",
|
||||
)}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="portal-qb__eula portal-agreement__accept">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
checked={confirmed}
|
||||
disabled={!scrolledToEnd}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
<span>{t("portal.procurement.agreement.confirm")}</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="portal-proc__error">
|
||||
{t("portal.procurement.agreement.signError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="portal-proc__payment-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
disabled={!checked}
|
||||
onClick={onAgree}
|
||||
loading={busy || signing}
|
||||
disabled={!ready}
|
||||
onClick={sign}
|
||||
>
|
||||
{t("portal.procurement.agreement.agreeCta")}
|
||||
</Button>
|
||||
|
||||
@@ -257,6 +257,48 @@ export const procurementSaasHandlers = [
|
||||
(deal as Record<string, unknown>).stage = "security";
|
||||
return HttpResponse.json(deal);
|
||||
}),
|
||||
http.get(`${SAAS}/api/v1/procurement/agreement/document`, () => {
|
||||
const q = (deal as { latestQuote: { quoteNumber?: string } | null })
|
||||
.latestQuote;
|
||||
if (!q) return new HttpResponse(null, { status: 404 });
|
||||
return HttpResponse.json({
|
||||
docId: "enterprise-agreement",
|
||||
version: "0.9.1",
|
||||
versionLabel: "SEA v0.9.1",
|
||||
displayName: "Stirling Enterprise Agreement",
|
||||
effectiveDate: "2026-07-10",
|
||||
status: "draft",
|
||||
markdown: [
|
||||
"# Stirling Enterprise Agreement",
|
||||
"## Part A — Master Services Agreement",
|
||||
"Provider will provide the Stirling PDF Processor and Editor as described in the Order Form.",
|
||||
`## Part B — Order Form · ${q.quoteNumber ?? "Q-MOCK"}`,
|
||||
"| Term | Value |",
|
||||
"| --- | --- |",
|
||||
"| Subscription | Enterprise · Stirling Cloud |",
|
||||
"| Escalator | +3% at each anniversary during the Term |",
|
||||
"## Part C — Data Processing Addendum",
|
||||
"Provider processes Personal Data only on Customer's documented instructions.",
|
||||
].join("\n\n"),
|
||||
});
|
||||
}),
|
||||
http.post(
|
||||
`${SAAS}/api/v1/procurement/agreement/sign`,
|
||||
async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<{
|
||||
signatoryName: string;
|
||||
authorityConfirmed: boolean;
|
||||
}>;
|
||||
if (!body.signatoryName || !body.authorityConfirmed) {
|
||||
return new HttpResponse(null, { status: 400 });
|
||||
}
|
||||
return HttpResponse.json({
|
||||
signatureId: 1,
|
||||
versionLabel: "SEA v0.9.1",
|
||||
pdfStored: true,
|
||||
});
|
||||
},
|
||||
),
|
||||
http.post(`${SAAS}/api/v1/procurement/go-live`, () => {
|
||||
const d = deal as Record<string, unknown>;
|
||||
if (d.dealId) {
|
||||
|
||||
@@ -1435,6 +1435,75 @@
|
||||
.portal-agreement__lines {
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
}
|
||||
.portal-agreement__md h1 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
margin: 1.1rem 0 0.5rem;
|
||||
}
|
||||
.portal-agreement__md h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-1);
|
||||
margin: 1rem 0 0.4rem;
|
||||
}
|
||||
.portal-agreement__md h3 {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-1);
|
||||
margin: 0.9rem 0 0.3rem;
|
||||
}
|
||||
.portal-agreement__md h1:first-child,
|
||||
.portal-agreement__md h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.portal-agreement__md p {
|
||||
margin: 0 0 0.55rem;
|
||||
}
|
||||
.portal-agreement__md strong {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.portal-agreement__md ul {
|
||||
margin: 0 0 0.6rem;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
.portal-agreement__md li {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.portal-agreement__md table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.4rem 0 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.portal-agreement__md th,
|
||||
.portal-agreement__md td {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.35rem 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.portal-agreement__md th {
|
||||
background: var(--color-bg);
|
||||
font-weight: 650;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.portal-agreement__draft {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--color-warning, #9c6b1e);
|
||||
}
|
||||
.portal-agreement__signfields {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.portal-proc__error {
|
||||
color: var(--color-danger, #b4522a);
|
||||
font-size: 0.8125rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
.portal-proc__reset {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user