From 53ce3f093b8fa39c527077c1b40b79abcaa9c8fd Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Tue, 14 Jul 2026 14:24:07 +0100 Subject: [PATCH] Procurement: download the signed agreement after signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Download signed agreement" button so the buyer can retrieve the final signed PDF (the sign step already renders + stores it; there was a backend endpoint but no UI to reach it). - Snapshot exposes agreementSignedVersion (the label of the latest signature that has a stored PDF) via a projection query that avoids loading the PDF bytes on the frequently-polled snapshot. - The button appears on the payment and live stages only when a downloadable PDF exists, and fetches it as an auth-gated blob (apiClient.saas.blob) → browser download, mirroring the quote-PDF pattern. - MSW mock: sign sets the version and serves a minimal PDF blob. Note: the stored PDF only exists when the render runtime (WeasyPrint) was available at signing; without it the signature still records but there's no PDF, so the button correctly won't show. Verified: saas compiles; portal typecheck, eslint, prettier, toml-sort, translation audits, 174 portal tests. --- .../api/ProcurementController.java | 5 ++- ...ocurementAgreementSignatureRepository.java | 13 ++++++ .../service/ProcurementService.java | 10 +++++ .../public/locales/en-US/translation.toml | 2 + frontend/editor/src/portal/api/procurement.ts | 7 ++++ .../procurement/DealStatusHero.stories.tsx | 1 + .../procurement/ProcurementFlow.tsx | 13 +++++- .../procurement/ProcurementStages.tsx | 41 +++++++++++++++++-- .../components/procurement/useProcurement.ts | 27 ++++++++++++ .../portal/mocks/handlers/procurementSaas.ts | 17 ++++++++ .../editor/src/portal/views/Home.stories.tsx | 1 + 11 files changed, 132 insertions(+), 5 deletions(-) diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 472835ac48..b645492cc0 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -194,6 +194,8 @@ public class ProcurementController { int trialExtensionsUsed, boolean licensed, String licenseKey, + // Version label of the signed agreement PDF available for download, else null. + String agreementSignedVersion, QuoteResponse latestQuote) {} /** The filled agreement for review: registry metadata + the rendered markdown body. */ @@ -236,7 +238,7 @@ public class ProcurementController { } private static final SnapshotResponse EMPTY_SNAPSHOT = - new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null); + new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null, null); /** * Download the offline / air-gapped licence file (.lic) for the team — available for an @@ -496,6 +498,7 @@ public class ProcurementController { deal.getTrialExtensionsUsed(), deal.getLicenseRef() != null, includeLicenseKey ? deal.getLicenseRef() : null, + procurement.downloadableAgreementLabel(deal.getDealId()).orElse(null), latest); } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java index a26761b62a..ca6035d7a0 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java @@ -1,8 +1,11 @@ package stirling.software.saas.procurement.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import stirling.software.saas.procurement.model.ProcurementAgreementSignature; @@ -12,4 +15,14 @@ public interface ProcurementAgreementSignatureRepository Optional findFirstByDealIdOrderBySignedAtDesc(Long dealId); Optional findFirstByQuoteIdOrderBySignedAtDesc(Long quoteId); + + /** + * Version labels of a deal's signatures that have a stored PDF, newest first. Projects just the + * label column so the frequently-polled snapshot never loads the PDF bytes. + */ + @Query( + "SELECT s.documentLabel FROM ProcurementAgreementSignature s" + + " WHERE s.dealId = :dealId AND s.pdf IS NOT NULL" + + " ORDER BY s.signedAt DESC") + List findDownloadableLabels(@Param("dealId") Long dealId); } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index 258a7675fc..ceb27a22b9 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -337,6 +337,16 @@ public class ProcurementService { deal.getDealId())); } + /** + * The version label of the deal's latest signed agreement that has a downloadable PDF, if any. + * Used to surface the "download signed agreement" action only when a file actually exists (the + * snapshot polls this, so it deliberately avoids loading the PDF bytes). + */ + @Transactional(readOnly = true) + public Optional downloadableAgreementLabel(Long dealId) { + return signatureRepo.findDownloadableLabels(dealId).stream().findFirst(); + } + private static String sha256(String s) { try { byte[] digest = diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d3a54844e1..388419059a 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7703,6 +7703,7 @@ upload = "Upload" [portal.procurement.agreement] agreeCta = "Agree & subscribe" 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." +downloadError = "Could not download the signed agreement. Please try again." 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." @@ -7929,6 +7930,7 @@ uploadTitle = "Upload your purchase order" [portal.procurement.payment] description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed." +downloadAgreement = "Download signed agreement" downloadInvoice = "Download invoice" title = "Subscription created" viewInvoice = "View & pay invoice" diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 0739da96c5..3410ec9f27 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -332,6 +332,8 @@ export interface ProcurementSnapshot { licensed: boolean; /** The team's Keygen licence key (present once licensed); shown in the portal to copy/install. */ licenseKey: string | null; + /** Version label of the signed agreement PDF available to download (e.g. "SEA v0.9.1"), else null. */ + agreementSignedVersion: string | null; latestQuote: QuoteResult | null; } @@ -468,6 +470,11 @@ export function fetchLegalDocument(docId: string): Promise { return apiClient.saas.json(`/api/v1/legal/${docId}`); } +/** Download the stored, signed enterprise-agreement PDF for the team (post-signing). */ +export function fetchSignedAgreementPdf(): Promise { + return apiClient.saas.blob("/api/v1/procurement/agreement/signature/pdf"); +} + /** * Record a clickwrap consent to a legal document (e.g. the EULA at trial start / quote generation). * Best-effort — never block the flow it accompanies on a consent-logging failure. diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index 9421de28bb..4febec15b6 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -12,6 +12,7 @@ const base: ProcurementSnapshot = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + agreementSignedVersion: null, latestQuote: null, }; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx index d1527c32bb..799bc0b92a 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -62,6 +62,8 @@ export function ProcurementFlow({ onAgree, onDownloadPdf, onDownloadOfflineLicense, + downloadingAgreement, + onDownloadSignedAgreement, } = controller; return ( @@ -139,10 +141,19 @@ export function ProcurementFlow({ )} - {!editing && stage === "active" && } + {!editing && stage === "active" && ( + + )} )} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index 35460a7ba5..7b7e53bf91 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -9,13 +9,20 @@ import "@portal/views/Procurement.css"; * a pure presentational view driven by props; ProcurementHome owns the state and the actions. */ -/** The subscription-created step: pay or download the first invoice. */ +/** The subscription-created step: pay or download the first invoice, and the signed agreement. */ export function PaymentStageCard({ invoiceUrl, invoicePdf, + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, }: { invoiceUrl?: string | null; invoicePdf?: string | null; + /** Version label of the signed agreement PDF, if one is available to download. */ + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; }) { const { t } = useTranslation(); return ( @@ -26,7 +33,7 @@ export function PaymentStageCard({

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

- {(invoiceUrl || invoicePdf) && ( + {(invoiceUrl || invoicePdf || signedAgreementVersion) && (
{invoiceUrl && ( + )}
)} @@ -52,7 +68,15 @@ export function PaymentStageCard({ } /** The live confirmation once the deal is active. */ -export function LiveStageCard() { +export function LiveStageCard({ + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, +}: { + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; +} = {}) { const { t } = useTranslation(); return ( @@ -65,6 +89,17 @@ export function LiveStageCard() {

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

+ {signedAgreementVersion && onDownloadSignedAgreement && ( +
+ +
+ )}
); } diff --git a/frontend/editor/src/portal/components/procurement/useProcurement.ts b/frontend/editor/src/portal/components/procurement/useProcurement.ts index 94a484458a..089fd88160 100644 --- a/frontend/editor/src/portal/components/procurement/useProcurement.ts +++ b/frontend/editor/src/portal/components/procurement/useProcurement.ts @@ -7,6 +7,7 @@ import { extendTrial, fetchLicenseFile, fetchQuotePdf, + fetchSignedAgreementPdf, fetchSnapshot, issueQuote, resetProcurement, @@ -40,6 +41,7 @@ export interface ProcurementController { busy: boolean; downloading: boolean; downloadingLicense: boolean; + downloadingAgreement: boolean; error: string | null; setError: (e: string | null) => void; open: boolean; @@ -59,6 +61,8 @@ export interface ProcurementController { onAgree: () => void; onDownloadPdf: () => Promise; onDownloadOfflineLicense: () => Promise; + /** Download the stored signed enterprise agreement (available once signed). */ + onDownloadSignedAgreement: () => Promise; } export function useProcurement(autoOpen = false): ProcurementController { @@ -75,6 +79,7 @@ export function useProcurement(autoOpen = false): ProcurementController { const [editing, setEditing] = useState(false); const [downloading, setDownloading] = useState(false); const [downloadingLicense, setDownloadingLicense] = useState(false); + const [downloadingAgreement, setDownloadingAgreement] = useState(false); const [invoicePdf, setInvoicePdf] = useState(null); const [error, setError] = useState(null); const [extra, setExtra] = useState(null); @@ -176,6 +181,26 @@ export function useProcurement(autoOpen = false): ProcurementController { } } + async function onDownloadSignedAgreement() { + setDownloadingAgreement(true); + try { + const blob = await fetchSignedAgreementPdf(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "stirling-enterprise-agreement.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch (e) { + console.error("[procurement] signed agreement download failed", e); + setError(t("portal.procurement.agreement.downloadError")); + } finally { + setDownloadingAgreement(false); + } + } + // A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal // yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows. useEffect(() => { @@ -194,6 +219,7 @@ export function useProcurement(autoOpen = false): ProcurementController { busy, downloading, downloadingLicense, + downloadingAgreement, error, setError, open, @@ -211,5 +237,6 @@ export function useProcurement(autoOpen = false): ProcurementController { onAgree, onDownloadPdf, onDownloadOfflineLicense, + onDownloadSignedAgreement, }; } diff --git a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts index f614c23353..7a9dafa304 100644 --- a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts +++ b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts @@ -17,6 +17,7 @@ const EMPTY = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + agreementSignedVersion: null, latestQuote: null, }; @@ -292,6 +293,8 @@ export const procurementSaasHandlers = [ if (!body.signatoryName || !body.authorityConfirmed) { return new HttpResponse(null, { status: 400 }); } + // Surface the signed agreement for download on the payment/live stage. + (deal as Record).agreementSignedVersion = "SEA v0.9.1"; return HttpResponse.json({ signatureId: 1, versionLabel: "SEA v0.9.1", @@ -299,6 +302,20 @@ export const procurementSaasHandlers = [ }); }, ), + http.get(`${SAAS}/api/v1/procurement/agreement/signature/pdf`, () => { + const signed = (deal as { agreementSignedVersion?: string }) + .agreementSignedVersion; + if (!signed) return new HttpResponse(null, { status: 404 }); + // A minimal one-page PDF so the browser download works in mock mode. + const pdf = + "%PDF-1.4\n1 0 obj<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "trailer<>\n%%EOF"; + return new HttpResponse(pdf, { + headers: { "Content-Type": "application/pdf" }, + }); + }), http.post(`${SAAS}/api/v1/procurement/go-live`, () => { const d = deal as Record; if (d.dealId) { diff --git a/frontend/editor/src/portal/views/Home.stories.tsx b/frontend/editor/src/portal/views/Home.stories.tsx index 9821157fdf..7c2c81c643 100644 --- a/frontend/editor/src/portal/views/Home.stories.tsx +++ b/frontend/editor/src/portal/views/Home.stories.tsx @@ -34,6 +34,7 @@ export const SubscribedInProcurement: Story = { trialEndsAt: "2026-07-21T00:00:00.000Z", trialExtensionsUsed: 0, licensed: true, + agreementSignedVersion: null, latestQuote: null, }), ),