# PDF2HTML SaaS MVP Specification

## Status

MVP product specification.

## Product rule

Do not design another PDF product. Wrap the existing visual-parity PDF-to-HTML converter in the smallest commercial workflow that can be sold and operated safely.

The canonical first-customer flow is:

```text
Register
  -> Buy credits with Stripe
  -> credit balance increases
  -> Upload PDF
  -> 1 credit is consumed when the conversion job is accepted
  -> Process
  -> Download ZIP
  -> Retrieve previous conversions later
```

**Payment happens before paid conversion work.** A user with `0` credits must not be able to start a conversion. The UI tells the user they are out of credits and provides a clear `Buy credits` CTA to Billing. The backend credit check/debit is authoritative; frontend gating is only a convenience.

The MVP exists to get the current converter in front of paying customers as quickly as possible. Anything not required for registration, buying credits, conversion, retrieval, or basic operation is out of scope.

## Launch commercial model

Use prepaid Stripe credits for MVP.

### Credit pricing

- billing provider: **Stripe**
- currency: **USD**
- **1 credit = $5.00**
- flat linear price with no bundles or discounts
- **1 credit = 1 accepted PDF conversion**
- user chooses the exact positive integer quantity of credits to buy
- examples:
  - 1 credit = $5
  - 2 credits = $10
  - 10 credits = $50
  - N credits = `N × $5`
- no subscription
- no recurring billing
- no trial
- no monthly allowance
- credits do not expire in MVP
- downloading/re-downloading an already completed conversion costs no credit

### Credit accounting rule

The application maintains a server-side credit balance using the existing platform ledger primitives.

For a normal conversion:

1. authenticate user
2. validate the PDF sufficiently to know a job may be accepted
3. atomically require `credit_balance >= 1`
4. atomically debit exactly `1` credit and create the conversion job
5. process the job
6. if conversion completes, the debit remains spent
7. if the converter, timeout, or packaging step fails, automatically restore exactly `1` application credit to the user's ledger

The restore on failure is an **application credit refund**, not a Stripe monetary refund.

Invalid/rejected uploads that never become an accepted conversion job consume no credit.

Repeated/double submission must not consume more than one credit for one accepted intended job.

### Credit purchase rule

Credits are granted only after verified successful Stripe payment state.

- Stripe Checkout is created server-side for the exact requested quantity
- server calculates total as `quantity × $5.00`; client-supplied total is never trusted
- successful Stripe payment grants exactly the purchased quantity
- canceled/failed payments grant `0` credits
- Checkout success redirect alone never grants credits
- signed Stripe webhook/payment verification is authoritative
- duplicate/replayed Stripe events must not grant credits twice
- multiple successful purchases add to the existing balance
- a verified refund, dispute, or chargeback creates an idempotent reversal ledger entry linked to the original grant
- spendable balance is exposed as `max(0, ledger net)` and deficit as `max(0, -ledger net)`; prior entries are never rewritten
- an account with any positive deficit cannot start new conversions until later valid grants are applied to the deficit or an audited operator adjustment resolves it
- completed outputs are not retroactively removed solely because a payment is reversed

This is the entire MVP commercial model.

## Confirmed launch decisions

- Delivery is ZIP-only. The service does not publicly host converted HTML in MVP.
- Accepted source PDFs expire at `source_expires_at = accepted_at + 48 hours`; generated ZIP packages expire at `zip_expires_at = completed_at + 48 hours`. Library rows remain as conversion history but show that an expired artifact is no longer downloadable.
- There is currently no customer-facing file-size or page-count limit. This does not remove mandatory internal safety controls: upload transport limits, converter CPU/memory/PID/time limits, output-size limits, and abuse throttles must be calibrated from measurements before production launch. Rejections caused by a safety ceiling consume no credit when detected before job acceptance, and restore the credit exactly once when reached during charged processing.
- Stripe Tax is required for launch. Sales are enabled only where Stripe Tax and the business registration/configuration are ready.

## Fixed deployment architecture

### Frontend

- Production domain: **`https://tools.press.zone`**.
- Production API domain: **`https://api.press.zone`**. Public DNS resolves through Cloudflare and HTTPS certificate verification succeeds; the currently observed root response is HTTP 404, so the CloudPanel application route and origin mapping still require deployment preflight.
- Hosted on Cloudflare.
- Application stack: **Astro + React + TypeScript**.
- UI built with `facebook/astryx`.
- Astro owns the application shell/routing/build; React is used for interactive application UI where needed.
- TypeScript is required for frontend application code.
- Communicates with the CloudPanel01 backend over HTTPS.
- Does not perform PDF conversion in the browser.
- The frontend must be deployable as a Cloudflare-hosted web application without introducing a separate frontend server.

### Backend

