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:
poduck
2025-12-05 02:29:35 -05:00
parent 6feaa8dda5
commit 35f4301fe1
8 changed files with 1156 additions and 186 deletions

View File

@@ -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 };
},
});
};

View File

@@ -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",

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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

View File

@@ -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):

View File

@@ -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"},

View 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">
&#x2713; VERIFIED - Document has not been altered since signing
</div>
{% else %}
<div class="verification-status mismatch">
&#x2717; 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>