mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
saas: DocumentClassifier + PAYG data model (#6460)
# 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<JobSource, Integer>` persisted via normalised child table `pricing_policy_step_limit`. `stripePriceIds` is `Set<String>` 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
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+173
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>{@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<MultipartFile> 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) {}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<MultipartFile> files, PricingPolicy policy);
|
||||
}
|
||||
@@ -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) {}
|
||||
+112
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<FeatureGate> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> metadata = new HashMap<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum CapPeriod {
|
||||
CALENDAR_MONTH,
|
||||
CALENDAR_QUARTER,
|
||||
CALENDAR_YEAR,
|
||||
BILLING_CYCLE
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum EntitlementState {
|
||||
FULL,
|
||||
WARNED,
|
||||
DEGRADED
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Where a tool invocation originated on the client side. <strong>Caller surface only</strong> —
|
||||
* 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}.
|
||||
*
|
||||
* <p>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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum JobStatus {
|
||||
OPEN,
|
||||
CLOSED,
|
||||
REFUNDED,
|
||||
PARTIAL_REFUND,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum JobStepStatus {
|
||||
OK,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum LedgerEntryType {
|
||||
CYCLE_GRANT,
|
||||
DEBIT,
|
||||
REFUND,
|
||||
EXPIRE,
|
||||
OVERAGE_REPORTED,
|
||||
ADJUSTMENT,
|
||||
LEGACY_BACKFILL
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<JobSource, Integer> 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).
|
||||
*
|
||||
* <p>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<String> 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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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<JobArtifactHash, JobArtifactHashId> {
|
||||
|
||||
/**
|
||||
* 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<Object[]> 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);
|
||||
}
|
||||
+22
@@ -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<PaygShadowCharge, Long> {
|
||||
|
||||
@Query(
|
||||
"SELECT s FROM PaygShadowCharge s"
|
||||
+ " WHERE s.occurredAt >= :from AND s.occurredAt < :to"
|
||||
+ " ORDER BY s.occurredAt DESC")
|
||||
List<PaygShadowCharge> findInWindow(
|
||||
@Param("from") LocalDateTime from, @Param("to") LocalDateTime to);
|
||||
}
|
||||
+14
@@ -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<PaygTeamExtensions, Long> {
|
||||
|
||||
Optional<PaygTeamExtensions> findByStripeCustomerId(String stripeCustomerId);
|
||||
}
|
||||
+16
@@ -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<PricingPolicy, Long> {
|
||||
|
||||
Optional<PricingPolicy> findByVersion(String version);
|
||||
|
||||
Optional<PricingPolicy> findFirstByIsDefaultTrue();
|
||||
}
|
||||
+26
@@ -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<ProcessingJob, UUID> {
|
||||
|
||||
List<ProcessingJob> 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<ProcessingJob> findStale(
|
||||
@Param("status") JobStatus status, @Param("cutoff") LocalDateTime cutoff);
|
||||
}
|
||||
+15
@@ -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<ProcessingJobStep, Long> {
|
||||
|
||||
List<ProcessingJobStep> findByJobIdOrderByStartedAtAsc(UUID jobId);
|
||||
}
|
||||
+26
@@ -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<WalletEntitlementSnapshot, WalletEntitlementSnapshotId> {
|
||||
|
||||
/** Team-wide snapshot lookup. */
|
||||
default Optional<WalletEntitlementSnapshot> findTeamWide(Long teamId) {
|
||||
return findById(
|
||||
new WalletEntitlementSnapshotId(
|
||||
teamId, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID));
|
||||
}
|
||||
|
||||
/** Per-member snapshot lookup. */
|
||||
default Optional<WalletEntitlementSnapshot> findForMember(Long teamId, Long userId) {
|
||||
return findById(new WalletEntitlementSnapshotId(teamId, userId));
|
||||
}
|
||||
}
|
||||
+50
@@ -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<WalletLedgerEntry, Long> {
|
||||
|
||||
List<WalletLedgerEntry> 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);
|
||||
}
|
||||
+14
@@ -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<WalletPolicy, Long> {
|
||||
|
||||
Optional<WalletPolicy> findByTeamId(Long teamId);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, Object> metadata = new HashMap<>();
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String> notificationEmails = new ArrayList<>();
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String> 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<String> 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<String> 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<String> actual = Set.copyOf(Arrays.asList(annotation.value()));
|
||||
assertThat(actual)
|
||||
.as("Every package holding @Entity classes must be listed")
|
||||
.containsAll(EXPECTED_ENTITY_PACKAGES);
|
||||
}
|
||||
}
|
||||
+231
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user