- Hosted on `cloudpanel01`.
- Uses existing `../platform/` modules rather than reimplementing shared primitives.
- Prefer the existing platform packages for:
  - `auth` / `auth-react` for authentication UI/session behavior
  - `billing` / `billing-react` with the Stripe adapter for payment integration
  - `ledger` for credit grants, debits, balances, and failure credit refunds
  - `uploads` / `uploads-react` for upload primitives
  - `jobs` for persisted/background job primitives where they fit the converter worker
  - `db` for persistence

### Stripe billing

Stripe is the only MVP billing provider and is used for **one-time credit purchases**.

The application needs:

- Stripe customer association for the authenticated user where useful
- one server-controlled unit price of **$5.00 USD per credit**
- server-created Stripe Checkout sessions for one-time payments
- Checkout quantity equal to the exact positive integer number of credits selected by the user
- server-calculated amount equal to `quantity × $5.00`
- a Stripe webhook endpoint with signature verification
- idempotent mapping from verified successful payment to one platform-ledger credit grant
- payment history data sufficient for the Billing page
- no subscription product
- no recurring invoice logic
- no custom card form when Stripe-hosted Checkout can complete the purchase

Never expose a Stripe secret/restricted key to the frontend.

### Conversion worker

- Runs on `cloudpanel01`.
- Runs separately from the web/API process, even though both are on the same server.
- Performs the PDF-to-HTML conversion in an isolated process/container.
- The API must not execute long PDF conversions synchronously inside a web request.

### Storage

The backend manages:

- uploaded source PDFs
- generated HTML package files
- generated ZIP downloads
- conversion metadata

For MVP, use private S3-compatible object storage through the existing platform upload/storage adapter seam. Source PDFs and generated ZIPs receive a 48-hour expiry. Lifecycle deletion is the primary mechanism and an application cleanup job reconciles missed deletions. Downloads require an authenticated ownership check before issuing a short-lived signed download URL.

## System shape

```text
Browser
  |
  v
Cloudflare frontend
  tools.press.zone
  Astro + React + TypeScript
  |
  | HTTPS API
  v
CloudPanel01
  +-- SaaS API/backend
  |    +-- platform auth
  |    +-- platform billing -> Stripe
  |    +-- platform ledger / credit balance
  |    +-- platform uploads/jobs/db
  |    +-- authenticated download endpoints
  |
  +-- conversion worker/container
  |       |
  |       v
  |    PDF -> HTML package -> ZIP
  |
  +-- private file/object storage

Stripe
  |
  +-- one-time Checkout
  +-- signed webhooks -> CloudPanel01 backend -> credit ledger
```

## Existing converter

The SaaS wraps the converter already being developed in this repository.

The current visual-parity POC uses `pdf2htmlEX` and demonstrates browser-native fixed-layout output with:

- positioned DOM text
- extracted web fonts
- CSS
- image/background assets
- no embedded PDF viewer
- no canvas-based PDF rendering

Manual inspection found close visual resemblance for the five-page catalog sample, but no reproducible parity threshold has been measured. The sample uses page-sized raster backgrounds, includes rasterized text in those backgrounds, and does not demonstrate browser-native links or semantic structure. These are Phase 0 gaps, not production capabilities. Production output must reconstruct text as DOM and may use page-wide raster artwork only when it represents non-text artwork that cannot be preserved as bounded image/vector assets; it must not use a full-page screenshot or rasterized text as the visible page representation.

The longer-term production converter architecture remains defined in `pdf-to-html-visual-parity-poc.md`:

- low-level visual extraction, such as MuPDF
- semantic extraction, such as Docling
- alignment of semantic structure with visual geometry

The SaaS interface must not depend on the internal converter implementation. The backend submits a conversion job and receives a generated package.

Before application implementation depends on it, the converter must expose a versioned contract containing deterministic inputs, manifest fields, exit/failure codes, cancellation behavior, and package validation results. The POC container/image is not automatically the production converter artifact.

Generated HTML is untrusted derived content. Before packaging, the worker must validate and sanitize relative paths, URL schemes, links, scripts, styles, fonts, asset references, file count, file sizes, total output size, and ZIP structure. Packages must not contain traversal paths, absolute paths, symlinks, device files, `javascript:` URLs, or unapproved active content. A restrictive default CSP must accompany the generated package.

## Generated package

The target downloadable result is a ZIP containing the converted document and all required assets.

Target structure:

```text
output/
  index.html
  document.css
  assets/
    fonts/
    images/
    vectors/
  metadata.json
```

The exact internal layout may initially match the current converter output while the conversion pipeline is still being finalized.

# Route and access model

No separate marketing-site product is required for MVP.

Canonical application routing on `https://tools.press.zone`:

```text
/
  anonymous      -> /login
  authenticated  -> /app/upload
```

