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:
@@ -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;
|
||||
|
||||
+20
-7
@@ -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.';
|
||||
+77
-24
@@ -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);
|
||||
}
|
||||
|
||||
+25
-8
@@ -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}}"
|
||||
|
||||
+41
-6
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user