From b4535ce8d2afa87ec6a35b55d1a45f585a6e8984 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Tue, 14 Jul 2026 15:39:01 +0100 Subject: [PATCH] Prepaid bundles (slice 2, purchase): leader quote endpoint + credit RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-prices a prepaid-capacity request and records a short-lived, leader-authorized purchase ticket the Stripe checkout edge fn acts on (mirrors procurement: Java authorizes + records intent, the edge fn owns Stripe, the pool lands via an RPC — Java never touches the Stripe SDK). - V40: payg_bundle_quote ticket table + payg_credit_bundle(...) RPC that opens one pool, applies the 12-month term, idempotent on stripe_ref. - PrepaidPurchaseService prices units x rate x 10/12 (12-for-10), null-safe when the rate has not synced; PaygBundleController POST /payg/bundle/quote is leader-only (401 unauth / 403 member), team from the principal. - Stripe stays the source of truth for purchased quantity + amount; the edge fns + Supabase twin are specced for the SaaS repo. --- .../saas/payg/api/PaygBundleController.java | 134 +++++++++++++ .../saas/payg/bundle/PrepaidBundleQuote.java | 72 +++++++ .../bundle/PrepaidBundleQuoteRepository.java | 12 ++ .../payg/bundle/PrepaidPurchaseService.java | 146 ++++++++++++++ .../V40__payg_bundle_quote_and_credit.sql | 92 +++++++++ .../payg/api/PaygBundleControllerTest.java | 182 ++++++++++++++++++ .../bundle/PrepaidPurchaseServiceTest.java | 154 +++++++++++++++ 7 files changed, 792 insertions(+) create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/PaygBundleController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuote.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuoteRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidPurchaseService.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V40__payg_bundle_quote_and_credit.sql create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/api/PaygBundleControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidPurchaseServiceTest.java 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 new file mode 100644 index 0000000000..889ad32a3e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygBundleController.java @@ -0,0 +1,134 @@ +package stirling.software.saas.payg.api; + +import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.payg.bundle.PrepaidPurchaseService; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Prepaid-bundle purchase surface. {@code POST /api/v1/payg/bundle/quote} server-prices a capacity + * request and returns a short-lived quote ticket the portal hands to the + * create-payg-bundle-checkout edge function (which owns Stripe — this controller never touches it, + * mirroring {@link stirling.software.saas.procurement.api.ProcurementController}). + * + *

Buying prepaid capacity is a commercial action, so it is leader-only: the team is + * resolved from the authenticated principal (never trusted from the request), and a member gets + * 403. Crediting the pool happens only on the Stripe webhook (idempotent on the session id via + * {@code payg_credit_bundle}), never on a client callback. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/payg/bundle") +@Profile("saas") +public class PaygBundleController { + + private static final DateTimeFormatter ISO_DATE_TIME = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + private final PrepaidPurchaseService purchaseService; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public PaygBundleController( + PrepaidPurchaseService purchaseService, + TeamMembershipRepository memberRepo, + UserRepository userRepository) { + this.purchaseService = Objects.requireNonNull(purchaseService, "purchaseService"); + this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); + 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 priced quote for the calculator/checkout. Money fields are minor units of {@link #currency} + * and null when the rate is unknown; {@code unitAmountMinor} may be fractional. {@code + * expiresAt} is ISO local date-time. + */ + public record QuoteResponse( + long quoteId, + long units, + String currency, + BigDecimal unitAmountMinor, + Long listAmountMinor, + Long totalAmountMinor, + Long savingsMinor, + int monthsGranted, + int monthsPaid, + String expiresAt) {} + + @PostMapping("/quote") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity quote( + @Valid @RequestBody QuoteRequest req, Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + Optional primary = primaryMembership(user.getId()); + if (primary.isEmpty() || primary.get().getRole() != TeamRole.LEADER) { + // Members (and team-less callers) can see prepaid state but not buy it. + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + Long teamId = primary.get().getTeam().getId(); + + try { + PrepaidPurchaseService.PrepaidQuote q = purchaseService.quote(teamId, req.units()); + return ResponseEntity.ok(toResponse(q)); + } catch (IllegalArgumentException e) { + log.debug("bundle quote rejected for team {}: {}", teamId, e.getMessage()); + return ResponseEntity.badRequest().build(); + } + } + + private static QuoteResponse toResponse(PrepaidPurchaseService.PrepaidQuote q) { + return new QuoteResponse( + q.quoteId(), + q.units(), + q.currency(), + q.unitAmountMinor(), + q.listAmountMinor(), + q.totalAmountMinor(), + q.savingsMinor(), + q.monthsGranted(), + q.monthsPaid(), + ISO_DATE_TIME.format(q.expiresAt())); + } + + private Optional primaryMembership(Long userId) { + List rows = memberRepo.findPrimaryMembership(userId); + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + } +} 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 new file mode 100644 index 0000000000..2af64e003a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuote.java @@ -0,0 +1,72 @@ +package stirling.software.saas.payg.bundle; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A short-lived, leader-authorized prepaid-bundle purchase ticket. Written by {@code POST + * /api/v1/payg/bundle/quote} once the caller has been verified as a team leader; read once by the + * create-payg-bundle-checkout edge function, which turns it into a Stripe Checkout Session. + * + *

