Skip to content

Commit 3dba176

Browse files
committed
PAYG rework: address code-review findings
- Gate the run-id header (#1): PaygChargeInterceptor now honours X-Stirling-Run-Id only when the internal-dispatch marker (X-Stirling-Automation) is also present. A raw external API call can no longer pin a constant run id to collapse separate same-content charges into one, which would defeat "charge per API call". InternalApiClient stamps both headers on every loopback sub-step, so legitimate automation is unaffected. Extracts hasAutomationHeader() (DRY across category/source determination) + two interceptor tests (honoured-with / ignored-without). - Usage card cost (#3): show "avg per PDF" as a formatted currency amount (meter units x per-unit rate / input files) instead of raw units, matching the requested cents-per-PDF; omitted when the rate is unknown. - Empty-state (#4): don't show "No processing yet" when there is unsynced instance-local (units-only) usage but zero synced PDFs. - Style (#7): import AutomationRunContext / UUID instead of fully-qualified inline references in PipelineProcessor, PolicyEngine, AiWorkflowService. - Refresh the now-stale PdfsProcessedCard class javadoc.
1 parent 7f109b1 commit 3dba176

7 files changed

Lines changed: 124 additions & 36 deletions

File tree

app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.Locale;
1111
import java.util.Map;
1212
import java.util.Map.Entry;
13+
import java.util.UUID;
1314

1415
import org.springframework.core.io.FileSystemResource;
1516
import org.springframework.core.io.Resource;
@@ -27,6 +28,7 @@
2728
import stirling.software.SPDF.model.PipelineOperation;
2829
import stirling.software.SPDF.model.PipelineResult;
2930
import stirling.software.SPDF.service.ApiDocService;
31+
import stirling.software.common.service.AutomationRunContext;
3032
import stirling.software.common.service.InternalApiClient;
3133
import stirling.software.common.util.TempFileManager;
3234
import stirling.software.common.util.ZipExtractionUtils;
@@ -74,9 +76,8 @@ PipelineResult runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfi
7476
// One pipeline execution = one automation run. Scope a run id so every tool sub-step
7577
// dispatched via InternalApiClient groups into a single charge on the SaaS billing side
7678
// (see AutomationRunContext); pipeline steps run synchronously on this thread.
77-
try (stirling.software.common.service.AutomationRunContext.Scope ignored =
78-
stirling.software.common.service.AutomationRunContext.open(
79-
java.util.UUID.randomUUID().toString())) {
79+
try (AutomationRunContext.Scope ignored =
80+
AutomationRunContext.open(UUID.randomUUID().toString())) {
8081
return runPipelineAgainstFilesInternal(outputFiles, config);
8182
}
8283
}

app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import lombok.extern.slf4j.Slf4j;
2222

2323
import stirling.software.common.model.job.ResultFile;
24+
import stirling.software.common.service.AutomationRunContext;
2425
import stirling.software.common.service.FileStorage;
2526
import stirling.software.common.service.InternalApiTimeoutException;
2627
import stirling.software.common.service.JobOwnershipService;
@@ -202,8 +203,7 @@ private void runToCompletion(
202203
// One policy run = one automation run. Scope the run id on this worker thread (the async
203204
// hop already happened) so every tool sub-step dispatched via InternalApiClient groups into
204205
// a single charge, and two separate policy runs on the same document stay distinct charges.
205-
try (stirling.software.common.service.AutomationRunContext.Scope runScope =
206-
stirling.software.common.service.AutomationRunContext.open(runId)) {
206+
try (AutomationRunContext.Scope runScope = AutomationRunContext.open(runId)) {
207207
try {
208208
run.markRunning();
209209
PolicyExecutionResult result =

app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.util.LinkedHashMap;
88
import java.util.List;
99
import java.util.Map;
10+
import java.util.UUID;
1011
import java.util.stream.Collectors;
1112

1213
import org.apache.commons.io.FilenameUtils;
@@ -27,6 +28,7 @@
2728
import lombok.extern.slf4j.Slf4j;
2829

2930
import stirling.software.common.model.ApplicationProperties;
31+
import stirling.software.common.service.AutomationRunContext;
3032
import stirling.software.common.service.CustomPDFDocumentFactory;
3133
import stirling.software.common.service.FileStorage;
3234
import stirling.software.common.service.InternalApiTimeoutException;
@@ -159,9 +161,8 @@ public AiWorkflowResponse orchestrate(AiWorkflowRequest request, ProgressListene
159161
// One AI orchestration = one automation run. Scope a run id (on whichever thread runs
160162
// orchestrate — request thread for sync, stream-executor for streaming) so every tool
161163
// sub-step it dispatches via PolicyExecutor → InternalApiClient groups into one charge.
162-
try (stirling.software.common.service.AutomationRunContext.Scope ignored =
163-
stirling.software.common.service.AutomationRunContext.open(
164-
java.util.UUID.randomUUID().toString())) {
164+
try (AutomationRunContext.Scope ignored =
165+
AutomationRunContext.open(UUID.randomUUID().toString())) {
165166

166167
// Key by opaque file id, not filename. Filenames aren't guaranteed unique across an
167168
// upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files

app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,17 @@ private void doPreHandle(
285285
request.setAttribute(ATTR_INPUT_BYTES, totalInputBytes);
286286
request.setAttribute(ATTR_TOOL_ID, resolveToolId(request));
287287

288-
// Automation-run correlation id set by the orchestrator (pipeline / policy / AI-workflow)
289-
// and propagated by InternalApiClient. Null for a direct one-off call → its own charge.
288+
// Automation-run correlation id, honoured ONLY from an internal automation dispatch.
289+
// InternalApiClient stamps X-Stirling-Automation on every loopback sub-step alongside the
290+
// run id, so a genuine pipeline / policy / AI run always carries both. A raw external
291+
// request that sets X-Stirling-Run-Id on its own is ignored (each such call stays its own
292+
// charge): otherwise an API caller could pin a constant run id to collapse separate
293+
// same-content calls into one charge, defeating "charge per API call". Null → standalone.
290294
String headerRunId = request.getHeader(AutomationRunContext.RUN_ID_HEADER);
291-
String runId = (headerRunId != null && !headerRunId.isBlank()) ? headerRunId : null;
295+
String runId =
296+
(hasAutomationHeader(request) && headerRunId != null && !headerRunId.isBlank())
297+
? headerRunId
298+
: null;
292299
ChargeContext ctx =
293300
new ChargeContext(
294301
currentUser.getId(),
@@ -504,9 +511,20 @@ private User resolveUser(Authentication auth) {
504511
}
505512
}
506513

514+
/**
515+
* True when the request carries the internal-dispatch marker InternalApiClient stamps on every
516+
* loopback sub-step ({@code X-Stirling-Automation: true}). This is the trust boundary for both
517+
* the AUTOMATION billing category and for honouring {@code X-Stirling-Run-Id}: an external
518+
* caller can't group charges via a run id without also declaring itself automation (which
519+
* changes its own billing category).
520+
*/
521+
private static boolean hasAutomationHeader(HttpServletRequest request) {
522+
String header = request.getHeader(AUTOMATION_HEADER);
523+
return header != null && "true".equalsIgnoreCase(header.trim());
524+
}
525+
507526
private static JobSource determineSource(HttpServletRequest request, Authentication auth) {
508-
String automationHeader = request.getHeader(AUTOMATION_HEADER);
509-
if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) {
527+
if (hasAutomationHeader(request)) {
510528
return JobSource.PIPELINE;
511529
}
512530
String desktopHeader = request.getHeader(DESKTOP_CLIENT_HEADER);
@@ -533,8 +551,7 @@ private static JobSource determineSource(HttpServletRequest request, Authenticat
533551
*/
534552
private static BillingCategory determineCategory(
535553
HandlerMethod handler, HttpServletRequest request, Authentication auth) {
536-
String automationHeader = request.getHeader(AUTOMATION_HEADER);
537-
if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) {
554+
if (hasAutomationHeader(request)) {
538555
return BillingCategory.AUTOMATION;
539556
}
540557
RequiresFeature ann =

app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,52 @@ void preHandle_pipelineHeader_setsJobSourcePipeline() throws Exception {
434434
.isEqualTo(stirling.software.saas.payg.model.JobSource.PIPELINE);
435435
}
436436

437+
@Test
438+
void preHandle_runId_honouredOnlyWithAutomationHeader() throws Exception {
439+
// An internal dispatch carries BOTH X-Stirling-Automation and X-Stirling-Run-Id, so the
440+
// run id flows onto the ChargeContext (sub-steps of one run group into a single charge).
441+
authenticateWithApiKey(makeUser(7L, 42L));
442+
UUID jobId = UUID.randomUUID();
443+
when(chargeService.openProcess(any(), anyList()))
444+
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
445+
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
446+
org.mockito.ArgumentCaptor.forClass(
447+
stirling.software.saas.payg.charge.ChargeContext.class);
448+
449+
MockMultipartHttpServletRequest req = newMultipart();
450+
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
451+
req.addHeader("X-Stirling-Automation", "true");
452+
req.addHeader("X-Stirling-Run-Id", "run-abc");
453+
454+
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController());
455+
456+
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
457+
assertThat(ctxCaptor.getValue().runId()).isEqualTo("run-abc");
458+
}
459+
460+
@Test
461+
void preHandle_runIdWithoutAutomationHeader_isIgnored() throws Exception {
462+
// A raw external API call that sets X-Stirling-Run-Id on its own must NOT be able to group
463+
// charges — the run id is dropped so each call stays its own charge ("charge per API
464+
// call").
465+
authenticateWithApiKey(makeUser(7L, 42L));
466+
UUID jobId = UUID.randomUUID();
467+
when(chargeService.openProcess(any(), anyList()))
468+
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
469+
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
470+
org.mockito.ArgumentCaptor.forClass(
471+
stirling.software.saas.payg.charge.ChargeContext.class);
472+
473+
MockMultipartHttpServletRequest req = newMultipart();
474+
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
475+
req.addHeader("X-Stirling-Run-Id", "run-spoofed");
476+
477+
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController());
478+
479+
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
480+
assertThat(ctxCaptor.getValue().runId()).isNull();
481+
}
482+
437483
// --- BillingCategory categorisation + bypass fast-path -------------------------------------
438484

439485
@Test

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6483,7 +6483,8 @@ segmentApiLabel = "API"
64836483
segmentAutomationDesc = "Automations & pipelines"
64846484
segmentAutomationLabel = "Automation"
64856485
sizeMultiplier = "{{formatted}} PDFs used a size multiplier"
6486-
summary = "{{unique}} unique · {{units}} meter units · {{avg}} avg units/PDF"
6486+
summary = "{{unique}} unique · {{units}} meter units · {{avg}} avg per PDF"
6487+
summaryNoRate = "{{unique}} unique · {{units}} meter units"
64876488
unit = "PDFs"
64886489

64896490
[portal.billing.spendLimit]

frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import { useTranslation } from "react-i18next";
22
import { Card } from "@app/ui";
3+
import { formatMinor } from "@app/billing";
34
import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing";
45
import type { LocalUsage } from "@portal/api/link";
56

67
/**
7-
* "PDFs processed this period" headline + a stacked split of where the metered
8-
* PDFs went. The split reuses the wallet's existing {@code categoryBreakdown}
9-
* (API / Agents / Automation — the same buckets the entitlement service tracks;
10-
* the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when
11-
* nothing metered has run yet.
8+
* "PDFs processed this period" headline (the input-file count) plus a summary line
9+
* separating that count from the size-scaled meter units, and a stacked split of
10+
* where the PDFs went by category (API / Agents / Automation — the "AI" bucket
11+
* surfaces as "Agents" here), driven by the wallet's per-category {@code categoryDocs}
12+
* counts. Real data only: everything hides when nothing has run this period.
1213
*
13-
* <p>When a linked instance has accrued usage SaaS hasn't billed yet ({@code
14-
* unsynced}), it's folded into the headline + split so "current usage" reflects
15-
* work done since the last daily sync. The synced-vs-pending split is an internal
16-
* detail the customer doesn't need, so it's not surfaced — just the combined total.
14+
* <p>Instance-local usage a linked instance has accrued but SaaS hasn't billed yet
15+
* ({@code unsynced}) is units-only, so it folds into the meter-units figure (and the
16+
* avg-per-PDF that derives from it) but NOT the PDF count or the per-category split,
17+
* which reflect synced processing. The synced-vs-pending distinction is an internal
18+
* detail, so only the combined unit total is surfaced.
1719
*/
1820
const SEGMENTS: ReadonlyArray<{
1921
key: keyof WalletCategoryBreakdown;
@@ -70,7 +72,18 @@ export function PdfsProcessedCard({
7072
// API"); units are surfaced in the aggregate summary line, not per bucket.
7173
const perDocs: WalletCategoryBreakdown = wallet.categoryDocs;
7274
const totalDocs = perDocs.api + perDocs.ai + perDocs.automation;
73-
const avgUnitsPerPdf = docs > 0 ? meterUnits / docs : 0;
75+
76+
// Average cost per PDF in minor currency units — meter units × the per-unit rate,
77+
// spread over the input files processed. Shown only when the rate is known (free-tier
78+
// and unknown-price snapshots omit the term rather than imply $0.00).
79+
const rate = wallet.pricePerDocMinor;
80+
const showAvgCost = docs > 0 && rate != null;
81+
const avgCostMinor =
82+
rate != null && docs > 0 ? (meterUnits / docs) * rate : 0;
83+
84+
// Something ran once there are either counted PDFs or metered units (instance-local
85+
// unsynced usage is units-only, so it keeps the card out of the empty state).
86+
const hasActivity = docs > 0 || meterUnits > 0;
7487

7588
return (
7689
<Card padding="loose">
@@ -87,18 +100,27 @@ export function PdfsProcessedCard({
87100
</span>
88101
</div>
89102

90-
{docs > 0 ? (
103+
{hasActivity ? (
91104
<>
92105
<p className="portal-billing__section-sub">
93-
{t(
94-
"portal.billing.pdfsProcessed.summary",
95-
"{{unique}} unique · {{units}} meter units · {{avg}} avg units/PDF",
96-
{
97-
unique: uniquePdfs.toLocaleString(),
98-
units: meterUnits.toLocaleString(),
99-
avg: avgUnitsPerPdf.toFixed(1),
100-
},
101-
)}
106+
{showAvgCost
107+
? t(
108+
"portal.billing.pdfsProcessed.summary",
109+
"{{unique}} unique · {{units}} meter units · {{avg}} avg per PDF",
110+
{
111+
unique: uniquePdfs.toLocaleString(),
112+
units: meterUnits.toLocaleString(),
113+
avg: formatMinor(avgCostMinor, wallet.currency),
114+
},
115+
)
116+
: t(
117+
"portal.billing.pdfsProcessed.summaryNoRate",
118+
"{{unique}} unique · {{units}} meter units",
119+
{
120+
unique: uniquePdfs.toLocaleString(),
121+
units: meterUnits.toLocaleString(),
122+
},
123+
)}
102124
</p>
103125
{totalDocs > 0 ? (
104126
<>

0 commit comments

Comments
 (0)