Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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,105 @@
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.Index;
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",
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative creator
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which builds
// the partial forms (WHERE units_remaining > 0 / WHERE stripe_ref IS NOT NULL). Flyway was
// retired for SaaS (#7100), so there is no migration twin — names match the CLI migration.
indexes = {
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every billable
// charge past the free grant; without it that degrades to a locked scan as the table grows.
@Index(
name = "idx_payg_prepaid_bundle_team_expiry",
columnList = "team_id, expires_at"),
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid can't
// credit the same purchase twice.
@Index(
name = "uq_payg_prepaid_bundle_stripe_ref",
columnList = "stripe_ref",
unique = true),
})
@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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package stirling.software.saas.payg.bundle;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import jakarta.persistence.LockModeType;

@Repository
public interface PrepaidBundleRepository extends JpaRepository<PrepaidBundle, Long> {

/**
* A team's still-drawable pools (units left, not expired), soonest-expiring first, locked for
* the draw transaction. Mirrors {@code PaygTeamExtensionsRepository.findByIdForUpdate}: the
* PESSIMISTIC_WRITE lock serialises concurrent charges for the same team so two jobs can't both
* draw the same remaining unit, keeping the per-job {@code bundle_units_consumed} split exact.
* Drawn FIFO — the caller depletes the earliest-expiring pool first so capacity is used before
* it lapses. Filters on {@code expires_at} so an expired pool is never drawn even if the expiry
* sweep hasn't run (lazy expiry).
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.unitsRemaining > 0"
+ " AND b.expiresAt > :now ORDER BY b.expiresAt ASC")
List<PrepaidBundle> findDrawableForUpdate(
@Param("teamId") Long teamId, @Param("now") LocalDateTime now);

/**
* A team's in-term pools (not yet expired), soonest-expiring first — read-only, for the wallet
* snapshot. Includes exhausted-but-in-term pools so the "X of Y used" meter keeps the right
* denominator for the current term. Small per team; the service aggregates in Java.
*/
@Query(
"SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.expiresAt > :now"
+ " ORDER BY b.expiresAt ASC")
List<PrepaidBundle> findInTerm(@Param("teamId") Long teamId, @Param("now") LocalDateTime now);

/**
* A team's in-term pools (not yet expired), soonest-expiring first, locked — for the refund
* restore path. Unlike {@link #findDrawableForUpdate} this includes pools already drawn to zero
* (that's exactly where a just-drawn charge's units go back), capped at {@code units_total} by
* the caller.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.expiresAt > :now"
+ " ORDER BY b.expiresAt ASC")
List<PrepaidBundle> findInTermForUpdate(
@Param("teamId") Long teamId, @Param("now") LocalDateTime now);
}
Loading
Loading