From da799ec3ef052ab4a82a0384c0b1b71ba47131fd Mon Sep 17 00:00:00 2001
From: Ediminator <211899731+Ediminator@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:04:50 +0100
Subject: [PATCH 1/2] feat(proposals): add DSAR export, erasure, and commercial
hold providers (TASK-0059)
Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com>
---
docs/privacy/data-inventory.md | 32 +-
messages/de.json | 3 +
messages/en.json | 3 +
src/components/clients/erasure-dialog.tsx | 7 +-
src/server/clients/activity.ts | 2 +
src/server/privacy/erasure/allowlist.ts | 6 +-
src/server/privacy/erasure/index.ts | 2 +-
src/server/privacy/erasure/registry.ts | 3 +
src/server/privacy/erasure/service.ts | 37 +-
src/server/privacy/export/categories.ts | 33 ++
src/server/privacy/export/html.ts | 19 +
src/server/privacy/export/registry.ts | 2 +
.../proposals/privacy/activity-provider.ts | 64 ++
.../proposals/privacy/erasure-provider.ts | 61 ++
.../proposals/privacy/export-provider.ts | 304 ++++++++++
src/server/proposals/privacy/hold-provider.ts | 71 +++
src/server/proposals/privacy/index.ts | 23 +
src/server/retention/policies.ts | 1 +
src/server/retention/policy.ts | 50 ++
src/server/retention/runner.ts | 9 +
src/server/worker/retention-runner.ts | 15 +
tests/integration/proposals-erasure.test.ts | 446 ++++++++++++++
tests/unit/erasure-coverage.test.ts | 12 +
.../unit/proposals-activity-provider.test.ts | 62 ++
tests/unit/proposals-erasure-provider.test.ts | 111 ++++
tests/unit/proposals-export-provider.test.ts | 556 ++++++++++++++++++
tests/unit/proposals-hold-provider.test.ts | 160 +++++
27 files changed, 2055 insertions(+), 39 deletions(-)
create mode 100644 src/server/proposals/privacy/activity-provider.ts
create mode 100644 src/server/proposals/privacy/erasure-provider.ts
create mode 100644 src/server/proposals/privacy/export-provider.ts
create mode 100644 src/server/proposals/privacy/hold-provider.ts
create mode 100644 src/server/proposals/privacy/index.ts
create mode 100644 src/server/retention/policies.ts
create mode 100644 src/server/worker/retention-runner.ts
create mode 100644 tests/integration/proposals-erasure.test.ts
create mode 100644 tests/unit/proposals-activity-provider.test.ts
create mode 100644 tests/unit/proposals-erasure-provider.test.ts
create mode 100644 tests/unit/proposals-export-provider.test.ts
create mode 100644 tests/unit/proposals-hold-provider.test.ts
diff --git a/docs/privacy/data-inventory.md b/docs/privacy/data-inventory.md
index f606360..828331b 100644
--- a/docs/privacy/data-inventory.md
+++ b/docs/privacy/data-inventory.md
@@ -164,19 +164,19 @@ Appointments track client and third-party meetings scheduled by the studio owner
Proposals track pre-contractual commercial quotations, package/add-on option selections, and formal client contract acceptances or declines.
-| Data Field / Entity | Purpose | Lawful Basis (GDPR) | Retention Period | Deletion / Export |
-| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `proposals.id` | Monotonic UUIDv7 entity identifier | Art. 6(1)(b) Contract / pre-contractual steps | Retained with proposal record | Hard-deleted on project deletion or client erasure (unaccepted proposals) |
-| `proposals.project_id` | FK to `projects.id` associating quotation with specific photography engagement | Art. 6(1)(b) Contract / pre-contractual steps | Lifetime of project profile | Cascade deleted on project deletion; unaccepted proposals deleted on client erasure (Art. 17) |
-| `proposals.client_id` | FK to `clients.id` linking commercial agreement to client profile | Art. 6(1)(b) Contract / pre-contractual steps | Lifetime of client profile; accepted proposals held under §147 AO / §257 HGB | Unaccepted proposals erased on client erasure (Art. 17); accepted proposals held under statutory commercial hold (TASK-0059) |
-| `proposals.title` | Descriptive title of the proposal | Art. 6(1)(b) Pre-contractual steps | Retained with proposal record | Included in DSAR export; erased on client erasure (unaccepted) |
-| `proposals.status` | State machine status (`draft`, `sent`, `accepted`, `declined`, `expired`, `superseded`) | Art. 6(1)(b) Pre-contractual steps | Dictates statutory hold: `accepted` triggers 8-year commercial retention (§147 AO / §257 HGB); unaccepted proposals follow client retention | Included in DSAR export; erased on client erasure if not accepted |
-| `proposals.token_hash` | SHA-256 digest of secret token for secure customer portal access | Art. 6(1)(b) Pre-contractual steps / Art. 32 Security of processing | Retained while proposal is active / valid | Invalidated on revocation (`token_revoked_at`); erased with proposal record |
-| `proposals.accepted_by_name` | Full name of client or authorized representative who accepted proposal | Art. 6(1)(b) Contract / Art. 6(1)(c) Legal obligation (§147 AO) | 8 years from close of calendar year in which proposal was accepted (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during mandatory statutory commercial retention period (§147 AO / §257 HGB; TASK-0059); never logged in audit metadata |
-| `proposals.accepted_by_email` | Email address of person who accepted proposal (used for confirmation) | Art. 6(1)(b) Contract / Art. 6(1)(c) Legal obligation (§147 AO) | 8 years from close of calendar year in which proposal was accepted (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during mandatory statutory commercial retention period (§147 AO / §257 HGB; TASK-0059); never logged in audit metadata |
-| `proposals.acceptance_ip_anonymized` | Truncated IP address (IPv4 `/24`, IPv6 `/48`) proving acceptance without persistent single-device identification | Art. 6(1)(f) Legitimate interest (fraud prevention / proof) | 8 years matching accepted contract record | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold; never logged in audit metadata |
-| `proposals.acceptance_user_agent` | Browser / device user-agent string at time of acceptance | Art. 6(1)(f) Legitimate interest (evidentiary integrity) | 8 years matching accepted contract record | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold; never logged in audit metadata |
-| `proposals.accepted_options_snapshot` | Immutable JSON snapshot of accepted line items, quantities, and financial totals | Art. 6(1)(b) Contract / Art. 6(1)(c) Statutory commercial record | 8 years matching commercial agreement (§147 AO / §257 HGB) | Included in client DSAR export; permanently locked against modification; held under statutory retention (TASK-0059) |
-| `proposals.decline_reason` | Optional free-text reason provided by client when declining proposal | Art. 6(1)(b) Pre-contractual steps | Retained until proposal purge or client erasure | Included in client DSAR export; deleted on lead/client erasure; never logged in audit metadata (only boolean `has_decline_reason` logged in audit metadata) |
-| `proposals.internal_notes` | Photographer internal pricing and negotiation notes | Art. 6(1)(f) Legitimate interest (studio business management) | Retained with proposal record | Excluded from client portal and public token view (`internalNotes: null`); included in owner audit export; deleted on client erasure if not subject to statutory hold |
-| `proposal_line_items.*` | Quotation line items (package/catalogue snapshots, unit prices, tax rates, quantities) | Art. 6(1)(b) Pre-contractual steps / Contract | Follows parent proposal lifecycle | Included in DSAR export; cascade deleted on parent proposal deletion |
+| Data Field / Entity | Purpose | Lawful Basis (GDPR) | Retention Period | Deletion / Export |
+| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `proposals.id` | Monotonic UUIDv7 entity identifier | Art. 6(1)(b) Contract / pre-contractual steps | Retained with proposal record | Hard-deleted on project deletion or client erasure (unaccepted proposals) |
+| `proposals.project_id` | FK to `projects.id` associating quotation with specific photography engagement | Art. 6(1)(b) Contract / pre-contractual steps | Lifetime of project profile | Cascade deleted on project deletion; unaccepted proposals deleted on client erasure (Art. 17) |
+| `proposals.client_id` | FK to `clients.id` linking commercial agreement to client profile | Art. 6(1)(b) Contract / pre-contractual steps | Lifetime of client profile; accepted proposals held under §147 AO / §257 HGB | Unaccepted proposals erased on client erasure (Art. 17); accepted proposals held under statutory commercial hold (TASK-0059) |
+| `proposals.title` | Descriptive title of the proposal | Art. 6(1)(b) Pre-contractual steps | Retained with proposal record | Included in DSAR export; erased on client erasure (unaccepted) |
+| `proposals.status` | State machine status (`draft`, `sent`, `accepted`, `declined`, `expired`, `superseded`) | Art. 6(1)(b) Pre-contractual steps / §147 AO | Dictates statutory hold: `accepted` triggers 10-year commercial retention (§147 AO / §257 HGB); unaccepted proposals follow client retention | Included in DSAR export; erased on client erasure if not accepted |
+| `proposals.token_hash` | SHA-256 digest of secret token for secure customer portal access | Art. 6(1)(b) Pre-contractual steps / Art. 32 Security of processing | Retained while proposal is active / valid | Invalidated on revocation (`token_revoked_at`); erased with proposal record |
+| `proposals.accepted_by_name` | Full name of client or authorized representative who accepted proposal | Art. 6(1)(b) / §147 AO (Contract / statutory legal obligation) | 10-year statutory retention from close of calendar year in which proposal was accepted (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during mandatory statutory commercial retention period (§147 AO / §257 HGB; TASK-0059); never logged in audit metadata |
+| `proposals.accepted_by_email` | Email address of person who accepted proposal (used for confirmation) | Art. 6(1)(b) / §147 AO (Contract / statutory legal obligation) | 10-year statutory retention from close of calendar year in which proposal was accepted (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during mandatory statutory commercial retention period (§147 AO / §257 HGB; TASK-0059); never logged in audit metadata |
+| `proposals.acceptance_ip_anonymized` | Truncated IP address (IPv4 `/24`, IPv6 `/48`) proving acceptance without persistent single-device identification | Art. 6(1)(b) / §147 AO / Art. 6(1)(f) Legitimate interest | 10-year statutory retention matching accepted contract record (§147 AO / §257 HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold; never logged in audit metadata |
+| `proposals.acceptance_user_agent` | Browser / device user-agent string at time of acceptance | Art. 6(1)(b) / §147 AO / Art. 6(1)(f) Legitimate interest | 10-year statutory retention matching accepted contract record (§147 AO / §257 HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold; never logged in audit metadata |
+| `proposals.accepted_options_snapshot` | Immutable JSON snapshot of accepted line items, quantities, and financial totals | Art. 6(1)(b) / §147 AO (Statutory commercial agreement record) | 10-year statutory retention matching commercial agreement (§147 AO / §257 HGB) | Included in client DSAR export; permanently locked against modification; held under statutory retention (TASK-0059) |
+| `proposals.decline_reason` | Optional free-text reason provided by client when declining proposal | Art. 6(1)(b) Pre-contractual steps | Retained until proposal purge or client erasure | Included in client DSAR export; deleted on lead/client erasure; never logged in audit metadata (only boolean `has_decline_reason` logged in audit metadata) |
+| `proposals.internal_notes` | Photographer internal pricing and negotiation notes | Art. 6(1)(f) Legitimate interest (studio business management) | Retained with proposal record | Excluded from client portal and public token view (`internalNotes: null`); included in owner audit export; deleted on client erasure if not subject to statutory hold |
+| `proposal_line_items.*` | Quotation line items (package/catalogue snapshots, unit prices, tax rates, quantities) | Art. 6(1)(b) Pre-contractual steps / Contract | Follows parent proposal lifecycle | Included in DSAR export; cascade deleted on parent proposal deletion |
diff --git a/messages/de.json b/messages/de.json
index d153738..e59e3d8 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1381,6 +1381,9 @@
"holds": {
"active_project": "Laufendes Projekt / Auftrag",
"upcoming_shoot": "Bevorstehendes geplantes Shooting",
+ "statutory": {
+ "commercial_document": "Gesetzliche Aufbewahrungspflicht für Handelsdokumente (§ 147 AO, § 257 HGB)"
+ },
"manual": {
"dispute": "Rechtsstreitigkeit",
"statutory": "Gesetzliche Anforderung",
diff --git a/messages/en.json b/messages/en.json
index a960fdf..fb58902 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1381,6 +1381,9 @@
"holds": {
"active_project": "Active commission / project",
"upcoming_shoot": "Upcoming scheduled shoot",
+ "statutory": {
+ "commercial_document": "Statutory commercial agreement (§147 AO, §257 HGB)"
+ },
"manual": {
"dispute": "Dispute resolution",
"statutory": "Statutory requirement",
diff --git a/src/components/clients/erasure-dialog.tsx b/src/components/clients/erasure-dialog.tsx
index 7c558fd..8dfd400 100644
--- a/src/components/clients/erasure-dialog.tsx
+++ b/src/components/clients/erasure-dialog.tsx
@@ -263,7 +263,12 @@ export function ErasureDialogInner({
{holds.map((h, idx) => {
const manualKey = h.reason.startsWith('manual.') ? h.reason.slice(7) : null;
- const localizedReason = manualKey ? tHolds(`manual.${manualKey}`) : h.reason;
+ const statutoryKey = h.reason.startsWith('statutory.') ? h.reason.slice(10) : null;
+ const localizedReason = manualKey
+ ? tHolds(`manual.${manualKey}`)
+ : statutoryKey
+ ? tHolds(`statutory.${statutoryKey}`)
+ : h.reason;
return (
- timeline
// - consent_records -> consents
// - projects, project_participants -> projects
+ // - proposals, proposal_line_items -> proposals (TASK-0059)
// When future epics introduce tables with client_id or contact_id columns
// that have statutory retention or non-deletable data, register an exemption here with reasoning.
- {
- tableName: 'proposals',
- reason:
- 'Commercial proposals and statutory accepted records. GDPR Art. 17 erasure and commercial hold provider is scheduled in TASK-0059 as part of Epic E5.',
- },
];
diff --git a/src/server/privacy/erasure/index.ts b/src/server/privacy/erasure/index.ts
index 8689c58..c1f1fc6 100644
--- a/src/server/privacy/erasure/index.ts
+++ b/src/server/privacy/erasure/index.ts
@@ -2,5 +2,5 @@ export * from './types';
export * from './registry';
export * from './allowlist';
export * from './schema';
-export { planErasure, eraseSubject } from './service';
+export { planErasure, eraseSubject, checkErasureHolds } from './service';
export { planErasureAction, executeErasureAction, eraseClientDataAction } from './actions';
diff --git a/src/server/privacy/erasure/registry.ts b/src/server/privacy/erasure/registry.ts
index b81380e..c4b5c2c 100644
--- a/src/server/privacy/erasure/registry.ts
+++ b/src/server/privacy/erasure/registry.ts
@@ -10,6 +10,7 @@ import type { ErasureProvider, ErasureHoldProvider } from './types';
import { googleContactsErasureProvider } from '@/server/integrations/google/erasure-provider';
import { projectsErasureProvider, projectsHoldProvider } from '@/server/projects/privacy';
import { appointmentsErasureProvider } from './appointments';
+import { proposalsErasureProvider, proposalsHoldProvider } from '@/server/proposals/privacy';
const erasureProviders = new Map();
const holdProviders = new Map();
@@ -280,6 +281,7 @@ export const DEFAULT_ERASURE_PROVIDERS: ErasureProvider[] = [
},
},
googleContactsErasureProvider,
+ proposalsErasureProvider,
projectsErasureProvider,
appointmentsErasureProvider,
{
@@ -509,6 +511,7 @@ export const DEFAULT_HOLD_PROVIDERS: ErasureHoldProvider[] = [
},
},
projectsHoldProvider,
+ proposalsHoldProvider,
];
function initDefaultProviders(): void {
diff --git a/src/server/privacy/erasure/service.ts b/src/server/privacy/erasure/service.ts
index a047108..af210fd 100644
--- a/src/server/privacy/erasure/service.ts
+++ b/src/server/privacy/erasure/service.ts
@@ -114,6 +114,25 @@ async function resolveSubject(
};
}
+/**
+ * Evaluates all registered erasure hold providers for the given subject.
+ * Returns an array of active holds. If array is empty, erasure may proceed.
+ */
+export async function checkErasureHolds(
+ subject: ErasureSubject,
+ client: DbClient = defaultDb,
+): Promise {
+ const holdProviders = getErasureHoldProviders();
+ const holds: ErasureHold[] = [];
+ for (const hp of holdProviders) {
+ const provHolds = await hp.holds(subject, client);
+ if (provHolds.length > 0) {
+ holds.push(...provHolds);
+ }
+ }
+ return holds;
+}
+
/**
* Plans erasure of a client or contact.
* Aggregates counts across all erasure providers, evaluates holds,
@@ -131,14 +150,7 @@ export async function planErasure(
const { effectiveSubject, clientDisplayName, isMultiContact } = resolved;
// 1. Evaluate holds across all registered hold providers
- const holdProviders = getErasureHoldProviders();
- const holds: ErasureHold[] = [];
- for (const hp of holdProviders) {
- const provHolds = await hp.holds(effectiveSubject, client);
- if (provHolds.length > 0) {
- holds.push(...provHolds);
- }
- }
+ const holds = await checkErasureHolds(effectiveSubject, client);
// 2. Aggregate category counts from erasure providers
const providers = getErasureProviders();
@@ -203,14 +215,7 @@ export async function eraseSubject(
const { effectiveSubject } = resolved;
// 2. Re-evaluate holds inside transaction (TOCTOU protection, AC-5)
- const holdProviders = getErasureHoldProviders();
- const holds: ErasureHold[] = [];
- for (const hp of holdProviders) {
- const provHolds = await hp.holds(effectiveSubject, tx);
- if (provHolds.length > 0) {
- holds.push(...provHolds);
- }
- }
+ const holds = await checkErasureHolds(effectiveSubject, tx);
const targetUuid =
effectiveSubject.kind === 'client'
diff --git a/src/server/privacy/export/categories.ts b/src/server/privacy/export/categories.ts
index ddadec4..5496e8d 100644
--- a/src/server/privacy/export/categories.ts
+++ b/src/server/privacy/export/categories.ts
@@ -489,6 +489,39 @@ export const INITIAL_CATEGORIES: readonly CategoryDefinition[] = [
de: 'Von der teilnehmenden Person bei der Terminvereinbarung angegeben.',
},
},
+ {
+ id: 'proposals',
+ titleKey: 'privacy.export.categories.proposals.title',
+ purposeKey: 'privacy.export.categories.proposals.purpose',
+ lawfulBasisKey: 'privacy.export.categories.proposals.lawful_basis',
+ retentionKey: 'privacy.export.categories.proposals.retention',
+ recipientsKey: 'privacy.export.categories.proposals.recipients',
+ sourceKey: 'privacy.export.categories.proposals.source',
+ title: {
+ en: 'Proposals & Quotes',
+ de: 'Angebote & Kostenvoranschläge',
+ },
+ purpose: {
+ en: 'Preparation, submission, negotiation, and formal acceptance of service proposals and fee quotes.',
+ de: 'Erstellung, Übermittlung, Verhandlung und formelle Annahme von Angeboten und Kostenvoranschlägen.',
+ },
+ lawfulBasis: {
+ en: 'Art. 6(1)(b) GDPR (Performance of a contract / pre-contractual measures) and § 147 AO / § 257 HGB (Statutory accounting & tax obligations for accepted proposals).',
+ de: 'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung / vorvertragliche Maßnahmen) und § 147 AO / § 257 HGB (Steuer- und handelsrechtliche Aufbewahrungspflichten für angenommene Angebote).',
+ },
+ retention: {
+ en: 'Unaccepted/declined proposals are pruned after 12 months; accepted proposals are retained for 10 years pursuant to § 147 AO and § 257 HGB.',
+ de: 'Nicht angenommene/abgelehnte Angebote werden nach 12 Monaten gelöscht; angenommene Angebote werden 10 Jahre gemäß § 147 AO und § 257 HGB aufbewahrt.',
+ },
+ recipients: {
+ en: 'None (stored locally on the self-hosted studio instance).',
+ de: 'Keine (ausschließlich lokale Speicherung auf der selbst gehosteten Studio-Instanz).',
+ },
+ source: {
+ en: 'Provided by the studio owner and client during proposal negotiation and acceptance.',
+ de: 'Vom Studioinhaber und Kunden im Rahmen der Angebotsverhandlung und -annahme bereitgestellt.',
+ },
+ },
];
const categoryRegistry = new Map();
diff --git a/src/server/privacy/export/html.ts b/src/server/privacy/export/html.ts
index 7691341..a18d3c4 100644
--- a/src/server/privacy/export/html.ts
+++ b/src/server/privacy/export/html.ts
@@ -52,6 +52,25 @@ const GERMAN_COLUMN_HEADERS: Record = {
attendee_notes: 'Notizen',
cancellation_reason: 'Grund der Absage',
cancelled_at: 'Abgesagt am',
+ proposal: 'Angebot',
+ proposal_title: 'Angebot',
+ subtotal: 'Zwischensumme',
+ subtotal_minor: 'Zwischensumme',
+ tax: 'Umsatzsteuer',
+ tax_minor: 'Umsatzsteuer',
+ total: 'Gesamtbetrag',
+ total_minor: 'Gesamtbetrag',
+ valid_until: 'Gültig bis',
+ validity_date: 'Gültig bis',
+ sent_at: 'Gesendet am',
+ sent_date: 'Gesendet am',
+ accepted_at: 'Angenommen am',
+ acceptance_timestamp: 'Angenommen am',
+ accepted_by: 'Angenommen von',
+ accepted_by_name: 'Angenommen von (Name)',
+ accepted_by_email: 'Angenommen von (E-Mail)',
+ line_items: 'Positionen',
+ deliverables: 'Leistungen & Positionen',
};
/**
diff --git a/src/server/privacy/export/registry.ts b/src/server/privacy/export/registry.ts
index 0d60bdf..e58b053 100644
--- a/src/server/privacy/export/registry.ts
+++ b/src/server/privacy/export/registry.ts
@@ -19,6 +19,7 @@ import type {
import { googleContactsExportProvider } from '@/server/integrations/google/export-provider';
import { projectsExportProvider } from '@/server/projects/privacy/export-provider';
import { appointmentsExportProvider } from './appointments';
+import { proposalsExportProvider } from '@/server/proposals/privacy/export-provider';
const providerRegistry = new Map();
@@ -375,6 +376,7 @@ export const DEFAULT_PROVIDERS: ExportProvider[] = [
},
projectsExportProvider,
appointmentsExportProvider,
+ proposalsExportProvider,
];
function initDefaultProviders(): void {
diff --git a/src/server/proposals/privacy/activity-provider.ts b/src/server/proposals/privacy/activity-provider.ts
new file mode 100644
index 0000000..2545d72
--- /dev/null
+++ b/src/server/proposals/privacy/activity-provider.ts
@@ -0,0 +1,64 @@
+import { inArray, gte, or, and } from 'drizzle-orm';
+import type { DbClient } from '@/server/db/client';
+import { proposals } from '@/server/db/schema/proposals';
+import type { ActivityProvider } from '@/server/clients/activity';
+
+/**
+ * Retention sweep activity provider for proposals & quotes (ADR-0018 §6).
+ * Identifies clients that have active pre-contractual proposals, recent quotation updates,
+ * or accepted commercial agreements, preventing automated retention sweeps from marking
+ * them inactive or purging them.
+ */
+export const proposalsActivityProvider: ActivityProvider = {
+ id: 'proposals',
+
+ activeClientIds: async (
+ tx: DbClient,
+ since: Date,
+ candidateIds: readonly string[],
+ ): Promise => {
+ if (candidateIds.length === 0) {
+ return [];
+ }
+
+ const activeIds = new Set();
+ const now = new Date();
+ const candidateList = [...candidateIds];
+
+ // 1. Clients with active or accepted proposals (draft, sent, accepted)
+ const activeStatusRows = await tx
+ .selectDistinct({ clientId: proposals.clientId })
+ .from(proposals)
+ .where(
+ and(
+ inArray(proposals.clientId, candidateList),
+ inArray(proposals.status, ['draft', 'sent', 'accepted']),
+ ),
+ );
+
+ for (const row of activeStatusRows) {
+ activeIds.add(row.clientId);
+ }
+
+ // 2. Clients with proposals updated, sent, or valid recently or into the future
+ const recentProposalRows = await tx
+ .selectDistinct({ clientId: proposals.clientId })
+ .from(proposals)
+ .where(
+ and(
+ inArray(proposals.clientId, candidateList),
+ or(
+ gte(proposals.updatedAt, since),
+ gte(proposals.createdAt, since),
+ gte(proposals.validUntil, now),
+ ),
+ ),
+ );
+
+ for (const row of recentProposalRows) {
+ activeIds.add(row.clientId);
+ }
+
+ return [...activeIds];
+ },
+};
diff --git a/src/server/proposals/privacy/erasure-provider.ts b/src/server/proposals/privacy/erasure-provider.ts
new file mode 100644
index 0000000..8b821f6
--- /dev/null
+++ b/src/server/proposals/privacy/erasure-provider.ts
@@ -0,0 +1,61 @@
+import 'server-only';
+import { eq, inArray, and, count, ne } from 'drizzle-orm';
+import type { DbClient } from '@/server/db/client';
+import { proposals, proposalLineItems } from '@/server/db/schema/proposals';
+import type { ErasureProvider, ErasureSubject } from '@/server/privacy/erasure/types';
+
+/**
+ * Proposals Erasure Provider (TASK-0059 / GDPR Art. 17 / ADR-0018 §6).
+ * Permanently hard-deletes unaccepted, draft, expired, or declined proposals
+ * and their associated line items during client erasure.
+ *
+ * Pre-contractual discussions are not subject to statutory commercial retention.
+ * Accepted proposals are protected by proposalsHoldProvider and are never deleted.
+ */
+export const proposalsErasureProvider: ErasureProvider = {
+ id: 'proposals',
+ category: 'proposals',
+ coveredTables: ['proposals', 'proposal_line_items'] as const,
+ subjectKinds: ['client', 'contact'] as const,
+
+ plan: async (subject: ErasureSubject, tx: DbClient): Promise => {
+ if (subject.kind !== 'client') {
+ return 0;
+ }
+
+ const [res] = await tx
+ .select({ count: count() })
+ .from(proposals)
+ .where(and(eq(proposals.clientId, subject.clientId), ne(proposals.status, 'accepted')));
+
+ return res?.count ?? 0;
+ },
+
+ erase: async (subject: ErasureSubject, tx: DbClient): Promise => {
+ if (subject.kind !== 'client') {
+ return 0;
+ }
+
+ const unacceptedProposals = await tx
+ .select({ id: proposals.id })
+ .from(proposals)
+ .where(and(eq(proposals.clientId, subject.clientId), ne(proposals.status, 'accepted')));
+
+ if (unacceptedProposals.length === 0) {
+ return 0;
+ }
+
+ const proposalIds = unacceptedProposals.map((p) => p.id);
+
+ // 1. Hard-delete child line items to guarantee zero orphaned records
+ await tx.delete(proposalLineItems).where(inArray(proposalLineItems.proposalId, proposalIds));
+
+ // 2. Hard-delete unaccepted proposal headers
+ const deleted = await tx
+ .delete(proposals)
+ .where(inArray(proposals.id, proposalIds))
+ .returning({ id: proposals.id });
+
+ return deleted.length;
+ },
+};
diff --git a/src/server/proposals/privacy/export-provider.ts b/src/server/proposals/privacy/export-provider.ts
new file mode 100644
index 0000000..748debc
--- /dev/null
+++ b/src/server/proposals/privacy/export-provider.ts
@@ -0,0 +1,304 @@
+import 'server-only';
+import { eq, inArray, asc } from 'drizzle-orm';
+import type { DbClient } from '@/server/db/client';
+import { proposals, proposalLineItems } from '@/server/db/schema/proposals';
+import { projects } from '@/server/db/schema/projects';
+import type { ExportProvider, ExportSubject } from '@/server/privacy/export/types';
+import { escapeHtml } from '@/server/privacy/export/html';
+import { formatCurrency, formatDate } from '@/lib/formatters';
+
+export interface ProposalLineItemExport {
+ id: string;
+ proposal_id: string;
+ name: string;
+ item_name: string;
+ item_type: string;
+ description: string | null;
+ unit_price: number;
+ unit_price_minor: number;
+ quantity: number;
+ tax_rate: number;
+ tax_rate_basis_points: number;
+ is_optional: boolean;
+ is_selected: boolean;
+ selection_state: boolean;
+ position: number;
+}
+
+export interface FormattedProposalExport {
+ id: string;
+ project_id: string;
+ project_title: string | null;
+ title: string;
+ status: string;
+ currency: string;
+ subtotal: number;
+ subtotal_minor: number;
+ tax: number;
+ tax_minor: number;
+ total: number;
+ total_minor: number;
+ prices_include_tax: boolean;
+ is_kleinunternehmer: boolean;
+ valid_until: string | null;
+ validity_date: string | null;
+ sent_at: string | null;
+ sent_date: string | null;
+ accepted_at: string | null;
+ acceptance_timestamp: string | null;
+ declined_at: string | null;
+ decline_reason: string | null;
+ accepted_by_name: string | null;
+ accepted_by_email: string | null;
+ acceptance_ip_anonymized: string | null;
+ anonymized_ip: string | null;
+ acceptance_user_agent: string | null;
+ user_agent: string | null;
+ line_items: ProposalLineItemExport[];
+ created_at: string;
+ updated_at: string;
+
+ // Human-readable summary columns for DSAR table views
+ proposal?: string;
+ project?: string;
+}
+
+export const PROPOSAL_EXPORT_HEADERS = {
+ en: {
+ proposal: 'Proposal',
+ project: 'Project',
+ status: 'Status',
+ subtotal: 'Subtotal',
+ tax: 'Tax',
+ total: 'Total',
+ validUntil: 'Valid Until',
+ sentAt: 'Sent Date',
+ acceptedAt: 'Accepted Date',
+ acceptedBy: 'Accepted By',
+ deliverables: 'Deliverables & Line Items',
+ },
+ de: {
+ proposal: 'Angebot',
+ project: 'Projekt',
+ status: 'Status',
+ subtotal: 'Zwischensumme',
+ tax: 'Umsatzsteuer',
+ total: 'Gesamtbetrag',
+ validUntil: 'Gültig bis',
+ sentAt: 'Gesendet am',
+ acceptedAt: 'Angenommen am',
+ acceptedBy: 'Angenommen von',
+ deliverables: 'Leistungen & Positionen',
+ },
+} as const;
+
+export const PROPOSAL_STATUS_LABELS = {
+ en: {
+ draft: 'Draft',
+ sent: 'Sent',
+ accepted: 'Accepted',
+ declined: 'Declined',
+ expired: 'Expired',
+ superseded: 'Superseded',
+ },
+ de: {
+ draft: 'Entwurf',
+ sent: 'Versendet',
+ accepted: 'Angenommen',
+ declined: 'Abgelehnt',
+ expired: 'Abgelaufen',
+ superseded: 'Ersetzt',
+ },
+} as const;
+
+/**
+ * Formats a clean, accessible HTML table for proposals DSAR exports.
+ */
+export function formatProposalsHtml(
+ items: FormattedProposalExport[],
+ locale: 'en' | 'de' = 'en',
+): string {
+ if (items.length === 0) {
+ return locale === 'de'
+ ? '
In dieser Kategorie sind derzeit keine Datensätze gespeichert.
'
+ : 'No records stored in this category.
';
+ }
+
+ const h = locale === 'de' ? PROPOSAL_EXPORT_HEADERS.de : PROPOSAL_EXPORT_HEADERS.en;
+ const statusLabels = locale === 'de' ? PROPOSAL_STATUS_LABELS.de : PROPOSAL_STATUS_LABELS.en;
+ const numLocale = locale === 'de' ? 'de-DE' : 'en-US';
+
+ const thead = `
+
+ | ${escapeHtml(h.proposal)} |
+ ${escapeHtml(h.project)} |
+ ${escapeHtml(h.status)} |
+ ${escapeHtml(h.total)} |
+ ${escapeHtml(h.validUntil)} |
+ ${escapeHtml(h.acceptedAt)} |
+ ${escapeHtml(h.acceptedBy)} |
+ ${escapeHtml(h.deliverables)} |
+
+ `;
+
+ const statusMap = new Map(Object.entries(statusLabels));
+
+ const tbody = items
+ .map((item) => {
+ const statusText = statusMap.get(item.status) ?? item.status;
+ const formattedTotal = formatCurrency(item.total_minor, item.currency, numLocale);
+ const formattedValidUntil = item.valid_until ? formatDate(item.valid_until, locale) : '-';
+ const formattedAcceptedAt = item.accepted_at ? formatDate(item.accepted_at, locale) : '-';
+
+ let acceptedByText = '-';
+ if (item.accepted_by_name || item.accepted_by_email) {
+ acceptedByText = [
+ item.accepted_by_name,
+ item.accepted_by_email ? `(${item.accepted_by_email})` : null,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ }
+
+ const deliverablesText =
+ item.line_items.length > 0
+ ? item.line_items
+ .map((li) => {
+ const price = formatCurrency(li.unit_price_minor, item.currency, numLocale);
+ return `${li.item_name} (×${String(li.quantity)}, ${price})`;
+ })
+ .join('; ')
+ : '-';
+
+ return `
+ | ${escapeHtml(item.title)} |
+ ${escapeHtml(item.project_title ?? '-')} |
+ ${escapeHtml(statusText)} |
+ ${escapeHtml(formattedTotal)} |
+ ${escapeHtml(formattedValidUntil)} |
+ ${escapeHtml(formattedAcceptedAt)} |
+ ${escapeHtml(acceptedByText)} |
+ ${escapeHtml(deliverablesText)} |
+
`;
+ })
+ .join('');
+
+ const regionLabel = locale === 'de' ? 'Angebote & Kostenvoranschläge' : 'Proposals & Quotes';
+
+ return `
+
+
+ ${thead}
+
+
+ ${tbody}
+
+
+
`;
+}
+
+/**
+ * DSAR Export Provider for proposals & quotes (TASK-0059 / GDPR Art. 15 / ADR-0018 §6).
+ * Exports all proposals and line items for the data subject (client or contact).
+ */
+export const proposalsExportProvider: ExportProvider = {
+ id: 'proposals',
+ category: 'proposals',
+ coveredTables: ['proposals', 'proposal_line_items'] as const,
+ subjectKinds: ['client', 'contact'] as const,
+
+ collect: async (subject: ExportSubject, tx: DbClient): Promise => {
+ if (subject.kind === 'lead') {
+ return [];
+ }
+
+ // Both client and contact subjects map proposals by clientId
+ const clientProposals = await tx
+ .select()
+ .from(proposals)
+ .where(eq(proposals.clientId, subject.clientId))
+ .orderBy(asc(proposals.createdAt));
+
+ if (clientProposals.length === 0) {
+ return [];
+ }
+
+ const proposalIds = clientProposals.map((p) => p.id);
+ const projectIds = Array.from(new Set(clientProposals.map((p) => p.projectId)));
+
+ // Fetch line items
+ const allLineItems = await tx
+ .select()
+ .from(proposalLineItems)
+ .where(inArray(proposalLineItems.proposalId, proposalIds))
+ .orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt));
+
+ // Fetch project titles
+ const projectRows = await tx
+ .select({ id: projects.id, title: projects.title })
+ .from(projects)
+ .where(inArray(projects.id, projectIds));
+ const projectMap = new Map(projectRows.map((p) => [p.id, p.title]));
+
+ return clientProposals.map((p) => {
+ const itemsForProposal = allLineItems.filter((li) => li.proposalId === p.id);
+ const projectTitle = projectMap.get(p.projectId) ?? null;
+
+ const lineItems: ProposalLineItemExport[] = itemsForProposal.map((li) => ({
+ id: li.id,
+ proposal_id: li.proposalId,
+ name: li.name,
+ item_name: li.name,
+ item_type: li.itemType,
+ description: li.description ?? null,
+ unit_price: li.unitPriceMinor,
+ unit_price_minor: li.unitPriceMinor,
+ quantity: li.quantity,
+ tax_rate: li.taxRateBasisPoints,
+ tax_rate_basis_points: li.taxRateBasisPoints,
+ is_optional: li.isOptional,
+ is_selected: li.isSelected,
+ selection_state: li.isSelected,
+ position: li.position,
+ }));
+
+ return {
+ id: p.id,
+ project_id: p.projectId,
+ project_title: projectTitle,
+ title: p.title,
+ status: p.status,
+ currency: p.currency,
+ subtotal: p.subtotalMinor,
+ subtotal_minor: p.subtotalMinor,
+ tax: p.taxMinor,
+ tax_minor: p.taxMinor,
+ total: p.totalMinor,
+ total_minor: p.totalMinor,
+ prices_include_tax: p.pricesIncludeTax,
+ is_kleinunternehmer: p.isKleinunternehmer,
+ valid_until: p.validUntil ? p.validUntil.toISOString() : null,
+ validity_date: p.validUntil ? p.validUntil.toISOString() : null,
+ sent_at: p.sentAt ? p.sentAt.toISOString() : null,
+ sent_date: p.sentAt ? p.sentAt.toISOString() : null,
+ accepted_at: p.acceptedAt ? p.acceptedAt.toISOString() : null,
+ acceptance_timestamp: p.acceptedAt ? p.acceptedAt.toISOString() : null,
+ declined_at: p.declinedAt ? p.declinedAt.toISOString() : null,
+ decline_reason: p.declineReason ?? null,
+ accepted_by_name: p.acceptedByName ?? null,
+ accepted_by_email: p.acceptedByEmail ?? null,
+ acceptance_ip_anonymized: p.acceptanceIpAnonymized ?? null,
+ anonymized_ip: p.acceptanceIpAnonymized ?? null,
+ acceptance_user_agent: p.acceptanceUserAgent ?? null,
+ user_agent: p.acceptanceUserAgent ?? null,
+ line_items: lineItems,
+ created_at: p.createdAt.toISOString(),
+ updated_at: p.updatedAt.toISOString(),
+
+ // High-level summary columns matching AC-1 / AC-2
+ proposal: p.title,
+ project: projectTitle ?? '-',
+ };
+ });
+ },
+};
diff --git a/src/server/proposals/privacy/hold-provider.ts b/src/server/proposals/privacy/hold-provider.ts
new file mode 100644
index 0000000..20eb84f
--- /dev/null
+++ b/src/server/proposals/privacy/hold-provider.ts
@@ -0,0 +1,71 @@
+import 'server-only';
+import { eq, and } from 'drizzle-orm';
+import type { DbClient } from '@/server/db/client';
+import { proposals } from '@/server/db/schema/proposals';
+import type {
+ ErasureHoldProvider,
+ ErasureSubject,
+ ErasureHold,
+} from '@/server/privacy/erasure/types';
+
+/**
+ * Proposals Commercial Statutory Hold Provider (TASK-0059 / ADR-0018 §6).
+ * Enforces GDPR Art. 17(3)(b) retention exception:
+ *
+ * Under German tax and commercial law (§147 Abs. 1 Nr. 2 AO, §257 Abs. 1 Nr. 2 HGB),
+ * accepted commercial agreements, quotation vouchers, and binding commission deliverables
+ * must be retained for 10 years starting from the end of the calendar year of acceptance.
+ *
+ * Blocks client erasure with code 'statutory.commercial_document' while any accepted proposal exists.
+ */
+export const proposalsHoldProvider: ErasureHoldProvider = {
+ id: 'proposals',
+
+ holds: async (subject: ErasureSubject, tx: DbClient): Promise => {
+ if (subject.kind !== 'client') {
+ return [];
+ }
+
+ const acceptedProposals = await tx
+ .select({
+ id: proposals.id,
+ acceptedAt: proposals.acceptedAt,
+ updatedAt: proposals.updatedAt,
+ createdAt: proposals.createdAt,
+ })
+ .from(proposals)
+ .where(and(eq(proposals.clientId, subject.clientId), eq(proposals.status, 'accepted')));
+
+ if (acceptedProposals.length === 0) {
+ return [];
+ }
+
+ const firstProposal = acceptedProposals[0];
+ if (!firstProposal) {
+ return [];
+ }
+
+ const firstDate = firstProposal.acceptedAt ?? firstProposal.updatedAt;
+ let latestLockDate = new Date(
+ Date.UTC(firstDate.getUTCFullYear() + 10, 11, 31, 23, 59, 59, 999),
+ );
+
+ for (const p of acceptedProposals.slice(1)) {
+ const acceptanceDate = p.acceptedAt ?? p.updatedAt;
+ const acceptanceYear = acceptanceDate.getUTCFullYear();
+ // 10 years from end of calendar year of acceptance (§147(4) AO)
+ const lockDate = new Date(Date.UTC(acceptanceYear + 10, 11, 31, 23, 59, 59, 999));
+
+ if (lockDate.getTime() > latestLockDate.getTime()) {
+ latestLockDate = lockDate;
+ }
+ }
+
+ return [
+ {
+ reason: 'statutory.commercial_document',
+ retainUntil: latestLockDate.toISOString(),
+ },
+ ];
+ },
+};
diff --git a/src/server/proposals/privacy/index.ts b/src/server/proposals/privacy/index.ts
new file mode 100644
index 0000000..0610cde
--- /dev/null
+++ b/src/server/proposals/privacy/index.ts
@@ -0,0 +1,23 @@
+import {
+ proposalsExportProvider,
+ PROPOSAL_EXPORT_HEADERS,
+ PROPOSAL_STATUS_LABELS,
+ formatProposalsHtml,
+ type FormattedProposalExport,
+ type ProposalLineItemExport,
+} from './export-provider';
+import { proposalsErasureProvider } from './erasure-provider';
+import { proposalsHoldProvider } from './hold-provider';
+import { proposalsActivityProvider } from './activity-provider';
+
+export {
+ proposalsExportProvider,
+ PROPOSAL_EXPORT_HEADERS,
+ PROPOSAL_STATUS_LABELS,
+ formatProposalsHtml,
+ proposalsErasureProvider,
+ proposalsHoldProvider,
+ proposalsActivityProvider,
+ type FormattedProposalExport,
+ type ProposalLineItemExport,
+};
diff --git a/src/server/retention/policies.ts b/src/server/retention/policies.ts
new file mode 100644
index 0000000..62d5b84
--- /dev/null
+++ b/src/server/retention/policies.ts
@@ -0,0 +1 @@
+export * from './policy';
diff --git a/src/server/retention/policy.ts b/src/server/retention/policy.ts
index 56b3824..798791a 100644
--- a/src/server/retention/policy.ts
+++ b/src/server/retention/policy.ts
@@ -8,6 +8,7 @@ import { clients } from '@/server/db/schema/clients';
import { leads } from '@/server/db/schema/leads';
import { inquiryPowReplays } from '@/server/db/schema/inquiry-forms';
import { emailOutbox } from '@/server/db/schema/email-outbox';
+import { proposals, proposalLineItems } from '@/server/db/schema/proposals';
import { findActiveClientIds } from '@/server/clients/activity';
import { env } from '@/env';
@@ -576,6 +577,54 @@ export function createDefaultPolicies(): RetentionPolicy[] {
},
});
+ // 10. Unaccepted or declined proposals older than 12 months where no project was booked (ADR-0018 §6, TASK-0059 AC-5)
+ const unacceptedProposalsPolicy = defineRetentionPolicy<{ id: string }>({
+ id: 'unaccepted-proposals',
+ entity: 'proposals',
+ description:
+ 'Purges unaccepted or declined proposals older than 12 months where no project was booked',
+ after: { months: 12 },
+ action: 'delete',
+ batchSize: 500,
+ getCandidates: async (client, cutoff, limit, excludedIds) => {
+ const unbookedCondition = sql`NOT EXISTS (
+ SELECT 1 FROM projects p
+ INNER JOIN pipeline_stages ps ON p.stage_id = ps.id
+ WHERE p.id = ${proposals.projectId}
+ AND ps.category IN ('booked', 'in_progress', 'review', 'completed')
+ ) AND NOT EXISTS (
+ SELECT 1 FROM proposals p2
+ WHERE p2.project_id = ${proposals.projectId}
+ AND p2.status = 'accepted'
+ )`;
+
+ const basePredicate = and(
+ inArray(proposals.status, ['draft', 'sent', 'declined', 'expired', 'superseded']),
+ lt(proposals.updatedAt, cutoff),
+ unbookedCondition,
+ excludedIds.length > 0 ? notInArray(proposals.id, excludedIds) : undefined,
+ );
+
+ const rows = await client
+ .select({
+ id: proposals.id,
+ })
+ .from(proposals)
+ .where(basePredicate)
+ .orderBy(asc(proposals.updatedAt), asc(proposals.id))
+ .limit(limit);
+
+ return rows;
+ },
+ deleteBatch: async (client, ids) => {
+ if (ids.length === 0) return;
+ // 1. Delete associated line items
+ await client.delete(proposalLineItems).where(inArray(proposalLineItems.proposalId, ids));
+ // 2. Delete proposals
+ await client.delete(proposals).where(inArray(proposals.id, ids));
+ },
+ });
+
return [
auditEventsPolicy as unknown as RetentionPolicy,
expiredSessionsPolicy as unknown as RetentionPolicy,
@@ -586,6 +635,7 @@ export function createDefaultPolicies(): RetentionPolicy[] {
spamLeadsPolicy,
expiredPowReplaysPolicy,
failedEmailOutboxPolicy,
+ unacceptedProposalsPolicy,
];
}
diff --git a/src/server/retention/runner.ts b/src/server/retention/runner.ts
index 2c2102e..524174a 100644
--- a/src/server/retention/runner.ts
+++ b/src/server/retention/runner.ts
@@ -17,6 +17,7 @@ export interface CliExecutionResult extends SweepSummary {
inactiveClients?: { scanned: number; deleted: number; held: number };
unconvertedLeads?: { scanned: number; deleted: number; held: number };
spamLeads?: { scanned: number; deleted: number; held: number };
+ unacceptedProposals?: { scanned: number; deleted: number; held: number };
}
/**
@@ -75,6 +76,9 @@ export async function executeRetentionSweep(options: CliOptions = {}): Promise p.policyId === 'unconverted-leads');
const spamLeadsPolicy = summary.policies.find((p) => p.policyId === 'spam-leads');
+ const unacceptedProposalsPolicy = summary.policies.find(
+ (p) => p.policyId === 'unaccepted-proposals' || p.entity === 'proposals',
+ );
return {
...summary,
@@ -108,6 +112,11 @@ export async function executeRetentionSweep(options: CliOptions = {}): Promise {
+ return executeRetentionSweep(options);
+}
diff --git a/tests/integration/proposals-erasure.test.ts b/tests/integration/proposals-erasure.test.ts
new file mode 100644
index 0000000..7c5137e
--- /dev/null
+++ b/tests/integration/proposals-erasure.test.ts
@@ -0,0 +1,446 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+
+vi.mock('server-only', () => ({}));
+
+import { eq } from 'drizzle-orm';
+import { clients, clientContacts } from '@/server/db/schema/clients';
+import { projects, pipelineStages } from '@/server/db/schema/projects';
+import { proposals, proposalLineItems } from '@/server/db/schema/proposals';
+import { auditEvents } from '@/server/db/schema/audit';
+import { studioSettings } from '@/server/db/schema/studio-settings';
+import { user } from '@/server/db/schema/auth';
+import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper';
+import { eraseSubject, checkErasureHolds } from '@/server/privacy/erasure/service';
+import { runRetentionSweep } from '@/server/retention/sweep';
+import {
+ resetErasureHoldProviders,
+ resetErasureProviders,
+} from '@/server/privacy/erasure/registry';
+import { resetActivityProviders } from '@/server/clients/activity';
+import { resetPolicies } from '@/server/retention/policy';
+import type { DbClient } from '@/server/db/client';
+
+describe('TASK-0059: Proposals Erasure & Statutory Hold Integration (AC-3, AC-4, AC-5)', () => {
+ let testDb: TestDatabaseInstance;
+ let dbClient: DbClient;
+ const ownerUserId = '018f0000-0000-7000-8000-000000000001';
+ let defaultStageId = '01910000-0000-7000-8000-000000000010';
+ let archivedStageId = '01910000-0000-7000-8000-000000000011';
+
+ beforeEach(async () => {
+ resetErasureHoldProviders();
+ resetErasureProviders();
+ resetActivityProviders();
+ resetPolicies();
+
+ testDb = await createIsolatedTestDatabase();
+ dbClient = testDb.db as unknown as DbClient;
+ (globalThis as unknown as { db: DbClient | undefined }).db = dbClient;
+
+ // Seed studio owner user
+ await dbClient.insert(user).values({
+ id: ownerUserId,
+ name: 'Studio Owner',
+ email: 'owner@example.org',
+ role: 'owner',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ updatedAt: new Date('2024-01-01T00:00:00Z'),
+ });
+
+ // Seed studio settings
+ await dbClient.insert(studioSettings).values({
+ studio_name: 'Studio Test',
+ currency: 'EUR',
+ default_locale: 'en',
+ timezone: 'UTC',
+ created_at: new Date('2026-01-01T00:00:00Z'),
+ updated_at: new Date('2026-01-01T00:00:00Z'),
+ });
+
+ // Fetch existing inquiry and archived pipeline stages seeded by migrations
+ const [inquiryStage] = await dbClient
+ .select({ id: pipelineStages.id })
+ .from(pipelineStages)
+ .where(eq(pipelineStages.key, 'inquiry'))
+ .limit(1);
+
+ if (inquiryStage) {
+ defaultStageId = inquiryStage.id;
+ } else {
+ const [anyStage] = await dbClient
+ .select({ id: pipelineStages.id })
+ .from(pipelineStages)
+ .limit(1);
+ defaultStageId = anyStage?.id ?? '01910000-0000-7000-8000-000000000010';
+ }
+
+ const [archivedStage] = await dbClient
+ .select({ id: pipelineStages.id })
+ .from(pipelineStages)
+ .where(eq(pipelineStages.key, 'archived'))
+ .limit(1);
+
+ if (archivedStage) {
+ archivedStageId = archivedStage.id;
+ } else {
+ archivedStageId = defaultStageId;
+ }
+ });
+
+ afterEach(async () => {
+ delete (globalThis as unknown as { db: DbClient | undefined }).db;
+ resetErasureHoldProviders();
+ resetErasureProviders();
+ resetActivityProviders();
+ resetPolicies();
+ await testDb.destroy();
+ });
+
+ it('AC-3: hard-deletes unaccepted proposals and line items leaving zero orphaned rows when client is erased', async () => {
+ const clientId = '01910000-0000-7000-8000-0000000000c1';
+ const contactId = '01910000-0000-7000-8000-0000000000d1';
+ const projectId = '01910000-0000-7000-8000-0000000000e1';
+ const proposal1Id = '01910000-0000-7000-8000-0000000000f1';
+ const proposal2Id = '01910000-0000-7000-8000-0000000000f2';
+
+ // 1. Create client and contact
+ await dbClient.insert(clients).values({
+ id: clientId,
+ displayName: 'Unaccepted Client',
+ kind: 'person',
+ preferredLocale: 'en',
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ lastActivityAt: new Date('2026-01-01T00:00:00Z'),
+ });
+
+ await dbClient.insert(clientContacts).values({
+ id: contactId,
+ clientId,
+ givenName: 'Test',
+ familyName: 'Subject',
+ isPrimary: true,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ });
+
+ // 2. Create project
+ await dbClient.insert(projects).values({
+ id: projectId,
+ clientId,
+ stageId: archivedStageId,
+ title: 'Inquiry Project',
+ projectTypeKey: 'portrait',
+ currency: 'EUR',
+ priceMinor: 50000,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ lastActivityAt: new Date('2026-01-01T00:00:00Z'),
+ });
+
+ // 3. Create unaccepted proposals: draft and declined
+ await dbClient.insert(proposals).values([
+ {
+ id: proposal1Id,
+ projectId,
+ clientId,
+ title: 'Draft Proposal',
+ status: 'draft',
+ currency: 'EUR',
+ subtotalMinor: 50000,
+ taxMinor: 9500,
+ totalMinor: 59500,
+ tokenHash: 'token_hash_unaccepted_1',
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ updatedAt: new Date('2026-01-01T00:00:00Z'),
+ },
+ {
+ id: proposal2Id,
+ projectId,
+ clientId,
+ title: 'Declined Proposal',
+ status: 'declined',
+ currency: 'EUR',
+ subtotalMinor: 60000,
+ taxMinor: 11400,
+ totalMinor: 71400,
+ tokenHash: 'token_hash_unaccepted_2',
+ declineReason: 'Too expensive',
+ declinedAt: new Date('2026-01-05T00:00:00Z'),
+ createdAt: new Date('2026-01-02T00:00:00Z'),
+ updatedAt: new Date('2026-01-05T00:00:00Z'),
+ },
+ ]);
+
+ // 4. Create proposal line items
+ await dbClient.insert(proposalLineItems).values([
+ {
+ id: '01910000-0000-7000-8000-0000000000a1',
+ proposalId: proposal1Id,
+ itemType: 'base_package',
+ name: 'Basic Portrait',
+ unitPriceMinor: 50000,
+ quantity: 1,
+ taxRateBasisPoints: 1900,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ },
+ {
+ id: '01910000-0000-7000-8000-0000000000a2',
+ proposalId: proposal2Id,
+ itemType: 'addon',
+ name: 'Extra Retouched Photos',
+ unitPriceMinor: 10000,
+ quantity: 1,
+ taxRateBasisPoints: 1900,
+ createdAt: new Date('2026-01-02T00:00:00Z'),
+ },
+ ]);
+
+ // Verify records exist initially
+ const initialProposals = await dbClient
+ .select()
+ .from(proposals)
+ .where(eq(proposals.clientId, clientId));
+ expect(initialProposals).toHaveLength(2);
+ const initialLineItems = await dbClient.select().from(proposalLineItems);
+ expect(initialLineItems).toHaveLength(2);
+
+ // 5. Execute erasure for client
+ const res = await eraseSubject(
+ { kind: 'client', clientId },
+ { actor: { actorType: 'owner', actorId: ownerUserId } },
+ dbClient,
+ );
+
+ expect(res.success).toBe(true);
+ if (!res.success) throw new Error('Expected erasure to succeed');
+
+ // 6. Assert all proposals and line items were hard-deleted (zero orphaned database rows)
+ const remainingProposals = await dbClient
+ .select()
+ .from(proposals)
+ .where(eq(proposals.clientId, clientId));
+ expect(remainingProposals).toHaveLength(0);
+
+ const remainingLineItems = await dbClient.select().from(proposalLineItems);
+ expect(remainingLineItems).toHaveLength(0);
+
+ // Assert client itself was erased
+ const remainingClient = await dbClient.select().from(clients).where(eq(clients.id, clientId));
+ expect(remainingClient).toHaveLength(0);
+ });
+
+ it('AC-4: blocks erasure when an accepted proposal exists under statutory commercial hold (§147 AO, §257 HGB)', async () => {
+ const clientId = '01910000-0000-7000-8000-0000000000c2';
+ const contactId = '01910000-0000-7000-8000-0000000000d2';
+ const projectId = '01910000-0000-7000-8000-0000000000e2';
+ const acceptedProposalId = '01910000-0000-7000-8000-0000000000f3';
+
+ // 1. Create client and contact
+ await dbClient.insert(clients).values({
+ id: clientId,
+ displayName: 'Commercial Client',
+ kind: 'person',
+ preferredLocale: 'en',
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ lastActivityAt: new Date('2026-01-01T00:00:00Z'),
+ });
+
+ await dbClient.insert(clientContacts).values({
+ id: contactId,
+ clientId,
+ givenName: 'Commercial',
+ familyName: 'Buyer',
+ isPrimary: true,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ });
+
+ // 2. Create project
+ await dbClient.insert(projects).values({
+ id: projectId,
+ clientId,
+ stageId: defaultStageId,
+ title: 'Commercial Shoot',
+ projectTypeKey: 'commercial',
+ currency: 'EUR',
+ priceMinor: 150000,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ lastActivityAt: new Date('2026-01-01T00:00:00Z'),
+ });
+
+ // 3. Create accepted proposal
+ const acceptedAt = new Date('2026-06-15T12:00:00.000Z');
+ await dbClient.insert(proposals).values({
+ id: acceptedProposalId,
+ projectId,
+ clientId,
+ title: 'Commercial Campaign Proposal',
+ status: 'accepted',
+ currency: 'EUR',
+ subtotalMinor: 150000,
+ taxMinor: 28500,
+ totalMinor: 178500,
+ tokenHash: 'token_hash_accepted_1',
+ acceptedAt,
+ acceptedByName: 'Commercial Buyer',
+ acceptedByEmail: 'buyer@example.com',
+ acceptanceIpAnonymized: '192.168.1.0/24',
+ createdAt: new Date('2026-06-01T00:00:00Z'),
+ updatedAt: acceptedAt,
+ });
+
+ // 4. Check erasure holds
+ const holds = await checkErasureHolds({ kind: 'client', clientId }, dbClient);
+ expect(holds.length).toBeGreaterThan(0);
+ const statutoryHold = holds.find((h) => h.reason === 'statutory.commercial_document');
+ expect(statutoryHold).toBeDefined();
+ // 2026 acceptance year -> retainUntil 2036-12-31T23:59:59.999Z (10 years from end of calendar year per §147 AO)
+ expect(statutoryHold?.retainUntil).toBe('2036-12-31T23:59:59.999Z');
+
+ // 5. Attempt erasure and verify it is blocked
+ const res = await eraseSubject(
+ { kind: 'client', clientId },
+ { actor: { actorType: 'owner', actorId: ownerUserId } },
+ dbClient,
+ );
+
+ expect(res.success).toBe(false);
+ if (res.success) throw new Error('Expected erasure to fail');
+ expect(res.code).toBe('ERASURE_BLOCKED');
+ if (res.code !== 'ERASURE_BLOCKED') throw new Error('Expected ERASURE_BLOCKED');
+ expect(res.holds.some((h) => h.reason === 'statutory.commercial_document')).toBe(true);
+
+ // Verify all records remain completely intact
+ const clientRows = await dbClient.select().from(clients).where(eq(clients.id, clientId));
+ expect(clientRows).toHaveLength(1);
+ const proposalRows = await dbClient
+ .select()
+ .from(proposals)
+ .where(eq(proposals.id, acceptedProposalId));
+ expect(proposalRows).toHaveLength(1);
+
+ // Verify privacy.erasure.blocked audit event was emitted
+ const blockedEvents = await dbClient
+ .select()
+ .from(auditEvents)
+ .where(eq(auditEvents.action, 'privacy.erasure.blocked'));
+ expect(blockedEvents.length).toBeGreaterThan(0);
+ });
+
+ it('AC-5: retention runner prunes unaccepted proposals older than 12 months where no project was booked and records audit event', async () => {
+ const clientId = '01910000-0000-7000-8000-0000000000c3';
+ const projectId = '01910000-0000-7000-8000-0000000000e3';
+
+ // 1. Create client and project using the default inquiry stage
+ await dbClient.insert(clients).values({
+ id: clientId,
+ displayName: 'Inquiry Client',
+ kind: 'person',
+ preferredLocale: 'en',
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ lastActivityAt: new Date('2024-01-01T00:00:00Z'),
+ });
+
+ await dbClient.insert(projects).values({
+ id: projectId,
+ clientId,
+ stageId: defaultStageId,
+ title: 'Old Unbooked Project',
+ projectTypeKey: 'portrait',
+ currency: 'EUR',
+ priceMinor: 50000,
+ createdAt: new Date('2024-01-01T00:00:00Z'),
+ lastActivityAt: new Date('2024-01-01T00:00:00Z'),
+ });
+
+ const now = new Date('2026-10-10T12:00:00.000Z');
+ const fourteenMonthsAgo = new Date('2025-08-10T12:00:00.000Z');
+ const sixMonthsAgo = new Date('2026-04-10T12:00:00.000Z');
+
+ const oldExpiredProposalId = '01910000-0000-7000-8000-0000000000b1';
+ const recentDraftProposalId = '01910000-0000-7000-8000-0000000000b2';
+
+ // 2. Seed old proposal (> 12 months) and recent proposal (< 12 months)
+ await dbClient.insert(proposals).values([
+ {
+ id: oldExpiredProposalId,
+ projectId,
+ clientId,
+ title: 'Old Expired Proposal',
+ status: 'declined',
+ currency: 'EUR',
+ subtotalMinor: 50000,
+ taxMinor: 9500,
+ totalMinor: 59500,
+ tokenHash: 'token_old_expired',
+ createdAt: fourteenMonthsAgo,
+ updatedAt: fourteenMonthsAgo,
+ },
+ {
+ id: recentDraftProposalId,
+ projectId,
+ clientId,
+ title: 'Recent Draft Proposal',
+ status: 'draft',
+ currency: 'EUR',
+ subtotalMinor: 50000,
+ taxMinor: 9500,
+ totalMinor: 59500,
+ tokenHash: 'token_recent_draft',
+ createdAt: sixMonthsAgo,
+ updatedAt: sixMonthsAgo,
+ },
+ ]);
+
+ await dbClient.insert(proposalLineItems).values({
+ id: '01910000-0000-7000-8000-000000000081',
+ proposalId: oldExpiredProposalId,
+ itemType: 'base_package',
+ name: 'Old Package',
+ unitPriceMinor: 50000,
+ quantity: 1,
+ taxRateBasisPoints: 1900,
+ createdAt: fourteenMonthsAgo,
+ updatedAt: fourteenMonthsAgo,
+ });
+
+ // 3. Run retention sweep for unaccepted-proposals policy
+ const sweepResult = await runRetentionSweep({
+ policyIds: ['unaccepted-proposals'],
+ client: dbClient,
+ dryRun: false,
+ now,
+ });
+
+ const proposalPolicyResult = sweepResult.policies.find(
+ (p) => p.policyId === 'unaccepted-proposals',
+ );
+
+ expect(proposalPolicyResult).toBeDefined();
+ expect(proposalPolicyResult?.scannedCount).toBe(1);
+ expect(proposalPolicyResult?.deletedCount).toBe(1);
+
+ // 4. Assert old proposal was pruned, recent proposal was preserved
+ const survivingProposals = await dbClient
+ .select()
+ .from(proposals)
+ .where(eq(proposals.clientId, clientId));
+ expect(survivingProposals).toHaveLength(1);
+ expect(survivingProposals[0]?.id).toBe(recentDraftProposalId);
+
+ // Assert line items for old proposal were deleted
+ const survivingLineItems = await dbClient
+ .select()
+ .from(proposalLineItems)
+ .where(eq(proposalLineItems.proposalId, oldExpiredProposalId));
+ expect(survivingLineItems).toHaveLength(0);
+
+ // 5. Assert audit event recorded for retention sweep completion
+ const sweepAuditEvents = await dbClient
+ .select()
+ .from(auditEvents)
+ .where(eq(auditEvents.action, 'retention.sweep.completed'));
+ const proposalSweepEvent = sweepAuditEvents.find(
+ (e) => e.metadata?.policy_id === 'unaccepted-proposals',
+ );
+ expect(proposalSweepEvent).toBeDefined();
+ expect(proposalSweepEvent?.outcome).toBe('success');
+ });
+});
diff --git a/tests/unit/erasure-coverage.test.ts b/tests/unit/erasure-coverage.test.ts
index a57f642..610c751 100644
--- a/tests/unit/erasure-coverage.test.ts
+++ b/tests/unit/erasure-coverage.test.ts
@@ -75,6 +75,18 @@ describe('TASK-0016: Erasure schema coverage (AC-8)', () => {
).toEqual([]);
});
+ it('AC-6: proposals and proposal_line_items tables pass full erasure verification with zero exemptions', () => {
+ const providers = getErasureProviders();
+ const coveredTables = new Set(providers.flatMap((p) => p.coveredTables));
+ expect(coveredTables.has('proposals')).toBe(true);
+ expect(coveredTables.has('proposal_line_items')).toBe(true);
+
+ const proposalExemptions = ERASURE_COVERAGE_ALLOWLIST.filter(
+ (e) => e.tableName === 'proposals' || e.tableName === 'proposal_line_items',
+ );
+ expect(proposalExemptions).toHaveLength(0);
+ });
+
it('AC-8: fails naming the unmapped table when a fixture table with client_id is not covered', () => {
// Define a test-only fixture table with a client_id column
const fixtureUnmappedTable = pgTable('fixture_unmapped_erasure_client', {
diff --git a/tests/unit/proposals-activity-provider.test.ts b/tests/unit/proposals-activity-provider.test.ts
new file mode 100644
index 0000000..1d68219
--- /dev/null
+++ b/tests/unit/proposals-activity-provider.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('server-only', () => ({}));
+
+import { proposalsActivityProvider } from '@/server/proposals/privacy';
+import type { DbClient } from '@/server/db/client';
+
+describe('TASK-0059: Proposals Activity Provider Unit Tests', () => {
+ it('returns empty array when candidateIds is empty', async () => {
+ const tx = {} as DbClient;
+ const res = await proposalsActivityProvider.activeClientIds(tx, new Date(), []);
+ expect(res).toEqual([]);
+ });
+
+ it('identifies clients with active status proposals and recent activity', async () => {
+ let callCount = 0;
+ const tx = {
+ selectDistinct: () => ({
+ from: () => ({
+ where: () => {
+ callCount++;
+ if (callCount === 1) {
+ // active status proposals (draft, sent, accepted)
+ return Promise.resolve([{ clientId: 'client-1' }]);
+ }
+ // recent updates or valid until
+ return Promise.resolve([{ clientId: 'client-2' }, { clientId: 'client-1' }]);
+ },
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const res = await proposalsActivityProvider.activeClientIds(
+ tx,
+ new Date('2026-01-01T00:00:00Z'),
+ ['client-1', 'client-2', 'client-3'],
+ );
+
+ expect(res).toHaveLength(2);
+ expect(res).toContain('client-1');
+ expect(res).toContain('client-2');
+ expect(res).not.toContain('client-3');
+ });
+
+ it('returns empty array when no candidates have active or recent proposals', async () => {
+ const tx = {
+ selectDistinct: () => ({
+ from: () => ({
+ where: () => Promise.resolve([]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const res = await proposalsActivityProvider.activeClientIds(
+ tx,
+ new Date('2026-01-01T00:00:00Z'),
+ ['client-inactive'],
+ );
+
+ expect(res).toEqual([]);
+ });
+});
diff --git a/tests/unit/proposals-erasure-provider.test.ts b/tests/unit/proposals-erasure-provider.test.ts
new file mode 100644
index 0000000..baf0411
--- /dev/null
+++ b/tests/unit/proposals-erasure-provider.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('server-only', () => ({}));
+
+import * as PrivacyModule from '@/server/proposals/privacy/index';
+import { proposalsErasureProvider } from '@/server/proposals/privacy/index';
+import type { DbClient } from '@/server/db/client';
+
+describe('TASK-0059: Proposals Erasure Provider Unit Tests (AC-3)', () => {
+ it('exports all expected providers from privacy barrel index', () => {
+ expect(PrivacyModule.proposalsErasureProvider).toBeDefined();
+ expect(PrivacyModule.proposalsHoldProvider).toBeDefined();
+ expect(PrivacyModule.proposalsExportProvider).toBeDefined();
+ expect(PrivacyModule.proposalsActivityProvider).toBeDefined();
+ });
+
+ it('plan: returns 0 when subject is not a client', async () => {
+ const tx = {} as DbClient;
+ const planCount = await proposalsErasureProvider.plan({ kind: 'lead', leadId: 'lead-1' }, tx);
+ expect(planCount).toBe(0);
+
+ const contactPlanCount = await proposalsErasureProvider.plan(
+ { kind: 'contact', contactId: 'contact-1', clientId: 'client-1' },
+ tx,
+ );
+ expect(contactPlanCount).toBe(0);
+ });
+
+ it('plan: returns count of unaccepted proposals for client and handles undefined row', async () => {
+ const tx = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve([{ count: 3 }]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const count = await proposalsErasureProvider.plan({ kind: 'client', clientId: 'client-1' }, tx);
+ expect(count).toBe(3);
+
+ const txEmpty = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve([]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const countZero = await proposalsErasureProvider.plan(
+ { kind: 'client', clientId: 'client-1' },
+ txEmpty,
+ );
+ expect(countZero).toBe(0);
+ });
+
+ it('erase: returns 0 when subject is not a client', async () => {
+ const tx = {} as DbClient;
+ const deleted = await proposalsErasureProvider.erase({ kind: 'lead', leadId: 'lead-1' }, tx);
+ expect(deleted).toBe(0);
+ });
+
+ it('erase: returns 0 when client has no unaccepted proposals', async () => {
+ const tx = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve([]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const deleted = await proposalsErasureProvider.erase(
+ { kind: 'client', clientId: 'client-1' },
+ tx,
+ );
+ expect(deleted).toBe(0);
+ });
+
+ it('erase: deletes proposal line items and proposals when unaccepted proposals exist', async () => {
+ let deletedLineItems = false;
+ let deletedProposals = false;
+
+ const tx = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve([{ id: 'prop-1' }, { id: 'prop-2' }]),
+ }),
+ }),
+ delete: () => ({
+ where: () => {
+ if (!deletedLineItems) {
+ deletedLineItems = true;
+ return Promise.resolve();
+ }
+ deletedProposals = true;
+ return {
+ returning: () => Promise.resolve([{ id: 'prop-1' }, { id: 'prop-2' }]),
+ };
+ },
+ }),
+ } as unknown as DbClient;
+
+ const count = await proposalsErasureProvider.erase(
+ { kind: 'client', clientId: 'client-1' },
+ tx,
+ );
+
+ expect(count).toBe(2);
+ expect(deletedLineItems).toBe(true);
+ expect(deletedProposals).toBe(true);
+ });
+});
diff --git a/tests/unit/proposals-export-provider.test.ts b/tests/unit/proposals-export-provider.test.ts
new file mode 100644
index 0000000..ef369f6
--- /dev/null
+++ b/tests/unit/proposals-export-provider.test.ts
@@ -0,0 +1,556 @@
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('server-only', () => ({}));
+
+import {
+ proposalsExportProvider,
+ PROPOSAL_EXPORT_HEADERS,
+ formatProposalsHtml,
+ type FormattedProposalExport,
+} from '@/server/proposals/privacy';
+import { getCategoryDefinition } from '@/server/privacy/export/categories';
+import { formatColumnHeader } from '@/server/privacy/export/html';
+import { dsarExportSchema } from '@/server/privacy/export/schema';
+import { generateJsonExport } from '@/server/privacy/export/json';
+import type { ExportCollectedData } from '@/server/privacy/export/types';
+import type { DbClient } from '@/server/db/client';
+
+describe('TASK-0059: Proposals DSAR Export Provider (AC-1, AC-2)', () => {
+ const mockProposals = [
+ {
+ id: '01910000-0000-7000-8000-000000000001',
+ projectId: '01910000-0000-7000-8000-000000000010',
+ clientId: '01910000-0000-7000-8000-0000000000aa',
+ title: 'Full Day Wedding Photography',
+ status: 'accepted',
+ version: 1,
+ currency: 'EUR',
+ subtotalMinor: 250000,
+ taxMinor: 47500,
+ totalMinor: 297500,
+ pricesIncludeTax: true,
+ isKleinunternehmer: false,
+ taxNotice: null,
+ validUntil: new Date('2026-06-30T23:59:59.000Z'),
+ tokenHash: 'tokenhash123',
+ tokenRevokedAt: null,
+ sentAt: new Date('2026-05-01T10:00:00.000Z'),
+ acceptedAt: new Date('2026-05-15T14:30:00.000Z'),
+ declinedAt: null,
+ declineReason: null,
+ acceptedByName: 'Alice Smith',
+ acceptedByEmail: 'alice@example.com',
+ acceptanceIpAnonymized: '192.168.1.0/24',
+ acceptanceUserAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
+ acceptedOptionsSnapshot: null,
+ internalNotes: 'Client booked via referral',
+ createdAt: new Date('2026-05-01T09:00:00.000Z'),
+ updatedAt: new Date('2026-05-15T14:30:00.000Z'),
+ },
+ {
+ id: '01910000-0000-7000-8000-000000000002',
+ projectId: '01910000-0000-7000-8000-000000000099',
+ clientId: '01910000-0000-7000-8000-0000000000aa',
+ title: 'Engagement Shoot Add-on',
+ status: 'declined',
+ version: 1,
+ currency: 'EUR',
+ subtotalMinor: 40000,
+ taxMinor: 7600,
+ totalMinor: 47600,
+ pricesIncludeTax: true,
+ isKleinunternehmer: false,
+ taxNotice: null,
+ validUntil: null,
+ tokenHash: 'tokenhash456',
+ tokenRevokedAt: null,
+ sentAt: null,
+ acceptedAt: null,
+ declinedAt: new Date('2026-06-01T12:00:00.000Z'),
+ declineReason: 'Client chose alternative photographer',
+ acceptedByName: null,
+ acceptedByEmail: null,
+ acceptanceIpAnonymized: null,
+ acceptanceUserAgent: null,
+ acceptedOptionsSnapshot: null,
+ internalNotes: null,
+ createdAt: new Date('2026-05-20T10:00:00.000Z'),
+ updatedAt: new Date('2026-06-01T12:00:00.000Z'),
+ },
+ ];
+
+ const mockLineItems = [
+ {
+ id: '01910000-0000-7000-8000-00000000000a',
+ proposalId: '01910000-0000-7000-8000-000000000001',
+ packageId: '01910000-0000-7000-8000-0000000000c1',
+ catalogueItemId: null,
+ itemType: 'base_package',
+ name: '8-Hour Wedding Coverage',
+ description: 'Includes 2 photographers and online gallery',
+ unitPriceMinor: 250000,
+ quantity: 1,
+ minQuantity: 1,
+ maxQuantity: 1,
+ taxRateBasisPoints: 1900,
+ isOptional: false,
+ isSelected: true,
+ position: 0,
+ createdAt: new Date('2026-05-01T09:00:00.000Z'),
+ updatedAt: new Date('2026-05-01T09:00:00.000Z'),
+ },
+ {
+ id: '01910000-0000-7000-8000-00000000000b',
+ proposalId: '01910000-0000-7000-8000-000000000002',
+ packageId: null,
+ catalogueItemId: null,
+ itemType: 'addon',
+ name: 'Sunset Mini Session',
+ description: null,
+ unitPriceMinor: 40000,
+ quantity: 1,
+ minQuantity: 0,
+ maxQuantity: 1,
+ taxRateBasisPoints: 1900,
+ isOptional: true,
+ isSelected: false,
+ position: 0,
+ createdAt: new Date('2026-05-20T10:00:00.000Z'),
+ updatedAt: new Date('2026-05-20T10:00:00.000Z'),
+ },
+ ];
+
+ function createMockTx(
+ proposalsList = mockProposals,
+ lineItemsList = mockLineItems,
+ projectTitle = 'Emma & Liam Wedding',
+ ): DbClient {
+ let callIndex = 0;
+ return {
+ select: () => ({
+ from: () => ({
+ where: () => {
+ callIndex++;
+ if (callIndex === 1) {
+ // proposals query
+ return {
+ orderBy: () => Promise.resolve(proposalsList),
+ };
+ }
+ if (callIndex === 2) {
+ // line items query
+ return {
+ orderBy: () => Promise.resolve(lineItemsList),
+ };
+ }
+ if (callIndex === 3) {
+ // project titles query
+ return Promise.resolve([
+ { id: '01910000-0000-7000-8000-000000000010', title: projectTitle },
+ ]);
+ }
+ return Promise.resolve([]);
+ },
+ }),
+ }),
+ } as unknown as DbClient;
+ }
+
+ describe('Category registration & i18n disclosures', () => {
+ it('AC-1: proposals category is registered with statutory Art. 15 disclosures', () => {
+ const def = getCategoryDefinition('proposals');
+ expect(def).toBeDefined();
+ expect(def?.id).toBe('proposals');
+ expect(def?.title.en).toBe('Proposals & Quotes');
+ expect(def?.title.de).toBe('Angebote & Kostenvoranschläge');
+ expect(def?.purpose.en).toContain('proposals');
+ expect(def?.purpose.de).toContain('Angeboten');
+ expect(def?.lawfulBasis.en).toContain('Art. 6(1)(b) GDPR');
+ expect(def?.lawfulBasis.de).toContain('Art. 6 Abs. 1 lit. b DSGVO');
+ expect(def?.retention.en).toContain('§ 147 AO');
+ expect(def?.retention.de).toContain('§ 147 AO');
+ expect(def?.recipients.en).toContain('None (stored locally');
+ expect(def?.recipients.de).toContain('Keine (ausschließlich lokale');
+ expect(def?.source.en).toContain('studio owner and client');
+ expect(def?.source.de).toContain('Studioinhaber und Kunden');
+ });
+
+ it('AC-2: formatColumnHeader localizes proposal column headers into German', () => {
+ expect(formatColumnHeader('proposal', 'de')).toBe('Angebot');
+ expect(formatColumnHeader('proposal_title', 'de')).toBe('Angebot');
+ expect(formatColumnHeader('subtotal', 'de')).toBe('Zwischensumme');
+ expect(formatColumnHeader('tax', 'de')).toBe('Umsatzsteuer');
+ expect(formatColumnHeader('total', 'de')).toBe('Gesamtbetrag');
+ expect(formatColumnHeader('valid_until', 'de')).toBe('Gültig bis');
+ expect(formatColumnHeader('sent_at', 'de')).toBe('Gesendet am');
+ expect(formatColumnHeader('accepted_at', 'de')).toBe('Angenommen am');
+ expect(formatColumnHeader('accepted_by', 'de')).toBe('Angenommen von');
+
+ // English fallback
+ expect(formatColumnHeader('proposal', 'en')).toBe('Proposal');
+ expect(formatColumnHeader('subtotal', 'en')).toBe('Subtotal');
+ expect(formatColumnHeader('tax', 'en')).toBe('Tax');
+ expect(formatColumnHeader('total', 'en')).toBe('Total');
+
+ expect(PROPOSAL_EXPORT_HEADERS.de.proposal).toBe('Angebot');
+ expect(PROPOSAL_EXPORT_HEADERS.en.proposal).toBe('Proposal');
+ });
+ });
+
+ describe('AC-1: Structured JSON export matching ownlight.dsar.v1', () => {
+ it('AC-1: exports structured JSON matching ownlight.dsar.v1 with proposals, line items, and acceptance metadata', async () => {
+ const mockTx = createMockTx();
+ const subject = {
+ kind: 'client' as const,
+ clientId: '01910000-0000-7000-8000-0000000000aa',
+ contactId: '01910000-0000-7000-8000-0000000000bb',
+ contact: {} as never,
+ client: {} as never,
+ };
+
+ const items = (await proposalsExportProvider.collect(
+ subject,
+ mockTx,
+ )) as FormattedProposalExport[];
+
+ expect(items).toHaveLength(2);
+
+ const acceptedProposal = items[0];
+ if (!acceptedProposal) throw new Error('Expected acceptedProposal');
+
+ expect(acceptedProposal.id).toBe('01910000-0000-7000-8000-000000000001');
+ expect(acceptedProposal.project_id).toBe('01910000-0000-7000-8000-000000000010');
+ expect(acceptedProposal.title).toBe('Full Day Wedding Photography');
+ expect(acceptedProposal.status).toBe('accepted');
+ expect(acceptedProposal.currency).toBe('EUR');
+ expect(acceptedProposal.subtotal).toBe(250000);
+ expect(acceptedProposal.subtotal_minor).toBe(250000);
+ expect(acceptedProposal.tax).toBe(47500);
+ expect(acceptedProposal.tax_minor).toBe(47500);
+ expect(acceptedProposal.total).toBe(297500);
+ expect(acceptedProposal.total_minor).toBe(297500);
+ expect(acceptedProposal.valid_until).toBe('2026-06-30T23:59:59.000Z');
+ expect(acceptedProposal.sent_at).toBe('2026-05-01T10:00:00.000Z');
+ expect(acceptedProposal.accepted_at).toBe('2026-05-15T14:30:00.000Z');
+ expect(acceptedProposal.acceptance_timestamp).toBe('2026-05-15T14:30:00.000Z');
+ expect(acceptedProposal.accepted_by_name).toBe('Alice Smith');
+ expect(acceptedProposal.accepted_by_email).toBe('alice@example.com');
+ expect(acceptedProposal.acceptance_ip_anonymized).toBe('192.168.1.0/24');
+ expect(acceptedProposal.anonymized_ip).toBe('192.168.1.0/24');
+ expect(acceptedProposal.acceptance_user_agent).toContain('Mozilla/5.0');
+ expect(acceptedProposal.user_agent).toContain('Mozilla/5.0');
+
+ // Line items check
+ expect(acceptedProposal.line_items).toHaveLength(1);
+ const li = acceptedProposal.line_items[0];
+ if (!li) throw new Error('Expected line item');
+ expect(li.item_name).toBe('8-Hour Wedding Coverage');
+ expect(li.description).toBe('Includes 2 photographers and online gallery');
+ expect(li.unit_price).toBe(250000);
+ expect(li.quantity).toBe(1);
+ expect(li.tax_rate).toBe(1900);
+ expect(li.is_selected).toBe(true);
+ expect(li.selection_state).toBe(true);
+
+ // Verify overall DSAR JSON export matches schema
+ const collectedData: ExportCollectedData = {
+ schema: 'ownlight.dsar.v1',
+ generated_at: new Date().toISOString(),
+ studio: { name: 'Sunset Studio' },
+ subject: {
+ id: '01910000-0000-7000-8000-0000000000aa',
+ display_name: 'Alice Smith',
+ first_name: 'Alice',
+ last_name: 'Smith',
+ email: 'alice@example.com',
+ phone: null,
+ role: 'client',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ },
+ categories: {
+ proposals: items,
+ },
+ };
+
+ const jsonString = generateJsonExport(collectedData);
+ const parsed = JSON.parse(jsonString) as unknown;
+ const parseResult = dsarExportSchema.safeParse(parsed);
+ expect(parseResult.success).toBe(true);
+ });
+
+ it('AC-1: handles contact subject correctly by scoping proposals to clientId', async () => {
+ const mockTx = createMockTx();
+ const subject = {
+ kind: 'contact' as const,
+ contactId: '01910000-0000-7000-8000-0000000000bb',
+ clientId: '01910000-0000-7000-8000-0000000000aa',
+ contact: {} as never,
+ client: {} as never,
+ };
+
+ const items = (await proposalsExportProvider.collect(
+ subject,
+ mockTx,
+ )) as FormattedProposalExport[];
+ expect(items).toHaveLength(2);
+ });
+
+ it('AC-1: returns empty array for lead subjects', async () => {
+ const mockTx = {} as DbClient;
+ const subject = {
+ kind: 'lead' as const,
+ leadId: '01910000-0000-7000-8000-0000000000cc',
+ lead: {} as never,
+ };
+
+ const items = await proposalsExportProvider.collect(subject, mockTx);
+ expect(items).toEqual([]);
+ });
+
+ it('AC-1: returns empty array when client has zero proposals', async () => {
+ const mockTx = {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ orderBy: () => Promise.resolve([]),
+ }),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const subject = {
+ kind: 'client' as const,
+ clientId: '01910000-0000-7000-8000-0000000000empty',
+ contactId: '01910000-0000-7000-8000-0000000000empty',
+ contact: {} as never,
+ client: {} as never,
+ };
+
+ const items = await proposalsExportProvider.collect(subject, mockTx);
+ expect(items).toEqual([]);
+ });
+ });
+
+ describe('AC-2: Human-readable localized HTML export table (en/de)', () => {
+ it('AC-2: renders accessible HTML table in English with currency formatting and line items', () => {
+ const sampleItems: FormattedProposalExport[] = [
+ {
+ id: '01910000-0000-7000-8000-000000000001',
+ project_id: '01910000-0000-7000-8000-000000000010',
+ project_title: 'Emma & Liam Wedding',
+ title: 'Full Day Wedding Photography',
+ status: 'accepted',
+ currency: 'EUR',
+ subtotal: 250000,
+ subtotal_minor: 250000,
+ tax: 47500,
+ tax_minor: 47500,
+ total: 297500,
+ total_minor: 297500,
+ prices_include_tax: true,
+ is_kleinunternehmer: false,
+ valid_until: '2026-06-30T23:59:59.000Z',
+ validity_date: '2026-06-30T23:59:59.000Z',
+ sent_at: '2026-05-01T10:00:00.000Z',
+ sent_date: '2026-05-01T10:00:00.000Z',
+ accepted_at: '2026-05-15T14:30:00.000Z',
+ acceptance_timestamp: '2026-05-15T14:30:00.000Z',
+ declined_at: null,
+ decline_reason: null,
+ accepted_by_name: 'Alice Smith',
+ accepted_by_email: 'alice@example.com',
+ acceptance_ip_anonymized: '192.168.1.0/24',
+ anonymized_ip: '192.168.1.0/24',
+ acceptance_user_agent: 'Mozilla/5.0',
+ user_agent: 'Mozilla/5.0',
+ line_items: [
+ {
+ id: 'li-1',
+ proposal_id: '01910000-0000-7000-8000-000000000001',
+ name: '8-Hour Wedding Coverage',
+ item_name: '8-Hour Wedding Coverage',
+ item_type: 'base_package',
+ description: 'Includes 2 photographers',
+ unit_price: 250000,
+ unit_price_minor: 250000,
+ quantity: 1,
+ tax_rate: 1900,
+ tax_rate_basis_points: 1900,
+ is_optional: false,
+ is_selected: true,
+ selection_state: true,
+ position: 0,
+ },
+ ],
+ created_at: '2026-05-01T09:00:00.000Z',
+ updated_at: '2026-05-15T14:30:00.000Z',
+ },
+ ];
+
+ const htmlEn = formatProposalsHtml(sampleItems, 'en');
+
+ expect(htmlEn).toContain('Full Day Wedding Photography');
+ expect(htmlEn).toContain('Emma & Liam Wedding');
+ expect(htmlEn).toContain('Accepted');
+ expect(htmlEn).toContain('€2,975.00'); // Proper currency formatting
+ expect(htmlEn).toContain('8-Hour Wedding Coverage');
+ expect(htmlEn).toContain('Alice Smith');
+ expect(htmlEn).toContain('alice@example.com');
+ expect(htmlEn).toContain('');
+ expect(htmlEn).toContain('aria-label="Proposals & Quotes"');
+ });
+
+ it('AC-2: renders accessible HTML table in German with German currency formatting and line items', () => {
+ const sampleItems: FormattedProposalExport[] = [
+ {
+ id: '01910000-0000-7000-8000-000000000001',
+ project_id: '01910000-0000-7000-8000-000000000010',
+ project_title: 'Hochzeit Emma & Liam',
+ title: 'Ganztägige Hochzeitsfotografie',
+ status: 'accepted',
+ currency: 'EUR',
+ subtotal: 250000,
+ subtotal_minor: 250000,
+ tax: 47500,
+ tax_minor: 47500,
+ total: 297500,
+ total_minor: 297500,
+ prices_include_tax: true,
+ is_kleinunternehmer: false,
+ valid_until: '2026-06-30T23:59:59.000Z',
+ validity_date: '2026-06-30T23:59:59.000Z',
+ sent_at: '2026-05-01T10:00:00.000Z',
+ sent_date: '2026-05-01T10:00:00.000Z',
+ accepted_at: '2026-05-15T14:30:00.000Z',
+ acceptance_timestamp: '2026-05-15T14:30:00.000Z',
+ declined_at: null,
+ decline_reason: null,
+ accepted_by_name: 'Alice Smith',
+ accepted_by_email: 'alice@example.com',
+ acceptance_ip_anonymized: '192.168.1.0/24',
+ anonymized_ip: '192.168.1.0/24',
+ acceptance_user_agent: 'Mozilla/5.0',
+ user_agent: 'Mozilla/5.0',
+ line_items: [
+ {
+ id: 'li-1',
+ proposal_id: '01910000-0000-7000-8000-000000000001',
+ name: '8 Stunden Reportage',
+ item_name: '8 Stunden Reportage',
+ item_type: 'base_package',
+ description: 'Inkl. 2 Fotografen',
+ unit_price: 250000,
+ unit_price_minor: 250000,
+ quantity: 1,
+ tax_rate: 1900,
+ tax_rate_basis_points: 1900,
+ is_optional: false,
+ is_selected: true,
+ selection_state: true,
+ position: 0,
+ },
+ ],
+ created_at: '2026-05-01T09:00:00.000Z',
+ updated_at: '2026-05-15T14:30:00.000Z',
+ },
+ ];
+
+ const htmlDe = formatProposalsHtml(sampleItems, 'de');
+
+ expect(htmlDe).toContain('Ganztägige Hochzeitsfotografie');
+ expect(htmlDe).toContain('Hochzeit Emma & Liam');
+ expect(htmlDe).toContain('Angenommen');
+ expect(htmlDe).toContain('2.975,00 €'); // German currency formatting
+ expect(htmlDe).toContain('8 Stunden Reportage');
+ expect(htmlDe).toContain('Alice Smith');
+ expect(htmlDe).toContain('alice@example.com');
+ expect(htmlDe).toContain('aria-label="Angebote & Kostenvoranschläge"');
+ });
+
+ it('AC-2: renders empty notice when items array is empty', () => {
+ const emptyEn = formatProposalsHtml([], 'en');
+ expect(emptyEn).toContain('No records stored in this category.');
+
+ const emptyDe = formatProposalsHtml([], 'de');
+ expect(emptyDe).toContain('In dieser Kategorie sind derzeit keine Datensätze gespeichert.');
+ });
+
+ it('AC-2: formats proposals with null project_title, valid_until, accepted_at, and empty line items', () => {
+ const minimalItems: FormattedProposalExport[] = [
+ {
+ id: 'prop-min',
+ project_id: 'proj-min',
+ project_title: null,
+ title: 'Minimal Draft Proposal',
+ status: 'draft',
+ currency: 'EUR',
+ subtotal: 10000,
+ subtotal_minor: 10000,
+ tax: 1900,
+ tax_minor: 1900,
+ total: 11900,
+ total_minor: 11900,
+ prices_include_tax: true,
+ is_kleinunternehmer: false,
+ valid_until: null,
+ validity_date: null,
+ sent_at: null,
+ sent_date: null,
+ accepted_at: null,
+ acceptance_timestamp: null,
+ declined_at: null,
+ decline_reason: null,
+ accepted_by_name: null,
+ accepted_by_email: null,
+ acceptance_ip_anonymized: null,
+ anonymized_ip: null,
+ acceptance_user_agent: null,
+ user_agent: null,
+ line_items: [],
+ created_at: '2026-05-01T00:00:00.000Z',
+ updated_at: '2026-05-01T00:00:00.000Z',
+ proposal: 'Minimal Draft Proposal',
+ project: '-',
+ },
+ ];
+
+ const html = formatProposalsHtml(minimalItems, 'en');
+ expect(html).toContain('Minimal Draft Proposal');
+ expect(html).toContain('| - | ');
+ });
+
+ it('collect: returns empty array when subject is a lead', async () => {
+ const tx = {} as DbClient;
+ const res = await proposalsExportProvider.collect(
+ { kind: 'lead', leadId: 'lead-1', lead: {} as never },
+ tx,
+ );
+ expect(res).toEqual([]);
+ });
+
+ it('collect: returns empty array when client has no proposals', async () => {
+ const tx = {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ orderBy: () => Promise.resolve([]),
+ }),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const res = await proposalsExportProvider.collect(
+ {
+ kind: 'client',
+ clientId: 'client-empty',
+ contactId: 'contact-empty',
+ client: {} as never,
+ contact: {} as never,
+ },
+ tx,
+ );
+ expect(res).toEqual([]);
+ });
+ });
+});
diff --git a/tests/unit/proposals-hold-provider.test.ts b/tests/unit/proposals-hold-provider.test.ts
new file mode 100644
index 0000000..3bf0f39
--- /dev/null
+++ b/tests/unit/proposals-hold-provider.test.ts
@@ -0,0 +1,160 @@
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('server-only', () => ({}));
+
+import { proposalsHoldProvider } from '@/server/proposals/privacy/hold-provider';
+import type { DbClient } from '@/server/db/client';
+
+describe('TASK-0059: Proposals Commercial Hold Provider (AC-4)', () => {
+ it('AC-4: returns active hold with code statutory.commercial_document when client has an accepted proposal', async () => {
+ const acceptanceDate = new Date('2026-05-15T14:30:00.000Z');
+
+ const mockTx = {
+ select: () => ({
+ from: () => ({
+ where: () =>
+ Promise.resolve([
+ {
+ id: '01910000-0000-7000-8000-000000000001',
+ status: 'accepted',
+ acceptedAt: acceptanceDate,
+ updatedAt: acceptanceDate,
+ createdAt: new Date('2026-05-01T10:00:00.000Z'),
+ },
+ ]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const holds = await proposalsHoldProvider.holds(
+ { kind: 'client', clientId: '01910000-0000-7000-8000-0000000000aa' },
+ mockTx,
+ );
+
+ expect(holds).toHaveLength(1);
+ const hold = holds[0];
+ if (!hold) throw new Error('Expected hold');
+
+ expect(hold.reason).toBe('statutory.commercial_document');
+ // Lock date: 10 years from end of calendar year of acceptance (2026 -> 2036-12-31T23:59:59.999Z)
+ expect(hold.retainUntil).toBe('2036-12-31T23:59:59.999Z');
+ });
+
+ it('AC-4: calculates retention lock date based on latest accepted proposal when multiple accepted proposals exist', async () => {
+ const mockTx = {
+ select: () => ({
+ from: () => ({
+ where: () =>
+ Promise.resolve([
+ {
+ id: 'p-1',
+ status: 'accepted',
+ acceptedAt: new Date('2024-03-10T10:00:00.000Z'),
+ updatedAt: new Date('2024-03-10T10:00:00.000Z'),
+ createdAt: new Date('2024-03-01T10:00:00.000Z'),
+ },
+ {
+ id: 'p-2',
+ status: 'accepted',
+ acceptedAt: new Date('2026-11-20T12:00:00.000Z'),
+ updatedAt: new Date('2026-11-20T12:00:00.000Z'),
+ createdAt: new Date('2026-11-15T12:00:00.000Z'),
+ },
+ ]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const holds = await proposalsHoldProvider.holds(
+ { kind: 'client', clientId: 'c-multiple' },
+ mockTx,
+ );
+
+ expect(holds).toHaveLength(1);
+ // Based on 2026 acceptance, not 2024
+ expect(holds[0]?.retainUntil).toBe('2036-12-31T23:59:59.999Z');
+ });
+
+ it('AC-4: handles reverse order of proposals and fallback dates when acceptedAt is null', async () => {
+ const mockTx = {
+ select: () => ({
+ from: () => ({
+ where: () =>
+ Promise.resolve([
+ {
+ id: 'p-newer',
+ status: 'accepted',
+ acceptedAt: null,
+ updatedAt: new Date('2028-05-01T00:00:00.000Z'),
+ createdAt: new Date('2028-01-01T00:00:00.000Z'),
+ },
+ {
+ id: 'p-older',
+ status: 'accepted',
+ acceptedAt: null,
+ updatedAt: new Date('2025-01-01T00:00:00.000Z'),
+ createdAt: new Date('2025-01-01T00:00:00.000Z'),
+ },
+ ]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const holds = await proposalsHoldProvider.holds(
+ { kind: 'client', clientId: 'c-fallbacks' },
+ mockTx,
+ );
+
+ expect(holds).toHaveLength(1);
+ expect(holds[0]?.retainUntil).toBe('2038-12-31T23:59:59.999Z');
+ });
+
+ it('AC-4: returns zero holds when client has only unaccepted proposals (draft, sent, declined, expired)', async () => {
+ const mockTx = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve([]), // No accepted proposals
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const holds = await proposalsHoldProvider.holds(
+ { kind: 'client', clientId: 'c-unaccepted' },
+ mockTx,
+ );
+
+ expect(holds).toHaveLength(0);
+ });
+
+ it('AC-4: returns zero holds when client has no proposals at all', async () => {
+ const mockTx = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve([]),
+ }),
+ }),
+ } as unknown as DbClient;
+
+ const holds = await proposalsHoldProvider.holds(
+ { kind: 'client', clientId: 'c-empty' },
+ mockTx,
+ );
+
+ expect(holds).toHaveLength(0);
+ });
+
+ it('AC-4: ignores non-client subjects (lead, contact) and returns empty array', async () => {
+ const mockTx = {} as DbClient;
+ const leadHolds = await proposalsHoldProvider.holds(
+ { kind: 'lead', leadId: 'lead-123' },
+ mockTx,
+ );
+ expect(leadHolds).toEqual([]);
+
+ const contactHolds = await proposalsHoldProvider.holds(
+ { kind: 'contact', contactId: 'contact-1', clientId: 'client-1' },
+ mockTx,
+ );
+ expect(contactHolds).toEqual([]);
+ });
+});
From 7a532dd705f9421704bd9eb545d999c803261bc3 Mon Sep 17 00:00:00 2001
From: Ediminator <211899731+Ediminator@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:21:27 +0100
Subject: [PATCH 2/2] test: update existing suite expectations for proposals
privacy providers
Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com>
---
tests/integration/erasure.test.ts | 2 ++
tests/unit/client-activity.test.ts | 12 +++++++++---
tests/unit/retention-policy-branches.test.ts | 4 +++-
3 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/tests/integration/erasure.test.ts b/tests/integration/erasure.test.ts
index 6a4a7f7..fb0f85c 100644
--- a/tests/integration/erasure.test.ts
+++ b/tests/integration/erasure.test.ts
@@ -212,6 +212,7 @@ describe('TASK-0016: Erasure with retention-lock awareness integration', () => {
timeline: 1,
consents: 1,
google_contacts: 0,
+ proposals: 0,
projects: 0,
appointments: 0,
});
@@ -395,6 +396,7 @@ describe('TASK-0016: Erasure with retention-lock awareness integration', () => {
timeline: 1,
consents: 1,
google_contacts: 0,
+ proposals: 0,
projects: 0,
appointments: 0,
},
diff --git a/tests/unit/client-activity.test.ts b/tests/unit/client-activity.test.ts
index da2b6f3..6cce7d1 100644
--- a/tests/unit/client-activity.test.ts
+++ b/tests/unit/client-activity.test.ts
@@ -14,15 +14,21 @@ describe('TASK-0019: Client activity provider registry and domain providers', ()
resetActivityProviders();
});
- it('resets and returns default activity providers (timeline, notes, consents)', () => {
+ it('resets and returns default activity providers (timeline, notes, consents, projects, proposals)', () => {
const providers = getActivityProviders();
- expect(providers.map((p) => p.id)).toEqual(['timeline', 'notes', 'consents', 'projects']);
+ expect(providers.map((p) => p.id)).toEqual([
+ 'timeline',
+ 'notes',
+ 'consents',
+ 'projects',
+ 'proposals',
+ ]);
clearActivityProviders();
expect(getActivityProviders()).toHaveLength(0);
resetActivityProviders();
- expect(getActivityProviders()).toHaveLength(4);
+ expect(getActivityProviders()).toHaveLength(5);
});
it('findActiveClientIds returns empty Set when candidateIds is empty', async () => {
diff --git a/tests/unit/retention-policy-branches.test.ts b/tests/unit/retention-policy-branches.test.ts
index 523cf71..7a17380 100644
--- a/tests/unit/retention-policy-branches.test.ts
+++ b/tests/unit/retention-policy-branches.test.ts
@@ -87,7 +87,7 @@ describe('Retention policy utility and definition branches', () => {
it('createDefaultPolicies constructs policies that exercise getCandidates and deleteBatch branches', async () => {
const policies = createDefaultPolicies();
- expect(policies).toHaveLength(9);
+ expect(policies).toHaveLength(10);
const [
auditPolicy,
@@ -99,6 +99,7 @@ describe('Retention policy utility and definition branches', () => {
spamLeadsPolicy,
expiredPowReplaysPolicy,
failedEmailOutboxPolicy,
+ unacceptedProposalsPolicy,
] = policies;
expect(auditPolicy).toBeDefined();
expect(sessionPolicy).toBeDefined();
@@ -109,6 +110,7 @@ describe('Retention policy utility and definition branches', () => {
expect(spamLeadsPolicy).toBeDefined();
expect(expiredPowReplaysPolicy).toBeDefined();
expect(failedEmailOutboxPolicy).toBeDefined();
+ expect(unacceptedProposalsPolicy).toBeDefined();
// Mock DB client
let deletedCount = 0;