feat(contracts): Add legal export package and ESIGN compliance improvements
- Add export_legal endpoint for signed contracts that generates a ZIP with: - Signed contract PDF - Audit certificate PDF with signature details and hash verification - Machine-readable signature_record.json - Integrity verification report - README documentation - Add audit certificate template with: - Contract and signature information - Consent records with exact legal text - Document integrity verification (SHA-256 hash comparison) - ESIGN Act and UETA compliance statement - Update ContractSigning page for ESIGN/UETA compliance: - Consent checkbox text now matches backend-stored legal text - Added proper legal notice with ESIGN Act references - Add signed_at field to ContractListSerializer - Add view/print buttons for signed contracts in Contracts page - Allow viewing signed contracts via public signing URL 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -205,7 +205,7 @@ export const useContracts = (filters?: {
|
||||
template_version: c.template_version,
|
||||
scope: c.scope as ContractScope,
|
||||
status: c.status,
|
||||
content: c.content,
|
||||
content: c.content_html || c.content, // Backend returns content_html
|
||||
customer: c.customer ? String(c.customer) : undefined,
|
||||
customer_name: c.customer_name || undefined,
|
||||
customer_email: c.customer_email || undefined,
|
||||
@@ -219,7 +219,7 @@ export const useContracts = (filters?: {
|
||||
expires_at: c.expires_at,
|
||||
voided_at: c.voided_at,
|
||||
voided_reason: c.voided_reason,
|
||||
public_token: c.public_token,
|
||||
public_token: c.signing_token || c.public_token, // Backend returns signing_token
|
||||
created_at: c.created_at,
|
||||
updated_at: c.updated_at,
|
||||
}));
|
||||
@@ -258,7 +258,7 @@ export const useContract = (id: string) => {
|
||||
expires_at: data.expires_at,
|
||||
voided_at: data.voided_at,
|
||||
voided_reason: data.voided_reason,
|
||||
public_token: data.public_token,
|
||||
public_token: data.signing_token || data.public_token, // Backend returns signing_token
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
};
|
||||
@@ -270,9 +270,9 @@ export const useContract = (id: string) => {
|
||||
|
||||
interface ContractInput {
|
||||
template: string;
|
||||
customer?: string;
|
||||
appointment?: string;
|
||||
service?: string;
|
||||
customer_id?: string;
|
||||
event_id?: string;
|
||||
send_email?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,8 +352,8 @@ export const usePublicContract = (token: string) => {
|
||||
return useQuery<ContractPublicView>({
|
||||
queryKey: ['public-contracts', token],
|
||||
queryFn: async () => {
|
||||
// Use a plain axios instance without auth
|
||||
const { data } = await apiClient.get(`/contracts/public/${token}/`);
|
||||
// Use the public signing endpoint
|
||||
const { data } = await apiClient.get(`/contracts/sign/${token}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!token,
|
||||
@@ -368,21 +368,57 @@ export const useSignContract = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
token,
|
||||
signature_data,
|
||||
signer_name,
|
||||
signer_email,
|
||||
consent_checkbox_checked,
|
||||
electronic_consent_given,
|
||||
}: {
|
||||
token: string;
|
||||
signature_data: string;
|
||||
signer_name: string;
|
||||
signer_email: string;
|
||||
consent_checkbox_checked: boolean;
|
||||
electronic_consent_given: boolean;
|
||||
}) => {
|
||||
const { data } = await apiClient.post(`/contracts/public/${token}/sign/`, {
|
||||
signature_data,
|
||||
const { data } = await apiClient.post(`/contracts/sign/${token}/`, {
|
||||
signer_name,
|
||||
signer_email,
|
||||
consent_checkbox_checked,
|
||||
electronic_consent_given,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to export a legal compliance package for a signed contract
|
||||
*/
|
||||
export const useExportLegalPackage = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const response = await apiClient.get(`/contracts/${id}/export_legal/`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Extract filename from Content-Disposition header if available
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `legal_export_${id}.zip`;
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -70,7 +70,28 @@
|
||||
"rememberMe": "Remember me",
|
||||
"twoFactorRequired": "Two-factor authentication required",
|
||||
"enterCode": "Enter verification code",
|
||||
"verifyCode": "Verify Code"
|
||||
"verifyCode": "Verify Code",
|
||||
"login": {
|
||||
"title": "Sign in to your account",
|
||||
"subtitle": "Don't have an account?",
|
||||
"createAccount": "Create one now",
|
||||
"platformBadge": "Platform Login",
|
||||
"heroTitle": "Manage Your Business with Confidence",
|
||||
"heroSubtitle": "Access your dashboard to manage appointments, customers, and grow your business.",
|
||||
"features": {
|
||||
"scheduling": "Smart scheduling & resource management",
|
||||
"automation": "Automated reminders & follow-ups",
|
||||
"security": "Enterprise-grade security"
|
||||
},
|
||||
"privacy": "Privacy",
|
||||
"terms": "Terms"
|
||||
},
|
||||
"tenantLogin": {
|
||||
"welcome": "Welcome to {{business}}",
|
||||
"subtitle": "Sign in to manage your appointments",
|
||||
"staffAccess": "Staff Access",
|
||||
"customerBooking": "Customer Booking"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -679,6 +700,7 @@
|
||||
"edit": "Edit",
|
||||
"viewDetails": "View Details",
|
||||
"copyLink": "Copy Signing Link",
|
||||
"copied": "Copied!",
|
||||
"sendEmail": "Send Email",
|
||||
"openSigningPage": "Open Signing Page",
|
||||
"saveChanges": "Save Changes"
|
||||
@@ -723,7 +745,11 @@
|
||||
"status": "Status",
|
||||
"created": "Created",
|
||||
"contentPreview": "Content Preview",
|
||||
"signingLink": "Signing Link"
|
||||
"signingLink": "Signing Link",
|
||||
"signedTitle": "Contract Successfully Signed",
|
||||
"viewFullContract": "View Full Contract",
|
||||
"printContract": "Print Contract",
|
||||
"exportLegal": "Export Legal Package"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview Contract",
|
||||
@@ -861,7 +887,14 @@
|
||||
"noCustomersFound": "No customers found matching your search.",
|
||||
"addNewCustomer": "Add New Customer",
|
||||
"createCustomer": "Create Customer",
|
||||
"errorLoading": "Error loading customers"
|
||||
"errorLoading": "Error loading customers",
|
||||
"deleteCustomer": "Delete Customer",
|
||||
"deleteConfirmation": "Are you sure you want to delete this customer? This action cannot be undone.",
|
||||
"password": "Password",
|
||||
"newPassword": "New Password",
|
||||
"passwordPlaceholder": "Leave blank to keep current password",
|
||||
"accountInfo": "Account Information",
|
||||
"contactDetails": "Contact Details"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Resources",
|
||||
|
||||
@@ -1,36 +1,43 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { FileSignature, CheckCircle, XCircle, Loader } from 'lucide-react';
|
||||
import { FileSignature, CheckCircle, XCircle, Loader, Printer, Download } from 'lucide-react';
|
||||
import { usePublicContract, useSignContract } from '../hooks/useContracts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const ContractSigning: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [signerName, setSignerName] = useState('');
|
||||
const [signerEmail, setSignerEmail] = useState('');
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState('');
|
||||
const [electronicConsent, setElectronicConsent] = useState(false);
|
||||
|
||||
const { data: contractData, isLoading, error } = usePublicContract(token || '');
|
||||
const { data: contractData, isLoading, error, refetch } = usePublicContract(token || '');
|
||||
const signMutation = useSignContract();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token || !signatureData || !agreedToTerms) return;
|
||||
if (!token || !signerName.trim() || !agreedToTerms || !electronicConsent) return;
|
||||
|
||||
try {
|
||||
await signMutation.mutateAsync({
|
||||
token,
|
||||
signature_data: signatureData,
|
||||
signer_name: signerName,
|
||||
signer_email: signerEmail,
|
||||
signer_name: signerName.trim(),
|
||||
consent_checkbox_checked: agreedToTerms,
|
||||
electronic_consent_given: electronicConsent,
|
||||
});
|
||||
// Refetch to get updated contract with signature data
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error('Failed to sign contract:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
@@ -42,7 +49,8 @@ const ContractSigning: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !contractData) {
|
||||
if (error || !contractData || !contractData.contract) {
|
||||
console.error('Contract loading error:', { error, contractData });
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
@@ -58,27 +66,6 @@ const ContractSigning: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (contractData.contract.status === 'SIGNED') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.alreadySigned')}
|
||||
</h2>
|
||||
{contractData.signature && (
|
||||
<p className="text-gray-600">
|
||||
{t('contracts.signing.signedBy', {
|
||||
name: contractData.signature.signer_name,
|
||||
date: new Date(contractData.signature.signed_at).toLocaleDateString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (contractData.is_expired) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
@@ -95,22 +82,155 @@ const ContractSigning: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (signMutation.isSuccess) {
|
||||
// Show signed contract view
|
||||
if (contractData.contract?.status === 'SIGNED' || signMutation.isSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.success')}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('contracts.signing.thankYou')}
|
||||
</p>
|
||||
<div className="min-h-screen bg-gray-50 py-8 print:py-0 print:bg-white">
|
||||
<div className="max-w-4xl mx-auto px-4 print:px-0 print:max-w-none">
|
||||
{/* Print Header - Only visible when printing */}
|
||||
<div className="hidden print:block mb-8 border-b-2 border-gray-300 pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{contractData.business.name}</h1>
|
||||
<p className="text-gray-600">{contractData.template.name}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-600">
|
||||
<p>Contract ID: {contractData.contract.id}</p>
|
||||
<p>Status: SIGNED</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Banner - Hidden when printing */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6 print:hidden">
|
||||
<div className="flex items-center gap-4">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 flex-shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-green-800">
|
||||
Contract Successfully Signed
|
||||
</h2>
|
||||
<p className="text-green-700">
|
||||
This contract has been signed and is now legally binding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Hidden when printing */}
|
||||
<div className="flex gap-4 mb-6 print:hidden">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
Print Contract
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Contract Header */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6 print:shadow-none print:p-0 print:mb-4">
|
||||
<div className="flex items-center gap-3 mb-4 print:hidden">
|
||||
{contractData.business.logo_url ? (
|
||||
<img
|
||||
src={contractData.business.logo_url}
|
||||
alt={contractData.business.name}
|
||||
className="h-12 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<FileSignature className="w-12 h-12 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{contractData.business.name}
|
||||
</h1>
|
||||
<p className="text-gray-600">{contractData.template.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{contractData.customer && (
|
||||
<p className="text-gray-600">
|
||||
Contract for: <strong>{contractData.customer.name}</strong>
|
||||
{contractData.customer.email && ` (${contractData.customer.email})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contract Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6 print:shadow-none print:p-0 print:mb-4">
|
||||
<div
|
||||
className="prose max-w-none print:prose-sm"
|
||||
dangerouslySetInnerHTML={{ __html: contractData.contract.content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Signature Details */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 print:shadow-none print:p-0 print:border-t-2 print:border-gray-300 print:pt-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 print:text-base">
|
||||
Electronic Signature Record
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 print:gap-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Signer Name</p>
|
||||
<p className="text-lg font-medium text-gray-900">
|
||||
{contractData.signature?.signer_name || signerName || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Signer Email</p>
|
||||
<p className="text-gray-900">
|
||||
{contractData.signature?.signer_email || contractData.customer?.email || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Date Signed</p>
|
||||
<p className="text-gray-900">
|
||||
{contractData.signature?.signed_at
|
||||
? new Date(contractData.signature.signed_at).toLocaleString()
|
||||
: new Date().toLocaleString()
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Electronic Signature</p>
|
||||
<div className="mt-2 p-4 bg-gray-50 border border-gray-200 rounded-lg print:bg-white print:border-gray-400">
|
||||
<p className="text-2xl italic text-gray-800" style={{ fontFamily: 'cursive' }}>
|
||||
{contractData.signature?.signer_name || signerName || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Contract Status</p>
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium print:bg-transparent print:text-green-700 print:px-0">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Signed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal Notice */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 print:mt-4 print:pt-4">
|
||||
<p className="text-xs text-gray-500 print:text-gray-600">
|
||||
This document was signed electronically in compliance with the Electronic Signatures in Global and National Commerce Act (ESIGN Act, 15 U.S.C. section 7001 et seq.) and the Uniform Electronic Transactions Act (UETA). The signer expressly agreed to the terms and conditions, consented to conduct business electronically, and acknowledged that their electronic signature is the legal equivalent of a handwritten signature. A complete audit trail including timestamp, IP address, and document hash is maintained.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print Footer - Only visible when printing */}
|
||||
<div className="hidden print:block mt-8 pt-4 border-t border-gray-300 text-xs text-gray-500">
|
||||
<p>Printed on: {new Date().toLocaleString()}</p>
|
||||
<p>This is a copy of an electronically signed document.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show signing form
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
@@ -143,7 +263,7 @@ const ContractSigning: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Contract Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6 overflow-auto">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: contractData.contract.content }}
|
||||
@@ -161,88 +281,85 @@ const ContractSigning: React.FC = () => {
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* Electronic Signature - Type Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signerName')}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Type your full legal name to sign
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signerEmail')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={signerEmail}
|
||||
onChange={(e) => setSignerEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signatureLabel')}
|
||||
</label>
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||
<textarea
|
||||
placeholder={t('contracts.signing.signaturePlaceholder')}
|
||||
value={signatureData}
|
||||
onChange={(e) => setSignatureData(e.target.value)}
|
||||
className="w-full h-32 p-2 border border-gray-200 rounded resize-none"
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSignatureData('')}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{t('contracts.signing.clearSignature')}
|
||||
</button>
|
||||
</div>
|
||||
{signerName && (
|
||||
<div className="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<p className="text-xs text-gray-500 mb-2">Signature Preview:</p>
|
||||
<p className="text-2xl italic text-gray-800" style={{ fontFamily: 'cursive' }}>
|
||||
{signerName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agree"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="agree" className="text-sm text-gray-700">
|
||||
{t('contracts.signing.agreeToTerms')}
|
||||
</label>
|
||||
{/* Consent Checkboxes - Text must match backend storage for legal compliance */}
|
||||
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agree"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
className="w-5 h-5 mt-0.5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="agree" className="text-sm text-gray-700">
|
||||
I have read and agree to the terms and conditions outlined in this document. By checking this box, I understand that this constitutes a legal electronic signature under the ESIGN Act (15 U.S.C. section 7001 et seq.) and UETA.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="electronic-consent"
|
||||
checked={electronicConsent}
|
||||
onChange={(e) => setElectronicConsent(e.target.checked)}
|
||||
className="w-5 h-5 mt-0.5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="electronic-consent" className="text-sm text-gray-700">
|
||||
<span className="font-medium">I consent to conduct business electronically.</span> I understand that: (1) I am agreeing to use electronic records and signatures in place of paper; (2) I have the right to receive documents in paper form upon request; (3) I can withdraw this consent at any time; (4) I need internet access to access these documents; (5) I can request a paper copy at any time.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={signMutation.isPending || !agreedToTerms}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
disabled={signMutation.isPending || !agreedToTerms || !electronicConsent || !signerName.trim()}
|
||||
className="w-full px-6 py-4 bg-blue-600 text-white text-lg font-semibold rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{signMutation.isPending ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
{t('contracts.signing.submitting')}
|
||||
Signing...
|
||||
</>
|
||||
) : (
|
||||
t('contracts.signing.submitSignature')
|
||||
<>
|
||||
<FileSignature className="w-5 h-5" />
|
||||
Sign Contract
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{signMutation.isError && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">
|
||||
{t('contracts.signing.error')}
|
||||
Failed to sign the contract. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FileSignature, Plus, Search, Send, Eye, X, Loader2,
|
||||
Clock, CheckCircle, XCircle, AlertCircle, Copy, RefreshCw, Ban,
|
||||
ExternalLink, ChevronDown, ChevronRight, User, Pencil, Trash2,
|
||||
ExternalLink, ChevronDown, ChevronRight, User, Pencil, Trash2, Check, Printer,
|
||||
Download, FileArchive,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useContracts,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
useCreateContractTemplate,
|
||||
useUpdateContractTemplate,
|
||||
useDeleteContractTemplate,
|
||||
useExportLegalPackage,
|
||||
} from '../hooks/useContracts';
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
import apiClient from '../api/client';
|
||||
@@ -81,6 +83,7 @@ const Contracts: React.FC = () => {
|
||||
const sendContract = useSendContract();
|
||||
const voidContract = useVoidContract();
|
||||
const resendContract = useResendContract();
|
||||
const exportLegalPackage = useExportLegalPackage();
|
||||
|
||||
// Template mutations
|
||||
const createTemplate = useCreateContractTemplate();
|
||||
@@ -158,7 +161,9 @@ const Contracts: React.FC = () => {
|
||||
// Handlers
|
||||
const handleSelectCustomer = (customer: Customer) => {
|
||||
setSelectedCustomerId(customer.userId || customer.id);
|
||||
setSelectedCustomerName(`${customer.name} (${customer.email})`);
|
||||
const displayName = customer.name || customer.email || `Customer #${customer.id}`;
|
||||
const displayEmail = customer.email || 'No email';
|
||||
setSelectedCustomerName(`${displayName} (${displayEmail})`);
|
||||
setCustomerSearchTerm('');
|
||||
setIsCustomerDropdownOpen(false);
|
||||
};
|
||||
@@ -174,15 +179,12 @@ const Contracts: React.FC = () => {
|
||||
if (!selectedTemplateId || !selectedCustomerId) return;
|
||||
|
||||
try {
|
||||
const result = await createContract.mutateAsync({
|
||||
await createContract.mutateAsync({
|
||||
template: selectedTemplateId,
|
||||
customer: selectedCustomerId,
|
||||
customer_id: selectedCustomerId,
|
||||
send_email: sendEmailOnCreate,
|
||||
});
|
||||
|
||||
if (sendEmailOnCreate && result.id) {
|
||||
await sendContract.mutateAsync(String(result.id));
|
||||
}
|
||||
|
||||
setIsCreateContractModalOpen(false);
|
||||
setSelectedTemplateId('');
|
||||
setSelectedCustomerId('');
|
||||
@@ -225,11 +227,41 @@ const Contracts: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const copySigningLink = (contract: Contract) => {
|
||||
if (contract.public_token) {
|
||||
const link = `${window.location.origin}/sign/${contract.public_token}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
const [copiedContractId, setCopiedContractId] = useState<string | null>(null);
|
||||
|
||||
const copySigningLink = async (contract: Contract) => {
|
||||
if (!contract.public_token) {
|
||||
console.warn('No public_token found on contract:', contract);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = `${window.location.origin}/sign/${contract.public_token}`;
|
||||
|
||||
// Helper to copy using fallback method
|
||||
const fallbackCopy = () => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = link;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
// Use clipboard API if available (requires HTTPS), otherwise fallback
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} catch {
|
||||
fallbackCopy();
|
||||
}
|
||||
} else {
|
||||
fallbackCopy();
|
||||
}
|
||||
|
||||
setCopiedContractId(contract.id);
|
||||
setTimeout(() => setCopiedContractId(null), 2000);
|
||||
};
|
||||
|
||||
// Template handlers
|
||||
@@ -364,11 +396,11 @@ const Contracts: React.FC = () => {
|
||||
|
||||
{/* Templates Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<button
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full flex items-center justify-between p-4">
|
||||
<button
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="flex items-center gap-3 text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{templatesExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('contracts.templates')}
|
||||
@@ -376,18 +408,15 @@ const Contracts: React.FC = () => {
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
|
||||
{allTemplates?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openCreateTemplateModal();
|
||||
}}
|
||||
onClick={() => openCreateTemplateModal()}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('contracts.newTemplate')}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{templatesExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
@@ -497,11 +526,11 @@ const Contracts: React.FC = () => {
|
||||
|
||||
{/* Contracts Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<button
|
||||
onClick={() => setContractsExpanded(!contractsExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full flex items-center justify-between p-4">
|
||||
<button
|
||||
onClick={() => setContractsExpanded(!contractsExpanded)}
|
||||
className="flex items-center gap-3 text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{contractsExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('contracts.sentContracts')}
|
||||
@@ -509,19 +538,16 @@ const Contracts: React.FC = () => {
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
|
||||
{contracts?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCreateContractModalOpen(true);
|
||||
}}
|
||||
onClick={() => setIsCreateContractModalOpen(true)}
|
||||
disabled={!activeTemplates || activeTemplates.length === 0}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('contracts.actions.send')}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contractsExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
@@ -600,8 +626,8 @@ const Contracts: React.FC = () => {
|
||||
</button>
|
||||
{contract.status === 'PENDING' && (
|
||||
<>
|
||||
<button onClick={() => copySigningLink(contract)} className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title={t('contracts.actions.copyLink')}>
|
||||
<Copy size={16} />
|
||||
<button onClick={() => copySigningLink(contract)} className={`p-1.5 rounded transition-colors ${copiedContractId === contract.id ? 'text-green-600 bg-green-100 dark:bg-green-900/30' : 'text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700'}`} title={copiedContractId === contract.id ? t('contracts.actions.copied') : t('contracts.actions.copyLink')}>
|
||||
{copiedContractId === contract.id ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
{!contract.sent_at ? (
|
||||
<button onClick={() => handleSendContract(contract.id)} disabled={sendContract.isPending} className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title={t('contracts.actions.sendEmail')}>
|
||||
@@ -733,6 +759,21 @@ const Contracts: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Signed Contract Banner */}
|
||||
{viewingContract.status === 'SIGNED' && (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-8 h-8 text-green-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-green-800 dark:text-green-300">{t('contracts.contractDetails.signedTitle')}</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
{t('contracts.signedAt')}: {formatDate(viewingContract.signed_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('contracts.contractDetails.customer')}</p>
|
||||
@@ -758,13 +799,57 @@ const Contracts: React.FC = () => {
|
||||
<div className="prose dark:prose-invert max-w-none p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900 max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: viewingContract.content }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons for Signed Contracts */}
|
||||
{viewingContract.status === 'SIGNED' && viewingContract.public_token && (
|
||||
<div className="flex flex-wrap gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a
|
||||
href={`/sign/${viewingContract.public_token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Eye size={18} />
|
||||
{t('contracts.contractDetails.viewFullContract')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `/sign/${viewingContract.public_token}`;
|
||||
const printWindow = window.open(url, '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.onload = () => {
|
||||
setTimeout(() => printWindow.print(), 500);
|
||||
};
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Printer size={18} />
|
||||
{t('contracts.contractDetails.printContract')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportLegalPackage.mutate(viewingContract.id)}
|
||||
disabled={exportLegalPackage.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{exportLegalPackage.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<FileArchive size={18} />
|
||||
)}
|
||||
{t('contracts.contractDetails.exportLegal')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signing Link for Pending Contracts */}
|
||||
{viewingContract.public_token && viewingContract.status === 'PENDING' && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">{t('contracts.contractDetails.signingLink')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" readOnly value={`${window.location.origin}/sign/${viewingContract.public_token}`} className="flex-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-700 rounded" />
|
||||
<button onClick={() => copySigningLink(viewingContract)} className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<Copy size={16} />
|
||||
<button onClick={() => copySigningLink(viewingContract)} className={`px-3 py-2 rounded transition-colors ${copiedContractId === viewingContract.id ? 'bg-green-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-700'}`}>
|
||||
{copiedContractId === viewingContract.id ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,3 +297,286 @@ class ContractPDFService:
|
||||
logger.info(f"Generated preview PDF for template {template.id} ({template.name})")
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
@staticmethod
|
||||
def generate_audit_certificate(contract):
|
||||
"""
|
||||
Generate an audit certificate PDF for a signed contract.
|
||||
|
||||
Args:
|
||||
contract: Contract instance (must have signature)
|
||||
|
||||
Returns:
|
||||
BytesIO: PDF file as bytes
|
||||
|
||||
Raises:
|
||||
ValueError: If contract is not signed
|
||||
RuntimeError: If WeasyPrint is not available
|
||||
"""
|
||||
if not WEASYPRINT_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"WeasyPrint is not available. Please install system dependencies."
|
||||
)
|
||||
|
||||
if contract.status != 'SIGNED':
|
||||
raise ValueError("Contract must be signed to generate audit certificate")
|
||||
|
||||
if not hasattr(contract, 'signature') or not contract.signature:
|
||||
raise ValueError("Contract signature data is missing")
|
||||
|
||||
import hashlib
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from core.models import Tenant
|
||||
|
||||
signature = contract.signature
|
||||
|
||||
# Get tenant/business info
|
||||
tenant = None
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name=connection.schema_name)
|
||||
except Tenant.DoesNotExist:
|
||||
logger.warning(f"Could not find tenant for schema: {connection.schema_name}")
|
||||
|
||||
# Calculate current hash for verification
|
||||
current_hash = hashlib.sha256(contract.content_html.encode()).hexdigest()
|
||||
hash_verified = (current_hash == signature.document_hash_at_signing)
|
||||
|
||||
# Prepare context
|
||||
context = {
|
||||
'contract': contract,
|
||||
'signature': signature,
|
||||
'business_name': tenant.name if tenant else 'SmoothSchedule',
|
||||
'current_hash': current_hash,
|
||||
'hash_verified': hash_verified,
|
||||
'generated_at': timezone.now(),
|
||||
}
|
||||
|
||||
# Render HTML from template
|
||||
html_string = render_to_string('contracts/audit_certificate.html', context)
|
||||
|
||||
# Configure fonts
|
||||
font_config = FontConfiguration()
|
||||
|
||||
# Generate PDF
|
||||
html = HTML(string=html_string, base_url=settings.STATIC_URL or '/')
|
||||
|
||||
css_string = """
|
||||
@page {
|
||||
size: letter;
|
||||
margin: 0.75in;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
"""
|
||||
css = CSS(string=css_string, font_config=font_config)
|
||||
|
||||
pdf_bytes = BytesIO()
|
||||
html.write_pdf(pdf_bytes, stylesheets=[css], font_config=font_config)
|
||||
pdf_bytes.seek(0)
|
||||
|
||||
logger.info(f"Generated audit certificate for contract {contract.id}")
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
@staticmethod
|
||||
def generate_legal_export_package(contract):
|
||||
"""
|
||||
Generate a complete legal export package as a ZIP file.
|
||||
|
||||
Contains:
|
||||
- signed_contract.pdf - The signed contract document
|
||||
- audit_certificate.pdf - Official audit trail certificate
|
||||
- signature_record.json - Machine-readable audit data
|
||||
- integrity_verification.txt - Hash verification report
|
||||
|
||||
Args:
|
||||
contract: Contract instance (must be signed)
|
||||
|
||||
Returns:
|
||||
BytesIO: ZIP file as bytes
|
||||
|
||||
Raises:
|
||||
ValueError: If contract is not signed
|
||||
RuntimeError: If WeasyPrint is not available
|
||||
"""
|
||||
import zipfile
|
||||
import json
|
||||
import hashlib
|
||||
from django.utils import timezone
|
||||
from django.db import connection
|
||||
from core.models import Tenant
|
||||
|
||||
if contract.status != 'SIGNED':
|
||||
raise ValueError("Contract must be signed for legal export")
|
||||
|
||||
if not hasattr(contract, 'signature') or not contract.signature:
|
||||
raise ValueError("Contract signature data is missing")
|
||||
|
||||
signature = contract.signature
|
||||
|
||||
# Get tenant info
|
||||
tenant = None
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name=connection.schema_name)
|
||||
except Tenant.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Calculate current hash
|
||||
current_hash = hashlib.sha256(contract.content_html.encode()).hexdigest()
|
||||
hash_verified = (current_hash == signature.document_hash_at_signing)
|
||||
|
||||
# Create ZIP in memory
|
||||
zip_buffer = BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
|
||||
# 1. Add signed contract PDF
|
||||
try:
|
||||
contract_pdf = ContractPDFService.generate_pdf(contract)
|
||||
zip_file.writestr('signed_contract.pdf', contract_pdf.read())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate contract PDF: {e}")
|
||||
# Add error note instead
|
||||
zip_file.writestr('signed_contract_error.txt',
|
||||
f"Could not generate contract PDF: {e}")
|
||||
|
||||
# 2. Add audit certificate PDF
|
||||
try:
|
||||
audit_pdf = ContractPDFService.generate_audit_certificate(contract)
|
||||
zip_file.writestr('audit_certificate.pdf', audit_pdf.read())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate audit certificate: {e}")
|
||||
zip_file.writestr('audit_certificate_error.txt',
|
||||
f"Could not generate audit certificate: {e}")
|
||||
|
||||
# 3. Add signature record JSON
|
||||
signature_record = {
|
||||
"export_metadata": {
|
||||
"generated_at": timezone.now().isoformat(),
|
||||
"export_type": "legal_compliance_package",
|
||||
"format_version": "1.0",
|
||||
},
|
||||
"contract": {
|
||||
"id": str(contract.id),
|
||||
"title": contract.title,
|
||||
"signing_token": str(contract.signing_token),
|
||||
"template_name": contract.template.name if contract.template else None,
|
||||
"template_version": contract.template_version,
|
||||
"status": contract.status,
|
||||
"created_at": contract.created_at.isoformat(),
|
||||
"sent_at": contract.sent_at.isoformat() if contract.sent_at else None,
|
||||
"expires_at": contract.expires_at.isoformat() if contract.expires_at else None,
|
||||
},
|
||||
"business": {
|
||||
"name": tenant.name if tenant else None,
|
||||
"subdomain": tenant.subdomain if tenant else None,
|
||||
},
|
||||
"customer": {
|
||||
"id": contract.customer.id,
|
||||
"name": contract.customer.get_full_name() or contract.customer.email,
|
||||
"email": contract.customer.email,
|
||||
},
|
||||
"signature": {
|
||||
"signer_name": signature.signer_name,
|
||||
"signer_email": signature.signer_email,
|
||||
"signed_at": signature.signed_at.isoformat(),
|
||||
"ip_address": signature.ip_address,
|
||||
"user_agent": signature.user_agent,
|
||||
"latitude": str(signature.latitude) if signature.latitude else None,
|
||||
"longitude": str(signature.longitude) if signature.longitude else None,
|
||||
"consent_checkbox_checked": signature.consent_checkbox_checked,
|
||||
"consent_text": signature.consent_text,
|
||||
"electronic_consent_given": signature.electronic_consent_given,
|
||||
"electronic_consent_text": signature.electronic_consent_text,
|
||||
"document_hash_at_signing": signature.document_hash_at_signing,
|
||||
},
|
||||
"event": {
|
||||
"id": contract.event.id if contract.event else None,
|
||||
"service_name": contract.event.service.name if contract.event and contract.event.service else None,
|
||||
"start_time": contract.event.start_time.isoformat() if contract.event else None,
|
||||
} if contract.event else None,
|
||||
"integrity": {
|
||||
"hash_at_signing": signature.document_hash_at_signing,
|
||||
"current_hash": current_hash,
|
||||
"verified": hash_verified,
|
||||
"algorithm": "SHA-256",
|
||||
},
|
||||
}
|
||||
zip_file.writestr('signature_record.json',
|
||||
json.dumps(signature_record, indent=2))
|
||||
|
||||
# 4. Add integrity verification report
|
||||
verification_report = f"""
|
||||
DOCUMENT INTEGRITY VERIFICATION REPORT
|
||||
======================================
|
||||
Generated: {timezone.now().strftime('%Y-%m-%d %H:%M:%S %Z')}
|
||||
|
||||
Contract Information
|
||||
--------------------
|
||||
Contract ID: {contract.id}
|
||||
Contract Title: {contract.title}
|
||||
Signing Token: {contract.signing_token}
|
||||
|
||||
Hash Verification
|
||||
-----------------
|
||||
Algorithm: SHA-256
|
||||
Hash at Time of Signing: {signature.document_hash_at_signing}
|
||||
Current Document Hash: {current_hash}
|
||||
|
||||
Verification Result: {'VERIFIED - Document integrity confirmed' if hash_verified else 'MISMATCH - Document may have been modified'}
|
||||
|
||||
{'The document content has not been altered since the signature was applied.' if hash_verified else 'WARNING: The current document hash does not match the hash recorded at signing. This may indicate the document has been modified after signing.'}
|
||||
|
||||
Signature Details
|
||||
-----------------
|
||||
Signer Name: {signature.signer_name}
|
||||
Signer Email: {signature.signer_email}
|
||||
Date/Time: {signature.signed_at.strftime('%Y-%m-%d %H:%M:%S %Z')}
|
||||
IP Address: {signature.ip_address}
|
||||
Geolocation: {f'{signature.latitude}, {signature.longitude}' if signature.latitude else 'Not captured'}
|
||||
|
||||
Legal Compliance
|
||||
----------------
|
||||
This electronic signature complies with:
|
||||
- Electronic Signatures in Global and National Commerce Act (ESIGN Act, 15 U.S.C. section 7001 et seq.)
|
||||
- Uniform Electronic Transactions Act (UETA)
|
||||
|
||||
The signer acknowledged and accepted the terms electronically, with explicit consent to
|
||||
conduct business electronically. All audit trail data has been preserved.
|
||||
"""
|
||||
zip_file.writestr('integrity_verification.txt', verification_report.strip())
|
||||
|
||||
# 5. Add README
|
||||
readme = f"""
|
||||
LEGAL EXPORT PACKAGE
|
||||
====================
|
||||
|
||||
This package contains the complete audit trail and supporting documentation
|
||||
for the electronically signed contract.
|
||||
|
||||
Contents:
|
||||
---------
|
||||
1. signed_contract.pdf - The complete signed contract document
|
||||
2. audit_certificate.pdf - Official audit certificate with signature details
|
||||
3. signature_record.json - Machine-readable data for automated processing
|
||||
4. integrity_verification.txt - Document hash verification report
|
||||
|
||||
Package Details:
|
||||
----------------
|
||||
Contract: {contract.title}
|
||||
Customer: {contract.customer.get_full_name() or contract.customer.email}
|
||||
Signed: {signature.signed_at.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
Generated: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
This package is intended for legal and compliance purposes, including
|
||||
use as evidence in legal proceedings.
|
||||
|
||||
For questions, contact {tenant.name if tenant else 'the business'}.
|
||||
"""
|
||||
zip_file.writestr('README.txt', readme.strip())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
logger.info(f"Generated legal export package for contract {contract.id}")
|
||||
|
||||
return zip_buffer
|
||||
|
||||
@@ -33,7 +33,7 @@ class ContractTemplateListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for dropdowns/lists"""
|
||||
class Meta:
|
||||
model = ContractTemplate
|
||||
fields = ["id", "name", "scope", "status", "version"]
|
||||
fields = ["id", "name", "description", "content", "scope", "status", "version", "expires_after_days"]
|
||||
|
||||
|
||||
class ServiceContractRequirementSerializer(serializers.ModelSerializer):
|
||||
@@ -99,13 +99,14 @@ class ContractListSerializer(serializers.ModelSerializer):
|
||||
customer_email = serializers.CharField(source="customer.email", read_only=True)
|
||||
template_name = serializers.SerializerMethodField()
|
||||
is_signed = serializers.SerializerMethodField()
|
||||
signed_at = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Contract
|
||||
fields = [
|
||||
"id", "title", "customer", "customer_name", "customer_email",
|
||||
"status", "is_signed", "template_name", "expires_at",
|
||||
"sent_at", "created_at"
|
||||
"id", "template", "title", "content_html", "customer", "customer_name", "customer_email",
|
||||
"status", "is_signed", "template_name", "template_version", "expires_at",
|
||||
"sent_at", "signed_at", "created_at", "signing_token"
|
||||
]
|
||||
|
||||
def get_customer_name(self, obj):
|
||||
@@ -117,36 +118,73 @@ class ContractListSerializer(serializers.ModelSerializer):
|
||||
def get_is_signed(self, obj):
|
||||
return obj.status == Contract.Status.SIGNED
|
||||
|
||||
def get_signed_at(self, obj):
|
||||
if hasattr(obj, 'signature') and obj.signature:
|
||||
return obj.signature.signed_at
|
||||
return None
|
||||
|
||||
class PublicContractSerializer(serializers.ModelSerializer):
|
||||
|
||||
class PublicContractSerializer(serializers.Serializer):
|
||||
"""Serializer for public signing endpoint (no auth required)"""
|
||||
business_name = serializers.SerializerMethodField()
|
||||
business_logo = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Contract
|
||||
fields = [
|
||||
"id", "title", "content_html", "status", "expires_at",
|
||||
"business_name", "business_logo"
|
||||
]
|
||||
|
||||
def get_business_name(self, obj):
|
||||
def to_representation(self, instance):
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from core.models import Tenant
|
||||
|
||||
# Get business info
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name=connection.schema_name)
|
||||
return tenant.name
|
||||
except Tenant.DoesNotExist:
|
||||
return "Business"
|
||||
|
||||
def get_business_logo(self, obj):
|
||||
from django.db import connection
|
||||
from core.models import Tenant
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name=connection.schema_name)
|
||||
return tenant.logo.url if tenant.logo else None
|
||||
business_name = tenant.name
|
||||
business_logo = tenant.logo.url if tenant.logo else None
|
||||
except (Tenant.DoesNotExist, ValueError):
|
||||
return None
|
||||
business_name = "Business"
|
||||
business_logo = None
|
||||
|
||||
# Check expiration
|
||||
is_expired = bool(instance.expires_at and timezone.now() > instance.expires_at)
|
||||
|
||||
# Can sign if pending and not expired
|
||||
can_sign = instance.status == Contract.Status.PENDING and not is_expired
|
||||
|
||||
# Get signature if exists
|
||||
signature_data = None
|
||||
if hasattr(instance, 'signature') and instance.signature:
|
||||
sig = instance.signature
|
||||
signature_data = {
|
||||
"signer_name": sig.signer_name,
|
||||
"signer_email": sig.signer_email,
|
||||
"signed_at": sig.signed_at.isoformat() if sig.signed_at else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"contract": {
|
||||
"id": str(instance.id),
|
||||
"title": instance.title,
|
||||
"content": instance.content_html,
|
||||
"status": instance.status,
|
||||
"expires_at": instance.expires_at.isoformat() if instance.expires_at else None,
|
||||
},
|
||||
"template": {
|
||||
"name": instance.template.name if instance.template else instance.title,
|
||||
"content": instance.content_html,
|
||||
},
|
||||
"business": {
|
||||
"name": business_name,
|
||||
"logo_url": business_logo,
|
||||
},
|
||||
"customer": {
|
||||
"name": instance.customer.get_full_name() or instance.customer.email,
|
||||
"email": instance.customer.email,
|
||||
} if instance.customer else None,
|
||||
"appointment": {
|
||||
"service_name": instance.event.service.name if instance.event and instance.event.service else None,
|
||||
"start_time": instance.event.start_time.isoformat() if instance.event else None,
|
||||
} if instance.event else None,
|
||||
"is_expired": is_expired,
|
||||
"can_sign": can_sign,
|
||||
"signature": signature_data,
|
||||
}
|
||||
|
||||
|
||||
class ContractSignatureInputSerializer(serializers.Serializer):
|
||||
|
||||
@@ -321,6 +321,65 @@ class ContractViewSet(viewsets.ModelViewSet):
|
||||
except Exception:
|
||||
return Response({"error": "PDF not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def export_legal(self, request, pk=None):
|
||||
"""
|
||||
Export a complete legal compliance package for a signed contract.
|
||||
|
||||
Returns a ZIP file containing:
|
||||
- signed_contract.pdf - The signed contract document
|
||||
- audit_certificate.pdf - Official audit trail certificate
|
||||
- signature_record.json - Machine-readable audit data
|
||||
- integrity_verification.txt - Hash verification report
|
||||
"""
|
||||
from django.http import HttpResponse
|
||||
from .pdf_service import ContractPDFService, WEASYPRINT_AVAILABLE
|
||||
|
||||
contract = self.get_object()
|
||||
|
||||
# Validate contract is signed
|
||||
if contract.status != Contract.Status.SIGNED:
|
||||
return Response(
|
||||
{"error": "Only signed contracts can be exported for legal compliance"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not hasattr(contract, 'signature') or not contract.signature:
|
||||
return Response(
|
||||
{"error": "Contract signature data is missing"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not WEASYPRINT_AVAILABLE:
|
||||
return Response(
|
||||
{"error": "PDF generation not available on this server"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
try:
|
||||
zip_buffer = ContractPDFService.generate_legal_export_package(contract)
|
||||
|
||||
# Create filename with customer name and date
|
||||
safe_title = "".join(c for c in contract.title if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||
safe_customer = "".join(c for c in (contract.customer.get_full_name() or contract.customer.email) if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||
signed_date = contract.signature.signed_at.strftime("%Y%m%d")
|
||||
filename = f"legal_export_{safe_title}_{safe_customer}_{signed_date}.zip"
|
||||
|
||||
response = HttpResponse(zip_buffer.read(), content_type="application/zip")
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to generate legal export: {e}")
|
||||
return Response(
|
||||
{"error": "Failed to generate legal export package"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class PublicContractSigningView(APIView):
|
||||
"""
|
||||
@@ -333,11 +392,11 @@ class PublicContractSigningView(APIView):
|
||||
"""Get contract details for signing page"""
|
||||
contract = get_object_or_404(Contract, signing_token=token)
|
||||
|
||||
# For signed contracts, return the data so the signing page can show the signed view
|
||||
if contract.status == Contract.Status.SIGNED:
|
||||
return Response(
|
||||
{"error": "Contract already signed", "status": "signed"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
serializer = PublicContractSerializer(contract)
|
||||
return Response(serializer.data)
|
||||
|
||||
if contract.status == Contract.Status.VOIDED:
|
||||
return Response(
|
||||
{"error": "Contract has been voided", "status": "voided"},
|
||||
|
||||
319
smoothschedule/templates/contracts/audit_certificate.html
Normal file
319
smoothschedule/templates/contracts/audit_certificate.html
Normal file
@@ -0,0 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Audit Certificate - {{ contract.title }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 3px solid #1e40af;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #1e40af;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
.certificate-id {
|
||||
background: #f3f4f6;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.certificate-id .label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.certificate-id .value {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
.info-value {
|
||||
color: #1f2937;
|
||||
}
|
||||
.info-value.monospace {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.consent-box {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.consent-box .consent-title {
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.consent-box .consent-text {
|
||||
font-size: 13px;
|
||||
color: #15803d;
|
||||
}
|
||||
.integrity-section {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.integrity-title {
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.hash-comparison {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.hash-row {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hash-label {
|
||||
width: 150px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
.hash-value {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #1f2937;
|
||||
word-break: break-all;
|
||||
}
|
||||
.verification-status {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.verification-status.verified {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.verification-status.mismatch {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.legal-notice {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background: #fefce8;
|
||||
border: 1px solid #fde047;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #713f12;
|
||||
}
|
||||
.legal-notice-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Electronic Signature Audit Certificate</h1>
|
||||
<div class="subtitle">Official Record of Electronic Contract Execution</div>
|
||||
</div>
|
||||
|
||||
<div class="certificate-id">
|
||||
<div class="label">Certificate Reference Number</div>
|
||||
<div class="value">{{ contract.signing_token }}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Contract Information</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-label">Contract ID:</div>
|
||||
<div class="info-value">{{ contract.id }}</div>
|
||||
|
||||
<div class="info-label">Contract Title:</div>
|
||||
<div class="info-value">{{ contract.title }}</div>
|
||||
|
||||
<div class="info-label">Template Used:</div>
|
||||
<div class="info-value">{{ contract.template.name }} (Version {{ contract.template_version }})</div>
|
||||
|
||||
<div class="info-label">Status:</div>
|
||||
<div class="info-value">{{ contract.status }}</div>
|
||||
|
||||
<div class="info-label">Created:</div>
|
||||
<div class="info-value">{{ contract.created_at|date:"F j, Y, g:i:s A e" }}</div>
|
||||
|
||||
{% if contract.sent_at %}
|
||||
<div class="info-label">Sent to Customer:</div>
|
||||
<div class="info-value">{{ contract.sent_at|date:"F j, Y, g:i:s A e" }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contract.expires_at %}
|
||||
<div class="info-label">Expiration Date:</div>
|
||||
<div class="info-value">{{ contract.expires_at|date:"F j, Y, g:i:s A e" }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Parties</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-label">Business:</div>
|
||||
<div class="info-value">{{ business_name }}</div>
|
||||
|
||||
<div class="info-label">Customer Name:</div>
|
||||
<div class="info-value">{{ contract.customer.get_full_name|default:contract.customer.email }}</div>
|
||||
|
||||
<div class="info-label">Customer Email:</div>
|
||||
<div class="info-value">{{ contract.customer.email }}</div>
|
||||
|
||||
{% if contract.event %}
|
||||
<div class="info-label">Related Appointment:</div>
|
||||
<div class="info-value">
|
||||
{{ contract.event.service.name }} on {{ contract.event.start_time|date:"F j, Y" }} at {{ contract.event.start_time|date:"g:i A" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if signature %}
|
||||
<div class="section">
|
||||
<div class="section-title">Electronic Signature Record</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-label">Signer Name:</div>
|
||||
<div class="info-value">{{ signature.signer_name }}</div>
|
||||
|
||||
<div class="info-label">Signer Email:</div>
|
||||
<div class="info-value">{{ signature.signer_email }}</div>
|
||||
|
||||
<div class="info-label">Date/Time Signed:</div>
|
||||
<div class="info-value">{{ signature.signed_at|date:"F j, Y, g:i:s A e" }}</div>
|
||||
|
||||
<div class="info-label">IP Address:</div>
|
||||
<div class="info-value monospace">{{ signature.ip_address }}</div>
|
||||
|
||||
<div class="info-label">User Agent:</div>
|
||||
<div class="info-value monospace" style="font-size: 11px;">{{ signature.user_agent }}</div>
|
||||
|
||||
{% if signature.latitude and signature.longitude %}
|
||||
<div class="info-label">Geolocation:</div>
|
||||
<div class="info-value monospace">{{ signature.latitude }}, {{ signature.longitude }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Consent Records</div>
|
||||
|
||||
<div class="consent-box">
|
||||
<div class="consent-title">Terms Agreement ({{ signature.consent_checkbox_checked|yesno:"Accepted,Not Accepted" }})</div>
|
||||
<div class="consent-text">{{ signature.consent_text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="consent-box">
|
||||
<div class="consent-title">Electronic Consent ({{ signature.electronic_consent_given|yesno:"Accepted,Not Accepted" }})</div>
|
||||
<div class="consent-text">{{ signature.electronic_consent_text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="integrity-section">
|
||||
<div class="integrity-title">Document Integrity Verification</div>
|
||||
<p>The document hash is computed using SHA-256 algorithm on the contract content at the time of signing.</p>
|
||||
|
||||
<div class="hash-comparison">
|
||||
<div class="hash-row">
|
||||
<div class="hash-label">Hash at Signing:</div>
|
||||
<div class="hash-value">{{ signature.document_hash_at_signing }}</div>
|
||||
</div>
|
||||
<div class="hash-row">
|
||||
<div class="hash-label">Current Hash:</div>
|
||||
<div class="hash-value">{{ current_hash }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if hash_verified %}
|
||||
<div class="verification-status verified">
|
||||
✓ VERIFIED - Document has not been altered since signing
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="verification-status mismatch">
|
||||
✗ MISMATCH - Document may have been modified after signing
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="legal-notice">
|
||||
<div class="legal-notice-title">Legal Compliance Statement</div>
|
||||
<p>
|
||||
This audit certificate documents an electronic signature transaction conducted in compliance with:
|
||||
</p>
|
||||
<ul>
|
||||
<li>The Electronic Signatures in Global and National Commerce Act (ESIGN Act, 15 U.S.C. § 7001 et seq.)</li>
|
||||
<li>The Uniform Electronic Transactions Act (UETA)</li>
|
||||
</ul>
|
||||
<p>
|
||||
The signer expressly agreed to conduct business electronically and acknowledged that their electronic
|
||||
signature carries the same legal weight as a handwritten signature. All audit trail data, including
|
||||
timestamp, IP address, and document hash, has been captured and preserved for evidentiary purposes.
|
||||
</p>
|
||||
<p>
|
||||
This certificate serves as an official record of the electronic signature event and may be used
|
||||
as evidence in legal proceedings to demonstrate the authenticity and integrity of the signed document.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by SmoothSchedule Contract Management System</p>
|
||||
<p class="timestamp">Certificate generated: {{ generated_at|date:"F j, Y, g:i:s A e" }}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user