From 83ea07ed6a812cd85acc4a863590cbd07eac7fca Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 29 May 2026 13:03:01 +0100 Subject: [PATCH] saas: DocumentClassifier + PAYG data model (#6460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Two layers — the `DocumentClassifier` utility plus the full data model for the new billing engine. Nothing wires the entities into application behaviour yet; services and controllers land in follow-up PRs. **Companion PR:** [Stirling-PDF-SaaS#296](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/296) — Supabase migration for the v3 dev branch, schema-equivalent to the Flyway migration in this PR. ## 1. DocumentClassifier (under `payg.docs`) `DocumentClassifier` computes the doc-unit cost of an uploaded file (or multi-file input) under a `PricingPolicy`. PDFs read page count via `stirling.software.jpdfium.PdfDocument`; non-PDFs are bytes-only. Formula: `max(ceil(pages / docPagesPerUnit), ceil(bytes / docBytesPerUnit))` clamped to `[1, fileUnitCap]`. Multi-file is the sum of raw per-file units capped at `fileUnitCap × file_count`. Two floors, by design: the classifier returns `docUnits` with an absolute `1` floor for non-empty input; the policy-level `minChargeUnits` is intentionally applied later, at process-open time in `JobChargeService`, per design § 3.4 (`unitsForProcess = max(policy.min_charge_units, docUnits)`). Documented in the interface + impl javadoc. Upload bytes are materialised through `TempFileManager.createManagedTempFile` so jpdfium gets a `Path`; the temp file auto-deletes on close. Twelve tests, all in-memory fixtures generated with PDFBox at test time — no committed binary blobs. ## 2. PAYG data model (under `payg.*`) JPA entities, repositories, and a Flyway migration covering the full schema in §6 of the design. **Enums** (`payg.model`): `JobSource`, `ProcessType`, `JobStatus`, `JobStepStatus`, `ArtifactKind`, `LedgerEntryType`, `LedgerBucket`, `ReferenceType`, `EntitlementState`, `FeatureSet`, `FeatureGate`, `WalletEngine`, `CapPeriod`, `AutoGroupStrategy`. **Entities + repositories:** | Entity | Table | Notes | |---|---|---| | `PricingPolicy` | `pricing_policy` | Promoted from a record. `stepLimits` is `Map` persisted via normalised child table `pricing_policy_step_limit`. `stripePriceIds` is `Set` persisted via `pricing_policy_stripe_price` — currency comes from `stripe.prices` via Sync Engine, not stored locally. | | `ProcessingJob` | `processing_job` | UUID PK. Tracks lineage window via `step_count` and `last_step_at`. | | `ProcessingJobStep` | `processing_job_step` | Per-tool-call audit. | | `JobArtifactHash` | `job_artifact_hash` | Composite key `(job_id, content_hash, kind)`. `content_hash VARCHAR(128)` so multiple signature schemes coexist as `"type:value"` storage keys. Lineage detector queries this. | | `WalletLedgerEntry` | `wallet_ledger` | Append-only, signed `amount_units`. Two unique indexes kill double-posting. | | `WalletPolicy` | `wallet_policy` | Per-team engine + cap + degradation rules + lineage strategy. No `@Version` — admin-only writes (documented in javadoc). | | `WalletEntitlementSnapshot` | `wallet_entitlement_snapshot` | Composite key `(team_id, user_id)`; `user_id = 0` is the team-wide sentinel. No `@Version` — full-row recompute via `EntitlementService.recompute` (documented in javadoc). | | `PaygShadowCharge` | `payg_shadow_charge` | Per-job diff while in `PAYG_SHADOW` engine mode. | | `PaygTeamExtensions` | `payg_team_extensions` | Sidecar 1:1 with `teams` carrying `pricing_policy_id` (per-team override) + `stripe_customer_id`. Sidecar pattern (mirrors `saas_team_extensions`) so OSS Hibernate ddl-auto never sees PAYG columns on `teams`. | **Column adds:** - `team_memberships.cap_units` (optional per-member sub-cap) **Width split (intentional, documented in V11):** per-row deltas (`wallet_ledger.amount_units`, `processing_job.charged_units`) are `INTEGER` because no single charge realistically approaches 2B units. Cap and period-rollup columns (`team_memberships.cap_units`, `wallet_policy.cap_units`, `wallet_entitlement_snapshot.period_spend_units / period_cap_units`) are `BIGINT` because they accumulate across a billing period and admins may legitimately set headroom-cap values into the millions. **JPA wiring:** `SaasJpaConfig` was updated to include `stirling.software.saas.payg.repository` in `@EnableJpaRepositories.basePackages` and `stirling.software.saas.payg` in `@EntityScan` (covers `payg.policy` / `payg.job` / `payg.wallet` / `payg.entitlement` / `payg.shadow` recursively). New `SaasJpaConfigScanTest` reads the annotations reflectively and asserts every expected package is wired — catches the next time someone adds a new sub-package without updating the scan paths. **Migration:** `V11__saas_payg_model.sql` (purely additive). Schema-equivalent to the Supabase migration in the companion PR — including the `VARCHAR(128) content_hash` width that's needed for the multi-signature-scheme storage encoding the lineage layer uses. ## 3. Smoke tests `PaygEntitiesSmokeTest` exercises each entity via the no-arg ctor JPA requires, plus getter/setter round-trips and composite-key equality — catches Lombok/annotation regressions without needing a database. Real-DB integration coverage lands alongside the services that consume each entity. ## Why this is safe to land now - All schema changes are additive — no existing rows modified, no columns dropped. - The entities are not yet referenced from any production code path; they exist for the next PRs to build on. - The v3 Supabase dev branch picks up the schema via the companion PR; the main repo's Flyway migration applies the same shape when an instance boots against a freshly-migrated v3 database. ## Open decisions made - **Step-limits keyed by `JobSource`** rather than by `ProcessType`. Captures the "self-hosted gets a different knob" framing in earlier feedback. Trivially overridable per pricing policy version. - **Step limits + Stripe price IDs normalised into child tables** rather than JSONB on `pricing_policy` (per Connor's review on #296). Typed columns, queryable directly, no JSON parsing. - **Currency dropped from `pricing_policy_stripe_price`** — it lives on `stripe.prices.currency` and is resolved via Sync Engine. App is currency-blind. ## Rollback Straight `git revert` on this PR. The Supabase migration in #296 is additive and can be left in place safely — the running app ignores tables it doesn't reference. --- ## Checklist - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings - [x] I have run `task check` (via `./gradlew :saas:test` with `ENABLE_SAAS=true`) — passes --- .../software/saas/config/SaasJpaConfig.java | 12 +- .../software/saas/model/TeamMembership.java | 7 + .../payg/docs/DefaultDocumentClassifier.java | 173 +++++++++++++ .../saas/payg/docs/DocumentClassifier.java | 25 ++ .../saas/payg/docs/DocumentMetrics.java | 12 + .../WalletEntitlementSnapshot.java | 112 +++++++++ .../saas/payg/job/JobArtifactHash.java | 82 +++++++ .../software/saas/payg/job/ProcessingJob.java | 99 ++++++++ .../saas/payg/job/ProcessingJobStep.java | 62 +++++ .../saas/payg/model/ArtifactKind.java | 7 + .../saas/payg/model/AutoGroupStrategy.java | 10 + .../software/saas/payg/model/CapPeriod.java | 8 + .../saas/payg/model/EntitlementState.java | 7 + .../software/saas/payg/model/FeatureGate.java | 9 + .../software/saas/payg/model/FeatureSet.java | 8 + .../software/saas/payg/model/JobSource.java | 19 ++ .../software/saas/payg/model/JobStatus.java | 9 + .../saas/payg/model/JobStepStatus.java | 7 + .../saas/payg/model/LedgerBucket.java | 8 + .../saas/payg/model/LedgerEntryType.java | 11 + .../software/saas/payg/model/ProcessType.java | 11 + .../saas/payg/model/ReferenceType.java | 9 + .../saas/payg/model/WalletEngine.java | 8 + .../saas/payg/policy/PaygTeamExtensions.java | 77 ++++++ .../saas/payg/policy/PricingPolicy.java | 147 +++++++++++ .../repository/JobArtifactHashRepository.java | 41 ++++ .../PaygShadowChargeRepository.java | 22 ++ .../PaygTeamExtensionsRepository.java | 14 ++ .../repository/PricingPolicyRepository.java | 16 ++ .../repository/ProcessingJobRepository.java | 26 ++ .../ProcessingJobStepRepository.java | 15 ++ .../WalletEntitlementSnapshotRepository.java | 26 ++ .../repository/WalletLedgerRepository.java | 50 ++++ .../repository/WalletPolicyRepository.java | 14 ++ .../saas/payg/shadow/PaygShadowCharge.java | 61 +++++ .../saas/payg/wallet/WalletLedgerEntry.java | 86 +++++++ .../saas/payg/wallet/WalletPolicy.java | 94 +++++++ .../migration/saas/V11__saas_payg_model.sql | 226 +++++++++++++++++ .../saas/config/SaasJpaConfigScanTest.java | 60 +++++ .../docs/DefaultDocumentClassifierTest.java | 231 ++++++++++++++++++ .../payg/model/PaygEntitiesSmokeTest.java | 151 ++++++++++++ 41 files changed, 2069 insertions(+), 3 deletions(-) create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentClassifier.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentMetrics.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshot.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/job/JobArtifactHash.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJobStep.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/ArtifactKind.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/AutoGroupStrategy.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/CapPeriod.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/EntitlementState.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/FeatureGate.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/FeatureSet.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/JobStatus.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/JobStepStatus.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/LedgerBucket.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/LedgerEntryType.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/ProcessType.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/WalletEngine.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobStepRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/WalletEntitlementSnapshotRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/WalletPolicyRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletPolicy.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql create mode 100644 app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java index 1589b4407..3c6b0d14d 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java @@ -5,18 +5,24 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -/** Registers the {@code :saas} module's entities and repositories with Spring Data JPA. */ +/** + * Registers the {@code :saas} module's entities and repositories with Spring Data JPA. Any new + * package holding {@code @Repository} or {@code @Entity} classes must be added here, or the beans + * won't wire at startup. + */ @Configuration @Profile("saas") @EnableJpaRepositories( basePackages = { "stirling.software.saas.repository", "stirling.software.saas.billing.repository", - "stirling.software.saas.ai.repository" + "stirling.software.saas.ai.repository", + "stirling.software.saas.payg.repository" }) @EntityScan({ "stirling.software.saas.model", "stirling.software.saas.billing.model", - "stirling.software.saas.ai.model" + "stirling.software.saas.ai.model", + "stirling.software.saas.payg" }) public class SaasJpaConfig {} diff --git a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java b/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java index 6f3bb9b25..422164343 100644 --- a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java +++ b/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java @@ -73,6 +73,13 @@ public class TeamMembership implements Serializable { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + /** + * Optional per-member spend cap inside the team's wallet, in doc units. NULL means the member + * is bounded only by the team-wide cap. + */ + @Column(name = "cap_units") + private Long capUnits; + public boolean isLeader() { return role == TeamRole.LEADER; } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java new file mode 100644 index 000000000..a46d97587 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java @@ -0,0 +1,173 @@ +package stirling.software.saas.payg.docs; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.saas.payg.policy.PricingPolicy; + +/** + * Reads pages via jpdfium for PDF inputs; treats every other content type as bytes-only. + * + *

For PDFs, units are the larger of {@code ceil(pages / docPagesPerUnit)} and {@code ceil(bytes + * / docBytesPerUnit)}. For non-PDFs, only the bytes axis contributes. A single file is clamped to + * {@code [1, policy.fileUnitCap]}; a multi-file group is clamped to {@code [1, policy.fileUnitCap * + * file_count]} applied to the sum of raw per-file units. Malformed/encrypted PDFs fall back to + * bytes-only. + * + *

{@code policy.minChargeUnits} is applied by the charge service, not here. The classifier only + * enforces an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on + * "non-empty input → at least 1 unit". + */ +@Slf4j +@Component +@Profile("saas") +@RequiredArgsConstructor +public class DefaultDocumentClassifier implements DocumentClassifier { + + private static final String PDF_CONTENT_TYPE = "application/pdf"; + private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; + + /** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */ + private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; + + private final TempFileManager tempFileManager; + + @Override + public DocumentMetrics classify(MultipartFile file, PricingPolicy policy) { + Objects.requireNonNull(file, "file"); + Objects.requireNonNull(policy, "policy"); + + FileFacts facts = inspect(file); + long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy); + // toIntExact: fail loud on overflow rather than silently wrapping a billing number. + int units = + Math.toIntExact( + Math.max( + MIN_UNITS_PER_NONEMPTY_FILE, + Math.min(policy.getFileUnitCap(), rawUnits))); + return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units); + } + + @Override + public DocumentMetrics classify(List files, PricingPolicy policy) { + Objects.requireNonNull(files, "files"); + Objects.requireNonNull(policy, "policy"); + if (files.isEmpty()) { + throw new IllegalArgumentException("files must not be empty"); + } + + int totalPages = 0; + long totalBytes = 0; + long rawUnitsSum = 0; + String firstContentType = null; + + for (MultipartFile file : files) { + FileFacts facts = inspect(file); + // Sum the *raw* (unclamped) per-file units so the group cap below can actually bind. + // Per-file clamping in this loop would make the group cap a no-op. + rawUnitsSum = + saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy)); + totalPages = saturatedAdd(totalPages, facts.pages); + totalBytes = saturatedAdd(totalBytes, facts.bytes); + if (firstContentType == null) { + firstContentType = facts.contentType; + } + } + + long groupCap = (long) policy.getFileUnitCap() * files.size(); + // toIntExact: fail loud on overflow rather than silently wrapping. + int totalUnits = + Math.toIntExact( + Math.max( + (long) MIN_UNITS_PER_NONEMPTY_FILE, + Math.min(groupCap, rawUnitsSum))); + + return new DocumentMetrics( + totalPages, + totalBytes, + firstContentType != null ? firstContentType : DEFAULT_CONTENT_TYPE, + totalUnits); + } + + private FileFacts inspect(MultipartFile file) { + long bytes = file.getSize(); + String contentType = + file.getContentType() != null ? file.getContentType() : DEFAULT_CONTENT_TYPE; + int pages = isPdf(contentType, file.getOriginalFilename()) ? readPageCount(file) : 0; + return new FileFacts(pages, bytes, contentType); + } + + private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) { + long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L; + long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit()); + return Math.max(pageUnits, byteUnits); + } + + private static long ceilDiv(long numerator, long divisor) { + if (numerator <= 0) { + return 0; + } + return (numerator + divisor - 1) / divisor; + } + + private static boolean isPdf(String contentType, String filename) { + if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) { + return true; + } + return filename != null && filename.toLowerCase().endsWith(".pdf"); + } + + /** + * Materialises the upload to a managed temp file and asks jpdfium for the page count. Returns 0 + * if the file can't be parsed — the byte-derived axis still produces a charge. + */ + private int readPageCount(MultipartFile file) { + try (TempFile temp = tempFileManager.createManagedTempFile(".pdf")) { + try (InputStream in = file.getInputStream(); + OutputStream out = Files.newOutputStream(temp.getPath())) { + in.transferTo(out); + } + try (PdfDocument doc = PdfDocument.open(temp.getPath())) { + return doc.pageCount(); + } + } catch (IOException | RuntimeException e) { + log.debug( + "Could not read PDF page count for {} ({}); falling back to bytes-only units", + file.getOriginalFilename(), + e.getClass().getSimpleName()); + return 0; + } + } + + private static int saturatedAdd(int a, int b) { + long sum = (long) a + b; + if (sum > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + return (int) sum; + } + + private static long saturatedAdd(long a, long b) { + try { + return Math.addExact(a, b); + } catch (ArithmeticException e) { + return Long.MAX_VALUE; + } + } + + private record FileFacts(int pages, long bytes, String contentType) {} +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentClassifier.java new file mode 100644 index 000000000..c7af9deaf --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentClassifier.java @@ -0,0 +1,25 @@ +package stirling.software.saas.payg.docs; + +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.saas.payg.policy.PricingPolicy; + +/** + * Computes the doc-unit cost of an uploaded file (or multi-file input) under a given policy. + * + *

Returns {@code docUnits} with an absolute floor of 1 for non-empty input. {@code + * policy.minChargeUnits} is applied at charge time, not here. + */ +public interface DocumentClassifier { + + /** Classify a single uploaded file. Returns at least 1 unit, capped at {@code fileUnitCap}. */ + DocumentMetrics classify(MultipartFile file, PricingPolicy policy); + + /** + * Classify a multi-file input (e.g. a merge or overlay). Returns the sum of each file's raw + * units, capped at {@code fileUnitCap × files.size()} and floored at 1. + */ + DocumentMetrics classify(List files, PricingPolicy policy); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentMetrics.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentMetrics.java new file mode 100644 index 000000000..ea0d01083 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DocumentMetrics.java @@ -0,0 +1,12 @@ +package stirling.software.saas.payg.docs; + +/** + * Output of {@link DocumentClassifier#classify}. {@code pages} is {@code 0} for non-PDF inputs. + * + * @param pages page count (0 for non-PDFs and for files whose page count couldn't be read) + * @param bytes raw byte length of the file + * @param contentType MIME type as reported by the upload, or {@code "application/octet-stream"} + * when unknown + * @param docUnits computed unit cost, clamped to the policy's {@code fileUnitCap} + */ +public record DocumentMetrics(int pages, long bytes, String contentType, int docUnits) {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshot.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshot.java new file mode 100644 index 000000000..872aea390 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshot.java @@ -0,0 +1,112 @@ +package stirling.software.saas.payg.entitlement; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** + * Cached entitlement state for the team (one row with {@code user_id = 0}, the team-wide sentinel) + * plus optional per-member rows when a member sub-cap is configured. Read on the hot path by the + * entitlement guard. + * + *

Composite PK {@code (team_id, user_id)} uses 0 as the team-wide sentinel because Postgres + * treats {@code NULL} as not-equal-to-NULL in unique constraints — 0 keeps the PK well-defined. + * + *

No {@code @Version} — rows are produced by full-row recompute, no read-modify-write race. + */ +@Entity +@Table(name = "wallet_entitlement_snapshot") +@NoArgsConstructor +@Getter +@Setter +public class WalletEntitlementSnapshot implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final long TEAM_WIDE_USER_ID = 0L; + + @EmbeddedId private WalletEntitlementSnapshotId id; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + @Column(name = "period_end", nullable = false) + private LocalDateTime periodEnd; + + @Column(name = "period_spend_units", nullable = false) + private Long periodSpendUnits = 0L; + + @Column(name = "period_cap_units") + private Long periodCapUnits; + + @Enumerated(EnumType.STRING) + @Column(name = "state", nullable = false, length = 16) + private EntitlementState state = EntitlementState.FULL; + + @Enumerated(EnumType.STRING) + @Column(name = "feature_set", nullable = false, length = 32) + private FeatureSet featureSet = FeatureSet.FULL; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "enabled_gates", columnDefinition = "jsonb", nullable = false) + private List enabledGates = new ArrayList<>(); + + @CreationTimestamp + @Column(name = "computed_at", nullable = false, updatable = false) + private LocalDateTime computedAt; + + @Embeddable + @NoArgsConstructor + @Getter + @Setter + public static class WalletEntitlementSnapshotId implements Serializable { + + private static final long serialVersionUID = 1L; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** Use {@link #TEAM_WIDE_USER_ID} for the team-wide row. */ + @Column(name = "user_id", nullable = false) + private Long userId; + + public WalletEntitlementSnapshotId(Long teamId, Long userId) { + this.teamId = teamId; + this.userId = userId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof WalletEntitlementSnapshotId other)) return false; + return Objects.equals(teamId, other.teamId) && Objects.equals(userId, other.userId); + } + + @Override + public int hashCode() { + return Objects.hash(teamId, userId); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobArtifactHash.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobArtifactHash.java new file mode 100644 index 000000000..c1b864a54 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobArtifactHash.java @@ -0,0 +1,82 @@ +package stirling.software.saas.payg.job; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.ArtifactKind; + +/** + * Per-step input/output content hash. Used by the lineage detector to decide whether a tool call + * joins an open process (matching an earlier input or output) or opens a new one. + */ +@Entity +@Table(name = "job_artifact_hash") +@NoArgsConstructor +@Getter +@Setter +public class JobArtifactHash implements Serializable { + + private static final long serialVersionUID = 1L; + + @EmbeddedId private JobArtifactHashId id; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Embeddable + @NoArgsConstructor + @Getter + @Setter + public static class JobArtifactHashId implements Serializable { + + private static final long serialVersionUID = 1L; + + @Column(name = "job_id", nullable = false) + private UUID jobId; + + /** {@code "type:value"} signature key; 128 chars fits SHA-256 plus future schemes. */ + @Column(name = "content_hash", nullable = false, length = 128) + private String contentHash; + + @Enumerated(EnumType.STRING) + @Column(name = "kind", nullable = false, length = 8) + private ArtifactKind kind; + + public JobArtifactHashId(UUID jobId, String contentHash, ArtifactKind kind) { + this.jobId = jobId; + this.contentHash = contentHash; + this.kind = kind; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof JobArtifactHashId other)) return false; + return Objects.equals(jobId, other.jobId) + && Objects.equals(contentHash, other.contentHash) + && kind == other.kind; + } + + @Override + public int hashCode() { + return Objects.hash(jobId, contentHash, kind); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java new file mode 100644 index 000000000..d80bad16d --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java @@ -0,0 +1,99 @@ +package stirling.software.saas.payg.job; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.JobStatus; +import stirling.software.saas.payg.model.ProcessType; + +/** + * One process — a workflow that may comprise multiple lineage-linked tool calls but is billed once + * at process open. Closed by an explicit caller, by the frontend, or by the stale-close scheduler. + */ +@Entity +@Table(name = "processing_job") +@NoArgsConstructor +@Getter +@Setter +public class ProcessingJob implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "job_id") + private UUID id; + + @Column(name = "owner_user_id", nullable = false) + private Long ownerUserId; + + @Column(name = "owner_team_id") + private Long ownerTeamId; + + @Enumerated(EnumType.STRING) + @Column(name = "process_type", nullable = false, length = 32) + private ProcessType processType; + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 32) + private JobSource source; + + /** SHA-256 of the union of input file hashes; null if the input set is mixed or unknown. */ + @Column(name = "document_fingerprint", length = 64) + private String documentFingerprint; + + @Column(name = "doc_units", nullable = false) + private Integer docUnits = 0; + + @Column(name = "step_count", nullable = false) + private Integer stepCount = 0; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "last_step_at", nullable = false) + private LocalDateTime lastStepAt; + + @Column(name = "closed_at") + private LocalDateTime closedAt; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + /** Filled at close-time; absent while the job is still OPEN. */ + @Column(name = "charged_units") + private Integer chargedUnits; + + /** Cached money equivalent for receipts; not used by cap evaluation. */ + @Column(name = "charged_cents") + private Integer chargedCents; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private JobStatus status; + + /** Stable idempotency key for the open-process Stripe meter event. */ + @Column(name = "idempotency_key", unique = true, length = 128) + private String idempotencyKey; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata = new HashMap<>(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJobStep.java b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJobStep.java new file mode 100644 index 000000000..5ae7c1ad3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJobStep.java @@ -0,0 +1,62 @@ +package stirling.software.saas.payg.job; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +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; + +import stirling.software.saas.payg.model.JobStepStatus; + +/** One tool invocation inside a {@link ProcessingJob}. Free after the first; carries audit data. */ +@Entity +@Table(name = "processing_job_step") +@NoArgsConstructor +@Getter +@Setter +public class ProcessingJobStep implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "step_id") + private Long id; + + @Column(name = "job_id", nullable = false) + private UUID jobId; + + /** Endpoint path, e.g. {@code /api/v1/general/split-pages}. */ + @Column(name = "tool_id", nullable = false, length = 128) + private String toolId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private JobStepStatus status; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "input_pages") + private Integer inputPages; + + @Column(name = "input_bytes") + private Long inputBytes; + + @Column(name = "error_code", length = 64) + private String errorCode; +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/ArtifactKind.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ArtifactKind.java new file mode 100644 index 000000000..e7ba5d35a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ArtifactKind.java @@ -0,0 +1,7 @@ +package stirling.software.saas.payg.model; + +/** Whether a recorded content hash belongs to a job step's input or its output. */ +public enum ArtifactKind { + INPUT, + OUTPUT +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/AutoGroupStrategy.java b/app/saas/src/main/java/stirling/software/saas/payg/model/AutoGroupStrategy.java new file mode 100644 index 000000000..4e5880ec3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/AutoGroupStrategy.java @@ -0,0 +1,10 @@ +package stirling.software.saas.payg.model; + +/** + * Whether a team's tool calls auto-group into multi-step processes via content-hash lineage. {@code + * OFF} forces every call into its own single-step process. + */ +public enum AutoGroupStrategy { + AUTO, + OFF +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/CapPeriod.java b/app/saas/src/main/java/stirling/software/saas/payg/model/CapPeriod.java new file mode 100644 index 000000000..90cd9d052 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/CapPeriod.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +public enum CapPeriod { + CALENDAR_MONTH, + CALENDAR_QUARTER, + CALENDAR_YEAR, + BILLING_CYCLE +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/EntitlementState.java b/app/saas/src/main/java/stirling/software/saas/payg/model/EntitlementState.java new file mode 100644 index 000000000..0786a4e5e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/EntitlementState.java @@ -0,0 +1,7 @@ +package stirling.software.saas.payg.model; + +public enum EntitlementState { + FULL, + WARNED, + DEGRADED +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureGate.java b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureGate.java new file mode 100644 index 000000000..edfecbcb5 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureGate.java @@ -0,0 +1,9 @@ +package stirling.software.saas.payg.model; + +/** Coarse capability flags evaluated by the entitlement guard before letting a request proceed. */ +public enum FeatureGate { + OFFSITE_PROCESSING, + AUTOMATION, + AI_SUPPORT, + CLIENT_SIDE +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureSet.java b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureSet.java new file mode 100644 index 000000000..ad4ae7e70 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/FeatureSet.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +/** Bundles of {@link FeatureGate}s exposed at the team / member level. */ +public enum FeatureSet { + FULL, + MINIMAL, + CLIENT_ONLY +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java new file mode 100644 index 000000000..8f7d2b3df --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java @@ -0,0 +1,19 @@ +package stirling.software.saas.payg.model; + +/** + * Where a tool invocation originated on the client side. Caller surface only — + * this enum does not encode whether the request was served by SaaS or by a self-hosted instance. + * That distinction lives at the team / policy level: self-hosted instances bind to their own team + * (via {@code license_keys.team_id}) which carries its own {@code pricing_policy_id}. + * + *

Used as the key for per-source step limits on {@code pricing_policy.step_limits}. + */ +public enum JobSource { + WEB, + API, + PIPELINE, + /** + * The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend. + */ + DESKTOP_APP +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobStatus.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStatus.java new file mode 100644 index 000000000..a172bb893 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStatus.java @@ -0,0 +1,9 @@ +package stirling.software.saas.payg.model; + +public enum JobStatus { + OPEN, + CLOSED, + REFUNDED, + PARTIAL_REFUND, + FAILED +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobStepStatus.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStepStatus.java new file mode 100644 index 000000000..8c8eeb910 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobStepStatus.java @@ -0,0 +1,7 @@ +package stirling.software.saas.payg.model; + +public enum JobStepStatus { + OK, + FAILED, + SKIPPED +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerBucket.java b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerBucket.java new file mode 100644 index 000000000..6aed3aa71 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerBucket.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +/** Which pool a ledger entry touches. Debits flow CYCLE → BOUGHT → OVERAGE in that order. */ +public enum LedgerBucket { + CYCLE, + BOUGHT, + OVERAGE +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerEntryType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerEntryType.java new file mode 100644 index 000000000..4a7b3697a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/LedgerEntryType.java @@ -0,0 +1,11 @@ +package stirling.software.saas.payg.model; + +public enum LedgerEntryType { + CYCLE_GRANT, + DEBIT, + REFUND, + EXPIRE, + OVERAGE_REPORTED, + ADJUSTMENT, + LEGACY_BACKFILL +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/ProcessType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ProcessType.java new file mode 100644 index 000000000..76fdd6d9a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ProcessType.java @@ -0,0 +1,11 @@ +package stirling.software.saas.payg.model; + +/** + * Shape of the workflow the job represents. Recorded for analytics; per-process step limits live on + * {@link JobSource} now. + */ +public enum ProcessType { + SINGLE_TOOL, + CHAIN, + AUTOMATION +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java new file mode 100644 index 000000000..d966b3d22 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java @@ -0,0 +1,9 @@ +package stirling.software.saas.payg.model; + +/** What a {@code wallet_ledger.reference_id} points at. */ +public enum ReferenceType { + JOB, + INVOICE, + STRIPE_EVENT, + ADMIN +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/WalletEngine.java b/app/saas/src/main/java/stirling/software/saas/payg/model/WalletEngine.java new file mode 100644 index 000000000..6414632ce --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/WalletEngine.java @@ -0,0 +1,8 @@ +package stirling.software.saas.payg.model; + +/** Which charging engine a wallet is running. Flipped per-team during cutover. */ +public enum WalletEngine { + LEGACY, + PAYG_SHADOW, + PAYG +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java new file mode 100644 index 000000000..2c0603fc8 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java @@ -0,0 +1,77 @@ +package stirling.software.saas.payg.policy; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.model.Team; + +/** + * Sidecar carrying PAYG-only team fields. 1:1 with {@link Team} via shared PK so OSS Hibernate + * (which only sees the proprietary {@link Team} entity) never tries to add PAYG columns to the + * shared {@code teams} table. Mirrors the existing {@code SaasTeamExtensions} pattern. + * + *

Created lazily on first PAYG access for a team. + */ +@Entity +@Table(name = "payg_team_extensions") +@NoArgsConstructor +@Getter +@Setter +public class PaygTeamExtensions implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "team_id") + private Long teamId; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "team_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private Team team; + + /** Per-team policy override; NULL means use the default row in {@code pricing_policy}. */ + @Column(name = "pricing_policy_id") + private Long pricingPolicyId; + + /** Stripe customer id for this team. Eager-created so every team has billing identity. */ + @Column(name = "stripe_customer_id", unique = true, length = 128) + private String stripeCustomerId; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Version + @Column(name = "version") + private Long version; + + public PaygTeamExtensions(Team team) { + this.team = team; + this.teamId = team.getId(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java new file mode 100644 index 000000000..f1443664c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java @@ -0,0 +1,147 @@ +package stirling.software.saas.payg.policy; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapKeyColumn; +import jakarta.persistence.MapKeyEnumerated; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.saas.payg.model.JobSource; + +/** + * Versioned pricing policy. Unit-calculation knobs, per-source step limits, and the per-currency + * Stripe price IDs that turn doc-units into invoice amounts. Money lives in Stripe; this row + * carries everything else. + */ +@Entity +@Table(name = "pricing_policy") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class PricingPolicy implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "policy_id") + private Long id; + + /** Human-readable version label, e.g. {@code v1-2026-06}. Unique across all policies. */ + @Column(name = "version", nullable = false, unique = true, length = 32) + private String version; + + @Column(name = "effective_from", nullable = false) + private LocalDateTime effectiveFrom; + + /** Null while the policy is the current one in its lineage. */ + @Column(name = "effective_to") + private LocalDateTime effectiveTo; + + @Column(name = "doc_pages_per_unit", nullable = false) + private Integer docPagesPerUnit; + + @Column(name = "doc_bytes_per_unit", nullable = false) + private Long docBytesPerUnit; + + @Column(name = "min_charge_units", nullable = false) + private Integer minChargeUnits = 1; + + @Column(name = "file_unit_cap", nullable = false) + private Integer fileUnitCap = 1000; + + /** + * Max tool steps allowed in one process before it splits, keyed by the caller's {@link + * JobSource}. Self-hosted teams typically get a higher limit via a per-team policy override. + * + *

Persisted as a normalized child table {@code pricing_policy_step_limit (policy_id, + * job_source, step_limit)} rather than JSONB — values are typed and queryable directly. + */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "pricing_policy_step_limit", + joinColumns = @JoinColumn(name = "policy_id")) + @MapKeyEnumerated(EnumType.STRING) + @MapKeyColumn(name = "job_source", length = 32) + @Column(name = "step_limit", nullable = false) + private Map stepLimits = new HashMap<>(); + + /** + * Stripe Price IDs this policy resolves to — one per currency we support. Currency is not + * stored here; it comes from {@code stripe.prices.currency} via Sync Engine when picking the + * right Price for a customer's subscription. All prices must share the same Billing Meter and + * the same free-tier upper bound in units (enforced by a deploy-time CI check). + * + *

Persisted as {@code pricing_policy_stripe_price (policy_id, stripe_price_id)}. + */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "pricing_policy_stripe_price", + joinColumns = @JoinColumn(name = "policy_id")) + @Column(name = "stripe_price_id", nullable = false, length = 128) + private Set stripePriceIds = new HashSet<>(); + + /** + * Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx. + */ + @Column(name = "is_default", nullable = false) + private Boolean isDefault = false; + + @Column(name = "notes", columnDefinition = "text") + private String notes; + + @Column(name = "created_by", length = 255) + private String createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * Convenience ctor for the unit-calc-only fields used by the document classifier and tests. + * Other fields are filled with sensible defaults; persistence callers should set the rest + * before saving. + */ + public PricingPolicy( + int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) { + if (docPagesPerUnit <= 0) { + throw new IllegalArgumentException("docPagesPerUnit must be > 0"); + } + if (docBytesPerUnit <= 0) { + throw new IllegalArgumentException("docBytesPerUnit must be > 0"); + } + if (minChargeUnits < 1) { + throw new IllegalArgumentException("minChargeUnits must be >= 1"); + } + if (fileUnitCap < 1) { + throw new IllegalArgumentException("fileUnitCap must be >= 1"); + } + this.docPagesPerUnit = docPagesPerUnit; + this.docBytesPerUnit = docBytesPerUnit; + this.minChargeUnits = minChargeUnits; + this.fileUnitCap = fileUnitCap; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java new file mode 100644 index 000000000..c9cb2af9c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java @@ -0,0 +1,41 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.job.JobArtifactHash; +import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId; +import stirling.software.saas.payg.model.JobStatus; + +@Repository +public interface JobArtifactHashRepository + extends JpaRepository { + + /** + * Lineage lookup: find the open job (if any) whose recorded input/output hashes include the + * supplied content hash, scoped to one user and the workflow window. + */ + @Query( + "SELECT j.ownerUserId, h.id.jobId FROM JobArtifactHash h" + + " JOIN ProcessingJob j ON j.id = h.id.jobId" + + " WHERE j.ownerUserId = :userId" + + " AND j.status = :openStatus" + + " AND j.lastStepAt > :since" + + " AND h.id.contentHash = :contentHash") + List findLineageMatches( + @Param("userId") Long userId, + @Param("openStatus") JobStatus openStatus, + @Param("since") LocalDateTime since, + @Param("contentHash") String contentHash); + + /** Prunes rows older than {@code cutoff}; run from a scheduled task. */ + @Modifying + @Query("DELETE FROM JobArtifactHash h WHERE h.createdAt < :cutoff") + int deleteOlderThan(@Param("cutoff") LocalDateTime cutoff); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java new file mode 100644 index 000000000..ec1a51963 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java @@ -0,0 +1,22 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.shadow.PaygShadowCharge; + +@Repository +public interface PaygShadowChargeRepository extends JpaRepository { + + @Query( + "SELECT s FROM PaygShadowCharge s" + + " WHERE s.occurredAt >= :from AND s.occurredAt < :to" + + " ORDER BY s.occurredAt DESC") + List findInWindow( + @Param("from") LocalDateTime from, @Param("to") LocalDateTime to); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java new file mode 100644 index 000000000..3473eeeff --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java @@ -0,0 +1,14 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.policy.PaygTeamExtensions; + +@Repository +public interface PaygTeamExtensionsRepository extends JpaRepository { + + Optional findByStripeCustomerId(String stripeCustomerId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java new file mode 100644 index 000000000..a1f8cfaaf --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java @@ -0,0 +1,16 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.policy.PricingPolicy; + +@Repository +public interface PricingPolicyRepository extends JpaRepository { + + Optional findByVersion(String version); + + Optional findFirstByIsDefaultTrue(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobRepository.java new file mode 100644 index 000000000..a930a2bb3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobRepository.java @@ -0,0 +1,26 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.job.ProcessingJob; +import stirling.software.saas.payg.model.JobStatus; + +@Repository +public interface ProcessingJobRepository extends JpaRepository { + + List findByOwnerUserIdAndStatus(Long ownerUserId, JobStatus status); + + /** + * Jobs left {@code OPEN} past the workflow window; the stale-close scheduler picks these up. + */ + @Query("SELECT j FROM ProcessingJob j WHERE j.status = :status AND j.lastStepAt < :cutoff") + List findStale( + @Param("status") JobStatus status, @Param("cutoff") LocalDateTime cutoff); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobStepRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobStepRepository.java new file mode 100644 index 000000000..3958c6a5c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/ProcessingJobStepRepository.java @@ -0,0 +1,15 @@ +package stirling.software.saas.payg.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.job.ProcessingJobStep; + +@Repository +public interface ProcessingJobStepRepository extends JpaRepository { + + List findByJobIdOrderByStartedAtAsc(UUID jobId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletEntitlementSnapshotRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletEntitlementSnapshotRepository.java new file mode 100644 index 000000000..e15222c21 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletEntitlementSnapshotRepository.java @@ -0,0 +1,26 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot; +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId; + +@Repository +public interface WalletEntitlementSnapshotRepository + extends JpaRepository { + + /** Team-wide snapshot lookup. */ + default Optional findTeamWide(Long teamId) { + return findById( + new WalletEntitlementSnapshotId( + teamId, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID)); + } + + /** Per-member snapshot lookup. */ + default Optional findForMember(Long teamId, Long userId) { + return findById(new WalletEntitlementSnapshotId(teamId, userId)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java new file mode 100644 index 000000000..2a2a00342 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java @@ -0,0 +1,50 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.model.LedgerEntryType; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; + +@Repository +public interface WalletLedgerRepository extends JpaRepository { + + List findByTeamIdOrderByOccurredAtDesc(Long teamId); + + /** Sum of signed amounts over a team's entries — the wallet's current balance in units. */ + @Query( + "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e WHERE e.teamId = :teamId") + long sumBalanceForTeam(@Param("teamId") Long teamId); + + /** Period-bounded spend for one team in units (debits only). */ + @Query( + "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e" + + " WHERE e.teamId = :teamId" + + " AND e.entryType = :entryType" + + " AND e.occurredAt >= :periodStart" + + " AND e.occurredAt < :periodEnd") + long sumPeriodAmount( + @Param("teamId") Long teamId, + @Param("entryType") LedgerEntryType entryType, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); + + /** Per-member period spend (only when the member has a sub-cap configured). */ + @Query( + "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e" + + " WHERE e.teamId = :teamId AND e.actorUserId = :actorUserId" + + " AND e.entryType = :entryType" + + " AND e.occurredAt >= :periodStart" + + " AND e.occurredAt < :periodEnd") + long sumPeriodAmountForMember( + @Param("teamId") Long teamId, + @Param("actorUserId") Long actorUserId, + @Param("entryType") LedgerEntryType entryType, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletPolicyRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletPolicyRepository.java new file mode 100644 index 000000000..f8c3076e5 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletPolicyRepository.java @@ -0,0 +1,14 @@ +package stirling.software.saas.payg.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.saas.payg.wallet.WalletPolicy; + +@Repository +public interface WalletPolicyRepository extends JpaRepository { + + Optional findByTeamId(Long teamId); +} 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 new file mode 100644 index 000000000..495930c08 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java @@ -0,0 +1,61 @@ +package stirling.software.saas.payg.shadow; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +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; + +/** + * Per-job comparison row written while a team is in {@code PAYG_SHADOW} mode: what the legacy + * engine actually charged vs. what the PAYG engine would have charged. Aggregated daily by the + * shadow-reconciliation report; deletable after promotion. + */ +@Entity +@Table(name = "payg_shadow_charge") +@NoArgsConstructor +@Getter +@Setter +public class PaygShadowCharge implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "shadow_id") + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + @Column(name = "job_id", nullable = false) + private UUID jobId; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "payg_units", nullable = false) + private Integer paygUnits; + + @Column(name = "legacy_credits_charged", nullable = false) + private Integer legacyCreditsCharged; + + /** Signed percent difference: {@code 100 * (payg - legacy) / max(1, legacy)}. */ + @Column(name = "diff_pct", nullable = false) + private Integer diffPct; + + @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 new file mode 100644 index 000000000..90aed63cd --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -0,0 +1,86 @@ +package stirling.software.saas.payg.wallet; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +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; + +import stirling.software.saas.payg.model.LedgerBucket; +import stirling.software.saas.payg.model.LedgerEntryType; +import stirling.software.saas.payg.model.ReferenceType; + +/** + * Append-only ledger keyed on {@code team_id}. {@code amount_units} is signed (positive = credit, + * negative = debit). Two unique indexes (reference triple, stripe event id) prevent double-posting. + */ +@Entity +@Table(name = "wallet_ledger") +@NoArgsConstructor +@Getter +@Setter +public class WalletLedgerEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "entry_id") + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** Which team member triggered this entry; null for system grants. */ + @Column(name = "actor_user_id") + private Long actorUserId; + + @Enumerated(EnumType.STRING) + @Column(name = "entry_type", nullable = false, length = 32) + private LedgerEntryType entryType; + + @Enumerated(EnumType.STRING) + @Column(name = "bucket", nullable = false, length = 16) + private LedgerBucket bucket; + + /** Signed: positive = credit, negative = debit. The only quantity the app tracks. */ + @Column(name = "amount_units", nullable = false) + private Integer amountUnits; + + @Enumerated(EnumType.STRING) + @Column(name = "reference_type", nullable = false, length = 32) + private ReferenceType referenceType; + + @Column(name = "reference_id", nullable = false, length = 128) + private String referenceId; + + @Column(name = "policy_id") + private Long policyId; + + @Column(name = "stripe_event_id", length = 128) + private String stripeEventId; + + @CreationTimestamp + @Column(name = "occurred_at", nullable = false, updatable = false) + private LocalDateTime occurredAt; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata = new HashMap<>(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletPolicy.java new file mode 100644 index 000000000..91cc78ad0 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletPolicy.java @@ -0,0 +1,94 @@ +package stirling.software.saas.payg.wallet; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +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; + +import stirling.software.saas.payg.model.AutoGroupStrategy; +import stirling.software.saas.payg.model.CapPeriod; +import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.model.WalletEngine; + +/** + * Per-team wallet configuration: charging engine, period spend cap, warn/degrade thresholds, the + * degraded feature set, and the lineage-detection strategy. + * + *

No {@code @Version} — admin-only writes, no concurrent writers on a single row. + */ +@Entity +@Table(name = "wallet_policy") +@NoArgsConstructor +@Getter +@Setter +public class WalletPolicy implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "policy_id") + private Long id; + + @Column(name = "team_id", nullable = false, unique = true) + private Long teamId; + + @Enumerated(EnumType.STRING) + @Column(name = "engine", nullable = false, length = 16) + private WalletEngine engine = WalletEngine.LEGACY; + + @Enumerated(EnumType.STRING) + @Column(name = "cap_period", nullable = false, length = 16) + private CapPeriod capPeriod = CapPeriod.CALENDAR_MONTH; + + /** Null = unlimited. Doc-units per period. */ + @Column(name = "cap_units") + private Long capUnits; + + /** + * Original money cap input ("$50/month") in smallest currency unit; null if set as units. The + * currency comes from {@code stripe.customers.currency} at recompute time — we don't duplicate + * it here. + */ + @Column(name = "cap_source_money") + private Long capSourceMoney; + + @Column(name = "warn_at_pct", nullable = false) + private Integer warnAtPct = 80; + + @Column(name = "degrade_at_pct", nullable = false) + private Integer degradeAtPct = 100; + + @Enumerated(EnumType.STRING) + @Column(name = "degraded_feature_set", nullable = false, length = 32) + private FeatureSet degradedFeatureSet = FeatureSet.MINIMAL; + + @Enumerated(EnumType.STRING) + @Column(name = "auto_group_strategy", nullable = false, length = 16) + private AutoGroupStrategy autoGroupStrategy = AutoGroupStrategy.AUTO; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "notification_emails", columnDefinition = "jsonb", nullable = false) + private List notificationEmails = new ArrayList<>(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql b/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql new file mode 100644 index 000000000..b7450984b --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql @@ -0,0 +1,226 @@ +-- PAYG data model: pricing policy, processing jobs + lineage, wallet ledger, wallet policy, +-- entitlement snapshots, shadow-mode comparison rows, plus a payg_team_extensions sidecar table +-- carrying team-level PAYG fields, and a cap_units column on team_memberships. +-- +-- Sidecar pattern (mirrors saas_team_extensions): PAYG-only team fields don't sit directly on +-- `teams`, so OSS deployments running Hibernate ddl-auto=update against the proprietary Team +-- entity never see PAYG columns they don't have entities for. +-- +-- Everything is purely additive. No existing rows are modified, no columns are dropped. + +-- --------------------------------------------------------------------------------------------- +-- 1. pricing_policy — versioned economic config (units, lifecycle metadata). +-- step_limits and stripe_price_ids live on normalised child tables below — typed columns, no +-- JSON parsing, queryable directly. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS pricing_policy ( + policy_id BIGSERIAL PRIMARY KEY, + version VARCHAR(32) NOT NULL UNIQUE, + effective_from TIMESTAMP NOT NULL, + effective_to TIMESTAMP, + doc_pages_per_unit INTEGER NOT NULL, + doc_bytes_per_unit BIGINT NOT NULL, + min_charge_units INTEGER NOT NULL DEFAULT 1, + file_unit_cap INTEGER NOT NULL DEFAULT 1000, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + notes TEXT, + created_by VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_pricing_policy_default + ON pricing_policy (is_default) WHERE is_default = TRUE; + +-- Max steps allowed per process for each caller surface (JobSource). +CREATE TABLE IF NOT EXISTS pricing_policy_step_limit ( + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE, + job_source VARCHAR(32) NOT NULL, + step_limit INTEGER NOT NULL, + PRIMARY KEY (policy_id, job_source) +); + +-- Stripe Price IDs this policy resolves to, one per supported currency. Currency itself isn't +-- stored here — it lives on stripe.prices.currency and is looked up via Sync Engine when picking +-- the right Price for a customer's subscription. All prices in one policy must share the same +-- Billing Meter and the same first-tier upper bound in units (deploy-time CI check). +CREATE TABLE IF NOT EXISTS pricing_policy_stripe_price ( + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE, + stripe_price_id VARCHAR(128) NOT NULL, + PRIMARY KEY (policy_id, stripe_price_id) +); + +-- --------------------------------------------------------------------------------------------- +-- 2. payg_team_extensions — sidecar carrying PAYG-only team fields. 1:1 with teams via shared PK. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS payg_team_extensions ( + team_id BIGINT PRIMARY KEY REFERENCES teams(team_id) ON DELETE CASCADE, + pricing_policy_id BIGINT REFERENCES pricing_policy(policy_id), + stripe_customer_id VARCHAR(128) UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +COMMENT ON COLUMN payg_team_extensions.pricing_policy_id IS + 'Override policy for this team. NULL means use the row in pricing_policy with is_default=TRUE.'; +COMMENT ON COLUMN payg_team_extensions.stripe_customer_id IS + 'Stripe customer id for this team. Eager-created so every team has billing identity on file.'; + +-- --------------------------------------------------------------------------------------------- +-- 3. team_memberships column addition: optional per-member sub-cap. Lives directly on the table +-- because team_memberships is already a SaaS-only table. +-- --------------------------------------------------------------------------------------------- +ALTER TABLE team_memberships + ADD COLUMN IF NOT EXISTS cap_units BIGINT; +COMMENT ON COLUMN team_memberships.cap_units IS + 'Per-period spend cap for this member inside their team wallet, in doc units. NULL = no member-level cap.'; + +-- --------------------------------------------------------------------------------------------- +-- 4. processing_job — one billable process; step_count and last_step_at track the workflow window. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS processing_job ( + job_id UUID PRIMARY KEY, + owner_user_id BIGINT NOT NULL, + owner_team_id BIGINT, + process_type VARCHAR(32) NOT NULL, + source VARCHAR(32) NOT NULL, + document_fingerprint VARCHAR(64), + doc_units INTEGER NOT NULL DEFAULT 0, + step_count INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_step_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP, + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id), + charged_units INTEGER, + charged_cents INTEGER, + status VARCHAR(32) NOT NULL, + idempotency_key VARCHAR(128) UNIQUE, + metadata JSONB +); + +CREATE INDEX IF NOT EXISTS idx_processing_job_owner_open + ON processing_job (owner_user_id, status) WHERE status = 'OPEN'; + +CREATE INDEX IF NOT EXISTS idx_processing_job_last_step + ON processing_job (status, last_step_at) WHERE status = 'OPEN'; + +-- --------------------------------------------------------------------------------------------- +-- 5. processing_job_step — per-tool-call audit within a job. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS processing_job_step ( + step_id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE, + tool_id VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + input_pages INTEGER, + input_bytes BIGINT, + error_code VARCHAR(64) +); + +CREATE INDEX IF NOT EXISTS idx_processing_job_step_job + ON processing_job_step (job_id); + +-- --------------------------------------------------------------------------------------------- +-- 6. job_artifact_hash — per-step input/output content hashes used by the lineage detector. +-- --------------------------------------------------------------------------------------------- +-- content_hash holds "type:value" signature keys; VARCHAR(128) fits SHA-256 and future schemes. +CREATE TABLE IF NOT EXISTS job_artifact_hash ( + job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE, + content_hash VARCHAR(128) NOT NULL, + kind VARCHAR(8) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (job_id, content_hash, kind) +); + +CREATE INDEX IF NOT EXISTS idx_artifact_hash_lookup + ON job_artifact_hash (content_hash, created_at); + +-- --------------------------------------------------------------------------------------------- +-- 7. wallet_ledger — append-only signed-amount ledger keyed on team_id. +-- amount_units is INTEGER (per-row delta, always small); cap and rollup columns are BIGINT +-- because they accumulate across a billing period. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wallet_ledger ( + entry_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, + actor_user_id BIGINT, + entry_type VARCHAR(32) NOT NULL, + bucket VARCHAR(16) NOT NULL, + amount_units INTEGER NOT NULL, + reference_type VARCHAR(32) NOT NULL, + reference_id VARCHAR(128) NOT NULL, + policy_id BIGINT, + stripe_event_id VARCHAR(128), + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata JSONB +); + +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team + ON wallet_ledger (team_id, occurred_at); + +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_actor + ON wallet_ledger (team_id, actor_user_id, occurred_at) WHERE actor_user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_ref + ON wallet_ledger (reference_type, reference_id, entry_type, bucket); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_stripe_event + ON wallet_ledger (stripe_event_id) WHERE stripe_event_id IS NOT NULL; + +-- --------------------------------------------------------------------------------------------- +-- 8. wallet_policy — per-team charging engine, cap, degradation rules, lineage strategy. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wallet_policy ( + policy_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL UNIQUE REFERENCES teams(team_id) ON DELETE CASCADE, + engine VARCHAR(16) NOT NULL DEFAULT 'LEGACY', + cap_period VARCHAR(16) NOT NULL DEFAULT 'CALENDAR_MONTH', + cap_units BIGINT, + -- Customer's money intent ("I want $50/month"); the currency comes from the team's Stripe + -- customer at recompute time, not stored separately here. + cap_source_money BIGINT, + warn_at_pct INTEGER NOT NULL DEFAULT 80, + degrade_at_pct INTEGER NOT NULL DEFAULT 100, + degraded_feature_set VARCHAR(32) NOT NULL DEFAULT 'MINIMAL', + auto_group_strategy VARCHAR(16) NOT NULL DEFAULT 'AUTO', + notification_emails JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- --------------------------------------------------------------------------------------------- +-- 9. wallet_entitlement_snapshot — hot-path state for the entitlement guard. +-- user_id = 0 is the team-wide sentinel (Postgres treats NULL as not-equal-to-NULL in unique +-- constraints, so 0 is the cleaner choice for a composite PK). +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wallet_entitlement_snapshot ( + team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, + user_id BIGINT NOT NULL DEFAULT 0, + period_start TIMESTAMP NOT NULL, + period_end TIMESTAMP NOT NULL, + period_spend_units BIGINT NOT NULL DEFAULT 0, + period_cap_units BIGINT, + state VARCHAR(16) NOT NULL DEFAULT 'FULL', + feature_set VARCHAR(32) NOT NULL DEFAULT 'FULL', + enabled_gates JSONB NOT NULL DEFAULT '[]'::jsonb, + computed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (team_id, user_id) +); + +-- --------------------------------------------------------------------------------------------- +-- 10. payg_shadow_charge — per-job legacy-vs-PAYG diff during PAYG_SHADOW engine mode. +-- --------------------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS payg_shadow_charge ( + shadow_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, + job_id UUID NOT NULL, + policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id), + payg_units INTEGER NOT NULL, + legacy_credits_charged INTEGER NOT NULL, + diff_pct INTEGER NOT NULL, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_payg_shadow_team_time + ON payg_shadow_charge (team_id, occurred_at); diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java new file mode 100644 index 000000000..faf7f561e --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java @@ -0,0 +1,60 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Guards {@link SaasJpaConfig}'s scan paths from drifting out of sync with the actual entity and + * repository packages — without this, a missing package goes undetected until a runtime "No + * qualifying bean of type" startup failure that Mockito-based tests can't catch. + * + *

Reflection-based rather than a real Spring boot because the production schema uses + * Postgres-specific features H2 doesn't fully support. + */ +class SaasJpaConfigScanTest { + + private static final List EXPECTED_REPO_PACKAGES = + List.of( + "stirling.software.saas.repository", + "stirling.software.saas.billing.repository", + "stirling.software.saas.ai.repository", + "stirling.software.saas.payg.repository"); + + private static final List EXPECTED_ENTITY_PACKAGES = + List.of( + "stirling.software.saas.model", + "stirling.software.saas.billing.model", + "stirling.software.saas.ai.model", + // Recursive — covers all payg.* sub-packages. + "stirling.software.saas.payg"); + + @Test + void enableJpaRepositoriesIncludesAllExpectedPackages() { + EnableJpaRepositories annotation = + SaasJpaConfig.class.getAnnotation(EnableJpaRepositories.class); + assertThat(annotation).as("SaasJpaConfig must carry @EnableJpaRepositories").isNotNull(); + + Set actual = Set.copyOf(Arrays.asList(annotation.basePackages())); + assertThat(actual) + .as("Every package holding @Repository interfaces must be listed") + .containsAll(EXPECTED_REPO_PACKAGES); + } + + @Test + void entityScanIncludesAllExpectedPackages() { + EntityScan annotation = SaasJpaConfig.class.getAnnotation(EntityScan.class); + assertThat(annotation).as("SaasJpaConfig must carry @EntityScan").isNotNull(); + + Set actual = Set.copyOf(Arrays.asList(annotation.value())); + assertThat(actual) + .as("Every package holding @Entity classes must be listed") + .containsAll(EXPECTED_ENTITY_PACKAGES); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierTest.java b/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierTest.java new file mode 100644 index 000000000..4f8c61018 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierTest.java @@ -0,0 +1,231 @@ +package stirling.software.saas.payg.docs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.saas.payg.policy.PricingPolicy; + +class DefaultDocumentClassifierTest { + + /** Same shape as the V1 default we'd seed in pricing_policy. */ + private static final PricingPolicy DEFAULT_POLICY = + new PricingPolicy( + /* docPagesPerUnit= */ 25, + /* docBytesPerUnit= */ 10L * 1024 * 1024, + /* minChargeUnits= */ 1, + /* fileUnitCap= */ 1000); + + private final DefaultDocumentClassifier classifier = + new DefaultDocumentClassifier(buildTempFileManager()); + + @Test + void singlePagePdf_isOneUnit() throws Exception { + MultipartFile pdf = pdf("one.pdf", 1); + + DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(1); + assertThat(metrics.docUnits()).isEqualTo(1); + assertThat(metrics.contentType()).isEqualTo("application/pdf"); + } + + @Test + void multiPagePdf_chargesByPageAxisWhenBytesAreTiny() throws Exception { + // 100 pages, well under 10 MiB → page axis dominates. ceil(100 / 25) = 4 units. + MultipartFile pdf = pdf("hundred.pdf", 100); + + DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(100); + assertThat(metrics.docUnits()).isEqualTo(4); + } + + @Test + void bytesAxisDominatesWhenFileIsLargeButFewPages() { + // Use a KiB-scale unit so the test allocation stays small. + PricingPolicy bytesy = new PricingPolicy(25, 10L * 1024, 1, 1000); // 10 KiB per unit + // 30 KiB / 10 KiB = 3 units. + byte[] payload = new byte[30 * 1024]; + MultipartFile blob = new MockMultipartFile("file", "scan.tiff", "image/tiff", payload); + + DocumentMetrics metrics = classifier.classify(blob, bytesy); + + assertThat(metrics.pages()).isZero(); + assertThat(metrics.docUnits()).isEqualTo(3); + assertThat(metrics.contentType()).isEqualTo("image/tiff"); + } + + @Test + void singleFileFileUnitCap_clampsExtremelyLargeInputs() { + PricingPolicy tightCap = new PricingPolicy(25, 10L * 1024, 1, /* fileUnitCap= */ 10); + // 200 KiB → 20 raw units; per-file cap pins to 10. + byte[] payload = new byte[200 * 1024]; + MultipartFile blob = + new MockMultipartFile("file", "huge.bin", "application/octet-stream", payload); + + DocumentMetrics metrics = classifier.classify(blob, tightCap); + + assertThat(metrics.docUnits()).isEqualTo(10); + } + + @Test + void emptyFile_chargesTheOneUnitFloor() { + MultipartFile empty = + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]); + + DocumentMetrics metrics = classifier.classify(empty, DEFAULT_POLICY); + + assertThat(metrics.bytes()).isZero(); + assertThat(metrics.docUnits()).isEqualTo(1); + } + + @Test + void malformedPdf_fallsBackToBytesOnlyClassification() { + byte[] junk = "%PDF-not-really-a-pdf-but-claims-to-be".getBytes(); + MultipartFile bad = new MockMultipartFile("file", "broken.pdf", "application/pdf", junk); + + DocumentMetrics metrics = classifier.classify(bad, DEFAULT_POLICY); + + assertThat(metrics.pages()).isZero(); + assertThat(metrics.docUnits()).isEqualTo(1); + } + + @Test + void encryptedPdf_isStillClassifiable() throws Exception { + byte[] bytes = encryptedPdfBytes(5, "ownerpwd", "userpwd"); + MultipartFile encrypted = + new MockMultipartFile("file", "secret.pdf", "application/pdf", bytes); + + DocumentMetrics metrics = classifier.classify(encrypted, DEFAULT_POLICY); + + // Page count behaviour on encrypted PDFs varies by reader; the stable property is that + // the byte axis still produces a charge. + assertThat(metrics.docUnits()).isGreaterThanOrEqualTo(1); + assertThat(metrics.bytes()).isEqualTo(bytes.length); + } + + @Test + void nullContentType_defaultsToOctetStream() { + MultipartFile noType = + new MockMultipartFile( + "file", "unknown.dat", /* contentType= */ null, new byte[100]); + + DocumentMetrics metrics = classifier.classify(noType, DEFAULT_POLICY); + + assertThat(metrics.contentType()).isEqualTo("application/octet-stream"); + } + + @Test + void pdfDetectedByExtension_whenContentTypeIsGeneric() throws Exception { + byte[] pdfBytes = pdfBytes(50); + MultipartFile pdf = + new MockMultipartFile("file", "report.pdf", "application/octet-stream", pdfBytes); + + DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(50); + } + + @Test + void multiFile_aggregatesUnits() throws Exception { + // Two 50-page PDFs: each is ceil(50/25) = 2 raw units; total = 4. Group cap of 1000 × 2 + // doesn't bind. + DocumentMetrics metrics = + classifier.classify(List.of(pdf("a.pdf", 50), pdf("b.pdf", 50)), DEFAULT_POLICY); + + assertThat(metrics.docUnits()).isEqualTo(4); + assertThat(metrics.pages()).isEqualTo(100); + } + + @Test + void multiFile_groupCapBindsOnSumOfRawUnits() { + // Asymmetric file sizes are required to actually exercise the group cap: + // File A: 50 raw units (well over fileUnitCap) + // File B: 1 raw unit + // Raw sum: 51 + // Group cap = fileUnitCap (25) × file_count (2) = 50 + // + // With a buggy per-file clamp inside the loop: (25, 1) → sum 26. + // With the fixed group cap on the raw sum: min(50, 51) = 50. + PricingPolicy policy = + new PricingPolicy( + /* docPagesPerUnit= */ 25, + /* docBytesPerUnit= */ 1L * 1024, // 1 KiB per unit + /* minChargeUnits= */ 1, + /* fileUnitCap= */ 25); + + byte[] big = new byte[50 * 1024]; // 50 KiB → 50 raw units + byte[] small = new byte[1 * 1024]; // 1 KiB → 1 raw unit + MultipartFile a = new MockMultipartFile("file", "a.bin", "application/octet-stream", big); + MultipartFile b = new MockMultipartFile("file", "b.bin", "application/octet-stream", small); + + DocumentMetrics metrics = classifier.classify(List.of(a, b), policy); + + assertThat(metrics.docUnits()) + .as( + "Group cap should clamp the raw sum (51) to fileUnitCap × fileCount (50)." + + " A result of 26 here means per-file clamping has snuck back in" + + " and the group cap is dead.") + .isEqualTo(50); + } + + @Test + void multiFile_emptyListRejected() { + assertThatThrownBy(() -> classifier.classify(List.of(), DEFAULT_POLICY)) + .isInstanceOf(IllegalArgumentException.class); + } + + // --- Fixture helpers ------------------------------------------------------------------------ + + private static MultipartFile pdf(String name, int pages) throws IOException { + return new MockMultipartFile("file", name, "application/pdf", pdfBytes(pages)); + } + + private static byte[] pdfBytes(int pages) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage()); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static byte[] encryptedPdfBytes(int pages, String ownerPwd, String userPwd) + throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage()); + } + doc.protect(new StandardProtectionPolicy(ownerPwd, userPwd, new AccessPermission())); + doc.save(baos); + return baos.toByteArray(); + } + } + + /** + * Constructs a real {@link TempFileManager} backed by the OS temp dir. Cheaper and more + * faithful than mocking — the classifier exercises the actual write+read+delete path the way it + * would in production. + */ + private static TempFileManager buildTempFileManager() { + return new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + } +} 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 new file mode 100644 index 000000000..bffc17bd9 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java @@ -0,0 +1,151 @@ +package stirling.software.saas.payg.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot; +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId; +import stirling.software.saas.payg.job.JobArtifactHash; +import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId; +import stirling.software.saas.payg.job.ProcessingJob; +import stirling.software.saas.payg.job.ProcessingJobStep; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.shadow.PaygShadowCharge; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; +import stirling.software.saas.payg.wallet.WalletPolicy; + +/** + * Boots each PAYG entity via the no-arg constructor that JPA requires, exercises a few getter / + * setter pairs, and confirms composite-key equality where applicable. Catches Lombok / annotation + * regressions without needing a database. + */ +class PaygEntitiesSmokeTest { + + @Test + void pricingPolicy_instantiatesAndRoundTripsFields() { + PricingPolicy p = new PricingPolicy(); + p.setVersion("v1-2026-06"); + p.setDocPagesPerUnit(25); + p.setDocBytesPerUnit(10L * 1024 * 1024); + p.setStepLimits(Map.of(JobSource.WEB, 10, JobSource.API, 20)); + p.setStripePriceIds(Set.of("price_abc", "price_def")); + + assertThat(p.getVersion()).isEqualTo("v1-2026-06"); + assertThat(p.getStepLimits()) + .containsEntry(JobSource.WEB, 10) + .containsEntry(JobSource.API, 20) + .hasSize(2); + assertThat(p.getStripePriceIds()).containsExactlyInAnyOrder("price_abc", "price_def"); + } + + @Test + void pricingPolicy_convenienceCtorValidates() { + // Existing classifier callsite uses this ctor — verify the validation it carries from the + // previous record stays in place. + PricingPolicy p = new PricingPolicy(25, 10L * 1024 * 1024, 1, 1000); + assertThat(p.getDocPagesPerUnit()).isEqualTo(25); + assertThat(p.getFileUnitCap()).isEqualTo(1000); + } + + @Test + void processingJob_acceptsAllStatuses() { + ProcessingJob job = new ProcessingJob(); + job.setId(UUID.randomUUID()); + job.setOwnerUserId(42L); + job.setProcessType(ProcessType.CHAIN); + job.setSource(JobSource.WEB); + job.setStatus(JobStatus.OPEN); + job.setStartedAt(LocalDateTime.now()); + job.setLastStepAt(LocalDateTime.now()); + + assertThat(job.getProcessType()).isEqualTo(ProcessType.CHAIN); + assertThat(job.getStatus()).isEqualTo(JobStatus.OPEN); + } + + @Test + void processingJobStep_isInstantiable() { + ProcessingJobStep step = new ProcessingJobStep(); + step.setJobId(UUID.randomUUID()); + step.setToolId("/api/v1/general/compress"); + step.setStatus(JobStepStatus.OK); + + assertThat(step.getStatus()).isEqualTo(JobStepStatus.OK); + } + + @Test + void jobArtifactHash_compositeIdEqualityHolds() { + UUID jobId = UUID.randomUUID(); + JobArtifactHashId a = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT); + JobArtifactHashId b = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT); + JobArtifactHashId different = new JobArtifactHashId(jobId, "abc123", ArtifactKind.OUTPUT); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(different); + + JobArtifactHash row = new JobArtifactHash(); + row.setId(a); + assertThat(row.getId().getKind()).isEqualTo(ArtifactKind.INPUT); + } + + @Test + void walletLedgerEntry_signedAmountAllowed() { + WalletLedgerEntry entry = new WalletLedgerEntry(); + entry.setTeamId(7L); + entry.setEntryType(LedgerEntryType.DEBIT); + entry.setBucket(LedgerBucket.CYCLE); + entry.setAmountUnits(-4); + entry.setReferenceType(ReferenceType.JOB); + entry.setReferenceId("job:abc"); + + assertThat(entry.getAmountUnits()).isEqualTo(-4); + } + + @Test + void walletPolicy_carriesSensibleDefaults() { + WalletPolicy policy = new WalletPolicy(); + + assertThat(policy.getEngine()).isEqualTo(WalletEngine.LEGACY); + assertThat(policy.getCapPeriod()).isEqualTo(CapPeriod.CALENDAR_MONTH); + assertThat(policy.getWarnAtPct()).isEqualTo(80); + assertThat(policy.getDegradeAtPct()).isEqualTo(100); + assertThat(policy.getDegradedFeatureSet()).isEqualTo(FeatureSet.MINIMAL); + assertThat(policy.getAutoGroupStrategy()).isEqualTo(AutoGroupStrategy.AUTO); + } + + @Test + void walletEntitlementSnapshot_compositeIdHandlesTeamWideSentinel() { + WalletEntitlementSnapshotId teamWide = + new WalletEntitlementSnapshotId(7L, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID); + WalletEntitlementSnapshotId memberA = new WalletEntitlementSnapshotId(7L, 42L); + + assertThat(teamWide).isNotEqualTo(memberA); + assertThat(teamWide.getUserId()).isZero(); + + WalletEntitlementSnapshot snap = new WalletEntitlementSnapshot(); + snap.setId(teamWide); + snap.setEnabledGates(List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.AUTOMATION)); + + assertThat(snap.getState()).isEqualTo(EntitlementState.FULL); + assertThat(snap.getEnabledGates()).hasSize(2); + } + + @Test + void paygShadowCharge_isInstantiable() { + PaygShadowCharge row = new PaygShadowCharge(); + row.setTeamId(7L); + row.setJobId(UUID.randomUUID()); + row.setPolicyId(1L); + row.setPaygUnits(4); + row.setLegacyCreditsCharged(20); + row.setDiffPct(-80); + + assertThat(row.getDiffPct()).isNegative(); + } +}