mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
3056e5ff44e6ef6e4f89cfb608629ff9e8bf6f53
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3056e5ff44 |
Reset the PAYG free grant each billing period (#7709)
Needs the schema half: Stirling-Tools/Stirling-PDF-SaaS#327 ## Current state The PAYG free allowance is a one-time lifetime pool. `pricing_policy.free_tier_units` is copied into `payg_team_extensions.free_units_remaining` once, at team creation (V14 trigger, updated in V19), and the charge pipeline decrements it until it reaches zero. Nothing ever puts it back. ## Problem The product promises a monthly allowance the billing model does not grant. - The account-link connect dialog advertises "500 free per month". That has **merged to main** (#7415), so the claim is live and unhonoured until this lands. - The wallet meter already read "Process 500 PDFs free, then $X/PDF", which reads as an allowance-then-meter model. - `SignupRequiredBootstrap`'s own doc comment described a "free 500-op/month allowance" while its copy said only "500 free operations". Three separate comments asserted the opposite in code (`billing/types.ts`, `WalletSnapshotResponse`, `TeamBillingContext`), so the two halves of the repo disagreed about what a customer is owed. ## Solution The grant now recurs each billing period, **for every team**. Paying does not cost you the allowance: a subscribed team draws its grant first each period and meters only the excess, which is what the meter's copy always described. That also matches how the grant already worked at charge time, where it reduced metered units regardless of subscription. ### The reset is lazy, with no scheduler `payg_team_extensions` gains `free_units_period_start`: the period `free_units_remaining` was last written for. - A stamp older than the current period start, or absent as on every existing row, means the reset is owed. `TeamBillingService.remainingForPeriod` projects it to a full grant, so the entitlement gate and the wallet both show it the instant the period turns. - `JobChargeService.consumeFreeGrant` persists it on the next charge, under the pessimistic row lock that already makes the per-job free/paid split exact. One rule, both callers, so display and enforcement cannot drift onto separate schedules. A team that runs nothing for a month has nothing to write, and no job is needed to hand out the grant. ### One period definition "Per period" is `TeamBillingContext.periodStart`: the Stripe subscription's current period when subscribed, the calendar month otherwise. It was already the only period notion in the system, so the grant joined it rather than inventing its own: - `InstanceEntitlement.periodCapUnits` is enforced over the same window. - `localUsageService.currentPeriodUnsynced` already buckets a linked instance's local usage by the `periodStart` it reads from the same snapshot, and resets its counters on that boundary. For an un-subscribed team, the only kind the grant gates, that window is the calendar month, which is what the copy promises. The period rule stays in Java by choice, not necessity: SQL could reach the Stripe period through the sync engine, but restating the rule there would give it a second home to drift from. Hence a nullable column and no backfill in the migration — NULL already means "stale", so every existing team reads as owed the current period's grant. ### Refunds A refund landing after the period turned would have stacked last period's units on top of the fresh grant. `JobChargeService.restoreFreeGrant` now clamps the restore to one period's grant, taking the same row lock, and the bulk-increment `restoreFreeUnits` query is gone. Removing it also removed a `@Query` string that no test would have parsed before application startup. ### Copy and comments Every comment and user-facing string that asserted the lifetime model is corrected. The strings that changed (code defaults and `en-US` TOML updated together): | Key | Now reads | | --- | --- | | `portal.billing.walletMeter.title` / `titleWithRate` | "500 free credits every month, then $X per PDF" | | `portal.billing.walletMeter.capSuffix` / `barAria` | "of 500 free credits left this month" / "Free credits remaining" | | `payg.free.hero.capSuffix` | "of 500 free PDFs left this month" | | `plan.freeLimit.message` | "...this month. ... It resets next month, or keep the momentum going now..." | | `payg.signupRequired.body` | "500 free operations a month" | Main rewrote these keys to "500 free credits to start" while this branch was open. The merge keeps main's credits vocabulary and drops "to start", which asserts the one-time grant this branch removes and which main's own connect dialog already contradicts. Also fixed in passing: `testing/compose/payg/saas-seed.sql` still inserted `free_tier_units_per_cycle`, the pre-V19 column name, so that INSERT had been failing since the rename. ## How to test Backend: ```bash STIRLING_FLAVOR=saas ./gradlew :saas:test spotlessCheck ``` Frontend: ```bash task frontend:typecheck && task frontend:lint && task frontend:format:check ``` New coverage, 10 tests: - `TeamBillingServiceMoreTest` — a past-period stamp reads as a fresh grant, a current stamp reads the stored balance, an unstamped row reads as a fresh grant, the grant follows the Stripe window rather than the calendar month, plus the `remainingForPeriod` rule itself including a future stamp and null/negative balances. - `JobChargeServiceTest` — the first charge of a new period resets and re-stamps, an unstamped row resets, a zero-grant policy still advances the stamp, and a refund crossing a period boundary does not exceed the grant. Manually, against a team whose grant is spent: set `free_units_period_start` back a month (or leave it NULL) and the wallet, the sidebar meter and the entitlement gate should all show a full grant before any job runs. The first billable job should then draw from it and write the reset. Three tests fail on a local Windows run and pass in CI, on files this branch does not touch: `workbenchSession.test.ts`, `notificationActions.test.tsx`, and `:proprietary` `FolderIdentitiesTest.identityAgreesAcrossASymlinkedAliasOfTheDirectory`. Nothing to do here — noted so a local run does not look like a regression. ## Merge order The migration is additive, and Hibernate `ddl-auto=update` will add the column in a dev environment, so either order works locally. Beyond that the schema goes first: Stirling-Tools/Stirling-PDF-SaaS#327 targets `v3` (staging), so it needs to reach an environment before this lands there. |
||
|
|
84aca12055 |
PR-S4: shadow-mode hardening (review follow-ups) (#6523)
## What this PR does Bundles the **low-risk polish items** from the [multi-agent review of #6519](https://github.com/Stirling-Tools/Stirling-PDF/pull/6519). Each change is independent, mechanical, and ships with focused unit-test coverage. The medium-severity items (\`?async=true\` OUTPUT recording, JSON-consumes endpoint coverage, SpringBootTest harness) are tracked separately in [\`notes/PAYG_DESIGN.md\` §7.5 PR-S4](https://github.com/Stirling-Tools/Stirling-PDF/blob/payg-s4-hardening/notes/PAYG_DESIGN.md) — they need design decisions + bigger infrastructure work, so this PR sticks to the mechanical wins. Stacked on #6519. When that merges to main, this rebases cleanly — no code changes. ## Changes | Area | What | Why | |---|---|---| | **\`tool_id\` becomes route pattern** | \`PaygChargeInterceptor.resolveToolId()\` prefers \`HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE\` over \`request.getRequestURI()\`. Truncates to 128 + WARN log + \`payg.filter.errors\` increment when truncation fires. | Audit rollups aggregate by endpoint instead of by every individual request's path-variable / matrix-param variant. Silent truncation now louder. | | **Direct PDF magic-byte check** | \`PaygOutputExtractor.extract()\` magic-checks the body even for direct \`application/pdf\` responses. | Asymmetric with the ZIP-entry path which always magic-checks. A tool that emits \`application/pdf\` for a JSON / HTML payload would otherwise pollute \`job_artifact_hash\`. | | **DESKTOP_APP detection** | \`X-Stirling-Client: desktop\` header → \`JobSource.DESKTOP_APP\`. | The enum value was unreachable from \`determineSource()\`; Tauri shell traffic was mis-classified as WEB. No anti-spoof — V12 step limits are identical for WEB/DESKTOP_APP so the worst-case abuse value is zero today. | | **\`max-bytes\` sensible default** | 500 MiB instead of \`null\` (unbounded). | Covers the largest realistic Stirling responses (full split-to-ZIP on a 1000-page document) while preventing pathological cases from tying up the interceptor for minutes. Set to \`null\` to disable. | | **\`BufferedOutputStream\` for spill** | Wraps the spill \`OutputStream\` in 64 KiB \`BufferedOutputStream\`. | Previously every Tomcat chunk (default 8 KiB) was a separate syscall. Big spilled responses get a syscall-bound speedup. | | **Duration timer per phase** | \`payg.filter.duration\` tagged \`phase=preHandle\` vs \`phase=afterCompletion\`. | Two distinct latency distributions were blended into one histogram; hard to alert on. | ## Tests | Test | What it covers | |---|---| | \`PaygOutputExtractorTest.pdfContentType_butBodyMissingPdfMagic_returnsEmpty\` | New direct-PDF magic-byte gate. | | \`PaygChargeInterceptorTest.preHandle_desktopClientHeader_setsJobSourceDesktopApp\` | New \`X-Stirling-Client: desktop\` → DESKTOP_APP path. | | \`PaygChargeInterceptorTest.preHandle_toolId_prefersBestMatchingPattern\` | Route pattern wins over URI when both are set. | | \`PaygChargeInterceptorTest.preHandle_toolId_truncatesAndCountsWhenLongerThan128\` | Oversized values truncate + increment errors counter. | Full saas suite green (210 tests), coverage targets met. ## What's NOT in this PR (deliberately) - **\`?async=true\` OUTPUT recording.** The JobExecutorService returns a synchronous \`JobResponse{jobId}\` body before the async tool actually runs; \`afterCompletion\` fires too early. Needs a design decision: short-circuit PAYG when \`async=true\` OR hook into \`TaskManager\` completion. Tracked in PR-S4 design doc. - **JSON-consumes endpoint coverage.** The \`MultipartHttpServletRequest\` cast skips endpoints with \`consumes = APPLICATION_JSON_VALUE\` (e.g. \`ConvertPdfJsonController.exportPartialPdf\`). Fix is either extract a request-body hash for JSON or add a CI lint forbidding non-multipart \`@AutoJobPostMapping\`. Design discussion needed. - **SpringBootTest harness for filter + interceptor wiring.** Saas module doesn't have one yet. Separate work — PR-S3 takes a different approach (docker-compose + Behave); a SpringBootTest layer would be additive in-process coverage. These are tracked in \`notes/PAYG_DESIGN.md §7.5\` so they don't slip. ## Tracked in \`notes/PAYG_DESIGN.md\` §7.5 PR-S4. |
||
|
|
ff96a80947 |
PAYG B-3 / S-3: cucumber suite for shadow-mode flows + CI workflow (#6522)
## What this PR is End-to-end cucumber coverage for the PAYG shadow charging engine (the filter + interceptor stack from #6519), wired into CI via a new `docker-compose-tests-saas.yml` workflow that runs only on PAYG-touching PRs. Stacked on #6519. ## Automated scenarios (run by `docker-compose-tests-saas.yml`) See [`testing/cucumber/features/payg/shadow_charges.feature`](../tree/payg-s3-cucumber/testing/cucumber/features/payg/shadow_charges.feature): | Scenario | Validates | |---|---| | First tool call writes a CHARGED row | Filter + interceptor fire end-to-end | | Lineage join — second call on output | `JobService.joinOrOpen` matching; no new shadow row | | 4xx leaves the row CHARGED | "Customer paid for the attempt" semantics | | ZIP-returning tool records per-PDF OUTPUT | `PaygOutputExtractor` unpacks + records signatures | | Multi-file input writes a single shadow row | Multi-input group sizing | | `X-Stirling-Automation` sets PIPELINE source | Header → `JobSource` detection | All 6 run locally via `./testing/test-payg.sh` and will run on CI for any PR that touches `app/saas/**`, the PAYG cucumber features, the saas compose stack, or the workflow itself. ## Manual-only scenarios — documented in design doc, not in this suite Two parts of the shadow engine are deliberately not automated; the engine paths are unit-tested in `PaygChargeInterceptorTest.afterCompletion_5xx_opened_*`, and the manual procedures (which require a temporary throw endpoint or a container restart with a flag flipped) live in [`notes/PAYG_DESIGN.md` §7.5.2 "PAYG cucumber: manual-only scenarios"](../tree/payg-s3-cucumber/notes/PAYG_DESIGN.md). - **5xx first-step failure → REFUNDED + CLOSED.** No reliably-5xx-ing endpoint exists; manual procedure adds a throw endpoint, runs, asserts, removes. - **Kill-switch (`PAYG_FILTER_ENABLED=false`).** Needs a container restart mid-suite; manual procedure tears down, flips env, brings up, asserts zero shadow rows. If either gets a hot-reload path (test-only throw endpoint shipped behind a profile gate, or admin endpoint for the kill switch), automate it in a follow-up and drop the manual procedure. ## CI workflow `.github/workflows/docker-compose-tests-saas.yml` (new) — self-contained, not wired into `build.yml`'s `files-changed` matrix so the saas-cucumber job fails and succeeds independently. Triggers only on PAYG-relevant paths. No JaCoCo coverage in v1 (saas compose doesn't have the coverage override; can add later). ## Test infrastructure (recap) - **`testing/compose/docker-compose-saas.yml`** — Stirling-PDF backend with `STIRLING_FLAVOR=saas` + Postgres holding the `stirling_pdf` schema. Supabase JWT auto-config disabled; API-key auth via `SECURITY_CUSTOMGLOBALAPIKEY` is the live path the cucumber tests exercise. - **`testing/compose/payg/saas-init.sql`** + **`saas-seed.sql`** — schema bootstrap + idempotent seed (team / user / wallet_policy). - **`testing/cucumber/features/payg/shadow_charges.feature`** — the 6 scenarios above. - **`testing/cucumber/features/steps/payg_step_definitions.py`** — step defs using `requests` (HTTP) + `psycopg` (direct DB inspection). Direct DB reads are deliberate — we want to see the filter's side effects, not relay them through another API layer. - **`testing/test-payg.sh`** — companion runner to `testing/test.sh`. Brings up the saas compose, waits for health, seeds, runs behave, tears down. - **`behave.ini`** excludes `features/payg` from the default behave run (the saas-cucumber CI job invokes it explicitly). ## Why a separate harness from `testing/test.sh` The existing `test.sh` covers the proprietary-flavour stack (no PAYG tables, no saas profile). Coupling two CI matrices that fail and succeed independently into one script is asking for trouble. Keep the saas-cucumber job focused on its own concerns; once the harness is mature, the wider team can decide whether to merge them. ## Tracked in `notes/PAYG_DESIGN.md` §7.5 (PR-S3) + §7.5.2 (manual scenarios). |