Skip to content

Commit 3813ca3

Browse files
authored
PAYG prepaid usage bundles (#7032)
# Prepaid usage bundles Teams on pay‑as‑you‑go can **buy a year of PDF processing up front, at a discount** — *"12 months for the price of 10."* You pre‑buy a pool of credits; they're spent **before** any metered billing and sit **outside** the monthly spend limit; unused capacity expires after 12 months. --- ## What this PR delivers **Buy → quote → invoice → pay (Stripe‑Quotes‑native).** - A team lead sizes the pool in the calculator (persisted as a quote row), which doubles as the quote page with a **"Download quote (PDF)"** — the PDF is **Stripe's own rendered quote** (same mechanism procurement uses), not an app‑generated document. - **Finalise** turns the accepted quote into an invoice; the lead can **download the invoice** or **pay online** (Stripe hosted invoice). **Card and bank‑transfer / PO** are both supported (payment‑method fork), on net terms. - The billing page loads the in‑flight quote/invoice on open, so the CTA resumes the right step (**View quote** / **Pay invoice to complete**) and offers **Cancel purchase** (voids the invoice + quote and restarts). **Prepaid is usable on its own — no subscription required.** The entitlement gate honours a live prepaid pool in both cases: - *Unsubscribed*: once the one‑time free grant is spent, a live pool keeps the team **fully entitled** (all feature gates) rather than degraded. - *Subscribed*: a team **at/over its metered cap** but holding a live pool stays fully entitled — prepaid draws are netted out of metered spend, so the pool genuinely sits outside the cap. Only when the free grant **and** the prepaid pool are both empty do billable categories stop. **Coordinated SaaS change (ships with this — `Stirling-PDF-SaaS` `v3` branch):** the `invoice.paid` webhook credits the pool idempotently (keyed on the invoice id) and settles the quote. Metered‑subscription provisioning is best‑effort and **classified** — a permanent Stripe 4xx (the single‑use hosted‑invoice card can't be attached) is a claimed no‑op (HTTP 200, no retry) so Stripe doesn't redeliver forever; only transient errors (5xx / connection / rate‑limit) retry. A failed credit now retries rather than silently dropping a paid bundle. ### Flow 1. Lead sizes the pool and agrees to the terms → the browser sends team + capacity + consent, **never a price**. 2. A leader‑gated server function looks up the price and creates a Stripe **quote** (line quantity = capacity). 3. Lead **finalises** → the quote becomes a Stripe **invoice**; download it or pay online (card or bank transfer). 4. On `invoice.paid`, the webhook **credits the prepaid pool** (idempotent) and settles the quote. 5. Usage then draws **free grant → prepaid pool → meter**; the pool is usable with no subscription. <img width="1280" height="920" alt="01-activation-fork" src="https://github.com/user-attachments/assets/e8981dc7-809d-4fc5-bfde-71e619096b7b" /> <img width="1280" height="920" alt="02-calculator" src="https://github.com/user-attachments/assets/81f6b886-1797-4c54-ba18-97d126be78e2" /> <img width="1120" height="600" alt="03-free-plan" src="https://github.com/user-attachments/assets/c4057fde-d965-47dd-93e8-f2f0f612aa73" /> <img width="1105" height="1285" alt="04-subscribed-prepaid" src="https://github.com/user-attachments/assets/9d1d0ef1-29d0-4bd4-9c17-ac5f68f88ca8" /> --- ## In a follow‑up (not this PR) 1. **Authoritative price via an inline fixed‑amount coupon** *(in progress in a separate PR).* Replace the percentage 12‑for‑10 coupon with an edge‑function‑computed **`amount_off`** coupon: the invoice shows a concrete "−$X.00" discount line, the total is deterministic (no percentage‑rounding drift), and the persisted price becomes **server‑authoritative**. Money‑mechanism change — needs validation against the Stripe test env, so it warrants its own testable PR. 2. **Metered auto‑resume when the pool empties.** Save the paying card at invoice time (`setup_future_usage`) for card payers → real `charge_automatically`; a cardless `send_invoice` subscription for bank‑transfer / PO. This makes the "processing continues at the metered rate" promise true for everyone. 3. **Provisioning idempotency hardening** (SaaS repo). Idempotency key on subscription creation + a conditional link RPC, so a webhook redelivery or link‑RPC failure can't create duplicate or orphaned subscriptions. 4. **Repo‑wide "credits" copy** across *all* of usage & billing (this PR only makes its own additions consistent). --- ## Known edges (current state) - **Cardless teams degrade when the pool empties.** An unsubscribed bundle team that runs the pool dry hits DEGRADED (metered paused), not automatic metered continuation — because no metered subscription gets provisioned off a hosted‑invoice card. The consent copy states processing "continues at the metered rate"; that promise is intentionally **ahead of the mechanism** (follow‑up 2), and the 12‑month term is the runway to deliver it. The prepaid capacity itself stays fully usable in the meantime. - **In‑app total vs charge can differ by ≤1¢** until follow‑up 1 lands. The **shared approval document (the Stripe quote PDF) and the actual invoice are already Stripe‑authoritative**; the persisted price shown in‑app is still a front‑end estimate (percentage‑coupon rounding), so it can differ from Stripe by a rounding cent. Resume‑time drift is fixed (frozen to persisted); exact‑to‑the‑penny parity arrives with the authoritative‑price follow‑up. - **Provisioning idempotency is latent, not live.** The duplicate/orphan‑subscription window only becomes reachable once card‑linking (follow‑up 2) makes provisioning actually run; hardening is tracked as follow‑up 3. - **One job can overshoot the spend cap via a near‑empty pool.** A subscribed team that has hit its metered cap but still holds a *nearly‑exhausted* pool is let through (the pool overrides the cap gate); if a job needs more than the pool has left, the pool drains to zero and the **remainder meters**, so that single job's remainder can bill just past the "never past your spend limit" ceiling. Bounded to one job's overshoot and only at the pool's tail; the alternative — blocking the job — would strand paid‑for capacity, so this is a deliberate trade. --- ## Testing - **Java** — `EntitlementServiceTest` (18) incl. unsubscribed‑live‑pool‑stays‑FULL, subscribed‑over‑cap‑with‑pool‑stays‑FULL, and lazy‑read guards. - **Frontend** — `useBundleFlowState` + `Usage` render tests; portal & SaaS `tsc`; i18n audit; `lint:colors`; toml‑sort; prettier. - **SaaS webhook** (`v3`) — Deno tests for terminal‑vs‑transient provisioning classification (rate‑limit treated as retryable), credit‑error‑retries, and an end‑to‑end no‑storm assertion on the unusable‑card path. *Preview:* the checkout runs in a Supabase function in `Stirling-PDF-SaaS` (`v3`); a live V2 preview is linked in the auto‑deploy comment below. Screenshots to be refreshed — the checkout modal changed since the originals.
1 parent 1681b5d commit 3813ca3

46 files changed

Lines changed: 5025 additions & 251 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"stirling.software.saas.billing.repository",
2020
"stirling.software.saas.ai.repository",
2121
"stirling.software.saas.payg.repository",
22+
"stirling.software.saas.payg.bundle",
2223
"stirling.software.saas.procurement.repository"
2324
})
2425
@EntityScan({

app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
4242
import stirling.software.saas.payg.billing.TeamBillingContext;
4343
import stirling.software.saas.payg.billing.TeamBillingService;
44+
import stirling.software.saas.payg.bundle.PrepaidBundleService;
4445
import stirling.software.saas.payg.entitlement.EntitlementService;
4546
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
4647
import stirling.software.saas.payg.model.BillingCategory;
@@ -86,6 +87,8 @@ public class PaygWalletController {
8687
static final String STATUS_SUBSCRIBED = "subscribed";
8788
static final String ROLE_LEADER = "leader";
8889
static final String ROLE_MEMBER = "member";
90+
static final String BILLING_MODE_PREPAID = "prepaid";
91+
static final String BILLING_MODE_PAYG = "payg";
8992

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

108112
public PaygWalletController(
109113
EntitlementService entitlementService,
@@ -113,7 +117,8 @@ public PaygWalletController(
113117
WalletPolicyRepository policyRepo,
114118
WalletLedgerRepository ledgerRepo,
115119
PaygShadowChargeRepository shadowRepo,
116-
UserRepository userRepository) {
120+
UserRepository userRepository,
121+
PrepaidBundleService prepaidBundleService) {
117122
this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService");
118123
this.billingService = Objects.requireNonNull(billingService, "billingService");
119124
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
@@ -122,6 +127,8 @@ public PaygWalletController(
122127
this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo");
123128
this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo");
124129
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
130+
this.prepaidBundleService =
131+
Objects.requireNonNull(prepaidBundleService, "prepaidBundleService");
125132
}
126133

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

197+
// Prepaid bundles, aggregated across the team's in-term pools. Drawn ahead of the meter and
198+
// kept out of the spend cap, so they're a separate dimension from the metered spend above.
199+
PrepaidBundleService.PrepaidSummary prepaid = prepaidBundleService.summarize(teamId);
200+
long prepaidRemaining = prepaid == null ? 0L : prepaid.unitsRemaining();
201+
long prepaidTotal = prepaid == null ? 0L : prepaid.unitsTotal();
202+
String prepaidExpiresAt =
203+
prepaid == null || prepaid.expiresAt() == null
204+
? null
205+
: ISO_DATE.format(prepaid.expiresAt().toLocalDate());
206+
// Prepaid while pools still have units to draw; once exhausted the meter is live again.
207+
String billingMode = prepaidRemaining > 0 ? BILLING_MODE_PREPAID : BILLING_MODE_PAYG;
208+
190209
WalletSnapshotResponse body =
191210
new WalletSnapshotResponse(
192211
teamId,
@@ -211,7 +230,11 @@ public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
211230
breakdowns.docs(),
212231
analytics.docsProcessed(),
213232
analytics.uniquePdfs(),
214-
analytics.sizeMultiplierPdfs());
233+
analytics.sizeMultiplierPdfs(),
234+
prepaidRemaining,
235+
prepaidTotal,
236+
prepaidExpiresAt,
237+
billingMode);
215238
return ResponseEntity.ok(body);
216239
}
217240

@@ -485,6 +508,10 @@ private WalletSnapshotResponse emptySnapshot() {
485508
new CategoryBreakdown(0, 0, 0),
486509
0,
487510
0,
488-
0);
511+
0,
512+
0L,
513+
0L,
514+
null,
515+
BILLING_MODE_PAYG);
489516
}
490517
}

