Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c782352
Prepaid bundles (slice 1, DB): payg_prepaid_bundle table + entity/repo
ConnorYoh Jul 13, 2026
8383503
Prepaid bundles (slice 1, charge): free → prepaid → meter draw
ConnorYoh Jul 13, 2026
1604298
Prepaid bundles (slice 1, wallet API): surface balance + billingMode
ConnorYoh Jul 13, 2026
b4535ce
Prepaid bundles (slice 2, purchase): leader quote endpoint + credit RPC
ConnorYoh Jul 14, 2026
54c9b03
Prepaid bundles (slice 3, frontend): calculator, capacity meter, top-up
ConnorYoh Jul 14, 2026
edf688e
Prepaid bundles: capture prepay→PAYG consent at the quote step (ARL/E…
ConnorYoh Jul 15, 2026
75006b6
Prepaid bundles: bundle Stripe Price + coupon on the pricing policy
ConnorYoh Jul 15, 2026
a550aa0
Prepaid bundles: register payg.bundle repositories (fix SaaS boot)
ConnorYoh Jul 15, 2026
4b1f7f8
Prepaid bundles (portal): prepay CTA + activation fork + direct checkout
ConnorYoh Jul 15, 2026
d22ff9e
Prepaid bundles (review fixes): share CardPlaceholder/loadStripeOnce,…
ConnorYoh Jul 16, 2026
04912c7
Prepaid bundles: drop dead redirect branch in bundle checkout (Aikido…
ConnorYoh Jul 16, 2026
1ce1882
Prepaid bundles: tear out the dead server-side quote-ticket path
ConnorYoh Jul 16, 2026
1f09aff
Prepaid bundles (pivot P1): run-based brain + users-first calculator
ConnorYoh Jul 17, 2026
38f784e
Prepaid bundles (pivot P2a): quote object schema (Flyway twin)
ConnorYoh Jul 17, 2026
2c867e5
Prepaid bundles (pivot): align calculator copy to the demo
ConnorYoh Jul 17, 2026
3697809
Prepaid bundles (pivot P2c-ui): calculator matches the demo
ConnorYoh Jul 17, 2026
5a27d0c
Prepaid bundles (pivot P2c-data): wire calculator to the persisted quote
ConnorYoh Jul 20, 2026
25a38a0
Prepaid bundles: make the quote→checkout fallback real (Aikido)
ConnorYoh Jul 20, 2026
9659fc6
Prepaid bundles (pivot P3b): card | bank-transfer payment fork
ConnorYoh Jul 20, 2026
f5d66af
Prepaid bundles: drop the redundant Flyway twins (retired by #7100)
ConnorYoh Jul 21, 2026
c1647cd
Prepaid bundles: entitlement gate honours a live pool without a subsc…
ConnorYoh Jul 22, 2026
f726c29
Prepaid bundles: quote-native checkout, resume/cancel flow, free-view…
ConnorYoh Jul 22, 2026
49ff1e6
Prepaid bundles: a live pool overrides the metered cap gate, not just…
ConnorYoh Jul 23, 2026
97619f3
Merge origin/main into payg-prepaid-bundles (resolve billing.css)
ConnorYoh Jul 23, 2026
e2c4c17
Prepaid bundles: review nits — expiring-banner plural + accurate acce…
ConnorYoh Jul 23, 2026
d6806cf
Prepaid bundles: token-ise the selected cap-chip tint (lint:colors)
ConnorYoh Jul 23, 2026
0db97d8
Prepaid bundles: consistent 'credits' copy + freeze resume total + dr…
ConnorYoh Jul 23, 2026
d7f8945
Prepaid bundles: money-idempotency indexes + service tests + review nits
ConnorYoh Jul 23, 2026
c52626c
Prepaid bundles: spotless javadoc reflow (SaasClassificationRunBiller)
ConnorYoh Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository",
"stirling.software.saas.payg.bundle",
"stirling.software.saas.procurement.repository"
})
@EntityScan({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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");
}

/**
* A bundle purchase request. {@code units} is the chosen 12-month pool size; {@code consented}
* + {@code eulaVersion} carry the buyer's affirmative consent (ARL/EULA §7.2) to the
* prepaid→metered auto-transition, captured before payment. The quote is refused without
* consent.
*/
public record QuoteRequest(@Min(1) long units, boolean consented, String eulaVersion) {}

/**
* A priced quote for the calculator/checkout. Money fields are minor units of {@link #currency}
* 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();

// Consent is captured before payment; no EULA version reaches the service unless the buyer
// affirmatively consented, and the service rejects a blank one (400).
String consent = req.consented() ? req.eulaVersion() : null;
try {
PrepaidPurchaseService.PrepaidQuote q =
purchaseService.quote(teamId, req.units(), consent);
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.bundle.PrepaidBundleService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.model.BillingCategory;
Expand Down Expand Up @@ -86,6 +87,8 @@ public class PaygWalletController {
static final String STATUS_SUBSCRIBED = "subscribed";
static final String ROLE_LEADER = "leader";
static final String ROLE_MEMBER = "member";
static final String BILLING_MODE_PREPAID = "prepaid";
static final String BILLING_MODE_PAYG = "payg";

/**
* Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a
Expand All @@ -104,6 +107,7 @@ public class PaygWalletController {
private final WalletLedgerRepository ledgerRepo;
private final PaygShadowChargeRepository shadowRepo;
private final UserRepository userRepository;
private final PrepaidBundleService prepaidBundleService;

public PaygWalletController(
EntitlementService entitlementService,
Expand All @@ -113,7 +117,8 @@ public PaygWalletController(
WalletPolicyRepository policyRepo,
WalletLedgerRepository ledgerRepo,
PaygShadowChargeRepository shadowRepo,
UserRepository userRepository) {
UserRepository userRepository,
PrepaidBundleService prepaidBundleService) {
this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService");
this.billingService = Objects.requireNonNull(billingService, "billingService");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
Expand All @@ -122,6 +127,8 @@ public PaygWalletController(
this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo");
this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
this.prepaidBundleService =
Objects.requireNonNull(prepaidBundleService, "prepaidBundleService");
}

// ---------------------------------------------------------------------------------------
Expand Down Expand Up @@ -187,6 +194,18 @@ public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd())
: List.of();

// Prepaid bundles, aggregated across the team's in-term pools. Drawn ahead of the meter and
// kept out of the spend cap, so they're a separate dimension from the metered spend above.
PrepaidBundleService.PrepaidSummary prepaid = prepaidBundleService.summarize(teamId);
long prepaidRemaining = prepaid == null ? 0L : prepaid.unitsRemaining();
long prepaidTotal = prepaid == null ? 0L : prepaid.unitsTotal();
String prepaidExpiresAt =
prepaid == null || prepaid.expiresAt() == null
? null
: ISO_DATE.format(prepaid.expiresAt().toLocalDate());
// Prepaid while pools still have units to draw; once exhausted the meter is live again.
String billingMode = prepaidRemaining > 0 ? BILLING_MODE_PREPAID : BILLING_MODE_PAYG;

WalletSnapshotResponse body =
new WalletSnapshotResponse(
teamId,
Expand All @@ -211,7 +230,11 @@ public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
breakdowns.docs(),
analytics.docsProcessed(),
analytics.uniquePdfs(),
analytics.sizeMultiplierPdfs());
analytics.sizeMultiplierPdfs(),
prepaidRemaining,
prepaidTotal,
prepaidExpiresAt,
billingMode);
return ResponseEntity.ok(body);
}

Expand Down Expand Up @@ -485,6 +508,10 @@ private WalletSnapshotResponse emptySnapshot() {
new CategoryBreakdown(0, 0, 0),
0,
0,
0);
0,
0L,
0L,
null,
BILLING_MODE_PAYG);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,20 @@ public record WalletSnapshotResponse(
CategoryBreakdown categoryDocs,
int docsProcessedThisPeriod,
int uniquePdfsThisPeriod,
int sizeMultiplierPdfsThisPeriod) {
int sizeMultiplierPdfsThisPeriod,
long prepaidUnitsRemaining,
long prepaidUnitsTotal,
String prepaidExpiresAt,
String billingMode) {

// Prepaid usage bundles, aggregated across the team's in-term pools (drawn ahead of the meter,
// outside the spend cap):
// prepaidUnitsRemaining — Σ units left across active pools (0 when exhausted / none)
// prepaidUnitsTotal — Σ capacity of in-term pools (the "X of Y used" denominator; 0 = no
// bundle this term, so the FE hides the prepaid card)
// prepaidExpiresAt — soonest term end (ISO date) for the countdown; null when no bundle
// billingMode — "prepaid" while prepaid units remain, else "payg" (the meter is
// live)

// The count dimension, kept distinct from units (which now scale with file size):
// categoryDocs — per-category INPUT-file counts (parallel to
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 prepaid, expiring pool of PDF-process units bought up-front at a discount ("12 months for the
* price of 10"). Consumed after the team's free grant and before the meter (free -> prepaid ->
* metered); draws are booked to the {@code BOUGHT} ledger bucket, so a bundle never counts toward
* the spend cap or the Stripe meter.
*
* <p>Carries only capacity + term + the Stripe link. The one-time amount and currency live on the
* Stripe Checkout Session / PaymentIntent referenced by {@link #stripeRef}; how many units a PDF
* costs comes from the team's pricing policy at charge time, not from the bundle. Status is
* derived, never stored (see {@link #isDrawable}).
*
* <p>A team may hold several pools at once (top-ups); they are drawn FIFO by soonest {@link
* #expiresAt}. Unused units forfeit at expiry (no roll-over).
*/
@Entity
@Table(name = "payg_prepaid_bundle")
@NoArgsConstructor
@Getter
@Setter
public class PrepaidBundle implements Serializable {

private static final long serialVersionUID = 1L;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "bundle_id")
private Long id;

@Column(name = "team_id", nullable = false)
private Long teamId;

/** Capacity granted at purchase — the denominator of the "X of Y used" meter. */
@Column(name = "units_total", nullable = false)
private long unitsTotal;

/** Live balance; pessimistic-locked on draw. */
@Column(name = "units_remaining", nullable = false)
private long unitsRemaining;

@Column(name = "purchased_at", nullable = false)
private LocalDateTime purchasedAt;

/** {@code purchasedAt + 12 months}. Unused units forfeit after this instant. */
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;

/**
* Stripe Checkout Session / PaymentIntent id for the one-time payment that created this pool.
* The amount + currency + receipt live on that object; a unique index makes the webhook credit
* idempotent. {@code null} only for pools seeded outside the purchase flow (tests/backfill).
*/
@Column(name = "stripe_ref", length = 128)
private String stripeRef;

@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

/** In-term (not yet expired) as of {@code now} — regardless of remaining balance. */
public boolean isInTerm(LocalDateTime now) {
return expiresAt.isAfter(now);
}

/** Has units left AND is still in term — i.e. a charge may draw from it. */
public boolean isDrawable(LocalDateTime now) {
return unitsRemaining > 0 && isInTerm(now);
}
}
Loading
Loading