After successful registration, route to `/app/billing` because the new account starts with `0` credits.

Route classes:

- public: `https://tools.press.zone/login`, `https://tools.press.zone/register`
- authenticated: `https://tools.press.zone/app/upload`, `https://tools.press.zone/app/library`, `https://tools.press.zone/app/billing`
- admin-only: `https://tools.press.zone/admin`, `https://tools.press.zone/admin/jobs`, `https://tools.press.zone/admin/jobs/:id`, `https://tools.press.zone/admin/users`
- Stripe-hosted external surface: one-time Checkout

All authenticated routes require a valid session. A user may view Upload and Library with `0` credits, but the backend must atomically require and debit one credit before creating a new conversion job.

# UI shell

## Authenticated customer shell

Every customer application page uses one compact shell.

Header/navigation contains:

- product name/logo
- Upload
- Library
- Billing
- signed-in email or account menu
- Logout

Rules:

- Upload is the primary destination for authenticated users; its state is determined by current credit balance.
- The shell may show the current credit balance compactly; Billing is the place to buy more credits.
- No dashboard/home page is added; it would duplicate Upload/Library/Billing.
- No Settings page is added for MVP.
- Logout ends the local session and returns to Login.

## Admin shell

Admin routes use a compact operational shell with:

- Overview
- Jobs
- Users
- link back to customer application
- signed-in admin identity/logout

Admin UI is not a second product and must not recreate Stripe Dashboard.

# Customer UI pages

## Page C1 — Login (`/login`)

### Purpose

Authenticate an existing account.

### Entry conditions

Public. An already authenticated user is redirected to Upload.

### Required UI

- product name
- page title: `Sign in`
- email field
- password/auth control required by the platform auth flow
- primary `Sign in` action
- `Create account` link to Register
- inline form error area

### States

- idle
- submitting: disable duplicate submission and show progress on the primary action
- invalid credentials: stay on Login and show a generic authentication error
- network/server error: stay on Login with retryable error
- success: redirect to Upload; the Upload page renders according to current credit balance

### Not present

- social login unless already required by platform configuration
- onboarding wizard
- product dashboard

## Page C2 — Register (`/register`)

### Purpose

Create the minimum account needed to purchase access.

### Required UI

- product name
- page title: `Create account`
- email field
- password/auth fields required by platform auth
- primary `Create account` action
- `Already have an account? Sign in` link
- inline validation/error area

### States and behavior

- invalid field data: errors remain local to the affected form
- duplicate/existing email: show platform-safe error and link to Login
- submitting: prevent duplicate account creation
- successful registration: create/login session and redirect directly to Billing

**A newly registered user starts with `0` credits.** Registration redirects to Billing so the user can buy credits before starting the first conversion.

## Page C3 — Billing (`/app/billing`)

### Purpose

Show credit balance, let the user buy an exact number of credits with Stripe, and show payment history.

### Required UI

- title: `Billing`
- prominent current credit balance, e.g. `3 credits`
- unit price: `$5 per credit`
- positive-integer quantity input/stepper labeled `Credits to buy`
- live total display calculated as `quantity × $5`
- primary `Buy credits` action
- payment history list
- `Convert a PDF` navigation action when balance is greater than `0`

Examples the UI must calculate correctly:

```text
1 credit  -> $5
2 credits -> $10
10 credits -> $50
N credits -> N × $5
```

Do not present bundles, discounts, tiers, subscriptions, or recurring pricing.

### Quantity behavior

- minimum valid quantity: `1`
- quantity must be a positive integer
- user may type/select the exact desired quantity
- fractional, zero, negative, empty, NaN, or otherwise invalid values cannot start Checkout
- client displays the total for convenience
- backend independently validates quantity and calculates the authoritative total
- no arbitrary product bundle sizes are imposed; only Stripe/provider technical limits may constrain a purchase

### Sales jurisdiction and Stripe Tax

- the application maintains a server-side allowlist of launch jurisdictions with the required Stripe Tax registrations/configuration
- Billing collects the customer's billing country/jurisdiction before Checkout; the backend rejects Checkout creation when it is missing or not enabled
- Checkout requires authoritative billing-address collection and Stripe automatic tax
- the verified Checkout/payment tax and customer-location state is persisted with the purchase
- credits are granted only when the verified paid Checkout remains eligible for a configured launch jurisdiction; mismatches enter a reconciliation/refund state rather than granting credits silently
- the client cannot select an unconfigured jurisdiction or override tax eligibility

### Buy credits action

