+ The vendors marked below are announced but not yet
+ processing. They are listed here during the notice
+ period so operators can object before the change
+ takes effect, rather than after.
+
diff --git a/nextjs_space/lib/legal/subprocessor-announce.ts b/nextjs_space/lib/legal/subprocessor-announce.ts
new file mode 100644
index 00000000..9de83b89
--- /dev/null
+++ b/nextjs_space/lib/legal/subprocessor-announce.ts
@@ -0,0 +1,228 @@
+/**
+ * Announce a sub-processor change to every active operator.
+ *
+ * This is the mechanism behind DPA §6. Before it existed the promise was prose:
+ * the page told operators to *subscribe* by emailing us, while the DPA said we
+ * would notify them. Notice you have to opt into is not notice, and a controller
+ * who never heard about a change cannot exercise the objection right they were
+ * granted.
+ *
+ * Every active tenant is emailed. There is no subscriber list, deliberately.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-013).
+ */
+
+import { prisma } from "@/lib/db";
+import { logger } from "@/lib/logger";
+import { sendEmail } from "@/lib/email/email";
+import { createAuditLog } from "@/lib/audit-log";
+import { OBJECTION_WINDOW_DAYS, hasSufficientNotice } from "./subprocessor-notice";
+
+export interface AnnounceableSubprocessor {
+ id: string;
+ name: string;
+ purpose: string;
+ region: string;
+ transferMechanism: string;
+ effectiveFrom: Date;
+}
+
+export interface AnnounceResult {
+ announced: number;
+ failed: number;
+ skipped: string[];
+}
+
+function formatDate(date: Date): string {
+ return date.toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+}
+
+/**
+ * The notice body.
+ *
+ * Written to be read by an operator, not a lawyer: what is changing, when it
+ * starts, and what they can do about it. The objection deadline is stated as a
+ * date rather than "14 days from announcement", because a reader should not
+ * have to do arithmetic to find out how long they have.
+ */
+export function buildAnnouncementEmail(
+ entry: AnnounceableSubprocessor,
+ objectionDeadline: Date,
+): { subject: string; html: string } {
+ return {
+ subject: `Change to BudStacks sub-processors: ${entry.name}`,
+ html: `
+
Hello,
+
+ We are writing to tell you in advance about a change to the vendors
+ BudStacks uses to run your storefront. Under our Data Processing
+ Agreement you are entitled to at least 30 days' notice of this, and to
+ object.
+
+
+
Vendor
${entry.name}
+
What they do
${entry.purpose}
+
Where
${entry.region}
+
Transfer safeguard
${entry.transferMechanism}
+
Starts
${formatDate(entry.effectiveFrom)}
+
+
+ If you object, reply to this email or write to
+ legal@budstacks.io by
+ ${formatDate(objectionDeadline)}. Tell us which vendor
+ and why, and we will come back to you before the change takes effect.
+
+
+ You do not need to do anything if you are content with the change. The
+ full list is always at
+ budstacks.io/legal/subprocessors.
+
+
— BudStacks
+ `.trim(),
+ };
+}
+
+/**
+ * Send the announcement and stamp `announcedAt`.
+ *
+ * Refuses to announce a change that does not carry the notice the DPA promises:
+ * sending a "30 days' notice" email 5 days before the change is worse than not
+ * sending one, because it creates a record of having complied.
+ *
+ * Individual send failures do not abort the run — one operator's bad address
+ * must not stop the other ninety-nine being told. Failures are counted, logged
+ * and surfaced by the caller.
+ */
+export async function announceSubprocessor(
+ entryId: string,
+ now = new Date(),
+): Promise {
+ const entry = await prisma.subprocessors.findFirst({ where: { id: entryId } });
+ if (!entry) throw new Error(`Sub-processor ${entryId} not found`);
+
+ if (entry.announcedAt) {
+ logger.warn("[Legal] Sub-processor already announced; not re-sending", {
+ entryId,
+ announcedAt: entry.announcedAt,
+ });
+ return { announced: 0, failed: 0, skipped: ["already-announced"] };
+ }
+
+ if (!hasSufficientNotice(now, entry.effectiveFrom)) {
+ throw new Error(
+ `Refusing to announce ${entry.name}: effective ${entry.effectiveFrom.toISOString()} ` +
+ `does not give operators the notice the DPA promises. Move the date out.`,
+ );
+ }
+
+ // Annotated because `prisma` is exported as `any`.
+ interface NotifiableTenant {
+ id: string;
+ businessName: string;
+ users: Array<{ email: string; role: string | null }>;
+ }
+
+ const tenants: NotifiableTenant[] = await prisma.tenants.findMany({
+ where: { isActive: true, deletedAt: null },
+ select: { id: true, businessName: true, users: { select: { email: true, role: true } } },
+ });
+
+ const objectionDeadline = new Date(
+ now.getTime() + OBJECTION_WINDOW_DAYS * 24 * 60 * 60 * 1000,
+ );
+ const { subject, html } = buildAnnouncementEmail(entry, objectionDeadline);
+
+ let announced = 0;
+ let failed = 0;
+
+ for (const tenant of tenants) {
+ const recipient = tenant.users.find((user) => user.role === "TENANT_ADMIN")?.email;
+ if (!recipient) {
+ failed++;
+ logger.error("[Legal] No admin contact for tenant; sub-processor notice not sent", {
+ tenantId: tenant.id,
+ entryId,
+ });
+ continue;
+ }
+
+ try {
+ await sendEmail({
+ to: recipient,
+ subject,
+ html,
+ tenantId: tenant.id,
+ templateName: "subprocessor-change",
+ metadata: { subprocessorId: entry.id, effectiveFrom: entry.effectiveFrom },
+ });
+ announced++;
+ } catch (error) {
+ failed++;
+ logger.error("[Legal] Sub-processor notice failed to send", {
+ tenantId: tenant.id,
+ entryId,
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ // Stamped even when some sends failed: the objection window has started for
+ // everyone who did receive it, and re-announcing would restart the clock for
+ // them. Failures are surfaced so they can be chased individually.
+ await prisma.subprocessors.update({
+ where: { id: entry.id },
+ data: { announcedAt: now, updatedAt: now },
+ });
+
+ await createAuditLog({
+ action: "SUBPROCESSOR_ANNOUNCED",
+ entityType: "subprocessor",
+ entityId: entry.id,
+ metadata: {
+ name: entry.name,
+ announced,
+ failed,
+ effectiveFrom: entry.effectiveFrom.toISOString(),
+ },
+ });
+
+ logger.info("[Legal] Sub-processor change announced", {
+ entryId,
+ name: entry.name,
+ announced,
+ failed,
+ });
+
+ return { announced, failed, skipped: [] };
+}
+
+/**
+ * Flip pending entries whose effective date has arrived.
+ *
+ * Unannounced entries are left alone by `shouldActivate` — processing must not
+ * begin on a vendor operators were never told about, whatever the date says.
+ */
+export async function activateDueSubprocessors(now = new Date()): Promise {
+ const due: Array<{ id: string; name: string }> = await prisma.subprocessors.findMany({
+ where: {
+ status: "pending",
+ announcedAt: { not: null },
+ effectiveFrom: { lte: now },
+ },
+ select: { id: true, name: true },
+ });
+
+ for (const entry of due) {
+ await prisma.subprocessors.update({
+ where: { id: entry.id },
+ data: { status: "active", updatedAt: now },
+ });
+ logger.info("[Legal] Sub-processor now in force", { entryId: entry.id, name: entry.name });
+ }
+
+ return due.map((entry) => entry.id);
+}
diff --git a/nextjs_space/lib/legal/subprocessor-notice.ts b/nextjs_space/lib/legal/subprocessor-notice.ts
new file mode 100644
index 00000000..dbb13fd8
--- /dev/null
+++ b/nextjs_space/lib/legal/subprocessor-notice.ts
@@ -0,0 +1,100 @@
+/**
+ * Notice and objection rules for the sub-processor register.
+ *
+ * The DPA (§6) makes two promises to operators: at least 30 days' notice before
+ * a new sub-processor starts processing, and 14 days from announcement to
+ * object. Both were prose with no mechanism behind them. These are the rules
+ * that give them effect.
+ *
+ * Pure — no database, no clock of its own — so the windows can be tested
+ * exhaustively rather than by waiting a month.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012/013/014).
+ */
+
+/** DPA §6: minimum advance notice before a sub-processor may begin processing. */
+export const MIN_NOTICE_DAYS = 30;
+
+/** DPA §6: how long an operator has to object after announcement. */
+export const OBJECTION_WINDOW_DAYS = 14;
+
+export type SubprocessorStatus = "pending" | "active" | "retired";
+
+/**
+ * A register row.
+ *
+ * Declared by hand because `prisma` is exported as `any` (lib/db.ts), so query
+ * results carry no type and every callback parameter over them lands as an
+ * implicit `any`. Annotating the query result restores checking at the one
+ * boundary that matters.
+ */
+export interface SubprocessorRecord {
+ id: string;
+ name: string;
+ purpose: string;
+ region: string;
+ transferMechanism: string;
+ dpaUrl: string | null;
+ status: string;
+ effectiveFrom: Date;
+ announcedAt: Date | null;
+ retiredAt: Date | null;
+ notes: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export function addDays(date: Date, days: number): Date {
+ return new Date(date.getTime() + days * DAY_MS);
+}
+
+export function daysBetween(from: Date, to: Date): number {
+ return (to.getTime() - from.getTime()) / DAY_MS;
+}
+
+/** The earliest compliant effective date for something announced now. */
+export function earliestEffectiveFrom(now: Date): Date {
+ return addDays(now, MIN_NOTICE_DAYS);
+}
+
+/**
+ * Whether `effectiveFrom` gives operators the notice the DPA promises.
+ *
+ * Measured from announcement, not from creation: a record that sits unannounced
+ * for a fortnight has given nobody anything.
+ */
+export function hasSufficientNotice(announcedAt: Date, effectiveFrom: Date): boolean {
+ return daysBetween(announcedAt, effectiveFrom) >= MIN_NOTICE_DAYS;
+}
+
+/** Whether an objection arrived after the DPA's 14-day window. */
+export function isObjectionOutOfWindow(announcedAt: Date, raisedAt: Date): boolean {
+ return daysBetween(announcedAt, raisedAt) > OBJECTION_WINDOW_DAYS;
+}
+
+/**
+ * Whether a pending entry has reached its effective date and should flip to
+ * active. Unannounced entries never activate — processing must not begin on a
+ * vendor operators were never told about, whatever the date says.
+ */
+export function shouldActivate(
+ entry: { status: string; effectiveFrom: Date; announcedAt: Date | null },
+ now: Date,
+): boolean {
+ if (entry.status !== "pending") return false;
+ if (!entry.announcedAt) return false;
+ return now >= entry.effectiveFrom;
+}
+
+/** Human-readable summary of where a pending entry is in its notice period. */
+export function noticeState(
+ entry: { status: string; effectiveFrom: Date; announcedAt: Date | null },
+ now: Date,
+): "in-force" | "retired" | "awaiting-announcement" | "in-notice-period" | "due-to-activate" {
+ if (entry.status === "active") return "in-force";
+ if (entry.status === "retired") return "retired";
+ if (!entry.announcedAt) return "awaiting-announcement";
+ return now >= entry.effectiveFrom ? "due-to-activate" : "in-notice-period";
+}
diff --git a/nextjs_space/lib/legal/subprocessor-schema.ts b/nextjs_space/lib/legal/subprocessor-schema.ts
new file mode 100644
index 00000000..14b2180d
--- /dev/null
+++ b/nextjs_space/lib/legal/subprocessor-schema.ts
@@ -0,0 +1,63 @@
+import { z } from "zod";
+
+/**
+ * Validation for sub-processor register entries.
+ *
+ * Every field here is published on a public legal page and emailed to every
+ * operator, so blanks and placeholders are not acceptable input — an entry that
+ * says a vendor is in "US" with transfer mechanism "TBC" tells a controller
+ * nothing they can act on.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012).
+ */
+
+export const subprocessorSchema = z.object({
+ id: z
+ .string()
+ .trim()
+ .min(2)
+ .max(64)
+ .regex(/^[a-z0-9-]+$/, "Use a lower-case slug, e.g. postmark or aws-s3."),
+ name: z.string().trim().min(2, "Vendor name is required.").max(200),
+ purpose: z
+ .string()
+ .trim()
+ .min(10, "Describe what the vendor actually does with personal data.")
+ .max(500),
+ region: z.string().trim().min(2, "Where does the vendor process?").max(200),
+ transferMechanism: z
+ .string()
+ .trim()
+ .min(2, "State the safeguard, or that none is required.")
+ .max(200),
+ dpaUrl: z
+ .string()
+ .trim()
+ .url("Enter a full URL, or leave blank.")
+ .max(500)
+ .optional()
+ .or(z.literal("").transform(() => undefined)),
+ effectiveFrom: z.coerce.date(),
+ notes: z.string().trim().max(2000).optional(),
+ /**
+ * Set only to schedule a change sooner than the DPA's 30 days. Requires a
+ * reason, and both are written to the audit log — shortening operators'
+ * notice period should leave a trace with a name against it.
+ */
+ overrideNoticePeriod: z.boolean().optional().default(false),
+ overrideReason: z.string().trim().max(1000).optional(),
+});
+
+export type SubprocessorInput = z.input;
+
+export const subprocessorUpdateSchema = subprocessorSchema
+ .omit({ id: true, overrideNoticePeriod: true, overrideReason: true })
+ .partial();
+
+export const retireSchema = z.object({
+ reason: z
+ .string()
+ .trim()
+ .min(5, "Say why this vendor is being retired.")
+ .max(1000),
+});
diff --git a/nextjs_space/prisma/migrations/20260727020000_add_subprocessor_register/migration.sql b/nextjs_space/prisma/migrations/20260727020000_add_subprocessor_register/migration.sql
new file mode 100644
index 00000000..56d52a42
--- /dev/null
+++ b/nextjs_space/prisma/migrations/20260727020000_add_subprocessor_register/migration.sql
@@ -0,0 +1,123 @@
+-- Sub-processor register (GDPR Art. 28(2)/(4)).
+--
+-- The list was a hardcoded array in app/legal/subprocessors/page.tsx, so it
+-- could only change with a deploy and nothing could start the 30-day notice
+-- clock the DPA promises operators. Moving it into the database makes both
+-- possible, and makes every change auditable.
+--
+-- Seeds the nine vendors currently published, all already in force. Dr Green is
+-- seeded AS-IS: the agreed position is that it is an independent controller
+-- rather than our sub-processor, but that is pending written confirmation, and
+-- retiring an entry is exactly the operation this register exists to handle —
+-- with a changelog entry and operator notice — rather than a silent migration.
+--
+-- See docs/PRDS/prd-data-protection-remediation.md (WS3, US-011).
+
+CREATE TABLE "subprocessors" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "purpose" TEXT NOT NULL,
+ "region" TEXT NOT NULL,
+ "transferMechanism" TEXT NOT NULL,
+ "dpaUrl" TEXT,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "effectiveFrom" TIMESTAMP(3) NOT NULL,
+ "announcedAt" TIMESTAMP(3),
+ "retiredAt" TIMESTAMP(3),
+ "notes" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "subprocessors_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "subprocessors_status_effectiveFrom_idx"
+ ON "subprocessors"("status", "effectiveFrom");
+
+CREATE TABLE "subprocessor_objections" (
+ "id" TEXT NOT NULL,
+ "subprocessorId" TEXT NOT NULL,
+ "tenantId" TEXT NOT NULL,
+ "raisedByUserId" TEXT,
+ "reason" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'open',
+ "outOfWindow" BOOLEAN NOT NULL DEFAULT false,
+ "resolution" TEXT,
+ "resolvedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "subprocessor_objections_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "subprocessor_objections_subprocessorId_status_idx"
+ ON "subprocessor_objections"("subprocessorId", "status");
+CREATE INDEX "subprocessor_objections_tenantId_idx"
+ ON "subprocessor_objections"("tenantId");
+
+ALTER TABLE "subprocessor_objections"
+ ADD CONSTRAINT "subprocessor_objections_subprocessorId_fkey"
+ FOREIGN KEY ("subprocessorId") REFERENCES "subprocessors"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "subprocessor_objections"
+ ADD CONSTRAINT "subprocessor_objections_tenantId_fkey"
+ FOREIGN KEY ("tenantId") REFERENCES "tenants"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Seed: the vendors already published and in force. `announcedAt` is set to the
+-- publication date of the existing page so the objection window is computed
+-- from when operators could actually have seen the list, not from today.
+INSERT INTO "subprocessors"
+ ("id", "name", "purpose", "region", "transferMechanism", "dpaUrl",
+ "status", "effectiveFrom", "announcedAt", "notes", "updatedAt")
+VALUES
+ ('clerk', 'Clerk',
+ 'Authentication, session management, user identity',
+ 'United States', 'EU SCCs + UK addendum', 'https://clerk.com/legal/dpa',
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP),
+
+ ('railway', 'Railway',
+ 'Application hosting, build pipelines, deployment',
+ 'United States', 'EU SCCs + UK addendum', 'https://railway.com/legal/dpa',
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP),
+
+ ('aws-s3', 'Amazon Web Services (AWS S3)',
+ 'Object storage for tenant assets and backups',
+ 'EU (eu-west-1) primary; US for cross-region replication',
+ 'EU SCCs + UK addendum', 'https://aws.amazon.com/service-terms/',
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP),
+
+ ('postgres-railway', 'PostgreSQL (managed by Railway)',
+ 'Primary application database',
+ 'United States (Railway-managed)', 'EU SCCs + UK addendum', NULL,
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP),
+
+ ('redis-railway', 'Redis (managed by Railway)',
+ 'Cache, session store, background-job queues',
+ 'United States (Railway-managed)', 'EU SCCs + UK addendum', NULL,
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP),
+
+ ('stripe', 'Stripe',
+ 'Payment processing for platform subscription fees',
+ 'United States / Ireland',
+ 'EU SCCs + UK addendum; adequacy where applicable',
+ 'https://stripe.com/legal/dpa',
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP),
+
+ ('resend', 'Resend',
+ 'Transactional email delivery (system notifications)',
+ 'United States', 'EU SCCs + UK addendum', 'https://resend.com/legal/dpa',
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP),
+
+ ('dr-green-api', 'Dr. Green API',
+ 'Product catalogue and order routing for partner storefronts',
+ 'Portugal / European Union', 'Within EEA — no SCCs required', NULL,
+ 'active', '2026-04-25', '2026-04-25',
+ 'Classification under review: the agreed position is that Dr Green is an independent controller rather than a BudStacks sub-processor, pending written confirmation. Retire this entry through the register once confirmed, so operators receive notice of the change.',
+ CURRENT_TIMESTAMP),
+
+ ('sentry', 'Sentry',
+ 'Error monitoring and performance telemetry',
+ 'United States / EU', 'EU SCCs + UK addendum', 'https://sentry.io/legal/dpa/',
+ 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP);
diff --git a/nextjs_space/prisma/schema.prisma b/nextjs_space/prisma/schema.prisma
index d7a3db82..fb51ad4e 100644
--- a/nextjs_space/prisma/schema.prisma
+++ b/nextjs_space/prisma/schema.prisma
@@ -195,6 +195,64 @@ model compliance_purge_records {
details Json
}
+/// Vendors BudStacks engages to deliver the platform (GDPR Art. 28(2)/(4)).
+///
+/// Database-backed rather than hardcoded so the list can change without a
+/// deploy, and — more importantly — so adding or replacing a vendor can start
+/// the 30-day notice clock the DPA promises operators. A list nobody can change
+/// without an engineer is a list that silently goes stale.
+///
+/// See docs/PRDS/prd-data-protection-remediation.md (WS3, US-011).
+model subprocessors {
+ id String @id
+ name String
+ purpose String
+ region String
+ transferMechanism String
+ dpaUrl String?
+ /// pending = announced, not yet in force. active = processing. retired = gone.
+ status String @default("pending")
+ /// The date processing may begin. Must be >= 30 days after announcedAt
+ /// unless a super admin overrides, which is written to the audit log.
+ effectiveFrom DateTime
+ /// When operators were notified. Null until the notification job has run.
+ announcedAt DateTime?
+ /// Set when status becomes retired, so the register keeps its history.
+ retiredAt DateTime?
+ notes String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime
+
+ objections subprocessor_objections[]
+
+ @@index([status, effectiveFrom])
+}
+
+/// An operator's objection to a sub-processor, per DPA §6. Recorded against the
+/// specific vendor rather than left in an inbox — an objection that cannot be
+/// evidenced is one the operator cannot rely on.
+model subprocessor_objections {
+ id String @id
+ subprocessorId String
+ tenantId String
+ raisedByUserId String?
+ reason String
+ /// open | acknowledged | resolved | withdrawn
+ status String @default("open")
+ /// True when raised more than 14 days after announcement (DPA §6 window).
+ outOfWindow Boolean @default(false)
+ resolution String?
+ resolvedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime
+
+ subprocessor subprocessors @relation(fields: [subprocessorId], references: [id], onDelete: Cascade)
+ tenants tenants @relation(fields: [tenantId], references: [id], onDelete: Cascade)
+
+ @@index([subprocessorId, status])
+ @@index([tenantId])
+}
+
model consultations {
id String @id
userId String
@@ -499,7 +557,7 @@ model tenants {
drgreen_webhook_logs drgreen_webhook_logs[]
kyc_journey_logs kyc_journey_logs[]
email_event_mappings email_event_mappings[]
- tenant_legal_profiles tenant_legal_profiles?
+ subprocessor_objections subprocessor_objections[]
email_logs email_logs[]
email_templates email_templates[]
orders orders[]
diff --git a/nextjs_space/tests/unit/subprocessor-announce.test.ts b/nextjs_space/tests/unit/subprocessor-announce.test.ts
new file mode 100644
index 00000000..88e81e17
--- /dev/null
+++ b/nextjs_space/tests/unit/subprocessor-announce.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { buildAnnouncementEmail } from "@/lib/legal/subprocessor-announce";
+import { addDays } from "@/lib/legal/subprocessor-notice";
+
+/**
+ * WS3 US-013 — the notice operators actually receive.
+ *
+ * The point of the email is that a controller can act on it. So it has to name
+ * the vendor, say when processing starts, and state the objection deadline as a
+ * date — a reader should not have to do arithmetic to find out how long they
+ * have.
+ */
+
+const ENTRY = {
+ id: "postmark",
+ name: "Postmark",
+ purpose: "Transactional email delivery",
+ region: "United States",
+ transferMechanism: "EU SCCs + UK addendum",
+ effectiveFrom: new Date("2026-09-15T00:00:00Z"),
+};
+
+const DEADLINE = addDays(new Date("2026-08-01T00:00:00Z"), 14);
+
+describe("buildAnnouncementEmail", () => {
+ const { subject, html } = buildAnnouncementEmail(ENTRY, DEADLINE);
+
+ it("names the vendor in the subject, so it is not mistaken for marketing", () => {
+ expect(subject).toContain("Postmark");
+ expect(subject.toLowerCase()).toContain("sub-processor");
+ });
+
+ it("states what the vendor does and where", () => {
+ expect(html).toContain("Transactional email delivery");
+ expect(html).toContain("United States");
+ });
+
+ it("states the transfer safeguard", () => {
+ expect(html).toContain("EU SCCs + UK addendum");
+ });
+
+ it("gives the date processing starts", () => {
+ expect(html).toContain("15 September 2026");
+ });
+
+ it("gives the objection deadline as a date, not a duration", () => {
+ expect(html).toContain("15 August 2026");
+ expect(html).not.toMatch(/within 14 days/i);
+ });
+
+ it("tells the operator how to object", () => {
+ expect(html).toContain("legal@budstacks.io");
+ });
+
+ it("links the full register", () => {
+ expect(html).toContain("/legal/subprocessors");
+ });
+
+ it("says the 30-day entitlement out loud", () => {
+ expect(html).toContain("30 days");
+ });
+});
diff --git a/nextjs_space/tests/unit/subprocessor-notice.test.ts b/nextjs_space/tests/unit/subprocessor-notice.test.ts
new file mode 100644
index 00000000..a5f0cd0c
--- /dev/null
+++ b/nextjs_space/tests/unit/subprocessor-notice.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+import {
+ MIN_NOTICE_DAYS,
+ OBJECTION_WINDOW_DAYS,
+ addDays,
+ earliestEffectiveFrom,
+ hasSufficientNotice,
+ isObjectionOutOfWindow,
+ noticeState,
+ shouldActivate,
+} from "@/lib/legal/subprocessor-notice";
+
+/**
+ * WS3 — the DPA §6 windows.
+ *
+ * These were prose with no mechanism behind them: 30 days' notice before a new
+ * sub-processor processes, 14 days to object. Testing them by waiting a month
+ * is not an option, so the rules are pure and the clock is injected.
+ */
+
+const ANNOUNCED = new Date("2026-08-01T00:00:00Z");
+
+describe("windows match the DPA", () => {
+ it("requires 30 days' notice", () => {
+ expect(MIN_NOTICE_DAYS).toBe(30);
+ });
+
+ it("gives 14 days to object", () => {
+ expect(OBJECTION_WINDOW_DAYS).toBe(14);
+ });
+});
+
+describe("earliestEffectiveFrom", () => {
+ it("is 30 days out", () => {
+ expect(earliestEffectiveFrom(ANNOUNCED).toISOString()).toBe(
+ "2026-08-31T00:00:00.000Z",
+ );
+ });
+});
+
+describe("hasSufficientNotice", () => {
+ it("accepts exactly 30 days", () => {
+ expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, 30))).toBe(true);
+ });
+
+ it("accepts more than 30 days", () => {
+ expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, 45))).toBe(true);
+ });
+
+ it("rejects 29 days", () => {
+ expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, 29))).toBe(false);
+ });
+
+ it("rejects an effective date before the announcement", () => {
+ expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, -1))).toBe(false);
+ });
+});
+
+describe("isObjectionOutOfWindow", () => {
+ it("accepts an objection on day 14", () => {
+ expect(isObjectionOutOfWindow(ANNOUNCED, addDays(ANNOUNCED, 14))).toBe(false);
+ });
+
+ it("flags an objection on day 15", () => {
+ expect(isObjectionOutOfWindow(ANNOUNCED, addDays(ANNOUNCED, 15))).toBe(true);
+ });
+
+ it("accepts an objection raised the same day", () => {
+ expect(isObjectionOutOfWindow(ANNOUNCED, ANNOUNCED)).toBe(false);
+ });
+});
+
+describe("shouldActivate", () => {
+ const pending = {
+ status: "pending",
+ effectiveFrom: addDays(ANNOUNCED, 30),
+ announcedAt: ANNOUNCED,
+ };
+
+ it("activates on the effective date", () => {
+ expect(shouldActivate(pending, addDays(ANNOUNCED, 30))).toBe(true);
+ });
+
+ it("does not activate before the effective date", () => {
+ expect(shouldActivate(pending, addDays(ANNOUNCED, 29))).toBe(false);
+ });
+
+ it("never activates an unannounced entry, whatever the date", () => {
+ // Processing must not begin on a vendor operators were never told about.
+ expect(
+ shouldActivate({ ...pending, announcedAt: null }, addDays(ANNOUNCED, 365)),
+ ).toBe(false);
+ });
+
+ it("leaves an already-active entry alone", () => {
+ expect(
+ shouldActivate({ ...pending, status: "active" }, addDays(ANNOUNCED, 30)),
+ ).toBe(false);
+ });
+
+ it("does not resurrect a retired entry", () => {
+ expect(
+ shouldActivate({ ...pending, status: "retired" }, addDays(ANNOUNCED, 30)),
+ ).toBe(false);
+ });
+});
+
+describe("noticeState", () => {
+ const base = {
+ status: "pending",
+ effectiveFrom: addDays(ANNOUNCED, 30),
+ announcedAt: ANNOUNCED,
+ };
+
+ it.each([
+ ["in-force", { ...base, status: "active" }, ANNOUNCED],
+ ["retired", { ...base, status: "retired" }, ANNOUNCED],
+ ["awaiting-announcement", { ...base, announcedAt: null }, ANNOUNCED],
+ ["in-notice-period", base, addDays(ANNOUNCED, 5)],
+ ["due-to-activate", base, addDays(ANNOUNCED, 31)],
+ ])("reports %s", (expected, entry, now) => {
+ expect(noticeState(entry, now as Date)).toBe(expected);
+ });
+});