From a0dd6b76532af28fa76ebffc013d6f00ba6d10da Mon Sep 17 00:00:00 2001
From: sax3l
Date: Mon, 2 Nov 2026 20:14:12 +0100
Subject: [PATCH] fix(live): refuse billing in pilot mode and explain unparsed
documents
- Checkout returns 503 CREDITS_DISABLED while OPT_CREDITS_MODE=off, so a
customer can never pay for credits that the pipeline will not consume.
- Packs expose purchases_enabled; the portal hides Buy and says billing is off.
- Structuring an unparsed document (e.g. a scanned PDF with no text layer) now
answers 422 DOCUMENT_NOT_PARSED instead of a generic INVALID_REQUEST.
---
app/api/0pt/credits/checkout/route.ts | 9 ++++++
app/api/0pt/credits/packs/route.ts | 1 +
app/api/0pt/documents/structure/route.ts | 11 ++++++-
app/portal/krediter/page.tsx | 37 ++++++++++++++++++------
4 files changed, 48 insertions(+), 10 deletions(-)
diff --git a/app/api/0pt/credits/checkout/route.ts b/app/api/0pt/credits/checkout/route.ts
index 47c9097..fc86b39 100644
--- a/app/api/0pt/credits/checkout/route.ts
+++ b/app/api/0pt/credits/checkout/route.ts
@@ -27,6 +27,15 @@ export async function POST(request: NextRequest) {
if ('response' in verified) return verified.response;
const { tenantId } = verified.scope;
+ if (process.env.OPT_CREDITS_MODE === 'off') {
+ return NextResponse.json(
+ {
+ error: 'CREDITS_DISABLED',
+ message: 'Billing is not active yet (pilot mode). No payment can be taken.',
+ },
+ { status: 503 },
+ );
+ }
const book = readCreditPriceBook();
if (!book) {
return NextResponse.json(
diff --git a/app/api/0pt/credits/packs/route.ts b/app/api/0pt/credits/packs/route.ts
index 3250635..d8d0e3c 100644
--- a/app/api/0pt/credits/packs/route.ts
+++ b/app/api/0pt/credits/packs/route.ts
@@ -27,6 +27,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
version: book.version,
currency: book.currency,
+ purchases_enabled: process.env.OPT_CREDITS_MODE !== 'off',
packs: book.packs.map((pack) => ({
id: pack.id,
credits: pack.credits,
diff --git a/app/api/0pt/documents/structure/route.ts b/app/api/0pt/documents/structure/route.ts
index 7c54fc6..cfa50ba 100644
--- a/app/api/0pt/documents/structure/route.ts
+++ b/app/api/0pt/documents/structure/route.ts
@@ -163,13 +163,22 @@ export async function POST(request: NextRequest) {
const dataClass = body.data_class ?? 'low';
const format = body.format ?? 'json';
+ if (pipelineStage !== 'parsed') {
+ return NextResponse.json(
+ {
+ error: 'DOCUMENT_NOT_PARSED',
+ message:
+ 'The document has no verified text layer yet (parse_failed or not parsed). OCR is required before structuring.',
+ },
+ { status: 422 },
+ );
+ }
if (
!isValidScopeId(projectId) ||
!isValidScopeId(documentId) ||
!isValidScopeId(versionId) ||
typeof checksum !== 'string' ||
!/^[0-9a-f]{64}$/.test(checksum) ||
- pipelineStage !== 'parsed' ||
typeof instruction !== 'string' ||
!instruction.trim() ||
instruction.length > 4000 ||
diff --git a/app/portal/krediter/page.tsx b/app/portal/krediter/page.tsx
index 4c79f8b..570d6fc 100644
--- a/app/portal/krediter/page.tsx
+++ b/app/portal/krediter/page.tsx
@@ -12,6 +12,7 @@ export default function CreditsPage() {
const [balance, setBalance] = useState<{ available: number; reserved: number } | null>(null);
const [balanceNote, setBalanceNote] = useState(null);
const [packs, setPacks] = useState([]);
+ const [purchasesEnabled, setPurchasesEnabled] = useState(true);
const [currency, setCurrency] = useState('SEK');
const [packsNote, setPacksNote] = useState(null);
const [buying, setBuying] = useState(null);
@@ -31,10 +32,16 @@ export default function CreditsPage() {
}
try {
const r = await fetch('/api/0pt/credits/packs', { cache: 'no-store' });
- const body = (await r.json()) as { packs?: Pack[]; currency?: string; message?: string };
+ const body = (await r.json()) as {
+ packs?: Pack[];
+ currency?: string;
+ purchases_enabled?: boolean;
+ message?: string;
+ };
if (r.ok && body.packs) {
setPacks(body.packs);
setCurrency(body.currency ?? 'SEK');
+ setPurchasesEnabled(body.purchases_enabled !== false);
} else {
setPacksNote(
body.message ??
@@ -127,18 +134,30 @@ export default function CreditsPage() {
{formatSEK(pack.amount_minor / 100)}
- buy(pack.id)}
- disabled={buying !== null}
- className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.18em] disabled:opacity-40"
- >
- {buying === pack.id ? t({ en: 'opening…', sv: 'öppnar…' }) : t({ en: 'buy', sv: 'köp' })}
-
+ {purchasesEnabled ? (
+ buy(pack.id)}
+ disabled={buying !== null}
+ className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.18em] disabled:opacity-40"
+ >
+ {buying === pack.id
+ ? t({ en: 'opening…', sv: 'öppnar…' })
+ : t({ en: 'buy', sv: 'köp' })}
+
+ ) : null}
))}
)}
+ {!purchasesEnabled ? (
+
+ {t({
+ en: 'Billing is not active yet (pilot). No payment can be taken.',
+ sv: 'Betalning är inte aktiverad än (pilot). Ingen betalning kan tas.',
+ })}
+
+ ) : null}
{status ? {status}
: null}
{currency}