1. user chooses quantity and supplies required billing-jurisdiction data
2. frontend sends the requested integer quantity and jurisdiction input to backend
3. backend validates quantity and launch-jurisdiction eligibility
4. backend calculates `quantity × $5.00`
5. backend creates a one-time Stripe Checkout session with automatic tax and required billing-address collection, associated with the authenticated user and intended credit quantity
6. browser redirects to Stripe Checkout
7. Stripe handles payment details
8. Stripe sends signed payment/webhook state to backend
9. backend verifies/idempotently processes the successful payment
10. backend grants exactly the purchased quantity to the user's platform credit ledger
11. browser returns to Billing
12. Billing refetches authoritative balance/payment history
13. user can click `Convert a PDF`

### Checkout return states

Support:

- `Payment successful` after the ledger grant is visible
- `Confirming payment…` if browser returns before webhook/payment reconciliation has granted credits
- `Payment canceled` when user cancels Checkout
- `Payment failed` when payment does not succeed
- billing service unavailable/retryable error

`?checkout=success` or equivalent is only a presentation hint. It must never directly increment the balance.

### Existing-credit state

A user may buy more credits at any time, including while the balance is already greater than zero.

Successful purchases are additive:

```text
existing balance 3
buy 10
verified payment
new balance 13
```

### Payment history

Required compact list:

- date
- credits purchased
- amount paid
- payment status
- Stripe receipt/invoice/reference link when the existing integration provides one

Only successful purchases affect credit balance.

No custom accounting dashboard is required.

## Page C4 — Upload / Conversion (`/app/upload`)

### Purpose

Create one HTML package from one PDF for exactly one credit.

### Entry conditions

- authenticated session required
- page itself remains accessible with any credit balance
- current balance is fetched from backend
- backend atomically checks/debits one credit when conversion job creation is requested

### Zero-credit state

If `credit_balance == 0`, the page must clearly show:

- title: `Convert PDF`
- `You're out of credits`
- concise text: `Each PDF conversion costs 1 credit ($5).`
- primary `Buy credits` CTA -> Billing
- link to Library

The conversion submit action is disabled/unavailable.

The user must not have to discover the failure only after a long upload/process attempt.

### Ready state (`credit_balance >= 1`)

Required UI:

- title: `Convert PDF`
- current balance, e.g. `3 credits available`
- cost disclosure: `1 credit per conversion`
- PDF drop area/file picker
- accepted type: PDF only
- no customer-facing file-size or page-count limit is currently advertised
- if an operational safety ceiling rejects a file, show the measured rejection reason without exposing infrastructure details
- selected filename and file size after selection
- primary `Convert PDF — 1 credit` action
- link/navigation to Library

No conversion settings are shown for MVP unless the converter literally cannot run without a user choice.

### Client validation states

Before upload:

- no file selected -> Convert disabled
- non-PDF -> reject with `Choose a PDF file`
- file rejected by a configured operational safety ceiling -> reject with a clear limit message

Client-rejected files consume no credit. Server validation remains authoritative.

### Uploading / acceptance state

The implementation may upload before final job acceptance, but **credit is not consumed until the backend is ready to accept/create the conversion job**.

Show:

- selected filename
- `Uploading`
- upload progress only if existing upload primitives expose it cheaply
- duplicate submission disabled

If upload fails before job acceptance, consume `0` credits and offer Retry.

### Atomic credit debit and job creation

For an otherwise valid submission:

1. backend rechecks current balance
2. atomically require at least `1` credit
3. atomically debit exactly `1` credit and create one conversion job
4. return job id/status and updated balance

The debit and job creation must be protected against double-clicks, retries, multiple tabs, and concurrent requests.

If another request consumed the user's final credit first, reject this request with an out-of-credits response and show the `Buy credits` CTA.

### Queued state

Show:

- filename
- `Queued`
- updated credit balance
- job creation time
- no Download button yet
- safe navigation away; job continues server-side

### Processing state

Show:

- filename
- `Processing`
- no fake percent complete unless worker has real progress data
- no requirement to keep tab open
- link to Library

### Complete state

Show:

- filename
- `Complete`
- completion time when available
- primary `Download ZIP`
- secondary `Convert another PDF`
- updated remaining credit balance
- conversion is already present in Library

No additional credit is charged for download or re-download.

### Failed state

If a job fails in the converter, timeout, or packaging stage after one credit was debited:

- mark job `Failed`
- restore exactly `1` application credit automatically and idempotently
- show filename
- show short actionable public failure reason
- show that the credit was returned, e.g. `Your credit was returned.`
- offer `Try again`
- no raw stack trace/path

A failure-credit restore must happen at most once for the failed charged job.

### Balance changes during page use

- page balance may become stale because of another tab/purchase/conversion
- backend balance/debit at job creation is authoritative
- if stale UI says a credit exists but server balance is `0`, do not enqueue; return out-of-credits state + Buy credits CTA
- once a charged job is accepted, it can finish regardless of later balance changes

## Page C5 — Library (`/app/library`)

