Prepaid bundles: capture prepay→PAYG consent at the quote step (ARL/EULA §7.2)

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.
This commit is contained in:
Connor Yoh
2026-07-15 12:55:58 +01:00
parent 54c9b03122
commit edf688ef09
9 changed files with 234 additions and 55 deletions
@@ -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());
@@ -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;
@@ -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.
*
* <p>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(),
@@ -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.';
@@ -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<QuoteResponse> 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<String> 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<QuoteResponse> 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<QuoteResponse> 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<QuoteResponse> 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<QuoteResponse> resp = controller().quote(new QuoteRequest(50_000L), anon);
ResponseEntity<QuoteResponse> 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<QuoteResponse> 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);
}
@@ -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<PrepaidBundleQuote> 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());
}
@@ -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}}"
@@ -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<BundleQuote>;
/** Server-price + persist a quote ticket for {@code units}, carrying the consented EULA version. */
quoteBundle: (units: number, eulaVersion: string) => Promise<BundleQuote>;
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<string>("standard");
const [quoting, setQuoting] = useState(false);
const [quote, setQuote] = useState<BundleQuote | null>(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({
<Button
onClick={handleContinue}
loading={quoting}
disabled={capacity <= 0}
disabled={capacity <= 0 || !consented}
>
{t("payg.upgrade.button.continue", "Continue →")}
</Button>
@@ -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({
</div>
)}
</div>
{/* 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. */}
<div
style={{
marginTop: 16,
paddingTop: 14,
borderTop: "1px solid var(--upm-divider, rgba(128,128,128,0.2))",
}}
>
<Checkbox
checked={consented}
onChange={(e) => 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.",
)}
/>
</div>
</>
);
}
+13 -7
View File
@@ -111,12 +111,14 @@ export interface UseWalletResult {
*/
openPortal: () => Promise<void>;
/**
* 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<BundleQuote>;
quoteBundle: (units: number, eulaVersion: string) => Promise<BundleQuote>;
}
// ─── Implementation ─────────────────────────────────────────────────────
@@ -349,7 +351,7 @@ export function useWallet(): UseWalletResult {
}, [devPreview, wallet?.teamId]);
const quoteBundle = useCallback(
async (units: number): Promise<BundleQuote> => {
async (units: number, eulaVersion: string): Promise<BundleQuote> => {
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<BundleQuote>(
"/api/v1/payg/bundle/quote",
{ units },
{
units,
consented: true,
eulaVersion,
},
);
return res.data;
},