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