### Purpose

Retrieve prior conversion jobs and completed packages.

### Entry conditions

Authenticated session required. Credits are **not** required to view owned history or download previously completed outputs.

### Required UI

- title: `Library`
- `Convert PDF` action -> Upload; Upload itself shows the zero-credit CTA when balance is `0`
- conversion list ordered newest first

Each row contains:

- original PDF filename
- created/conversion date
- status
- completion date when useful/available
- Download ZIP action only when status is Complete

### Status display

Support:

- Queued
- Processing
- Complete
- Failed

### Empty state

- `No conversions yet`
- positive-balance user: `Convert a PDF` -> Upload
- zero-credit user: `Buy credits to convert` -> Billing

### In-progress rows

Queued/Processing jobs remain visible after page reload or a later login. Status may refresh by polling/refetching; real-time infrastructure is not required.

### Failed rows

Show status and short public failure reason if stored safely. A customer-side retry button is optional only if it is implemented as the same standard Upload flow without adding job-management complexity.

### Completed rows

`Download ZIP` calls an authenticated backend endpoint. Never expose a public filesystem/object URL that bypasses ownership checks. The endpoint may redirect to a short-lived signed object URL only after authentication and ownership authorization.

Accepted source objects use the persisted deadline `source_expires_at = accepted_at + 48 hours`; ZIP objects use `zip_expires_at = completed_at + 48 hours`. Object-store lifecycle deletion and application reconciliation must converge on those deadlines. A worker may claim only while the source object is unexpired; if expiry occurs before claim, the job fails safely and any charged credit is restored exactly once. A worker that already copied an unexpired source into its isolated work directory may finish from that private copy, which is deleted at terminal cleanup. After ZIP expiry, the Library keeps the historical row, displays `Expired`, removes Download, and offers a new standard conversion; regeneration follows the normal one-credit rule. Signed download URLs must expire no later than `zip_expires_at`.

# Stripe-hosted external UI

## External S1 — Stripe Checkout

Stripe Checkout is intentionally hosted by Stripe and is not recreated in ASTRYX.

Expected customer actions:

- review exact quantity of credits
- review total at `$5 × quantity`
- enter payment details
- complete or cancel one-time payment

Application requirements:

- Checkout session is created server-side for the signed-in user
- quantity and unit price are server-controlled/validated
- success URL returns to Billing success/confirming state
- cancel URL returns to Billing canceled state
- verified payment/webhook is authoritative for granting credits
- one successful Checkout purchase grants exactly its paid quantity once

There is no Stripe subscription management surface in MVP because credits are one-time purchases.

# Admin UI pages

## Page A1 — Admin Overview (`/admin`)

### Purpose

Give an operator enough information to see whether the service is functioning.

### Access

Admin role only. Non-admins receive access denied/not-found according to platform convention and never receive admin data.

### Required UI

Compact summary counts:

- Queued
- Processing
- Failed
- completed recently
- total users or recently registered users

Required lists:

- recent conversion jobs
- recent failures

Each job link opens Job Detail.

No charts are required.

## Page A2 — Admin Jobs (`/admin/jobs`)

### Purpose

Operational list of conversion jobs.

### Required UI

Rows contain:

- job id/short id
- user email/id
- PDF filename
- created time
- status
- started/completed time when available
- public-safe or operator failure summary for failed jobs

Minimal filtering:

- all
- queued/processing
- failed

Do not build arbitrary workflow/scheduler controls.

## Page A3 — Admin Job Detail (`/admin/jobs/:id`)

### Purpose

Diagnose one conversion and retry a failed job.

### Required UI

- job id
- owning user
- source filename
- current status
- created/started/completed timestamps
- source/output presence metadata without exposing raw public paths
- failure code/message
- worker attempt information if the chosen jobs primitive already records it

### Required actions

- `Retry` only for a failed/retryable job
- retry must create/record a controlled new attempt and must not double-run a currently queued/processing job

Admin retry is operational remediation of the same failed conversion. It does not create a Stripe purchase or debit another customer credit. Because the failed conversion already returned its credit, a later successful admin retry is treated as service recovery/goodwill rather than a second paid conversion. Retry is unavailable after `source_expires_at`; the customer must submit a new source through the standard one-credit flow.

## Page A4 — Admin Users (`/admin/users`)

### Purpose

Diagnose account/access problems without replacing Stripe Dashboard.

### Required UI

Rows contain:

- user email
- registration date
- current credit balance
- total purchased/spent credits if cheap to query from the platform ledger
- conversion count if cheap to query

Useful link/action:

- link to related jobs
- Stripe customer/payment reference may be shown to admins if useful for support, but do not expose secret data

No arbitrary user impersonation is required.

# Complete user journeys

