diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java b/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java new file mode 100644 index 0000000000..2b1c0fee74 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java @@ -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. + * + *

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. + * + *

Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream + * relies on {@code ordinal()}. + */ +public enum BillingCategory { + BYPASSED, + API, + AI, + AUTOMATION +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java index b2b8ed0c2a..258499740e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index 90aed63cd3..4971e296f3 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql new file mode 100644 index 0000000000..f78458a1b6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql @@ -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; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java index bffc17bd93..f1a24d0357 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java @@ -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); + } }