Prepaid bundles (slice 2, purchase): leader quote endpoint + credit RPC

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.
This commit is contained in:
Connor Yoh
2026-07-14 15:39:46 +01:00
parent 160429831a
commit b4535ce8d2
7 changed files with 792 additions and 0 deletions
@@ -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}).
*
* <p>Buying prepaid capacity is a commercial action, so it is <b>leader-only</b>: 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<QuoteResponse> 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<TeamMembership> 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<TeamMembership> primaryMembership(Long userId) {
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}
}
@@ -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.
*
* <p>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;
}
}
@@ -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<PrepaidBundleQuote, Long> {}
@@ -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.
*
* <p><b>Pricing.</b> Money lives in Stripe, so the actual charge is {@code units × <one-time Price
* unit_amount> × <coupon>} 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).
*
* <p>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) {}
}
@@ -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.';
@@ -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<QuoteResponse> 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<QuoteResponse> 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<QuoteResponse> 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<QuoteResponse> 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<QuoteResponse> 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());
}
}
@@ -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<PrepaidBundleQuote> 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());
}
}