Every journey below is part of the testable product contract. `docs/plans/pdf2html-saas-mvp-test-plan.md` maps each journey to tests.

## J1 — New customer registers, buys credits, converts, downloads

```text
Anonymous -> Register
-> account/session created
-> Billing with 0 credits
-> choose exact quantity (for example 2)
-> Stripe Checkout succeeds for $10
-> signed verified payment grants 2 credits
-> Billing shows 2 credits
-> Upload
-> select valid PDF
-> accepted job atomically consumes 1 credit
-> balance becomes 1
-> Queued -> Processing -> Complete
-> Download ZIP
-> Library contains conversion
```

Expected: conversion cannot start at balance `0`; successful purchase grants exact quantity; accepted conversion costs exactly one credit.

## J2 — New customer cancels credit purchase

```text
Register -> Billing -> choose credits -> Stripe Checkout -> Cancel
-> Billing shows Payment canceled
```

Expected: balance remains `0`; no conversion can be accepted; Buy credits remains available.

## J3 — Credit payment fails

```text
Billing -> choose credits -> Stripe Checkout -> payment fails
-> Billing payment problem state
```

Expected: no credits are granted and balance is unchanged.

## J4 — Stripe redirects success before credits are granted

```text
Stripe Checkout success -> Billing
-> Confirming payment…
-> verified payment/webhook arrives
-> exact credits are granted
```

Expected: browser redirect or query parameter cannot grant credits by itself.

## J5 — Returning user with credits logs in

```text
Login -> Upload -> existing positive balance shown
```

Expected: user can immediately submit a valid conversion and spend one credit.

## J6 — Returning user with zero credits logs in

```text
Login -> Upload -> You're out of credits -> Buy credits
-> Billing
```

Expected: Library remains accessible; new conversion creation is blocked until credits are purchased.

## J7 — Invalid upload is rejected without spending credit

Cases:

- non-PDF
- over size limit
- malformed/unsupported PDF rejected before accepted conversion job

Expected: balance is unchanged and no charged processing job is created.

## J8 — Upload transport fails before job acceptance

```text
Upload -> transfer fails -> Upload failed -> Retry
```

Expected: no credit debit and no orphan processing job.

## J9 — Successful conversion survives navigation/reload

```text
Upload -> one credit debited -> Queued/Processing -> leave/reload/close tab
-> later Login/Library
-> same job visible -> Complete -> Download
```

Expected: only the original one-credit debit exists; processing is server-side.

## J10 — Converter failure returns the credit

```text
Valid accepted PDF -> one credit debited -> Processing -> converter fails -> Failed
-> exactly one credit restored
```

Expected: customer sees safe error and credit-return message; admin sees diagnostics; no ZIP is offered.

## J11 — Timeout or packaging failure returns the credit

```text
Accepted PDF -> one credit debited -> timeout/package error -> Failed
-> exactly one credit restored
```

Expected: worker is cleaned up, job leaves Processing, and credit restore occurs once.

## J12 — Duplicate submit spends only one credit

```text
Balance 1 -> selected PDF -> double click/repeated same submission
```

Expected: one intended accepted job and one credit debit only.

## J13 — Retrieve an unexpired previous conversion for free

```text
Login -> Library -> unexpired Complete row -> Download ZIP -> download again
```

Expected: correct owned artifact before `zip_expires_at`; credit balance unchanged by downloads.

## J14 — Library behavior with zero credits and expiry

```text
Balance 0 -> Login -> Library -> unexpired Complete conversion -> Download ZIP
Balance 0 -> Library -> expired conversion -> Expired, no Download
```

Expected: an unexpired previous output remains downloadable; an expired output remains history only; starting a new conversion is blocked with Buy credits CTA.

## J15 — Ownership isolation

```text
User A owns conversion A
User B guesses/obtains A's id/download URL
```

Expected: User B cannot read status/source/output/download for A.

## J16 — Buy more credits before running out

```text
Existing balance 3 -> Billing -> buy 10 -> pay $50 -> verified grant
```

Expected: new balance is exactly `13`.

## J17 — Exact quantity pricing

Test representative quantities:

```text
1 -> $5
2 -> $10
10 -> $50
37 -> $185
```

Expected: frontend display and server-created Stripe payment both use exact `N × $5`; client cannot tamper the server total.

## J18 — Duplicate/replayed payment event does not duplicate credits

```text
Buy 10 -> successful verified Stripe payment -> +10 credits
-> same Stripe event delivered/replayed again
```

Expected: balance increases by exactly 10 total, never 20.

## J19 — Concurrent final-credit race

```text
Balance 1
-> two tabs submit two different valid conversions concurrently
```

Expected: exactly one job is accepted/debited; the other receives out-of-credits + Buy credits CTA; balance never becomes negative.

## J20 — Admin observes normal work and credit state

