From 7cbe967b5775df29a071953faddf9cf23c911683 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 17:50:33 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix(re-consent):=20make=20the=20terms=20pro?= =?UTF-8?q?mpt=20escapable,=20per=20our=20own=20ToS=20=C2=A717?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal shipped as a hard gate: preventClose, hidden close button, and a single Accept CTA. It mounts in the mobile-ui layout, so it covered the whole app, including /withdraw. That conflicts with the terms it enforces. §17.2 gives material changes a 30-day runway and offers the click-through as a way to accept sooner, by choice. We published on 2026-07-15, so the new terms take effect around 2026-08-14; a hard gate enforces them about two weeks early. §17.3 says a user who does not agree must stop using the Services. For a non-custodial wallet that must still leave a path to their own funds. So the prompt now defers instead of blocking: - "Not now" (plus close, backdrop and Escape) dismisses it. The checkbox does not gate the exit. - A dismissal snoozes the prompt for 3 days per user, in localStorage. It never writes a ledger row, because a refusal is not consent. - Accept reports modal_cta_clicked, not modal_dismissed. Both events existed already. We need the accept-vs-refuse ratio to measure the rollout. - Copy no longer says "To keep using Peanut". That claim is no longer true. Accept stays the primary action: purple, shadow, first in the stack. --- .../ReConsentModal/__tests__/index.test.tsx | 86 +++++++++++++++++-- .../Global/ReConsentModal/index.tsx | 68 +++++++++++---- src/components/Global/ReConsentModal/utils.ts | 48 +++++++++++ 3 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 src/components/Global/ReConsentModal/utils.ts diff --git a/src/components/Global/ReConsentModal/__tests__/index.test.tsx b/src/components/Global/ReConsentModal/__tests__/index.test.tsx index bc067ae52..c8088fa65 100644 --- a/src/components/Global/ReConsentModal/__tests__/index.test.tsx +++ b/src/components/Global/ReConsentModal/__tests__/index.test.tsx @@ -1,12 +1,13 @@ /** @jest-environment jsdom */ /** - * ReConsentModal — the ToS §17 blocking click-through. + * ReConsentModal — the ToS §17 click-through. * - * This modal gates the entire app, so the safety properties matter more than - * the happy path: a failed status check must NEVER lock the app (fail-open), - * a failed accept must keep the retry path alive, and an account switch must - * not leak the previous user's consent state (a regression already caught - * once in review). + * The safety properties matter more than the happy path: a failed status check + * must NEVER block the app (fail-open), a failed accept must keep the retry + * path alive, an account switch must not leak the previous user's consent state + * (a regression already caught once in review), and — because §17.2 gives a + * 30-day runway and §17.3 requires a decliner to still reach their funds — the + * prompt must ALWAYS be escapable and must never ledger a refusal as consent. */ import React from 'react' import { render, screen, fireEvent, act } from '@testing-library/react' @@ -64,7 +65,8 @@ jest.mock('@/components/Global/DocsLink', () => ({ default: ({ children }: { children: React.ReactNode }) => {children}, })) -jest.mock('posthog-js', () => ({ capture: jest.fn() })) +const mockCapture = jest.fn() +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) const mockCaptureException = jest.fn() jest.mock('@sentry/nextjs', () => ({ @@ -85,9 +87,14 @@ const flush = () => act(async () => {}) beforeEach(() => { jest.clearAllMocks() + // the snooze is persisted per-user in localStorage — a leaked entry would + // silently suppress the prompt in every later test + window.localStorage.clear() mockUser = { user: { userId: 'user-1' } } }) +const capturedEvents = () => mockCapture.mock.calls.map(([event]) => event) + describe('ReConsentModal', () => { it('fails open: a failed status check never shows (or locks) the modal', async () => { mockGetStatus.mockRejectedValue(new Error('api down')) @@ -138,6 +145,71 @@ describe('ReConsentModal', () => { expect.objectContaining({ slug: 'privacy' }), ]) expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + // acceptance must be distinguishable from refusal in analytics — + // the accept-vs-postpone ratio is the rollout's headline metric + expect(capturedEvents()).toEqual(['modal_shown', 'modal_cta_clicked']) + }) + + it('"Not now" is always available and never gated on the checkbox', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + render() + await flush() + + expect(screen.getByText('Accept & continue')).toBeDisabled() + // the escape hatch must not require ticking a consent box first + expect(screen.getByText('Not now')).not.toBeDisabled() + }) + + it('"Not now" dismisses without recording any consent (a refusal is not a ledger row)', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + render() + await flush() + + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + expect(mockAccept).not.toHaveBeenCalled() + expect(capturedEvents()).toEqual(['modal_shown', 'modal_dismissed']) + }) + + it('a postponed prompt stays away on the next session, then returns once the snooze lapses', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + const first = render() + await flush() + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + first.unmount() + + // fresh session (remount → fresh refs), still inside the snooze window + render() + await flush() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + // and we don't even spend the request while snoozed + expect(mockGetStatus).toHaveBeenCalledTimes(1) + + // snooze lapses → the prompt comes back + window.localStorage.setItem('peanut.reconsent.snoozedUntil.user-1', String(Date.now() - 1)) + render() + await flush() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('one user postponing does not suppress the prompt for a different account', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + const first = render() + await flush() + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + first.unmount() + + mockUser = { user: { userId: 'user-2' } } + render() + await flush() + expect(screen.getByTestId('modal')).toBeInTheDocument() }) it('a failed accept keeps the modal, shows the error, and leaves retry enabled', async () => { diff --git a/src/components/Global/ReConsentModal/index.tsx b/src/components/Global/ReConsentModal/index.tsx index 974aef1fe..9691c0a61 100644 --- a/src/components/Global/ReConsentModal/index.tsx +++ b/src/components/Global/ReConsentModal/index.tsx @@ -8,6 +8,7 @@ import { useAuth } from '@/context/authContext' import { acceptedLegalDocument, consentApi, type ConsentStatusDocument } from '@/services/consent' import { LEGAL_DOCUMENT_VERSIONS, type LegalDocumentSlug } from '@/constants/legal-versions.generated' import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' +import { isReConsentSnoozed, snoozeReConsent } from './utils' const DOC_LABELS: Record = { terms: { name: 'Terms of Service', href: '/terms' }, @@ -19,12 +20,21 @@ const DOC_LABELS: Record = { 'card-prohibited-activities': { name: 'Prohibited Activities Policy', href: '/card-prohibited-activities' }, } +/** Keep "Not now" stacked BELOW the primary CTA at every width — side-by-side + * would read as two equally-weighted choices. */ +const STACKED_CTAS = 'flex-col sm:flex-col' + /** * Re-consent click-through (tos-v1 phase 2, ToS §17): when a legal document's - * published version moves past what the user last provably accepted, this - * blocking modal lists the updated documents and appends fresh consent-ledger - * rows on acceptance. Backed by GET /users/consent/status and - * POST /users/consent/accept — see peanut-api. + * published version moves past what the user last provably accepted, this modal + * lists the updated documents and appends fresh consent-ledger rows on + * acceptance. Backed by GET /users/consent/status and POST /users/consent/accept. + * + * It is a PROMPT, not a gate. §17.2 gives material changes 30 days and offers + * the click-through as a way to accept sooner; §17.3 requires that a user who + * declines can still stop using the Services — which, for a non-custodial + * wallet, means they must be able to reach `/withdraw`. So "Not now" always + * exists, and dismissing never writes a ledger row (declining is not consent). */ const ReConsentModal = () => { const { user } = useAuth() @@ -45,6 +55,8 @@ const ReConsentModal = () => { setOutdatedDocs([]) setChecked(false) setError(null) + // a recent "Not now" defers the prompt — don't even spend the request + if (isReConsentSnoozed(userId)) return consentApi .getStatus() .then((status) => { @@ -62,7 +74,7 @@ const ReConsentModal = () => { }) }) .catch((e) => { - // a failed status check must never lock the app — retry next + // a failed status check must never block the app — retry next // session. Sentry (not console): a systematic failure here means // re-consent silently stops rolling out, and prod must say so. Sentry.captureException(e, { tags: { feature: 're-consent', action: 'status' } }) @@ -75,7 +87,9 @@ const ReConsentModal = () => { setError(null) try { await consentApi.accept(outdatedDocs.map((d) => acceptedLegalDocument(d.slug as LegalDocumentSlug))) - posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { + // CTA_CLICKED, not DISMISSED — acceptance and refusal must be + // distinguishable, since their ratio is the rollout's headline metric + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.RE_CONSENT, documents: outdatedDocs.map((d) => d.slug), }) @@ -84,8 +98,8 @@ const ReConsentModal = () => { // must start with an unticked box setChecked(false) } catch (e) { - // Sentry (not console): if /accept fails systematically, every user - // sits behind this undismissable modal — that must be visible in prod + // Sentry (not console): if /accept fails systematically, nobody can + // record consent at all — that must be visible in prod Sentry.captureException(e, { tags: { feature: 're-consent', action: 'accept' } }) setError('Could not save your acceptance — please try again.') } finally { @@ -93,27 +107,40 @@ const ReConsentModal = () => { } } + /** "Not now", the close button, backdrop and Escape all land here. */ + const handlePostpone = () => { + if (submitting) return + const userId = user?.user.userId + if (userId) snoozeReConsent(userId) + posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { + modal_type: MODAL_TYPES.RE_CONSENT, + documents: outdatedDocs.map((d) => d.slug), + }) + setOutdatedDocs([]) + setChecked(false) + setError(null) + } + if (!outdatedDocs.length) return null return ( undefined} - preventClose - hideModalCloseButton + onClose={handlePostpone} icon="info" title="We've updated our terms" content={ - <> +

- To keep using Peanut, please review and accept the updated documents: + We've refreshed the documents below. Give them a read and accept when you're ready — you can + keep using Peanut in the meantime.

-
    +
      {outdatedDocs.map((doc) => { const label = DOC_LABELS[doc.slug] ?? { name: doc.slug, href: `/${doc.slug}` } return (
    • - + {label.name}
    • @@ -121,7 +148,7 @@ const ReConsentModal = () => { })}
    {error &&

    {error}

    } - +
} checkbox={{ text: 'I have read and accept the updated documents', @@ -131,10 +158,19 @@ const ReConsentModal = () => { ctas={[ { text: submitting ? 'Saving…' : 'Accept & continue', + variant: 'purple', + shadowSize: '4', disabled: !checked || submitting, onClick: handleAccept, }, + { + text: 'Not now', + variant: 'transparent', + disabled: submitting, + onClick: handlePostpone, + }, ]} + ctaClassName={STACKED_CTAS} /> ) } diff --git a/src/components/Global/ReConsentModal/utils.ts b/src/components/Global/ReConsentModal/utils.ts new file mode 100644 index 000000000..188a32f5e --- /dev/null +++ b/src/components/Global/ReConsentModal/utils.ts @@ -0,0 +1,48 @@ +/** + * Local snooze for the re-consent prompt. + * + * ToS §17.2 gives material changes a 30-day runway and frames the in-app + * click-through as a way to accept *sooner* — voluntarily. §17.3 then says a + * user who does not agree must be able to stop using the Services, which for a + * non-custodial wallet has to include reaching their funds. So the prompt is a + * reminder, not a lock: "Not now" defers it instead of gating the app. + * + * Deliberately localStorage and not the consent ledger — declining is not a + * consent event and must never write a row. Losing the snooze (cleared storage, + * another device) just re-shows the prompt, which is the safe direction. + */ + +/** Long enough not to nag a wallet people open daily, short enough to still land. */ +export const RE_CONSENT_SNOOZE_DAYS = 3 + +const snoozeKey = (userId: string) => `peanut.reconsent.snoozedUntil.${userId}` + +/** Storage is unavailable during SSR and throws outright in Safari private mode. */ +function readStorage(key: string): string | null { + try { + return typeof window === 'undefined' ? null : window.localStorage.getItem(key) + } catch { + return null + } +} + +function writeStorage(key: string, value: string): void { + try { + if (typeof window !== 'undefined') window.localStorage.setItem(key, value) + } catch { + // a full or blocked quota just means the prompt returns next session + } +} + +export function isReConsentSnoozed(userId: string): boolean { + const raw = readStorage(snoozeKey(userId)) + if (!raw) return false + const until = Number(raw) + // a corrupt value must not suppress the prompt forever + return Number.isFinite(until) && until > Date.now() +} + +export function snoozeReConsent(userId: string): void { + const until = Date.now() + RE_CONSENT_SNOOZE_DAYS * 24 * 60 * 60 * 1000 + writeStorage(snoozeKey(userId), String(until)) +} From 9c776d9bb7153388b3053aa5ff8cf64276543597 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 18:00:43 +0100 Subject: [PATCH 2/3] fix(re-consent): defer to the document's effective date, not a fixed interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Not now" now snoozes until the documents actually take effect — the frontmatter version plus the 30 days our own ToS §17.2 promises — instead of a flat 3 days. A document posted today buys the user its full notice period. Past that date §17.3 already makes continued use acceptance, so the prompt only still asks in order to record explicit consent. That earns a gentle cadence, not a prompt on every app open, so the snooze floors at MIN_SNOOZE_DAYS. --- .../ReConsentModal/__tests__/index.test.tsx | 34 +++++++++++++ .../Global/ReConsentModal/index.tsx | 8 ++- src/components/Global/ReConsentModal/utils.ts | 51 ++++++++++++++----- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/components/Global/ReConsentModal/__tests__/index.test.tsx b/src/components/Global/ReConsentModal/__tests__/index.test.tsx index c8088fa65..b806d315a 100644 --- a/src/components/Global/ReConsentModal/__tests__/index.test.tsx +++ b/src/components/Global/ReConsentModal/__tests__/index.test.tsx @@ -74,6 +74,40 @@ jest.mock('@sentry/nextjs', () => ({ })) import ReConsentModal from '../index' +import { nextPromptAt, LEGAL_NOTICE_PERIOD_DAYS, MIN_SNOOZE_DAYS } from '../utils' + +const DAY_MS = 24 * 60 * 60 * 1000 + +describe('nextPromptAt', () => { + // 2026-07-15 + 30d = 2026-08-14, the date the current terms take effect + const posted = '2026-07-15' + const dayAfterPosting = Date.parse('2026-07-16') + + it('defers to the effective date — the full §17.2 notice period, not a fixed interval', () => { + expect(nextPromptAt([posted], dayAfterPosting)).toBe(Date.parse('2026-08-14')) + }) + + it('uses the LATEST effective date when several documents are shown at once', () => { + expect(nextPromptAt(['2026-07-01', posted], dayAfterPosting)).toBe(Date.parse('2026-08-14')) + }) + + it('floors to MIN_SNOOZE_DAYS once a document is already past its notice period', () => { + // §17.3 already makes continued use acceptance here, so the prompt only + // still asks to record explicit consent — it must not nag every open + const longAfter = Date.parse('2027-01-01') + expect(nextPromptAt([posted], longAfter)).toBe(longAfter + MIN_SNOOZE_DAYS * DAY_MS) + }) + + it('falls back to the floor rather than throwing on an unparseable version', () => { + const now = Date.parse('2026-07-29') + expect(nextPromptAt(['not-a-date'], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS) + expect(nextPromptAt([], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS) + }) + + it('pins the notice period to the 30 days our own ToS §17.2 promises', () => { + expect(LEGAL_NOTICE_PERIOD_DAYS).toBe(30) + }) +}) const statusDoc = (slug: string) => ({ slug, diff --git a/src/components/Global/ReConsentModal/index.tsx b/src/components/Global/ReConsentModal/index.tsx index 9691c0a61..6a70dc04c 100644 --- a/src/components/Global/ReConsentModal/index.tsx +++ b/src/components/Global/ReConsentModal/index.tsx @@ -111,7 +111,13 @@ const ReConsentModal = () => { const handlePostpone = () => { if (submitting) return const userId = user?.user.userId - if (userId) snoozeReConsent(userId) + // defer to the date these documents actually take effect (§17.2), not a + // fixed interval — a doc posted today buys the user its full 30 days + if (userId) + snoozeReConsent( + userId, + outdatedDocs.map((d) => d.currentVersion) + ) posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.RE_CONSENT, documents: outdatedDocs.map((d) => d.slug), diff --git a/src/components/Global/ReConsentModal/utils.ts b/src/components/Global/ReConsentModal/utils.ts index 188a32f5e..c07321277 100644 --- a/src/components/Global/ReConsentModal/utils.ts +++ b/src/components/Global/ReConsentModal/utils.ts @@ -1,23 +1,33 @@ /** * Local snooze for the re-consent prompt. * - * ToS §17.2 gives material changes a 30-day runway and frames the in-app - * click-through as a way to accept *sooner* — voluntarily. §17.3 then says a - * user who does not agree must be able to stop using the Services, which for a - * non-custodial wallet has to include reaching their funds. So the prompt is a - * reminder, not a lock: "Not now" defers it instead of gating the app. + * ToS §17.2 gives material changes a 30-day notice period and offers the in-app + * click-through as a way to accept sooner, by choice. §17.3 then says a user who + * does not agree must be able to stop using the Services — which, for a + * non-custodial wallet, has to include reaching their own funds. So the prompt + * is a reminder, not a lock: "Not now" defers it to the date the documents + * actually take effect. * * Deliberately localStorage and not the consent ledger — declining is not a * consent event and must never write a row. Losing the snooze (cleared storage, - * another device) just re-shows the prompt, which is the safe direction. + * another device) only re-shows the prompt, which is the safe direction. */ -/** Long enough not to nag a wallet people open daily, short enough to still land. */ -export const RE_CONSENT_SNOOZE_DAYS = 3 +/** ToS §17.2 — material changes take effect no earlier than 30 days after posting. */ +export const LEGAL_NOTICE_PERIOD_DAYS = 30 + +/** + * Floor for a document already past its notice period. Past that date §17.3 + * makes continued use acceptance on its own, so the prompt only still asks in + * order to record explicit consent — worth a gentle cadence, not every app open. + */ +export const MIN_SNOOZE_DAYS = 7 + +const DAY_MS = 24 * 60 * 60 * 1000 const snoozeKey = (userId: string) => `peanut.reconsent.snoozedUntil.${userId}` -/** Storage is unavailable during SSR and throws outright in Safari private mode. */ +/** Storage is absent during SSR and throws outright in Safari private mode. */ function readStorage(key: string): string | null { try { return typeof window === 'undefined' ? null : window.localStorage.getItem(key) @@ -30,10 +40,26 @@ function writeStorage(key: string, value: string): void { try { if (typeof window !== 'undefined') window.localStorage.setItem(key, value) } catch { - // a full or blocked quota just means the prompt returns next session + // a full or blocked quota only means the prompt returns next session } } +/** + * When the prompt may reappear after a "Not now": the latest effective date + * across the documents shown, floored to MIN_SNOOZE_DAYS. + * + * `currentVersion` is the document's frontmatter `last_updated` (an ISO date), + * so effective = version + 30 days. An unparseable version contributes nothing + * rather than throwing — the floor still applies. + */ +export function nextPromptAt(versions: string[], now: number = Date.now()): number { + const effectiveDates = versions + .map((version) => Date.parse(version)) + .filter((parsed) => Number.isFinite(parsed)) + .map((parsed) => parsed + LEGAL_NOTICE_PERIOD_DAYS * DAY_MS) + return Math.max(now + MIN_SNOOZE_DAYS * DAY_MS, ...effectiveDates) +} + export function isReConsentSnoozed(userId: string): boolean { const raw = readStorage(snoozeKey(userId)) if (!raw) return false @@ -42,7 +68,6 @@ export function isReConsentSnoozed(userId: string): boolean { return Number.isFinite(until) && until > Date.now() } -export function snoozeReConsent(userId: string): void { - const until = Date.now() + RE_CONSENT_SNOOZE_DAYS * 24 * 60 * 60 * 1000 - writeStorage(snoozeKey(userId), String(until)) +export function snoozeReConsent(userId: string, versions: string[]): void { + writeStorage(snoozeKey(userId), String(nextPromptAt(versions))) } From d7167876f90ec65047944745854760c713b29776 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 18:00:44 +0100 Subject: [PATCH 3/3] chore(legal-versions): regenerate from the pinned content submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed file said card-terms-international was 2026-06-01. The content submodule this branch pins says 2026-07-14 — the §8.4 Nigerian Users clause. `predev`/`prebuild` regenerate this file, so any dev server or Vercel build already produced the newer value and left the tree dirty. WARNING, cross-repo: peanut-api CURRENT_LEGAL_VERSIONS still says 2026-06-01 for the same document. sanitizeEchoedDocuments treats a client version newer than the server's as an attack and clamps it, dropping the hash. So the ledger would record 2026-06-01 for users who were shown 2026-07-14, and the clause update would never trigger re-consent. The constant needs the same bump in peanut-api-ts#1255. --- src/constants/legal-versions.generated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/constants/legal-versions.generated.ts b/src/constants/legal-versions.generated.ts index 2042141bf..c5973fe12 100644 --- a/src/constants/legal-versions.generated.ts +++ b/src/constants/legal-versions.generated.ts @@ -21,8 +21,8 @@ export const LEGAL_DOCUMENT_VERSIONS = { hash: 'd0ea9e1b239179c9ca4e5c26badb49506d8453399958ed040f68bb6650fd6e6c', }, 'card-terms-international': { - version: '2026-06-01', - hash: 'a6476057cd5bcf553f2c1faffc5261ad3c5e59eecbc8bf068e3b561df22035cf', + version: '2026-07-14', + hash: '9eb731e649de21ef8a28fca821f4123d63be55f0765a0843fe42bac31bbbe0bd', }, 'card-terms-us': { version: '2026-06-01',