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 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 Pricing. Money lives in Stripe, so the actual charge is {@code units × 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