Procurement: download the signed agreement after signing

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.
This commit is contained in:
Connor Yoh
2026-07-14 14:24:07 +01:00
parent c392326fe1
commit 53ce3f093b
11 changed files with 132 additions and 5 deletions
@@ -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);
}
@@ -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<ProcurementAgreementSignature> findFirstByDealIdOrderBySignedAtDesc(Long dealId);
Optional<ProcurementAgreementSignature> 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<String> findDownloadableLabels(@Param("dealId") Long dealId);
}
@@ -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<String> downloadableAgreementLabel(Long dealId) {
return signatureRepo.findDownloadableLabels(dealId).stream().findFirst();
}
private static String sha256(String s) {
try {
byte[] digest =
@@ -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"
@@ -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<AgreementDocument> {
return apiClient.saas.json<AgreementDocument>(`/api/v1/legal/${docId}`);
}
/** Download the stored, signed enterprise-agreement PDF for the team (post-signing). */
export function fetchSignedAgreementPdf(): Promise<Blob> {
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.
@@ -12,6 +12,7 @@ const base: ProcurementSnapshot = {
trialExtensionsUsed: 0,
licensed: false,
licenseKey: null,
agreementSignedVersion: null,
latestQuote: null,
};
@@ -62,6 +62,8 @@ export function ProcurementFlow({
onAgree,
onDownloadPdf,
onDownloadOfflineLicense,
downloadingAgreement,
onDownloadSignedAgreement,
} = controller;
return (
@@ -139,10 +141,19 @@ export function ProcurementFlow({
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={latest.invoicePdf ?? invoicePdf}
signedAgreementVersion={data?.agreementSignedVersion}
downloadingAgreement={downloadingAgreement}
onDownloadSignedAgreement={onDownloadSignedAgreement}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
{!editing && stage === "active" && (
<LiveStageCard
signedAgreementVersion={data?.agreementSignedVersion}
downloadingAgreement={downloadingAgreement}
onDownloadSignedAgreement={onDownloadSignedAgreement}
/>
)}
</>
)}
</ProcurementModal>
@@ -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({
<p className="portal-proc__subtitle">
{t("portal.procurement.payment.description")}
</p>
{(invoiceUrl || invoicePdf) && (
{(invoiceUrl || invoicePdf || signedAgreementVersion) && (
<div className="portal-proc__payment-actions">
{invoiceUrl && (
<Button
@@ -45,6 +52,15 @@ export function PaymentStageCard({
{t("portal.procurement.payment.downloadInvoice")}
</Button>
)}
{signedAgreementVersion && onDownloadSignedAgreement && (
<Button
variant="secondary"
loading={downloadingAgreement}
onClick={onDownloadSignedAgreement}
>
{t("portal.procurement.payment.downloadAgreement")}
</Button>
)}
</div>
)}
</Card>
@@ -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 (
<Card padding="loose">
@@ -65,6 +89,17 @@ export function LiveStageCard() {
<p className="portal-proc__subtitle">
{t("portal.procurement.live.description")}
</p>
{signedAgreementVersion && onDownloadSignedAgreement && (
<div className="portal-proc__payment-actions">
<Button
variant="secondary"
loading={downloadingAgreement}
onClick={onDownloadSignedAgreement}
>
{t("portal.procurement.payment.downloadAgreement")}
</Button>
</div>
)}
</Card>
);
}
@@ -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<void>;
onDownloadOfflineLicense: () => Promise<void>;
/** Download the stored signed enterprise agreement (available once signed). */
onDownloadSignedAgreement: () => Promise<void>;
}
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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [extra, setExtra] = useState<ProcurementExtra>(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,
};
}
@@ -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<string, unknown>).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<</Type/Catalog/Pages 2 0 R>>endobj\n" +
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 120]>>endobj\n" +
"trailer<</Root 1 0 R>>\n%%EOF";
return new HttpResponse(pdf, {
headers: { "Content-Type": "application/pdf" },
});
}),
http.post(`${SAAS}/api/v1/procurement/go-live`, () => {
const d = deal as Record<string, unknown>;
if (d.dealId) {
@@ -34,6 +34,7 @@ export const SubscribedInProcurement: Story = {
trialEndsAt: "2026-07-21T00:00:00.000Z",
trialExtensionsUsed: 0,
licensed: true,
agreementSignedVersion: null,
latestQuote: null,
}),
),