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