```text
Admin -> Overview -> Jobs -> Job Detail / Users
```

Expected: job state and user credit balance are visible as specified.

## J21 — Admin diagnoses/retries failed conversion

```text
Charged job fails -> automatic application credit restore -> Admin Job Detail -> Retry operationally
```

Expected: failure/refund is traceable and retry cannot create duplicate job execution. No Stripe purchase is created by admin retry.

## J22 — Non-admin attempts admin access

```text
Normal customer -> /admin or admin API
```

Expected: denied with no admin/user/job/credit data leakage.

## J23 — Session expires while using app

```text
Authenticated page -> session expires -> next protected API/action
```

Expected: no protected action executes; no credit is debited; user returns to Login.

## J24 — Balance changes between page load and conversion submit

```text
Upload opened showing balance 1
-> another tab consumes the credit
-> first tab clicks Convert
```

Expected: server sees balance 0, does not enqueue/debit, and frontend shows out-of-credits + Buy credits CTA.

# Backend MVP

## Required API responsibilities

The backend provides only product capabilities required by the UI:

- authenticate/create user through platform auth
- return current user/session
- return current credit balance
- validate requested credit purchase quantity and configured sales jurisdiction
- server-calculate purchase total at `$5 × quantity`
- create one-time Stripe Checkout session with required billing address and Stripe Tax for authenticated user
- receive and verify Stripe payment, refund, dispute, and chargeback webhooks/events
- idempotently claim each provider event and create linked grant or reversal ledger entries
- expose spendable balance and any credit deficit; block new conversion acceptance while deficit is positive
- expose payment history
- accept/validate PDF upload
- atomically require/debit one credit and create one conversion job
- expose conversion status
- allow worker to claim/process jobs
- persist completion/failure state
- automatically restore one application credit exactly once for charged jobs that fail before successful output
- associate jobs with owning user
- store output location
- provide authenticated ZIP download
- list user's previous conversions
- provide minimal admin users/jobs/failure endpoints

## Billing/credit boundary

Canonical new-conversion request:

```text
1. Authenticate session.
2. Resolve user.
3. Validate upload/document sufficiently for acceptance.
4. Start atomic/idempotent job-creation transaction.
5. Require credit deficit = 0 and spendable balance >= 1.
6. Debit exactly 1 credit.
7. Create exactly one conversion job tied to that debit.
8. Commit and return job id/status/new balance.
```

If balance is `0`, return an explicit out-of-credits response and do not enqueue.

The worker must never be the first place credit balance is checked. Zero-credit work must not enter processing.

If a charged job reaches a terminal converter/timeout/packaging failure, create an idempotent compensating ledger entry restoring exactly `1` credit. Repeated failure handling must not restore twice.

## Stripe webhook boundary

- verify `stripe-signature` using configured webhook secret
- reject unverifiable payloads
- process payment and reversal events idempotently by Stripe event plus payment/Checkout/refund/dispute identity as supported by platform billing
- atomically claim each provider event before ledger mutation
- resolve the intended application user and purchased integer quantity from server-controlled purchase metadata/state
- grant credits only for a verified successful payment in an enabled sales jurisdiction with authoritative Stripe Tax state
- credit grant amount equals exactly the paid quantity
- for a verified refund, dispute, or chargeback, append exactly one reversal entry linked to the original grant; never rewrite ledger history
- apply later valid grants against any existing deficit before exposing spendable credits
- block conversion acceptance while deficit is positive
- allow only an audited, idempotent operator adjustment to resolve exceptional deficits
- failed/canceled payment grants zero credits
- duplicate/replayed delivery must not duplicate a grant
- server never trusts a client-posted dollar total or credit grant
- log enough Stripe/payment reference for support without logging secrets/payment card data

## Conversion jobs

Long-running conversion must not block the web/API process.

Minimal flow:

```text
1. API receives/validates PDF.
2. API stores the file.
3. API atomically debits one credit and creates the conversion job.
4. Worker claims the job.
5. Worker runs converter in isolation.
6. Worker packages HTML/assets into ZIP.
7. Worker marks job Complete and stores output location.
8. Frontend/Library sees Complete and enables download.
```

Use existing platform `jobs` primitives where appropriate. Do not introduce a separate distributed queue system unless the platform primitive cannot support the required persisted worker flow.

## Minimal conversion record

- id
- user id
- source filename
- source file location/private storage key
- status
- created timestamp
- accepted timestamp and `source_expires_at`
- processing/completion timestamps as needed
- output ZIP location/private storage key
- `zip_expires_at` when complete
- failure code/message when failed
- idempotency/submission key sufficient to prevent duplicate creation/debit from repeated submit
- credit debit/ledger transaction reference
- credit refund ledger reference when a failed charged job is compensated