Carries only the capacity ({@link #units}) + {@link #currency} the leader committed to — the + * per-unit price and the "12 months for the price of 10" discount live in Stripe (a one-time Price + * at the same unit_amount as the meter + a coupon). The ticket is not a billing record; the {@link + * PrepaidBundle} pool opened on webhook is. An unused ticket simply lapses at {@link #expiresAt} + * and is inert thereafter — the edge fn refuses an expired ticket. + */ +@Entity +@Table(name = "payg_bundle_quote") +@NoArgsConstructor +@Getter +@Setter +public class PrepaidBundleQuote implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "quote_id") + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** + * Capacity the leader chose to buy — the 12-month pool size. Stripe bills this as the line + * quantity. + */ + @Column(name = "units", nullable = false) + private long units; + + /** Lower-case ISO 4217 the checkout Price is denominated in. */ + @Column(name = "currency", nullable = false, length = 8) + private String currency; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** Ticket validity — a checkout started after this instant is rejected. */ + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + public PrepaidBundleQuote(Long teamId, long units, String currency, LocalDateTime expiresAt) { + this.teamId = teamId; + this.units = units; + this.currency = currency; + this.expiresAt = expiresAt; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuoteRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuoteRepository.java new file mode 100644 index 0000000000..23e44c0805 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleQuoteRepository.java @@ -0,0 +1,12 @@ +package stirling.software.saas.payg.bundle; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Persistence for {@link PrepaidBundleQuote} purchase tickets. Java only ever writes here (on + * quote); the create-payg-bundle-checkout edge fn reads the ticket directly via the service-role + * client, so no read methods are needed on this side. + */ +@Repository +public interface PrepaidBundleQuoteRepository extends JpaRepository {} 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 new file mode 100644 index 0000000000..2b73f3b998 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidPurchaseService.java @@ -0,0 +1,146 @@ +package stirling.software.saas.payg.bundle; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; + +/** + * The purchase side of prepaid bundles: turns a leader's requested capacity into a persisted, + * server-priced quote ticket ({@link PrepaidBundleQuote}) the checkout edge function can act on. + * Consumption lives in {@link PrepaidBundleService}; this class never touches Stripe. + * + *

Pricing. Money lives in Stripe, so the actual charge is {@code units × × } computed by Stripe at checkout. The per-unit rate is the SAME one the + * meter bills at, so this service reuses the team's resolved rate ({@link + * TeamBillingContext#perDocMinor()}) only to show the buyer the figure they'll pay — an estimate, + * not the authority. The "12 months for the price of 10" benefit is {@code MONTHS_PAID / + * MONTHS_GRANTED}; the Stripe coupon must be configured to the matching percentage (the buyer sees + * a penny-exact estimate here, Stripe's coupon rounding is authoritative). + * + *

The requested capacity is the buyer's order size, priced per-unit by Stripe, so it is not an + * exploit surface — we only guard against nonsense/overflow. The ticket is the record that a leader + * authorized this quantity; it lapses after {@link #QUOTE_TTL} if never paid. + */ +@Slf4j +@Service +@Profile("saas") +public class PrepaidPurchaseService { + + /** Capacity term granted by one bundle. */ + static final int MONTHS_GRANTED = 12; + + /** Months actually paid for — the "12 for the price of 10" discount. */ + static final int MONTHS_PAID = 10; + + /** How long a quote ticket is valid before the buyer must re-quote. */ + static final Duration QUOTE_TTL = Duration.ofMinutes(30); + + /** + * Sanity bounds on the requested capacity — an overflow/nonsense guard only. Real purchase-size + * UX limits (recommended tiers, minimums) are a product/front-end concern, not enforced here. + */ + static final long MIN_UNITS = 1L; + + static final long MAX_UNITS = 1_000_000_000L; + + /** The app prices in dollars when a team has no Stripe currency yet (free, pre-checkout). */ + private static final String FALLBACK_CURRENCY = "usd"; + + private final PrepaidBundleQuoteRepository quoteRepository; + private final TeamBillingService billingService; + + public PrepaidPurchaseService( + PrepaidBundleQuoteRepository quoteRepository, TeamBillingService billingService) { + this.quoteRepository = Objects.requireNonNull(quoteRepository, "quoteRepository"); + this.billingService = Objects.requireNonNull(billingService, "billingService"); + } + + /** + * Price + persist a quote for {@code requestedUnits} of prepaid capacity for {@code teamId}. + * The money figures are null when the per-unit rate can't be resolved (free team before the + * 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. + * + * @throws IllegalArgumentException when the requested capacity is outside {@link + * #MIN_UNITS}..{@link #MAX_UNITS} + */ + @Transactional + public PrepaidQuote quote(Long teamId, long requestedUnits) { + Objects.requireNonNull(teamId, "teamId"); + if (requestedUnits < MIN_UNITS || requestedUnits > MAX_UNITS) { + throw new IllegalArgumentException( + "requested units out of range: " + + requestedUnits + + " (allowed " + + MIN_UNITS + + ".." + + MAX_UNITS + + ")"); + } + + TeamBillingContext billing = billingService.forTeam(teamId); + String currency = billing.currency() != null ? billing.currency() : FALLBACK_CURRENCY; + BigDecimal rate = billing.perDocMinor(); + + Long listMinor = null; + Long totalMinor = null; + Long savingsMinor = null; + 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. + listMinor = rate.multiply(units).setScale(0, RoundingMode.HALF_UP).longValue(); + totalMinor = + rate.multiply(units) + .multiply(BigDecimal.valueOf(MONTHS_PAID)) + .divide(BigDecimal.valueOf(MONTHS_GRANTED), 0, RoundingMode.HALF_UP) + .longValue(); + savingsMinor = listMinor - totalMinor; + } + + LocalDateTime expiresAt = LocalDateTime.now().plus(QUOTE_TTL); + PrepaidBundleQuote saved = + quoteRepository.save( + new PrepaidBundleQuote(teamId, requestedUnits, currency, expiresAt)); + + return new PrepaidQuote( + saved.getId(), + requestedUnits, + currency, + rate, + listMinor, + totalMinor, + savingsMinor, + MONTHS_GRANTED, + MONTHS_PAID, + expiresAt); + } + + /** + * A priced bundle quote. Money figures are in minor units of {@link #currency} and null when + * the rate is unknown; {@link #unitAmountMinor} may be fractional (sub-cent per-unit rates). + */ + public record PrepaidQuote( + long quoteId, + long units, + String currency, + BigDecimal unitAmountMinor, + Long listAmountMinor, + Long totalAmountMinor, + Long savingsMinor, + int monthsGranted, + int monthsPaid, + LocalDateTime expiresAt) {} +} diff --git a/app/saas/src/main/resources/db/migration/saas/V40__payg_bundle_quote_and_credit.sql b/app/saas/src/main/resources/db/migration/saas/V40__payg_bundle_quote_and_credit.sql new file mode 100644 index 0000000000..9b41ccce81 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V40__payg_bundle_quote_and_credit.sql @@ -0,0 +1,92 @@ +-- PAYG prepaid-bundle PURCHASE path: the leader-authorized quote ticket and the idempotent credit +-- RPC the Stripe webhook calls once a payment settles. Slice 1 (V39) added the pool + the draw; this +-- adds the way a pool is BOUGHT. Everything additive + idempotent. +-- +-- Flow mirrors procurement — Java authorizes + records intent, the edge function owns Stripe, and the +-- result lands via an RPC (Java never touches the Stripe SDK): +-- 1. A team LEADER hits POST /api/v1/payg/bundle/quote. Java validates the requested capacity and +-- writes a payg_bundle_quote row: a short-lived "this leader may buy N units" ticket. The +-- per-unit price is NOT set here — it lives in Stripe (a one-time Price at the SAME unit_amount +-- as the meter) and the "12 months for the price of 10" benefit is a Stripe coupon. So the +-- ticket carries only capacity + currency. +-- 2. The portal calls the create-payg-bundle-checkout edge fn with the quote id (user JWT). The edge +-- fn reads the ticket (unexpired, team-matched), then creates a mode=payment Checkout Session for +-- quantity = units at the bundle Price + coupon. Stripe is the source of truth for the purchased +-- quantity + amount. +-- 3. On checkout.session.completed the stripe-webhook edge fn (service-role) calls +-- payg_credit_bundle() below with the team, the paid line quantity, and the session id — opening +-- one payg_prepaid_bundle pool, idempotent on stripe_ref so Stripe's at-least-once delivery can't +-- double-credit. + +-- == payg_bundle_quote ========================================================= +CREATE TABLE IF NOT EXISTS stirling_pdf.payg_bundle_quote ( + quote_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL, + units BIGINT NOT NULL, + currency VARCHAR(8) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +-- The checkout edge fn looks a ticket up by id and re-checks it belongs to the team; the same index +-- backs a cheap sweep of lapsed tickets. +CREATE INDEX IF NOT EXISTS idx_payg_bundle_quote_team + ON stirling_pdf.payg_bundle_quote (team_id, expires_at); + +COMMENT ON TABLE stirling_pdf.payg_bundle_quote IS + 'Short-lived, leader-authorized prepaid-bundle purchase tickets. Carries the capacity (units) + ' + 'currency the leader committed to; the price lives in Stripe. Read once by the ' + 'create-payg-bundle-checkout edge fn; an expired ticket is inert. Not a billing record — the ' + 'payg_prepaid_bundle pool opened on webhook is.'; + +-- == payg_credit_bundle ======================================================== +-- Opens one prepaid pool for a settled purchase. Called by the stripe-webhook edge fn (service-role, +-- which bypasses RLS) on checkout.session.completed. Idempotent on stripe_ref: Stripe delivers +-- webhooks at least once, so a repeat call for the same session is a no-op that returns the pool that +-- already exists. The 12-month term is applied here so the expiry rule has a single home. Mirrors the +-- V14 payg_link_subscription pattern (SECURITY INVOKER; the only writers of pool state go through an +-- RPC rather than direct table writes from the edge fn). +CREATE OR REPLACE FUNCTION stirling_pdf.payg_credit_bundle( + p_team_id BIGINT, + p_units BIGINT, + p_stripe_ref TEXT, + p_term_months INTEGER DEFAULT 12 +) RETURNS BIGINT +LANGUAGE plpgsql +SECURITY INVOKER +AS $$ +DECLARE + v_bundle_id BIGINT; +BEGIN + IF p_units IS NULL OR p_units <= 0 THEN + RAISE EXCEPTION 'payg_credit_bundle: units must be positive (got %)', p_units + USING ERRCODE = 'check_violation'; + END IF; + IF p_stripe_ref IS NULL OR length(trim(p_stripe_ref)) = 0 THEN + RAISE EXCEPTION 'payg_credit_bundle: stripe_ref is required for idempotency' + USING ERRCODE = 'not_null_violation'; + END IF; + + INSERT INTO stirling_pdf.payg_prepaid_bundle( + team_id, units_total, units_remaining, purchased_at, expires_at, stripe_ref) + VALUES ( + p_team_id, p_units, p_units, now(), + now() + make_interval(months => p_term_months), p_stripe_ref) + ON CONFLICT (stripe_ref) WHERE stripe_ref IS NOT NULL + DO NOTHING + RETURNING bundle_id INTO v_bundle_id; + + -- Idempotent replay: the pool for this Stripe payment already exists — return its id. + IF v_bundle_id IS NULL THEN + SELECT bundle_id INTO v_bundle_id + FROM stirling_pdf.payg_prepaid_bundle + WHERE stripe_ref = p_stripe_ref; + END IF; + + RETURN v_bundle_id; +END $$; + +COMMENT ON FUNCTION stirling_pdf.payg_credit_bundle(BIGINT, BIGINT, TEXT, INTEGER) IS + 'Opens (or idempotently returns) a prepaid unit pool for a settled Stripe purchase. Idempotent on ' + 'stripe_ref; applies the 12-month term. Called by the stripe-webhook edge fn on ' + 'checkout.session.completed with the service-role key.'; 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 new file mode 100644 index 0000000000..ccc3b36509 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygBundleControllerTest.java @@ -0,0 +1,182 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.payg.api.PaygBundleController.QuoteRequest; +import stirling.software.saas.payg.api.PaygBundleController.QuoteResponse; +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. */ +@ExtendWith(MockitoExtension.class) +class PaygBundleControllerTest { + + @Mock private PrepaidPurchaseService purchaseService; + @Mock private TeamMembershipRepository memberRepo; + @Mock private UserRepository userRepository; + + private PaygBundleController controller() { + return new PaygBundleController(purchaseService, memberRepo, userRepository); + } + + @Test + void quote_leader_returnsPricedQuote() { + User leader = userWithId(20L, UUID.randomUUID()); + Team team = teamWithId(33L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); + 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)); + + ResponseEntity resp = + controller().quote(new QuoteRequest(120_000L), 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_member_isForbidden() { + User member = userWithId(21L, UUID.randomUUID()); + Team team = teamWithId(34L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(member)); + when(memberRepo.findPrimaryMembership(21L)) + .thenReturn(List.of(membership(team, member, TeamRole.MEMBER))); + + ResponseEntity resp = + controller().quote(new QuoteRequest(50_000L), jwtAuth(member.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(purchaseService); + } + + @Test + void quote_noTeam_isForbidden() { + User user = userWithId(22L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(22L)).thenReturn(List.of()); + + ResponseEntity resp = + controller().quote(new QuoteRequest(50_000L), jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(purchaseService); + } + + @Test + void quote_anonymous_is401() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller().quote(new QuoteRequest(50_000L), anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(purchaseService); + } + + @Test + void quote_serviceRejectsCapacity_isBadRequest() { + User leader = userWithId(23L, UUID.randomUUID()); + Team team = teamWithId(35L); + 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())) + .thenThrow(new IllegalArgumentException("out of range")); + + ResponseEntity resp = + controller() + .quote(new QuoteRequest(999_999_999_999L), jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + // ---- fixtures ----------------------------------------------------------- + + private static User userWithId(Long id, UUID supabaseId) { + User u = new User(); + u.setId(id); + u.setSupabaseId(supabaseId); + return u; + } + + private static Team teamWithId(Long id) { + Team t = new Team(); + t.setId(id); + t.setName("t-" + id); + return t; + } + + private static TeamMembership membership(Team team, User user, TeamRole role) { + TeamMembership m = new TeamMembership(); + m.setTeam(team); + m.setUser(user); + m.setRole(role); + return m; + } + + private static Authentication jwtAuth(UUID supabaseId) { + Jwt jwt = + Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", supabaseId.toString()) + .claim("email", "user@example.com") + .build(); + return new EnhancedJwtAuthenticationToken( + jwt, List.of(), "user@example.com", supabaseId.toString()); + } +} 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 new file mode 100644 index 0000000000..6addd4530e --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidPurchaseServiceTest.java @@ -0,0 +1,154 @@ +package stirling.software.saas.payg.bundle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +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 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. */ +@ExtendWith(MockitoExtension.class) +class PrepaidPurchaseServiceTest { + + @Mock private PrepaidBundleQuoteRepository quoteRepository; + @Mock private TeamBillingService billingService; + + private PrepaidPurchaseService service; + + private void init() { + service = new PrepaidPurchaseService(quoteRepository, billingService); + } + + /** + * Stub the save to echo back the ticket with a generated id — for tests that reach persistence. + */ + private void stubSave() { + when(quoteRepository.save(any(PrepaidBundleQuote.class))) + .thenAnswer( + inv -> { + PrepaidBundleQuote q = inv.getArgument(0); + q.setId(555L); + return q; + }); + } + + /** Billing facts with a known per-unit rate + currency — the subscribed / synced case. */ + private static TeamBillingContext billingWithRate(BigDecimal rate, String currency) { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new TeamBillingContext( + true, "sub_x", start, start.plusMonths(1), 500L, 0L, rate, currency, null, null); + } + + /** Billing facts with no resolvable rate — a free team before the Price has synced. */ + private static TeamBillingContext billingNoRate() { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new TeamBillingContext( + false, null, start, start.plusMonths(1), 500L, 500L, null, null, null, null); + } + + @Test + void quote_withKnownRate_appliesTwelveForTenDiscount() { + init(); + stubSave(); + when(billingService.forTeam(42L)).thenReturn(billingWithRate(BigDecimal.valueOf(2), "usd")); + + PrepaidQuote q = service.quote(42L, 120_000L); + + assertThat(q.quoteId()).isEqualTo(555L); + assertThat(q.units()).isEqualTo(120_000L); + assertThat(q.currency()).isEqualTo("usd"); + assertThat(q.unitAmountMinor()).isEqualByComparingTo(BigDecimal.valueOf(2)); + // 120k × 2 = 240,000 minor undiscounted; × 10/12 = 200,000 paid; saves 40,000. + assertThat(q.listAmountMinor()).isEqualTo(240_000L); + assertThat(q.totalAmountMinor()).isEqualTo(200_000L); + assertThat(q.savingsMinor()).isEqualTo(40_000L); + assertThat(q.monthsGranted()).isEqualTo(12); + assertThat(q.monthsPaid()).isEqualTo(10); + } + + @Test + void quote_subCentRate_roundsToMinorUnit() { + init(); + stubSave(); + // 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); + + assertThat(q.currency()).isEqualTo("gbp"); + assertThat(q.listAmountMinor()).isEqualTo(50_000L); + assertThat(q.totalAmountMinor()).isEqualTo(41_667L); + assertThat(q.savingsMinor()).isEqualTo(8_333L); + } + + @Test + void quote_freeTeamNoRate_persistsWithNullMoneyAndFallbackCurrency() { + init(); + stubSave(); + when(billingService.forTeam(9L)).thenReturn(billingNoRate()); + + PrepaidQuote q = service.quote(9L, 50_000L); + + assertThat(q.quoteId()).isEqualTo(555L); + assertThat(q.currency()).isEqualTo("usd"); // fallback — no Stripe currency yet + assertThat(q.unitAmountMinor()).isNull(); + assertThat(q.listAmountMinor()).isNull(); + assertThat(q.totalAmountMinor()).isNull(); + assertThat(q.savingsMinor()).isNull(); + } + + @Test + void quote_persistsTicketWithTtlAndInputs() { + init(); + stubSave(); + when(billingService.forTeam(11L)).thenReturn(billingWithRate(BigDecimal.valueOf(2), "usd")); + + LocalDateTime before = LocalDateTime.now(); + service.quote(11L, 30_000L); + LocalDateTime after = LocalDateTime.now(); + + ArgumentCaptor saved = + ArgumentCaptor.forClass(PrepaidBundleQuote.class); + verify(quoteRepository).save(saved.capture()); + PrepaidBundleQuote ticket = saved.getValue(); + assertThat(ticket.getTeamId()).isEqualTo(11L); + assertThat(ticket.getUnits()).isEqualTo(30_000L); + assertThat(ticket.getCurrency()).isEqualTo("usd"); + assertThat(ticket.getExpiresAt()) + .isBetween( + before.plus(PrepaidPurchaseService.QUOTE_TTL).minusSeconds(5), + after.plus(PrepaidPurchaseService.QUOTE_TTL).plusSeconds(5)); + } + + @Test + void quote_belowMinimum_isRejected() { + init(); + + assertThatThrownBy(() -> service.quote(1L, 0L)) + .isInstanceOf(IllegalArgumentException.class); + verify(quoteRepository, never()).save(any()); + } + + @Test + void quote_aboveMaximum_isRejected() { + init(); + + assertThatThrownBy(() -> service.quote(1L, PrepaidPurchaseService.MAX_UNITS + 1)) + .isInstanceOf(IllegalArgumentException.class); + verify(quoteRepository, never()).save(any()); + } +}