payg(c1): V16 billing_category column + BillingCategory enum + entity wiring

V16 adds wallet_ledger.billing_category, payg_shadow_charge.billing_category +
job_source, and pricing_policy_stripe_price.stripe_product_id (operator
populates manually). All adds are nullable so pre-V16 rows stay NULL — the
interceptor stamps the category for new rows.

PAYG stays on a single flat-priced Stripe meter forever; this axis is purely
for in-app breakdowns and analytics. The wallet_category_summary view
pre-groups debits by team / month / category for the dashboard widget.

BillingCategory enum lists BYPASSED first as the default sentinel; no
downstream relies on ordinal(). JPA mapping uses STRING so renames stay
backward-compatible.
This commit is contained in:
Connor Yoh
2026-06-09 12:53:13 +01:00
parent 09860bffef
commit c4d579ad4c
5 changed files with 141 additions and 0 deletions
@@ -0,0 +1,18 @@
package stirling.software.saas.payg.model;
/**
* Analytics / in-app breakdown axis stamped on every billable ledger entry and shadow charge. PAYG
* stays on a single flat-priced Stripe meter — category is metadata only, never affects pricing.
*
* <p>Precedence at translation time (interceptor): AUTOMATION → AI → API → BYPASSED. {@link
* #BYPASSED} is the default for manual UI tool calls that never hit a billable code path.
*
* <p>Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream
* relies on {@code ordinal()}.
*/
public enum BillingCategory {
BYPASSED,
API,
AI,
AUTOMATION
}
@@ -19,6 +19,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ShadowChargeStatus;
/**
@@ -75,6 +77,24 @@ public class PaygShadowCharge implements Serializable {
@Column(name = "refund_reason", length = 128)
private String refundReason;
/**
* PAYG analytics axis copied from the request that produced this shadow row. {@code null} for
* pre-V16 rows; populated by the charge interceptor going forward. Never affects what Stripe
* would meter — the flat-meter assumption holds, this is breakdown metadata.
*/
@Enumerated(EnumType.STRING)
@Column(name = "billing_category", length = 16)
private BillingCategory billingCategory;
/**
* Caller surface that originated the job (copied from {@code processing_job.source} at write
* time so the row stays self-describing after the job table is pruned). {@code null} for
* pre-V16 rows backfilled when {@code processing_job} is no longer present.
*/
@Enumerated(EnumType.STRING)
@Column(name = "job_source", length = 32)
private JobSource jobSource;
@CreationTimestamp
@Column(name = "occurred_at", nullable = false, updatable = false)
private LocalDateTime occurredAt;
@@ -22,6 +22,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.LedgerBucket;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.model.ReferenceType;
@@ -76,6 +77,15 @@ public class WalletLedgerEntry implements Serializable {
@Column(name = "stripe_event_id", length = 128)
private String stripeEventId;
/**
* PAYG analytics axis. {@code null} for system entries (grants, resets) and pre-V16 rows; set
* by the charge interceptor for billable debits. Stripe pricing never reads this — it's a
* single flat meter — so the column stays soft-typed (no NOT NULL, no FK).
*/
@Enumerated(EnumType.STRING)
@Column(name = "billing_category", length = 16)
private BillingCategory billingCategory;
@CreationTimestamp
@Column(name = "occurred_at", nullable = false, updatable = false)
private LocalDateTime occurredAt;
@@ -0,0 +1,58 @@
-- PAYG analytics axis: stamp every billable ledger entry / shadow row with the category that
-- produced it (API | AI | AUTOMATION | BYPASSED). PAYG stays on a single flat-priced Stripe meter
-- forever — this column is for in-app breakdowns and analytics, never for Stripe pricing.
--
-- All adds are nullable: pre-V16 rows have no category and stay NULL; the interceptor populates
-- it for new rows going forward.
-- ---------------------------------------------------------------------------------------------
-- 1. wallet_ledger.billing_category
-- ---------------------------------------------------------------------------------------------
ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL;
COMMENT ON COLUMN wallet_ledger.billing_category IS
'API | AI | AUTOMATION | BYPASSED. NULL = system entry or pre-V16 backfill.';
-- Partial index — only billable rows ever read this column, and NULLs would just bloat the tree.
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_category_period
ON wallet_ledger (team_id, billing_category, occurred_at)
WHERE billing_category IS NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- 2. payg_shadow_charge.billing_category + job_source
-- ---------------------------------------------------------------------------------------------
ALTER TABLE payg_shadow_charge
ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL,
ADD COLUMN IF NOT EXISTS job_source VARCHAR(32) NULL;
-- Backfill job_source from processing_job (best-effort — rows whose job has already been pruned
-- stay NULL, which is fine: the shadow row is self-describing post-V16 and only legacy ones lack
-- the column.)
UPDATE payg_shadow_charge sc
SET job_source = pj.source
FROM processing_job pj
WHERE pj.job_id = sc.job_id
AND sc.job_source IS NULL;
-- ---------------------------------------------------------------------------------------------
-- 3. pricing_policy_stripe_price.stripe_product_id
-- Operator populates this manually per row when seeding new policies. Nullable for backward
-- compatibility with existing rows that don't carry a Product reference.
-- ---------------------------------------------------------------------------------------------
ALTER TABLE pricing_policy_stripe_price
ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(128) NULL;
-- ---------------------------------------------------------------------------------------------
-- 4. wallet_category_summary view — pre-grouped per-team, per-month, per-category aggregate that
-- the in-app breakdown widget reads. Recomputed live on every SELECT; cheap thanks to the
-- partial index above.
-- ---------------------------------------------------------------------------------------------
CREATE OR REPLACE VIEW wallet_category_summary AS
SELECT
team_id,
date_trunc('month', occurred_at) AS period_start,
billing_category,
SUM(CASE WHEN amount_units < 0 THEN -amount_units ELSE 0 END) AS units_debited,
COUNT(*) FILTER (WHERE entry_type = 'DEBIT') AS debit_count
FROM wallet_ledger
WHERE billing_category IS NOT NULL
GROUP BY team_id, date_trunc('month', occurred_at), billing_category;
@@ -107,6 +107,16 @@ class PaygEntitiesSmokeTest {
assertThat(entry.getAmountUnits()).isEqualTo(-4);
}
@Test
void walletLedgerEntry_billingCategoryRoundTrips() {
WalletLedgerEntry entry = new WalletLedgerEntry();
// Default (unset) is null — captured by both the legacy debit path and pre-V16 rows.
assertThat(entry.getBillingCategory()).isNull();
entry.setBillingCategory(BillingCategory.AUTOMATION);
assertThat(entry.getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION);
}
@Test
void walletPolicy_carriesSensibleDefaults() {
WalletPolicy policy = new WalletPolicy();
@@ -148,4 +158,29 @@ class PaygEntitiesSmokeTest {
assertThat(row.getDiffPct()).isNegative();
}
@Test
void paygShadowCharge_billingCategoryAndJobSourceRoundTrip() {
PaygShadowCharge row = new PaygShadowCharge();
assertThat(row.getBillingCategory()).isNull();
assertThat(row.getJobSource()).isNull();
row.setBillingCategory(BillingCategory.AI);
row.setJobSource(JobSource.API);
assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.AI);
assertThat(row.getJobSource()).isEqualTo(JobSource.API);
}
@Test
void billingCategory_listingOrderIsStable() {
// No downstream relies on ordinal() today, but the comment in the enum claims BYPASSED is
// declared first as the default sentinel — guard against an accidental reorder.
assertThat(BillingCategory.values())
.containsExactly(
BillingCategory.BYPASSED,
BillingCategory.API,
BillingCategory.AI,
BillingCategory.AUTOMATION);
}
}