Additional metadata is stored only when required by converter or operation.

## Ownership and access

Every upload and generated package belongs to one authenticated user.

Backend ownership enforcement applies to:

- conversion status
- Library listing
- ZIP downloads
- source metadata
- deletion, if ever implemented

A user must never access another user's source PDF or generated package by guessing an identifier or URL.

# Processing and isolation

PDF files are untrusted input.

At minimum:

- dedicated per-job worker container
- non-root user
- network disabled
- read-only root filesystem
- capabilities dropped and no-new-privileges
- per-job working directory
- measured CPU, memory, PID, temporary-storage, output-size, file-count, and wall-time ceilings
- complete process-tree termination on timeout/cancel
- cleanup of temporary files on every terminal state
- deterministic public-safe failure codes
- pinned converter version and reproducible image provenance
- no public access to raw filesystem paths

The worker may run on the same physical server as the API. A separate worker server is not required for MVP.

# Failure handling

The MVP must cleanly handle:

- invalid login/registration
- invalid credit quantity
- Stripe Checkout canceled
- Stripe payment failure
- delayed Stripe webhook/payment reconciliation
- duplicate Stripe webhook/payment event
- zero credit balance
- concurrent last-credit race
- unsupported/non-PDF upload
- file exceeds configured size limit
- upload transport failure before job acceptance
- duplicate/repeated conversion submit
- converter failure with exactly-once credit restore
- conversion timeout with exactly-once credit restore
- output packaging failure with exactly-once credit restore
- session expiry
- unauthorized/other-user access
- admin authorization failure

User-facing errors are short and actionable. Technical details remain in server logs/admin job detail.

# Required production components

1. Cloudflare-hosted frontend at `https://tools.press.zone`.
2. Astro + React + TypeScript frontend application.
3. `facebook/astryx` UI.
4. `../platform/` auth, billing, ledger, upload/job/db primitives where applicable.
5. Stripe account/configuration for one-time credit purchases at `$5.00 USD` per credit.
6. Server-created Stripe Checkout and signed webhook/payment verification.
7. CloudPanel01 API/backend deployment.
8. CloudPanel01 conversion worker/container.
9. Database/job persistence.
10. Platform-ledger-backed credit balance/transactions.
11. Private storage for source PDFs and generated ZIPs.
12. Authenticated upload, status, Library and download APIs.
13. Minimal admin routes for users/jobs/failures/credit visibility.
14. Basic logging, cleanup, idempotency, and worker isolation needed for safe operation.

# Explicit non-goals for MVP

Do not add:

- document/HTML/PDF editor
- WYSIWYG editing
- templates
- projects/workspaces/folders
- teams/collaboration/comments/sharing
- public document hosting/custom domains
- AI chat/workflows
- conversion presets/advanced settings
- analytics/reporting product
- user-facing scheduler
- notifications system beyond strictly required transactional platform behavior
- mobile/desktop application
- third-party API product
- customer webhooks/integrations marketplace
- Kubernetes
- separate worker fleet
- custom distributed queue
- separate admin application
- custom card-entry UI when Stripe Checkout suffices
- custom invoice/accounting system
- multiple billing providers
- subscriptions or recurring billing
- credit bundles/discount tiers
- multiple credit prices
- multiple pricing tiers

# Simplicity rule

For every proposed feature or infrastructure component, ask:

> Is this required for a customer to register, buy $5 credits with Stripe, spend one credit on a PDF conversion, receive the ZIP, retrieve a previous conversion, or for an operator to keep that flow working safely?

If no, it does not belong in MVP.

# MVP acceptance flow

The product is ready for its first customer only when this works in production:

```text
1. Customer opens `https://tools.press.zone` and registers.
2. New customer starts with 0 credits and is sent to Billing.
3. Customer chooses an exact positive integer credit quantity.
4. Backend prices it at exactly quantity × $5 and creates Stripe Checkout.
5. Verified successful Stripe payment grants exactly that quantity once.
6. Billing shows the updated authoritative credit balance/payment history.
7. Customer opens Upload and submits a valid PDF.
8. Backend atomically requires/debits exactly 1 credit and creates exactly one job.
9. CloudPanel01 worker converts without blocking API.
10. HTML/assets are packaged into ZIP.
11. Customer downloads ZIP without another credit charge.
12. Conversion appears in Library after reload/login and can be downloaded again for free.
13. At balance 0, new conversion is blocked with an out-of-credits message + Buy credits CTA.
14. Failed charged conversion restores exactly 1 application credit once.
15. Admin can see jobs and diagnose/retry failures.
16. Another customer cannot access the first customer's files/jobs/credit data.
```

The exhaustive page/journey acceptance and test matrix is `docs/plans/pdf2html-saas-mvp-test-plan.md`.
