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:
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user