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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
@@ -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> {}
|
||||
+146
@@ -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());
|
||||
}
|
||||
}
|
||||
+154
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user