Reworks the Processor (PAYG) meter to **size-scaled units** while
keeping a true **PDF count** visible and distinct from units, and
replaces the fragile content+time lineage grouping with **explicit
per-run grouping**. Built as one PR across three slices.
> Status: **all three slices committed + verified.** `:saas` payg suite
green (418 tests, 0 failures); FE green (typecheck 0, 1260 tests, lint
0, format 0). Remaining before it takes effect in prod: run the
size-scaled default-policy SQL (below) in the Supabase SQL editor +
attach the $0.01/unit Stripe price.
## Model (what we're implementing)
- **Size scaling**: 1 unit per 50 MiB (bytes only, no page charge, no
cap). *(policy-row config, applied separately via SQL.)*
- **Charge = number of input files**: split (1→N outputs) = 1 charge;
merge (N→1) = N charges. `doc_count` = input files, fixed at open;
joined steps add 0.
- **Grouping by run id, not time**: a pipeline/policy/AI run = one
`run_id`; its tool sub-steps group into one charge (content-lineage
still maps split/merge journeys *within* the run). Two separate runs on
identical bytes = two charges. The 5-min window survives only as a
stale-job janitor.
- **10-tool split kept**: within a run's single-file lineage, an 11th
tool run opens a 2nd charge (step limit 10).
- **Count vs units surfaced**: usage page shows unique PDFs,
per-category (automation/AI/API) counts + units, and how many PDFs hit a
size multiplier with avg units/PDF.
## Slice 1 — run-id grouping (behavioural core)
- `AutomationRunContext` (common) — thread-scoped run id.
- `InternalApiClient` — stamps `X-Stirling-Run-Id`.
- Orchestrators open a run scope **on the worker thread that
dispatches** (async-safe): `PipelineProcessor.runPipelineAgainstFiles`,
`PolicyEngine.runToCompletion` (uses `run.getRunId()`),
`AiWorkflowService.orchestrate`.
- `ChargeContext` + `JobContext`: add `runId`; the charge interceptor
reads the header.
- `JobService.joinOrOpen`: `runId == null` → always open fresh
(standalone never joins); non-null → match scoped to the same `run_id`.
`JpaJobLineageStore`/`JobArtifactHashRepository`: add `run_id` filter to
the match query. Step-limit 10 unchanged.
## Slice 2 — doc_count + document_fingerprint
- V33 migration + entity fields.
- `JobService.openFresh`: set `docCount = inputs.size()`, compute
`document_fingerprint` from input signatures, and denormalise both onto
the DEBIT row in `JobChargeService.recordLedgerDebit`.
## Slice 3 — usage analytics API + FE
- `WalletLedgerRepository`: per-category `SUM(units)` +
`SUM(doc_count)`, `COUNT(DISTINCT document_fingerprint)`, and count of
rows whose units exceed their doc_count (a size multiplier fired), over
the period.
- `WalletSnapshotResponse` + `PaygWalletController`: add `categoryDocs`,
`docsProcessedThisPeriod`, `uniquePdfsThisPeriod`,
`sizeMultiplierPdfsThisPeriod`.
- FE `types.ts` + `PdfsProcessedCard` + `useWallet` + `walletFixtures` +
i18n: headline is the **PDF count**; a summary line shows "{unique}
unique · {units} meter units · {avg} avg units/PDF"; the split bar is
per-category PDF counts; a size-multiplier line shows how many PDFs
scaled. Count is separated from meter units so a 5-unit large PDF reads
as "1 PDF, 5 units".
## Config (out of PR — run in the Supabase SQL editor)
Wrap in one transaction. The partial-unique `is_default` index only
allows one default, so the old default is flipped off **before** the new
one is inserted. The new policy carries the prior default's
`free_tier_units` forward (change the literal if the launch grant should
differ).
```sql
BEGIN;
-- 1) flip default off the current policy + close its effective window
UPDATE stirling_pdf.pricing_policy
SET is_default = FALSE, effective_to = now()
WHERE is_default = TRUE;
-- 2) new default: 1 unit / 5 MiB, no page charge, no scaling cap.
-- free_tier_units carried from whatever the last policy granted (COALESCE→0).
INSERT INTO stirling_pdf.pricing_policy
(version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
min_charge_units, file_unit_cap, free_tier_units, is_default, notes, created_by)
VALUES
('v2-size-scaled-2026-07', now(),
2147483647, -- doc_pages_per_unit = INT_MAX → pages never drive units
52428800, -- doc_bytes_per_unit = 50 MiB → +1 unit per 50 MiB
1, -- min_charge_units
2147483647, -- file_unit_cap = INT_MAX → no cap on size scaling
COALESCE((SELECT free_tier_units FROM stirling_pdf.pricing_policy
ORDER BY effective_from DESC LIMIT 1), 0),
TRUE, 'Size-scaled: 1 unit/5MiB, bytes only, no cap', 'connor');
-- 3) per-source step limits: standalone ops = own charge; pipelines split at 10
INSERT INTO stirling_pdf.pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, s.src, s.lim
FROM stirling_pdf.pricing_policy p
CROSS JOIN (VALUES
('WEB',1),('API',1),('DESKTOP_APP',1),('LINKED_INSTANCE',1),('PIPELINE',10)
) AS s(src, lim)
WHERE p.version = 'v2-size-scaled-2026-07';
-- 4) attach the $0.01/unit Stripe price (you handle the real price id)
INSERT INTO stirling_pdf.pricing_policy_stripe_price (policy_id, stripe_price_id)
SELECT policy_id, 'price_XXXXXXXX'
FROM stirling_pdf.pricing_policy WHERE version = 'v2-size-scaled-2026-07';
COMMIT;
```
Note: the `free_tier_units` subquery reads the most-recent policy
*before* the insert — run it as written (the new row doesn't exist yet
at step 2's SELECT).
## Self-hosted parity — tracked follow-up (not in this PR)
Combined-billing (`stirling.billing.account-link.enabled`) is a
**separate metering engine** (`app/proprietary/accountlink` —
`UsageMeterService`/`LocalUsageService`/`UsageSyncService`). The unit
*math* is shared (`DocumentUnitCalculator`), so size scaling matches
once the policy is pushed. But run-id grouping, `doc_count`, and
fingerprints must be mirrored there, and the usage-sync protocol
extended to report counts/fingerprints, before the self-hosted usage
page shows the same breakdown. Frozen/deferred, so this PR does SaaS;
self-hosted mirrors when it ships.
130 lines
5.3 KiB
TypeScript
130 lines
5.3 KiB
TypeScript
/**
|
|
* saas (web) implementation of the @app/hooks/walletDevPreview seam.
|
|
*
|
|
* Houses the PAYG dev-preview side-channel that {@code useWallet} used to carry
|
|
* inline. It synthesises a wallet snapshot from {@code localStorage} when the
|
|
* hook is rendered outside the real saas app (the {@code /dev/payg-preview}
|
|
* route during local design work), where {@code AppConfigContext} is not mounted
|
|
* and no backend is available. This is the only place the banned-in-cloud reads
|
|
* ({@code import.meta.env.DEV}, {@code window.location}, {@code localStorage})
|
|
* live — cloud reaches them through {@link getWalletDevPreview}.
|
|
*
|
|
* Behaviour preserved verbatim from the pre-move saas useWallet:
|
|
* - both {@code import.meta.env.DEV} AND a {@code /dev/} path are required, so a
|
|
* production tenant whose URL happens to start with {@code /dev/} can't hit
|
|
* the fallback;
|
|
* - subscription state is read from / written to {@code localStorage} so the
|
|
* modal's "mark subscribed" action survives a reload.
|
|
*/
|
|
import type { Wallet, WalletRole } from "@app/hooks/useWallet";
|
|
import type { WalletDevPreview } from "@cloud/hooks/walletDevPreview";
|
|
|
|
export type { WalletDevPreview } from "@cloud/hooks/walletDevPreview";
|
|
|
|
const STORAGE_KEY = "stirling.payg.devSubscription";
|
|
|
|
/**
|
|
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
|
|
* shape the backend returns. Subscription state comes from localStorage so
|
|
* the modal's "mark subscribed" action survives a reload.
|
|
*/
|
|
function buildDevPreviewWallet(role: WalletRole): Wallet {
|
|
const subscribed =
|
|
typeof window !== "undefined" &&
|
|
(() => {
|
|
try {
|
|
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
const now = new Date();
|
|
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
|
|
|
|
return {
|
|
teamId: null,
|
|
status: subscribed ? "subscribed" : "free",
|
|
role,
|
|
billingPeriodStart: isoDay(periodStart),
|
|
billingPeriodEnd: isoDay(periodEnd),
|
|
billableUsed: 62,
|
|
billableLimit: subscribed ? 1250 : 500,
|
|
freeAllowance: 500,
|
|
// One-time grant: a free team has used 62 of 500 (438 left); the dev
|
|
// subscribed team is shown with its grant fully spent (kept across the
|
|
// subscribe — it just no longer gates them).
|
|
freeRemaining: subscribed ? 0 : 438,
|
|
// Free teams also carry a rate now — the backend resolves it from the
|
|
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
|
|
// PDFs/month") can render before subscribing. Mirror that here.
|
|
pricePerDocMinor: 2,
|
|
currency: "usd",
|
|
estimatedBillMinor: subscribed ? 0 : null,
|
|
capUsd: subscribed ? 25 : null,
|
|
noCap: false,
|
|
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
|
|
spendUnitsThisPeriod: 62,
|
|
// Count dimension (illustrative): input files processed vs the size-scaled
|
|
// meter units above — a few large PDFs pushed some charges past 1 unit.
|
|
docsProcessedThisPeriod: 50,
|
|
uniquePdfsThisPeriod: 48,
|
|
sizeMultiplierPdfsThisPeriod: 8,
|
|
categoryDocs: { api: 18, ai: 14, automation: 18 },
|
|
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
|
|
// hero panel can split AI / automation / API. Use realistic but
|
|
// tier-distinguishable mock values so the dev preview shows a
|
|
// different visual when the localStorage flip toggles subscribed.
|
|
categoryBreakdown: subscribed
|
|
? { api: 12, ai: 35, automation: 15 }
|
|
: { api: 5, ai: 40, automation: 17 },
|
|
// Members are populated in the leader view by the real backend
|
|
// (joining team_memberships); the dev preview returns an empty
|
|
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
|
|
// so empty members just hides the sub-caps card.
|
|
members: [],
|
|
// Activity feed is V1 = [], the backend ships this in Wave 2 once
|
|
// payg_meter_event_log is read-accessible from the wallet endpoint.
|
|
recent: [],
|
|
};
|
|
}
|
|
|
|
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
|
|
function isDevPreviewContext(): boolean {
|
|
// Both checks required: production builds drop the path check, so a real
|
|
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
|
|
// fallback.
|
|
if (!import.meta.env.DEV) return false;
|
|
if (typeof window === "undefined") return false;
|
|
return window.location.pathname.startsWith("/dev/");
|
|
}
|
|
|
|
/** Best-effort role read for dev preview — flips per query string ?role=member. */
|
|
function devPreviewRole(): WalletRole {
|
|
if (typeof window === "undefined") return "leader";
|
|
const url = new URL(window.location.href);
|
|
return url.searchParams.get("role") === "member" ? "member" : "leader";
|
|
}
|
|
|
|
/**
|
|
* Resolve the active dev-preview side-channel, or {@code null} when we're in a
|
|
* real build / on a real route (the common case). Both {@code import.meta.env.DEV}
|
|
* and a {@code /dev/} path must hold.
|
|
*/
|
|
export function getWalletDevPreview(): WalletDevPreview | null {
|
|
if (!isDevPreviewContext()) return null;
|
|
return {
|
|
buildWallet: buildDevPreviewWallet,
|
|
role: devPreviewRole,
|
|
markSubscribed: () => {
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, "subscribed");
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
},
|
|
};
|
|
}
|