app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,20 @@ public record WalletSnapshotResponse(
7979
CategoryBreakdown categoryDocs,
8080
int docsProcessedThisPeriod,
8181
int uniquePdfsThisPeriod,
82-
int sizeMultiplierPdfsThisPeriod) {
82+
int sizeMultiplierPdfsThisPeriod,
83+
long prepaidUnitsRemaining,
84+
long prepaidUnitsTotal,
85+
String prepaidExpiresAt,
86+
String billingMode) {
87+
88+
// Prepaid usage bundles, aggregated across the team's in-term pools (drawn ahead of the meter,
89+
// outside the spend cap):
90+
// prepaidUnitsRemaining — Σ units left across active pools (0 when exhausted / none)
91+
// prepaidUnitsTotal — Σ capacity of in-term pools (the "X of Y used" denominator; 0 = no
92+
// bundle this term, so the FE hides the prepaid card)
93+
// prepaidExpiresAt — soonest term end (ISO date) for the countdown; null when no bundle
94+
// billingMode — "prepaid" while prepaid units remain, else "payg" (the meter is
95+
// live)
8396

8497
// The count dimension, kept distinct from units (which now scale with file size):
8598
// categoryDocs — per-category INPUT-file counts (parallel to
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package stirling.software.saas.payg.bundle;
2+
3+
import java.io.Serializable;
4+
import java.time.LocalDateTime;
5+
6+
import org.hibernate.annotations.CreationTimestamp;
7+
8+
import jakarta.persistence.Column;
9+
import jakarta.persistence.Entity;
10+
import jakarta.persistence.GeneratedValue;
11+
import jakarta.persistence.GenerationType;
12+
import jakarta.persistence.Id;
13+
import jakarta.persistence.Index;
14+
import jakarta.persistence.Table;
15+
16+
import lombok.Getter;
17+
import lombok.NoArgsConstructor;
18+
import lombok.Setter;
19+
20+
/**
21+
* A prepaid, expiring pool of PDF-process units bought up-front at a discount ("12 months for the
22+
* price of 10"). Consumed after the team's free grant and before the meter (free -> prepaid ->
23+
* metered); draws are booked to the {@code BOUGHT} ledger bucket, so a bundle never counts toward
24+
* the spend cap or the Stripe meter.
25+
*
26+
* <p>Carries only capacity + term + the Stripe link. The one-time amount and currency live on the
27+
* Stripe Checkout Session / PaymentIntent referenced by {@link #stripeRef}; how many units a PDF
28+
* costs comes from the team's pricing policy at charge time, not from the bundle. Status is
29+
* derived, never stored (see {@link #isDrawable}).
30+
*
31+
* <p>A team may hold several pools at once (top-ups); they are drawn FIFO by soonest {@link
32+
* #expiresAt}. Unused units forfeit at expiry (no roll-over).
33+
*/
34+
@Entity
35+
@Table(
36+
name = "payg_prepaid_bundle",
37+
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative creator
38+
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which builds
39+
// the partial forms (WHERE units_remaining > 0 / WHERE stripe_ref IS NOT NULL). Flyway was
40+
// retired for SaaS (#7100), so there is no migration twin — names match the CLI migration.
41+
indexes = {
42+
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every billable
43+
// charge past the free grant; without it that degrades to a locked scan as the table grows.
44+
@Index(
45+
name = "idx_payg_prepaid_bundle_team_expiry",
46+
columnList = "team_id, expires_at"),
47+
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid can't
48+
// credit the same purchase twice.
49+
@Index(
50+
name = "uq_payg_prepaid_bundle_stripe_ref",
51+
columnList = "stripe_ref",
52+
unique = true),
53+
})
54+
@NoArgsConstructor
55+
@Getter
56+
@Setter
57+
public class PrepaidBundle implements Serializable {
58+
59+
private static final long serialVersionUID = 1L;
60+
61+
@Id
62+
@GeneratedValue(strategy = GenerationType.IDENTITY)
63+
@Column(name = "bundle_id")
64+
private Long id;
65+
66+
@Column(name = "team_id", nullable = false)
67+
private Long teamId;
68+
69+
/** Capacity granted at purchase — the denominator of the "X of Y used" meter. */
70+
@Column(name = "units_total", nullable = false)
71+
private long unitsTotal;
72+
73+
/** Live balance; pessimistic-locked on draw. */
74+
@Column(name = "units_remaining", nullable = false)
75+
private long unitsRemaining;
76+
77+
@Column(name = "purchased_at", nullable = false)
78+
private LocalDateTime purchasedAt;
79+
80+
/** {@code purchasedAt + 12 months}. Unused units forfeit after this instant. */
81+
@Column(name = "expires_at", nullable = false)
82+
private LocalDateTime expiresAt;
83+
84+
/**
85+
* Stripe Checkout Session / PaymentIntent id for the one-time payment that created this pool.
86+
* The amount + currency + receipt live on that object; a unique index makes the webhook credit
87+
* idempotent. {@code null} only for pools seeded outside the purchase flow (tests/backfill).
88+
*/
89+
@Column(name = "stripe_ref", length = 128)
90+
private String stripeRef;
91+
92+
@CreationTimestamp
93+
@Column(name = "created_at", nullable = false, updatable = false)
94+
private LocalDateTime createdAt;
95+
96+
/** In-term (not yet expired) as of {@code now} — regardless of remaining balance. */
97+
public boolean isInTerm(LocalDateTime now) {
98+
return expiresAt.isAfter(now);
99+
}
100+
101+
/** Has units left AND is still in term — i.e. a charge may draw from it. */
102+
public boolean isDrawable(LocalDateTime now) {
103+
return unitsRemaining > 0 && isInTerm(now);
104+
}
105+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package stirling.software.saas.payg.bundle;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.List;
5+
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Lock;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
10+
import org.springframework.stereotype.Repository;
11+
12+
import jakarta.persistence.LockModeType;
13+
14+
@Repository
15+
public interface PrepaidBundleRepository extends JpaRepository<PrepaidBundle, Long> {
16+
17+
/**
18+
* A team's still-drawable pools (units left, not expired), soonest-expiring first, locked for
19+
* the draw transaction. Mirrors {@code PaygTeamExtensionsRepository.findByIdForUpdate}: the
20+
* PESSIMISTIC_WRITE lock serialises concurrent charges for the same team so two jobs can't both
21+
* draw the same remaining unit, keeping the per-job {@code bundle_units_consumed} split exact.
22+
* Drawn FIFO — the caller depletes the earliest-expiring pool first so capacity is used before
23+
* it lapses. Filters on {@code expires_at} so an expired pool is never drawn even if the expiry
24+
* sweep hasn't run (lazy expiry).
25+
*/
26+
@Lock(LockModeType.PESSIMISTIC_WRITE)
27+
@Query(
28+
"SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.unitsRemaining > 0"
29+
+ " AND b.expiresAt > :now ORDER BY b.expiresAt ASC")
30+
List<PrepaidBundle> findDrawableForUpdate(
31+
@Param("teamId") Long teamId, @Param("now") LocalDateTime now);
32+
33+
/**
34+
* A team's in-term pools (not yet expired), soonest-expiring first — read-only, for the wallet
35+
* snapshot. Includes exhausted-but-in-term pools so the "X of Y used" meter keeps the right
36+
* denominator for the current term. Small per team; the service aggregates in Java.
37+
*/
38+
@Query(
39+
"SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.expiresAt > :now"
40+
+ " ORDER BY b.expiresAt ASC")
41+
List<PrepaidBundle> findInTerm(@Param("teamId") Long teamId, @Param("now") LocalDateTime now);
42+
43+
/**
44+
* A team's in-term pools (not yet expired), soonest-expiring first, locked — for the refund
45+
* restore path. Unlike {@link #findDrawableForUpdate} this includes pools already drawn to zero
46+
* (that's exactly where a just-drawn charge's units go back), capped at {@code units_total} by
47+
* the caller.
48+
*/
49+
@Lock(LockModeType.PESSIMISTIC_WRITE)
50+
@Query(
51+
"SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.expiresAt > :now"
52+
+ " ORDER BY b.expiresAt ASC")
53+
List<PrepaidBundle> findInTermForUpdate(
54+
@Param("teamId") Long teamId, @Param("now") LocalDateTime now);
55+
}

0 commit comments

Comments
 (0)