From edf688ef09cde77d44673d37616406e080a6d418 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 15 Jul 2026 12:55:58 +0100 Subject: [PATCH] =?UTF-8?q?Prepaid=20bundles:=20capture=20prepay=E2=86=92P?= =?UTF-8?q?AYG=20consent=20at=20the=20quote=20step=20(ARL/EULA=20=C2=A77.2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affirmative consent, before payment, to the automatic transition to metered PAYG when the prepaid term ends — required by California ARL (B&P §17602) / EULA §7.2. - V41: consented_at + eula_version + price_minor on payg_bundle_quote (proof of what was agreed). Additive + idempotent. - PaygBundleController /quote takes { consented, eulaVersion }; PrepaidPurchase service refuses a quote without consent and stamps the proof on the ticket. - BundleCheckoutModal gates Continue on an un-pre-checked consent box disclosing the metered rate + cancellation path; useWallet.quoteBundle forwards the EULA version. Copy is placeholder pending legal. Pairs with the SaaS-repo edge fn (create-payg-bundle-checkout), which refuses a ticket without consent as defence in depth. --- .../saas/payg/api/PaygBundleController.java | 15 ++- .../saas/payg/bundle/PrepaidBundleQuote.java | 20 ++++ .../payg/bundle/PrepaidPurchaseService.java | 27 +++-- .../saas/V41__payg_bundle_quote_consent.sql | 23 ++++ .../payg/api/PaygBundleControllerTest.java | 101 +++++++++++++----- .../bundle/PrepaidPurchaseServiceTest.java | 33 ++++-- .../public/locales/en-US/translation.toml | 3 + .../configSections/BundleCheckoutModal.tsx | 47 ++++++-- frontend/editor/src/cloud/hooks/useWallet.ts | 20 ++-- 9 files changed, 234 insertions(+), 55 deletions(-) create mode 100644 app/saas/src/main/resources/db/migration/saas/V41__payg_bundle_quote_consent.sql diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygBundleController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygBundleController.java index 889ad32a3e..bc65680a15 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygBundleController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygBundleController.java @@ -65,8 +65,13 @@ public class PaygBundleController { this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); } - /** Requested prepaid capacity in units — the buyer's chosen 12-month pool size. */ - public record QuoteRequest(@Min(1) long units) {} + /** + * A bundle purchase request. {@code units} is the chosen 12-month pool size; {@code consented} + * + {@code eulaVersion} carry the buyer's affirmative consent (ARL/EULA §7.2) to the + * prepaid→metered auto-transition, captured before payment. The quote is refused without + * consent. + */ + public record QuoteRequest(@Min(1) long units, boolean consented, String eulaVersion) {} /** * A priced quote for the calculator/checkout. Money fields are minor units of {@link #currency} @@ -104,8 +109,12 @@ public class PaygBundleController { } Long teamId = primary.get().getTeam().getId(); + // Consent is captured before payment; no EULA version reaches the service unless the buyer + // affirmatively consented, and the service rejects a blank one (400). + String consent = req.consented() ? req.eulaVersion() : null; try { - PrepaidPurchaseService.PrepaidQuote q = purchaseService.quote(teamId, req.units()); + PrepaidPurchaseService.PrepaidQuote q = + purchaseService.quote(teamId, req.units(), consent); return ResponseEntity.ok(toResponse(q)); } catch (IllegalArgumentException e) { log.debug("bundle quote rejected for team {}: {}", teamId, e.getMessage()); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuote.java b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuote.java index 2af64e003a..df70a255cf 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuote.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuote.java @@ -63,6 +63,26 @@ public class PrepaidBundleQuote implements Serializable { @Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; + /** + * When the leader affirmatively consented (ARL/EULA §7.2) to the prepaid→metered + * auto-transition, captured at quote time before payment. NULL = no consent; the checkout edge + * fn refuses the session. {@link #eulaVersion} + {@link #priceMinor} record exactly what was + * disclosed. + */ + @Column(name = "consented_at") + private LocalDateTime consentedAt; + + /** EULA version string shown at consent — proof of the agreed terms. */ + @Column(name = "eula_version", length = 32) + private String eulaVersion; + + /** + * One-time price disclosed at consent (minor units of {@link #currency}); null when rate + * unknown. + */ + @Column(name = "price_minor") + private Long priceMinor; + public PrepaidBundleQuote(Long teamId, long units, String currency, LocalDateTime expiresAt) { this.teamId = teamId; this.units = units; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidPurchaseService.java b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidPurchaseService.java index 2b73f3b998..da1d5a5ef7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidPurchaseService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidPurchaseService.java @@ -72,11 +72,16 @@ public class PrepaidPurchaseService { * Price has synced) — the ticket is still valid because the edge fn prices the checkout from * Stripe; the front end falls back to its own wallet rate for display. * + *

The quote IS the purchase intent, so it captures the buyer's affirmative consent (ARL/EULA + * §7.2) to the prepaid→metered auto-transition, before payment: {@code consentEulaVersion} is + * the EULA version the leader agreed to. We record it + the disclosed price on the ticket as + * proof; the checkout edge fn refuses a ticket without consent. + * * @throws IllegalArgumentException when the requested capacity is outside {@link - * #MIN_UNITS}..{@link #MAX_UNITS} + * #MIN_UNITS}..{@link #MAX_UNITS}, or when consent (a non-blank EULA version) is absent */ @Transactional - public PrepaidQuote quote(Long teamId, long requestedUnits) { + public PrepaidQuote quote(Long teamId, long requestedUnits, String consentEulaVersion) { Objects.requireNonNull(teamId, "teamId"); if (requestedUnits < MIN_UNITS || requestedUnits > MAX_UNITS) { throw new IllegalArgumentException( @@ -88,6 +93,10 @@ public class PrepaidPurchaseService { + MAX_UNITS + ")"); } + if (consentEulaVersion == null || consentEulaVersion.isBlank()) { + // Consent must be captured before payment — no ticket without it. + throw new IllegalArgumentException("consent (EULA version) is required to purchase"); + } TeamBillingContext billing = billingService.forTeam(teamId); String currency = billing.currency() != null ? billing.currency() : FALLBACK_CURRENCY; @@ -99,8 +108,8 @@ public class PrepaidPurchaseService { if (rate != null && rate.signum() > 0) { BigDecimal units = BigDecimal.valueOf(requestedUnits); // Undiscounted "worth" and the discounted price the buyer pays. Rounded independently - // to - // the minor unit; savings is the difference so the three figures stay self-consistent. + // to the minor unit; savings is the difference so the three figures stay + // self-consistent. listMinor = rate.multiply(units).setScale(0, RoundingMode.HALF_UP).longValue(); totalMinor = rate.multiply(units) @@ -111,9 +120,13 @@ public class PrepaidPurchaseService { } LocalDateTime expiresAt = LocalDateTime.now().plus(QUOTE_TTL); - PrepaidBundleQuote saved = - quoteRepository.save( - new PrepaidBundleQuote(teamId, requestedUnits, currency, expiresAt)); + PrepaidBundleQuote ticket = + new PrepaidBundleQuote(teamId, requestedUnits, currency, expiresAt); + // Proof of consent (ARL/EULA §7.2): version + timestamp + the disclosed price. + ticket.setConsentedAt(LocalDateTime.now()); + ticket.setEulaVersion(consentEulaVersion); + ticket.setPriceMinor(totalMinor); + PrepaidBundleQuote saved = quoteRepository.save(ticket); return new PrepaidQuote( saved.getId(), diff --git a/app/saas/src/main/resources/db/migration/saas/V41__payg_bundle_quote_consent.sql b/app/saas/src/main/resources/db/migration/saas/V41__payg_bundle_quote_consent.sql new file mode 100644 index 0000000000..123ca1b70a --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V41__payg_bundle_quote_consent.sql @@ -0,0 +1,23 @@ +-- Consent capture on the prepaid-bundle purchase ticket (California ARL B&P §17602 / EULA §7.2). +-- +-- The buyer must affirmatively consent, BEFORE payment, to the automatic transition to metered PAYG +-- when the prepaid term ends. We record that consent on the quote ticket (the leader-authorized +-- purchase intent) so we can prove what was agreed: EULA version + timestamp + the disclosed price. +-- Capacity (units) + currency already live on the row; the term is deterministic (12 months from +-- purchase), so it isn't stored separately. The create-payg-bundle-checkout edge fn refuses to mint a +-- Checkout Session for a ticket without consent. Additive + idempotent. + +ALTER TABLE stirling_pdf.payg_bundle_quote + ADD COLUMN IF NOT EXISTS consented_at TIMESTAMP; +ALTER TABLE stirling_pdf.payg_bundle_quote + ADD COLUMN IF NOT EXISTS eula_version VARCHAR(32); +ALTER TABLE stirling_pdf.payg_bundle_quote + ADD COLUMN IF NOT EXISTS price_minor BIGINT; + +COMMENT ON COLUMN stirling_pdf.payg_bundle_quote.consented_at IS + 'When the leader affirmatively consented (ARL/EULA §7.2) to the prepaid→metered auto-transition. ' + 'NULL = no consent captured; the checkout edge fn refuses to create a session.'; +COMMENT ON COLUMN stirling_pdf.payg_bundle_quote.eula_version IS + 'EULA version string shown at consent — proof of exactly what terms were agreed to.'; +COMMENT ON COLUMN stirling_pdf.payg_bundle_quote.price_minor IS + 'One-time price disclosed at consent, minor units of currency; NULL when the rate was unknown.'; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygBundleControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygBundleControllerTest.java index ccc3b36509..78d1e193d2 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygBundleControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygBundleControllerTest.java @@ -15,6 +15,7 @@ import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -36,10 +37,14 @@ import stirling.software.saas.payg.bundle.PrepaidPurchaseService; import stirling.software.saas.payg.bundle.PrepaidPurchaseService.PrepaidQuote; import stirling.software.saas.security.EnhancedJwtAuthenticationToken; -/** Unit tests for {@link PaygBundleController} — leader-only enforcement + quote passthrough. */ +/** + * Unit tests for {@link PaygBundleController} — leader-only enforcement, consent threading, quote. + */ @ExtendWith(MockitoExtension.class) class PaygBundleControllerTest { + private static final String EULA = "eula-2026-07"; + @Mock private PrepaidPurchaseService purchaseService; @Mock private TeamMembershipRepository memberRepo; @Mock private UserRepository userRepository; @@ -48,6 +53,20 @@ class PaygBundleControllerTest { return new PaygBundleController(purchaseService, memberRepo, userRepository); } + private static PrepaidQuote sampleQuote(LocalDateTime expires) { + return new PrepaidQuote( + 555L, + 120_000L, + "usd", + BigDecimal.valueOf(2), + 240_000L, + 200_000L, + 40_000L, + 12, + 10, + expires); + } + @Test void quote_leader_returnsPricedQuote() { User leader = userWithId(20L, UUID.randomUUID()); @@ -56,36 +75,61 @@ class PaygBundleControllerTest { when(memberRepo.findPrimaryMembership(20L)) .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); LocalDateTime expires = LocalDateTime.of(2026, 8, 1, 12, 0); - when(purchaseService.quote(33L, 120_000L)) - .thenReturn( - new PrepaidQuote( - 555L, - 120_000L, - "usd", - BigDecimal.valueOf(2), - 240_000L, - 200_000L, - 40_000L, - 12, - 10, - expires)); + when(purchaseService.quote(eq(33L), eq(120_000L), any())).thenReturn(sampleQuote(expires)); ResponseEntity resp = - controller().quote(new QuoteRequest(120_000L), jwtAuth(leader.getSupabaseId())); + controller() + .quote( + new QuoteRequest(120_000L, true, EULA), + jwtAuth(leader.getSupabaseId())); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); QuoteResponse body = resp.getBody(); assertThat(body).isNotNull(); assertThat(body.quoteId()).isEqualTo(555L); assertThat(body.units()).isEqualTo(120_000L); - assertThat(body.currency()).isEqualTo("usd"); assertThat(body.totalAmountMinor()).isEqualTo(200_000L); - assertThat(body.savingsMinor()).isEqualTo(40_000L); - assertThat(body.monthsGranted()).isEqualTo(12); - assertThat(body.monthsPaid()).isEqualTo(10); assertThat(body.expiresAt()).isEqualTo("2026-08-01T12:00:00"); } + @Test + void quote_leaderConsented_passesEulaVersionToService() { + User leader = userWithId(24L, UUID.randomUUID()); + Team team = teamWithId(37L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); + when(memberRepo.findPrimaryMembership(24L)) + .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); + when(purchaseService.quote(eq(37L), anyLong(), any())) + .thenReturn(sampleQuote(LocalDateTime.of(2026, 8, 1, 12, 0))); + + controller().quote(new QuoteRequest(60_000L, true, EULA), jwtAuth(leader.getSupabaseId())); + + ArgumentCaptor consent = ArgumentCaptor.forClass(String.class); + // The controller forwards the EULA version only because consented=true. + org.mockito.Mockito.verify(purchaseService).quote(eq(37L), eq(60_000L), consent.capture()); + assertThat(consent.getValue()).isEqualTo(EULA); + } + + @Test + void quote_notConsented_forwardsNullConsent() { + User leader = userWithId(25L, UUID.randomUUID()); + Team team = teamWithId(38L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); + when(memberRepo.findPrimaryMembership(25L)) + .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); + // Service rejects a null consent (mirrors the real service) → controller maps to 400. + when(purchaseService.quote(eq(38L), anyLong(), eq(null))) + .thenThrow(new IllegalArgumentException("consent required")); + + ResponseEntity resp = + controller() + .quote( + new QuoteRequest(60_000L, false, EULA), + jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + @Test void quote_member_isForbidden() { User member = userWithId(21L, UUID.randomUUID()); @@ -95,7 +139,10 @@ class PaygBundleControllerTest { .thenReturn(List.of(membership(team, member, TeamRole.MEMBER))); ResponseEntity resp = - controller().quote(new QuoteRequest(50_000L), jwtAuth(member.getSupabaseId())); + controller() + .quote( + new QuoteRequest(50_000L, true, EULA), + jwtAuth(member.getSupabaseId())); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(purchaseService); @@ -108,7 +155,10 @@ class PaygBundleControllerTest { when(memberRepo.findPrimaryMembership(22L)).thenReturn(List.of()); ResponseEntity resp = - controller().quote(new QuoteRequest(50_000L), jwtAuth(user.getSupabaseId())); + controller() + .quote( + new QuoteRequest(50_000L, true, EULA), + jwtAuth(user.getSupabaseId())); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(purchaseService); @@ -122,7 +172,8 @@ class PaygBundleControllerTest { "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); - ResponseEntity resp = controller().quote(new QuoteRequest(50_000L), anon); + ResponseEntity resp = + controller().quote(new QuoteRequest(50_000L, true, EULA), anon); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verifyNoInteractions(purchaseService); @@ -135,12 +186,14 @@ class PaygBundleControllerTest { when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); when(memberRepo.findPrimaryMembership(23L)) .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); - when(purchaseService.quote(eq(35L), anyLong())) + when(purchaseService.quote(eq(35L), anyLong(), any())) .thenThrow(new IllegalArgumentException("out of range")); ResponseEntity resp = controller() - .quote(new QuoteRequest(999_999_999_999L), jwtAuth(leader.getSupabaseId())); + .quote( + new QuoteRequest(999_999_999_999L, true, EULA), + jwtAuth(leader.getSupabaseId())); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidPurchaseServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidPurchaseServiceTest.java index 6addd4530e..4a4d172d3d 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidPurchaseServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidPurchaseServiceTest.java @@ -20,10 +20,12 @@ import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; import stirling.software.saas.payg.bundle.PrepaidPurchaseService.PrepaidQuote; -/** Unit tests for the prepaid-bundle pricing + quote-ticket persistence. */ +/** Unit tests for the prepaid-bundle pricing + quote-ticket persistence (incl. consent capture). */ @ExtendWith(MockitoExtension.class) class PrepaidPurchaseServiceTest { + private static final String EULA = "eula-2026-07"; + @Mock private PrepaidBundleQuoteRepository quoteRepository; @Mock private TeamBillingService billingService; @@ -66,7 +68,7 @@ class PrepaidPurchaseServiceTest { stubSave(); when(billingService.forTeam(42L)).thenReturn(billingWithRate(BigDecimal.valueOf(2), "usd")); - PrepaidQuote q = service.quote(42L, 120_000L); + PrepaidQuote q = service.quote(42L, 120_000L, EULA); assertThat(q.quoteId()).isEqualTo(555L); assertThat(q.units()).isEqualTo(120_000L); @@ -87,7 +89,7 @@ class PrepaidPurchaseServiceTest { // Half-cent per unit; 100,000 units → 50,000 minor list, × 10/12 = 41,667 (HALF_UP). when(billingService.forTeam(7L)).thenReturn(billingWithRate(new BigDecimal("0.5"), "gbp")); - PrepaidQuote q = service.quote(7L, 100_000L); + PrepaidQuote q = service.quote(7L, 100_000L, EULA); assertThat(q.currency()).isEqualTo("gbp"); assertThat(q.listAmountMinor()).isEqualTo(50_000L); @@ -101,7 +103,7 @@ class PrepaidPurchaseServiceTest { stubSave(); when(billingService.forTeam(9L)).thenReturn(billingNoRate()); - PrepaidQuote q = service.quote(9L, 50_000L); + PrepaidQuote q = service.quote(9L, 50_000L, EULA); assertThat(q.quoteId()).isEqualTo(555L); assertThat(q.currency()).isEqualTo("usd"); // fallback — no Stripe currency yet @@ -112,13 +114,13 @@ class PrepaidPurchaseServiceTest { } @Test - void quote_persistsTicketWithTtlAndInputs() { + void quote_persistsTicketWithTtlConsentAndInputs() { init(); stubSave(); when(billingService.forTeam(11L)).thenReturn(billingWithRate(BigDecimal.valueOf(2), "usd")); LocalDateTime before = LocalDateTime.now(); - service.quote(11L, 30_000L); + service.quote(11L, 30_000L, EULA); LocalDateTime after = LocalDateTime.now(); ArgumentCaptor saved = @@ -132,13 +134,28 @@ class PrepaidPurchaseServiceTest { .isBetween( before.plus(PrepaidPurchaseService.QUOTE_TTL).minusSeconds(5), after.plus(PrepaidPurchaseService.QUOTE_TTL).plusSeconds(5)); + // Consent proof captured at quote time (ARL/EULA §7.2). + assertThat(ticket.getConsentedAt()).isBetween(before.minusSeconds(5), after.plusSeconds(5)); + assertThat(ticket.getEulaVersion()).isEqualTo(EULA); + assertThat(ticket.getPriceMinor()).isEqualTo(50_000L); // 30k × 2 × 10/12 + } + + @Test + void quote_withoutConsent_isRejected() { + init(); + + assertThatThrownBy(() -> service.quote(1L, 5_000L, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.quote(1L, 5_000L, " ")) + .isInstanceOf(IllegalArgumentException.class); + verify(quoteRepository, never()).save(any()); } @Test void quote_belowMinimum_isRejected() { init(); - assertThatThrownBy(() -> service.quote(1L, 0L)) + assertThatThrownBy(() -> service.quote(1L, 0L, EULA)) .isInstanceOf(IllegalArgumentException.class); verify(quoteRepository, never()).save(any()); } @@ -147,7 +164,7 @@ class PrepaidPurchaseServiceTest { void quote_aboveMaximum_isRejected() { init(); - assertThatThrownBy(() -> service.quote(1L, PrepaidPurchaseService.MAX_UNITS + 1)) + assertThatThrownBy(() -> service.quote(1L, PrepaidPurchaseService.MAX_UNITS + 1, EULA)) .isInstanceOf(IllegalArgumentException.class); verify(quoteRepository, never()).save(any()); } diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index c9e5dd7a13..44149bf194 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5498,6 +5498,9 @@ body = "{{units}} PDFs of prepaid capacity are ready. They're used before metere paidLabel = "Paid today" title = "Your prepaid year is active" +[payg.prepaid.consent] +label = "I understand that when my prepaid capacity is used up or expires after 12 months, processing automatically continues at the standard metered pay-as-you-go rate (up to my spend cap) unless I cancel, and that I can cancel anytime from the billing portal." + [payg.prepaid.meter] capSuffix = "of {{total}} prepaid PDFs" expires = "Expires {{date}}" diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/BundleCheckoutModal.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/BundleCheckoutModal.tsx index 3197ceee8f..7bcef07762 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/BundleCheckoutModal.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/BundleCheckoutModal.tsx @@ -18,7 +18,7 @@ */ import React, { Suspense, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { Group, NumberInput, Stack } from "@mantine/core"; +import { Checkbox, Group, NumberInput, Stack } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import { SegmentedControl } from "@app/ui/SegmentedControl"; @@ -62,6 +62,13 @@ const SIZES: Tier[] = [ /** Above this yearly capacity the demo routes to enterprise; we just nudge. */ const ENTERPRISE_CAPACITY_HINT = 1_000_000; +/** + * EULA version the prepay consent is recorded against (ARL/EULA §7.2). Legal owns the exact value + + * copy; this is a placeholder until the terms are finalised. Sent to the quote endpoint as proof of + * what was agreed, and stored on the ticket. + */ +const CONSENT_EULA_VERSION = "2026-07-draft"; + function multFor(tiers: Tier[], id: string): number { return tiers.find((tt) => tt.id === id)?.mult ?? tiers[0].mult; } @@ -81,8 +88,8 @@ interface BundleCheckoutModalProps { pricePerDocMinor?: number | null; /** Lower-case ISO currency of the rate (e.g. {@code "usd"}). */ currency?: string | null; - /** Server-price + persist a quote ticket for {@code units}; from {@code useWallet}. */ - quoteBundle: (units: number) => Promise; + /** Server-price + persist a quote ticket for {@code units}, carrying the consented EULA version. */ + quoteBundle: (units: number, eulaVersion: string) => Promise; onClose: () => void; /** Fired after a completed purchase; the parent refetches the wallet. */ onComplete: () => void; @@ -109,6 +116,8 @@ export default function BundleCheckoutModal({ const [sizeId, setSizeId] = useState("standard"); const [quoting, setQuoting] = useState(false); const [quote, setQuote] = useState(null); + // Affirmative consent (ARL/EULA §7.2) — must be checked before payment. + const [consented, setConsented] = useState(false); // Hide the config modal behind us while open (same event the upgrade flow uses). useEffect(() => { @@ -138,14 +147,15 @@ export default function BundleCheckoutModal({ const closeAndReset = () => { setStep("calculator"); setQuote(null); + setConsented(false); onClose(); }; const handleContinue = async () => { - if (capacity <= 0) return; + if (capacity <= 0 || !consented) return; setQuoting(true); try { - const q = await quoteBundle(capacity); + const q = await quoteBundle(capacity, CONSENT_EULA_VERSION); setQuote(q); setStep("checkout"); } catch (e: unknown) { @@ -232,6 +242,8 @@ export default function BundleCheckoutModal({ priceMinor={priceMinor} savingsMinor={savingsMinor} currency={currency} + consented={consented} + setConsented={setConsented} /> )} {step === "checkout" && quote && ( @@ -258,7 +270,7 @@ export default function BundleCheckoutModal({ @@ -298,6 +310,8 @@ interface CalculatorStepProps { priceMinor: number | null; savingsMinor: number | null; currency?: string | null; + consented: boolean; + setConsented: (v: boolean) => void; } function CalculatorStep({ @@ -311,6 +325,8 @@ function CalculatorStep({ priceMinor, savingsMinor, currency, + consented, + setConsented, }: CalculatorStepProps) { const { t } = useTranslation(); @@ -450,6 +466,25 @@ function CalculatorStep({ )} + + {/* Affirmative consent to the prepaid→metered auto-transition, captured before payment + (ARL/EULA §7.2). Conspicuous + un-pre-checked; gates Continue. Exact copy is legal-owned. */} +

+ setConsented(e.currentTarget.checked)} + label={t( + "payg.prepaid.consent.label", + "I understand that when my prepaid capacity is used up or expires after 12 months, processing automatically continues at the standard metered pay-as-you-go rate (up to my spend cap) unless I cancel, and that I can cancel anytime from the billing portal.", + )} + /> +
); } diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index d886b4deb2..555e56d89a 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -111,12 +111,14 @@ export interface UseWalletResult { */ openPortal: () => Promise; /** - * Price a prepaid-bundle purchase of {@code units} capacity. Leader-only on the - * backend ({@code POST /api/v1/payg/bundle/quote}); returns a short-lived quote - * ticket the checkout flow hands to the bundle-checkout edge function. Throws on - * a non-2xx (403 for members) so the caller can surface it. + * Price a prepaid-bundle purchase of {@code units} capacity, carrying the buyer's + * affirmative consent (ARL/EULA §7.2) as the agreed {@code eulaVersion} — captured + * before payment in the checkout modal. Leader-only on the backend ({@code POST + * /api/v1/payg/bundle/quote}), which refuses a quote without consent; returns a + * short-lived quote ticket the checkout flow hands to the bundle-checkout edge + * function. Throws on a non-2xx (403 members / 400 no consent). */ - quoteBundle: (units: number) => Promise; + quoteBundle: (units: number, eulaVersion: string) => Promise; } // ─── Implementation ───────────────────────────────────────────────────── @@ -349,7 +351,7 @@ export function useWallet(): UseWalletResult { }, [devPreview, wallet?.teamId]); const quoteBundle = useCallback( - async (units: number): Promise => { + async (units: number, eulaVersion: string): Promise => { if (devPreview) { // No backend on the dev-preview route — synthesise a quote off the // synthesised wallet's rate so the calculator + checkout flow render. @@ -371,7 +373,11 @@ export function useWallet(): UseWalletResult { } const res = await apiClient.post( "/api/v1/payg/bundle/quote", - { units }, + { + units, + consented: true, + eulaVersion, + }, ); return res.data; },