From b21bfb2f1db8fbef10ef097bd0f152bb41c5b214 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Sun, 24 May 2026 13:42:27 +0200 Subject: [PATCH] Add NOK pricing catalog, credit ledger, success-based charging, and tier-gated model routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PricingCatalog.php: single source of truth for plans (free/plus/pro), top-ups, Stripe price env keys, tool costs (0–6 credits), STT variable billing, feature limits - FreeTier.php: monthly-first credit deduction, ledger (user_tool_credit_ledger), STT reservation/settle/release, monthly reset, trial logic - StripeClient.php: canonical SKUs (plus/pro/topup_100/300/1000), legacy aliases kept - stripe-checkout.php: subscription vs payment mode, trial gating, catalog metadata - stripe-webhook.php: idempotent via stripe_events, handles subscription lifecycle + invoice.paid renewal + one-time topup credit grants - All API tools: success-based credit deduction (check before, charge after) - transcribe.php: file-size heuristic reservation, settle from actual provider duration - ask.php + LegalTools.php: ToolModels engine resolution — Pro gets gpt-4o - KorrespondAgent.php + korrespond.php: tier-gated draft deployment — Free/Plus gets gpt-4o-mini, Pro gets gpt-4o - pricing.php: NOK-only, plan cards, top-up packs, Organisation contact card, tool cost table, separate monthly/prepaid balance display - 003_pricing_credit_catalog.sql: ledger and STT reservation tables Co-Authored-By: Claude Sonnet 4.6 --- account.php | 12 +- api/ask.php | 12 +- api/discrepancy.php | 6 +- api/extract.php | 7 +- api/korrespond-refine.php | 8 +- api/korrespond.php | 6 +- api/legal-analysis.php | 10 +- api/redact.php | 4 +- api/stripe-checkout.php | 34 +- api/stripe-webhook.php | 2 +- api/timeline.php | 4 +- api/transcribe.php | 48 +- api/translate.php | 9 +- assets/js/barnevernet.js | 3 + assets/js/deep-research.js | 3 + assets/js/discrepancy.js | 3 + assets/js/korrespond.js | 6 + assets/js/tools.js | 16 + billing.php | 11 +- dashboard.php | 2 +- includes/FreeTier.php | 363 ++++++++----- includes/KorrespondAgent.php | 9 +- includes/LegalTools.php | 8 +- includes/PricingCatalog.php | 297 ++++++++++ includes/StripeClient.php | 114 ++-- includes/bootstrap.php | 98 ++++ includes/i18n.php | 4 +- index.php | 9 +- pricing.php | 605 ++++++++++----------- scripts/sql/003_pricing_credit_catalog.sql | 44 ++ 30 files changed, 1171 insertions(+), 586 deletions(-) create mode 100644 includes/PricingCatalog.php create mode 100644 scripts/sql/003_pricing_credit_catalog.sql diff --git a/account.php b/account.php index 6f4da68..5e775b8 100644 --- a/account.php +++ b/account.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/includes/bootstrap.php'; require_once __DIR__ . '/includes/FreeTier.php'; +require_once __DIR__ . '/includes/PricingCatalog.php'; if (!dbnToolsIsAuthenticated()) { header('Location: /?return=' . urlencode($_SERVER['REQUEST_URI'] ?? '/account.php')); @@ -20,11 +21,12 @@ $role = (string)($authUser['role'] ?? ''); $detail = $isSso ? FreeTier::balanceDetail((int)$_SESSION['dbn_tools_sso_uid']) : null; $tier = $detail ? (string)$detail['tier'] : ($isSso ? 'free' : 'caveau'); +$catalogPlans = PricingCatalog::plans(); $tierLabels = [ - 'free' => ['Free', '#f3f4f6', '#374151'], - 'plus' => ['Plus', '#ddd6fe', '#5b21b6'], - 'pro' => ['Pro', '#bfdbfe', '#1e40af'], - 'caveau' => ['CaveauAI', '#d1fae5', '#065f46'], + 'free' => [$catalogPlans['free']['name'], '#f3f4f6', '#374151'], + 'plus' => [$catalogPlans['plus']['name'], '#ddd6fe', '#5b21b6'], + 'pro' => [$catalogPlans['pro']['name'], '#bfdbfe', '#1e40af'], + 'caveau' => ['CaveauAI', '#d1fae5', '#065f46'], ]; $tierLabel = $tierLabels[$tier] ?? $tierLabels['free']; @@ -106,7 +108,7 @@ window.DBN_TOOLS_LANG = ;

diff --git a/api/ask.php b/api/ask.php index f1207ae..31f3d0f 100644 --- a/api/ask.php +++ b/api/ask.php @@ -2,19 +2,19 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/LegalTools.php'; +require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); -$ftUid = dbnToolsFreeTierCheck('ask'); -$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'ask'); -if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } -$input = dbnToolsJsonInput(25000); +$ftUid = dbnToolsFreeTierCheck('ask'); +$engine = ToolModels::engineForUser($ftUid, 'azure_mini'); +$input = dbnToolsJsonInput(25000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); -dbnToolsWithTelemetry('ask', $language, function () use ($input, $language): array { +dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input, $language, $engine): array { $question = dbnToolsInjectDocContent($input, dbnToolsString($input, 'question', 4000, false)); if (mb_strlen(trim($question), 'UTF-8') < 5) { dbnToolsAbort('Enter a question or select a document before running.', 422, 'empty_text'); } - return (new DbnLegalToolsService())->ask($question, $language); + return (new DbnLegalToolsService())->ask($question, $language, $engine); }); diff --git a/api/discrepancy.php b/api/discrepancy.php index 135e517..3e182fb 100644 --- a/api/discrepancy.php +++ b/api/discrepancy.php @@ -8,7 +8,6 @@ require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); $ftUid = dbnToolsFreeTierCheck('discrepancy'); -$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy'); @ini_set('output_buffering', '0'); @ini_set('zlib.output_compression', '0'); @@ -19,7 +18,6 @@ ob_implicit_flush(true); header('Content-Type: application/x-ndjson; charset=utf-8'); header('Cache-Control: no-store'); header('X-Accel-Buffering: no'); -if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $language = 'en'; $startTime = microtime(true); @@ -146,6 +144,10 @@ try { 'deployment' => $result['trace_metadata']['deployment'] ?? null, ]); + $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy'); + if ($ftRemaining >= 0) { + $result['balance'] = $ftRemaining; + } $emit('final', ['result' => $result]); diff --git a/api/extract.php b/api/extract.php index 5ba70d7..598751c 100644 --- a/api/extract.php +++ b/api/extract.php @@ -6,8 +6,6 @@ require_once __DIR__ . '/../includes/bootstrap.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); $ftUid = dbnToolsFreeTierCheck('extract'); -$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract'); -if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } try { if (empty($_FILES['file']) || !is_array($_FILES['file'])) { @@ -15,6 +13,11 @@ try { } $result = dbnToolsExtractUploadedFile($_FILES['file']); + $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract'); + if ($ftRemaining >= 0) { + header('X-Credits-Remaining: ' . $ftRemaining); + $result['balance'] = $ftRemaining; + } dbnToolsRespond($result); } catch (DbnToolsHttpException $e) { dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra); diff --git a/api/korrespond-refine.php b/api/korrespond-refine.php index 4a3cdc1..80c5946 100644 --- a/api/korrespond-refine.php +++ b/api/korrespond-refine.php @@ -68,10 +68,6 @@ try { // Credit gate (refine is a paid pass) $ftUid = dbnToolsFreeTierCheck('korrespond_refine'); - $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond_refine'); - if ($ftRemaining >= 0) { - header('X-Credits-Remaining: ' . $ftRemaining); - } $emit('start', [ 'jurisdiction' => $jurisdiction, @@ -81,8 +77,12 @@ try { $agent = new DbnKorrespondAgent(); $result = $agent->refine($intake, $classify, $originalDraftNo, $jurisdiction, $emit); + $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond_refine'); $result['ok'] = true; $result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000); + if ($ftRemaining >= 0) { + $result['balance'] = $ftRemaining; + } dbnToolsLogMetadata([ 'tool' => 'korrespond_refine', diff --git a/api/korrespond.php b/api/korrespond.php index 1b91d6c..e3ff488 100644 --- a/api/korrespond.php +++ b/api/korrespond.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/KorrespondAgent.php'; +require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); @@ -165,11 +166,12 @@ try { // ── Deduct credit now (Pass 2 starts) ─────────────────────────────────────── $ftUid = dbnToolsFreeTierCheck('korrespond'); + $engine = ToolModels::engineForUser($ftUid, 'azure_mini'); $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond'); $creditDeducted = true; // ── Pass 2: retrieve law → draft → self-check → translate ────────────────── - $result = $agent->generate($intake, $classify, $emit); + $result = $agent->generate($intake, $classify, $emit, $engine); $result['ok'] = true; $result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000); if ($ftRemaining >= 0) { @@ -182,7 +184,7 @@ try { 'ok' => true, 'latency_ms' => $result['latency_ms'], 'source_count' => is_array($result['cited_law'] ?? null) ? count($result['cited_law']) : 0, - 'deployment' => 'gpt-4o', + 'deployment' => ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini', ]); diff --git a/api/legal-analysis.php b/api/legal-analysis.php index 5e21df7..c8f00ff 100644 --- a/api/legal-analysis.php +++ b/api/legal-analysis.php @@ -20,6 +20,8 @@ header('X-Accel-Buffering: no'); $startTime = microtime(true); $language = 'en'; $creditDeducted = false; +$ftUid = 0; +$ftRemaining = -1; $emit = function (string $event, array $payload = []) use ($startTime): void { $payload['event'] = $event; @@ -44,6 +46,7 @@ try { 422, 'empty_text' ); } + $ftUid = dbnToolsFreeTierCheck('legal-analysis'); $emit('start', [ 'mode' => 'legal-analysis', @@ -75,12 +78,8 @@ try { } // Deduct credit (gated until extract succeeds and at least one issue exists) - $ftUid = dbnToolsFreeTierCheck('legal-analysis'); $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'legal-analysis'); $creditDeducted = true; - if ($ftRemaining >= 0) { - header('X-Credits-Remaining: ' . $ftRemaining); - } $emit('issues_extracted', [ 'count' => count($issues), @@ -129,6 +128,9 @@ try { 'model' => 'dbn-legal-agent-v3', 'latency_ms' => (int)round((microtime(true) - $startTime) * 1000), ]; + if ($ftRemaining >= 0) { + $result['balance'] = $ftRemaining; + } dbnToolsLogMetadata([ 'tool' => 'legal-analysis', diff --git a/api/redact.php b/api/redact.php index e9efa22..ae6acd5 100644 --- a/api/redact.php +++ b/api/redact.php @@ -6,11 +6,9 @@ require_once __DIR__ . '/../includes/LegalTools.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); $ftUid = dbnToolsFreeTierCheck('redact'); -$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'redact'); -if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $input = dbnToolsJsonInput(400000); -dbnToolsWithTelemetry('redact', '', function () use ($input): array { +dbnToolsWithChargedTelemetry('redact', '', $ftUid, function () use ($input): array { $text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false)); if (mb_strlen(trim($text), 'UTF-8') < 10) { dbnToolsAbort('Paste text, upload a file, or select a document before running.', 422, 'empty_text'); diff --git a/api/stripe-checkout.php b/api/stripe-checkout.php index 4f02329..ab55c4c 100644 --- a/api/stripe-checkout.php +++ b/api/stripe-checkout.php @@ -16,12 +16,9 @@ if ($userId <= 0 || $email === '') { } $input = dbnToolsJsonInput(2000); -$sku = (string)($input['sku'] ?? ''); +$sku = StripeClient::canonicalSku((string)($input['sku'] ?? '')); -$validSubscriptions = ['plus', 'pro']; -$validTopups = ['topup_s', 'topup_m', 'topup_l']; - -if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) { +if (!StripeClient::isSubscriptionSku($sku) && !StripeClient::isTopupSku($sku)) { dbnToolsError('Unknown SKU.', 400, 'unknown_sku'); } @@ -33,7 +30,18 @@ try { $successUrl = $baseUrl . '/billing.php?status=success&session_id={CHECKOUT_SESSION_ID}'; $cancelUrl = $baseUrl . '/pricing.php?status=canceled'; - $isSub = in_array($sku, $validSubscriptions, true); + $isSub = StripeClient::isSubscriptionSku($sku); + $credits = $isSub ? 0 : StripeClient::topupCredits($sku); + $metadata = [ + 'user_id' => (string)$userId, + 'sku' => $sku, + 'catalog_version' => PricingCatalog::VERSION, + ]; + if ($isSub) { + $metadata['tier'] = $sku; + } else { + $metadata['credits'] = (string)$credits; + } $params = [ 'mode' => $isSub ? 'subscription' : 'payment', @@ -44,10 +52,7 @@ try { 'price' => StripeClient::priceId($sku), 'quantity' => 1, ]], - 'metadata' => [ - 'user_id' => (string)$userId, - 'sku' => $sku, - ], + 'metadata' => $metadata, 'allow_promotion_codes' => true, 'billing_address_collection' => 'auto', 'locale' => 'auto', @@ -58,10 +63,11 @@ try { FreeTier::ensureRow($userId); $detail = FreeTier::balanceDetail($userId); $params['subscription_data'] = [ - 'metadata' => ['user_id' => (string)$userId, 'tier' => $sku], + 'metadata' => $metadata, ]; - if ($sku === 'plus' && empty($detail['trial_started_at'])) { - $params['subscription_data']['trial_period_days'] = 14; + $trialDays = PricingCatalog::planTrialDays($sku); + if ($trialDays > 0 && empty($detail['trial_started_at'])) { + $params['subscription_data']['trial_period_days'] = $trialDays; $params['subscription_data']['trial_settings'] = [ 'end_behavior' => ['missing_payment_method' => 'cancel'], ]; @@ -69,7 +75,7 @@ try { $params['payment_method_collection'] = 'always'; } else { $params['payment_intent_data'] = [ - 'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)], + 'metadata' => $metadata, ]; } diff --git a/api/stripe-webhook.php b/api/stripe-webhook.php index bd26bbf..4034ee7 100644 --- a/api/stripe-webhook.php +++ b/api/stripe-webhook.php @@ -122,7 +122,7 @@ function handleCheckoutCompleted(PDO $db, array $session): void if ($mode === 'payment') { // One-time topup — grant credits immediately. - $sku = (string)($metadata['sku'] ?? ''); + $sku = StripeClient::canonicalSku((string)($metadata['sku'] ?? '')); $credits = StripeClient::topupCredits($sku); if ($credits > 0) { FreeTier::awardBonus($userId, $credits, 'topup:' . $sku); diff --git a/api/timeline.php b/api/timeline.php index 041da65..79b5a7a 100644 --- a/api/timeline.php +++ b/api/timeline.php @@ -7,12 +7,10 @@ require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); $ftUid = dbnToolsFreeTierCheck('timeline'); -$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'timeline'); -if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $input = dbnToolsJsonInput(400000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); -dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language, $ftUid): array { +dbnToolsWithChargedTelemetry('timeline', $language, $ftUid, function () use ($input, $language, $ftUid): array { $text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false)); if (mb_strlen(trim($text), 'UTF-8') < 10) { dbnToolsAbort('Paste text, upload a file, or select a document before running.', 422, 'empty_text'); diff --git a/api/transcribe.php b/api/transcribe.php index 7b4b721..62161b5 100644 --- a/api/transcribe.php +++ b/api/transcribe.php @@ -2,12 +2,14 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/LegalTools.php'; +require_once __DIR__ . '/../includes/FreeTier.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); -$ftUid = dbnToolsFreeTierCheck('transcribe'); -$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'transcribe'); -if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } +$ftUid = 0; +$ftRemaining = -1; +$sttReservationId = 0; +$sttSettled = false; set_time_limit(0); ignore_user_abort(true); @@ -92,6 +94,23 @@ $detectedMime = mime_content_type($file['tmp_name']) ?: 'application/octet-strea $timeOffset = max(0.0, (float)($_POST['time_offset'] ?? 0)); $t0 = microtime(true); +$estimatedCredits = FreeTier::estimateTranscribeCreditsFromFile((string)($file['name'] ?? ''), (int)($file['size'] ?? 0)); +$ftUid = dbnToolsFreeTierCheckAmount('transcribe', $estimatedCredits); +if ($ftUid > 0) { + $sttReservationId = FreeTier::createReservation($ftUid, 'transcribe', $estimatedCredits, [ + 'filename' => (string)($file['name'] ?? ''), + 'bytes' => (int)($file['size'] ?? 0), + 'mime' => $detectedMime, + 'language' => $language, + 'diarize' => $diarize, + ]); + register_shutdown_function(static function () use (&$sttSettled, $sttReservationId): void { + if (!$sttSettled && $sttReservationId > 0) { + FreeTier::releaseReservation($sttReservationId, ['reason' => 'request_ended_before_settlement']); + } + }); +} + // ── Auto-cascade: Azure → GCP → Whisper GPU ─────────────────────────────────── $result = null; @@ -186,6 +205,25 @@ $engineLabel = match($engineUsed) { // ── Log + respond ───────────────────────────────────────────────────────────── +$durationSec = round((float)($result['duration_seconds'] ?? $result['duration'] ?? 0), 2); +$creditsUsed = FreeTier::transcribeCreditsForSeconds($durationSec); +$ftRemaining = dbnToolsFreeTierDeductAmount($ftUid, 'transcribe', $creditsUsed, [ + 'engine' => $engineUsed, + 'duration_sec' => $durationSec, + 'estimated_credits' => $estimatedCredits, + 'reservation_id' => $sttReservationId, +]); +if ($ftRemaining >= 0) { + header('X-Credits-Remaining: ' . $ftRemaining); +} +if ($sttReservationId > 0) { + FreeTier::settleReservation($sttReservationId, $creditsUsed, $engineUsed, $durationSec, [ + 'estimated_credits' => $estimatedCredits, + 'balance_after' => $ftRemaining, + ]); + $sttSettled = true; +} + dbnToolsLogMetadata([ 'tool' => 'transcribe', 'engine' => $engineUsed, @@ -203,12 +241,14 @@ dbnToolsRespond([ 'speaker_roles' => $speakerRoles, 'num_speakers' => $numDetected, 'language' => (string)($result['language'] ?? $language), - 'duration_sec' => round((float)($result['duration_seconds'] ?? $result['duration'] ?? 0), 2), + 'duration_sec' => $durationSec, 'processing_sec'=> round((float)($result['processing_seconds'] ?? 0), 2), 'model' => $engineLabel, 'engine' => $engineUsed, 'latency_ms' => $latencyMs, 'cleaned_by' => $cleanedBy, + 'credits_used' => $creditsUsed, + 'balance' => $ftRemaining, ]); diff --git a/api/translate.php b/api/translate.php index 1f0effb..02dd7d1 100644 --- a/api/translate.php +++ b/api/translate.php @@ -55,10 +55,6 @@ try { } $ftUid = dbnToolsFreeTierCheck('translate'); - $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'translate'); - if ($ftRemaining >= 0) { - header('X-Credits-Remaining: ' . $ftRemaining); - } $emit('start', [ 'mode' => 'translate', @@ -150,6 +146,11 @@ PROMPT; 'deployment' => 'gpt-4o-mini', ]); + $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'translate'); + if ($ftRemaining >= 0) { + $result['balance'] = $ftRemaining; + } + $emit('final', ['result' => $result]); } catch (DbnToolsHttpException $e) { diff --git a/assets/js/barnevernet.js b/assets/js/barnevernet.js index 076f357..1331e2a 100644 --- a/assets/js/barnevernet.js +++ b/assets/js/barnevernet.js @@ -564,6 +564,9 @@ } lastResult = finalResult; + if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(finalResult.balance); + } const meta = finalResult.trace_metadata || {}; setStatus( `Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${meta.source_count || 0} sources · confidence ${meta.citation_confidence || '?'}`, diff --git a/assets/js/deep-research.js b/assets/js/deep-research.js index f40ec9a..dce5e4c 100644 --- a/assets/js/deep-research.js +++ b/assets/js/deep-research.js @@ -432,6 +432,9 @@ } finalResult.query = query; + if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(finalResult.balance); + } lastResult = finalResult; const meta = finalResult.trace_metadata || {}; const rc = meta.retrieval_counts || {}; diff --git a/assets/js/discrepancy.js b/assets/js/discrepancy.js index b40ec85..e77a8d6 100644 --- a/assets/js/discrepancy.js +++ b/assets/js/discrepancy.js @@ -354,6 +354,9 @@ } lastResult = finalResult; + if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(finalResult.balance); + } const meta = finalResult.trace_metadata || {}; setStatus( `Done · ${meta.conflict_count || 0} contradictions · ${meta.deleted_count || 0} deletions · ${meta.added_count || 0} additions · ${meta.source_count || 0} sources`, diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js index 6712abb..16b8800 100644 --- a/assets/js/korrespond.js +++ b/assets/js/korrespond.js @@ -382,6 +382,9 @@ } setStatus(t('done_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length }), 'ok'); + if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(finalResult.balance); + } lastFinal = finalResult; renderFinal(finalResult); pendingClarifications = {}; // reset for next run @@ -487,6 +490,9 @@ } setStatus(t('refined_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length, jur: jurLabel }), 'ok'); + if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(finalResult.balance); + } renderRefined(finalResult); } diff --git a/assets/js/tools.js b/assets/js/tools.js index f9e9d61..7fed5a7 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -1882,6 +1882,9 @@ async function runTranscribe() { const resp = await fetch('api/transcribe.php', { method: 'POST', credentials: 'same-origin', body: fd }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Transcription failed.'); + if (typeof data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(data.balance); + } if (typeof renderTranscribeResult === 'function') renderTranscribeResult(data); else renderResults(data); showSaveResultButton('transcribe', { audio_doc_id: storedAudioDocId }, data, { @@ -1968,6 +1971,9 @@ async function runTranscribe() { if (!resp.ok || !data.ok) { throw new Error(data.error?.message || currentUiT('transcribeFailed', resp.status)); } + if (typeof data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(data.balance); + } clearInterval(timer); item.status = 'done'; @@ -2713,6 +2719,9 @@ function laAddonEvent(data, listEl, pipelineEl, cards) { card.insertAdjacentHTML('beforeend', html); } else if (data.event === 'final' && data.result) { const res = data.result; + if (typeof res.balance === 'number' && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(res.balance); + } pipelineEl.innerHTML = '
' + escapeHtml(_laT('pass1')) + ' — ' + escapeHtml(_laT('pass1Found', { n: (res.issues || []).length })) + '
' + '
' + escapeHtml(_laT('pass2')) + ' — ' + escapeHtml(_laT('pass2Answered', { n: (res.issues || []).length })) + '
' @@ -2792,6 +2801,7 @@ function dbnFreeTierError(status, data) { const toast = document.createElement('div'); toast.className = 'credit-toast'; toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.'; + toast.textContent = data?.error?.message || 'Rate limit reached. Try again shortly or see pricing for higher caps.'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 5000); return; @@ -2810,6 +2820,12 @@ function dbnFreeTierError(status, data) { `; + overlay.querySelector('.credit-modal__icon').textContent = 'DBN'; + overlay.querySelector('#cmTitle').textContent = 'No credits remaining'; + overlay.querySelector('p').innerHTML = 'Your monthly and prepaid credits have been used.
Monthly credits reset next month, and prepaid top-ups never expire.'; + const pricingLink = overlay.querySelector('.credit-modal__cta'); + pricingLink.setAttribute('href', '/pricing.php'); + pricingLink.textContent = 'See plans and top-ups'; document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); } diff --git a/billing.php b/billing.php index 9ab75f1..f1dc944 100644 --- a/billing.php +++ b/billing.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/includes/bootstrap.php'; require_once __DIR__ . '/includes/FreeTier.php'; +require_once __DIR__ . '/includes/PricingCatalog.php'; if (!dbnToolsIsAuthenticated()) { header('Location: /?return=' . urlencode('/billing.php')); @@ -28,12 +29,8 @@ $storageMb = round($detail['storage_used_bytes'] / 1048576, 1); $quotaMb = $detail['storage_quota_bytes'] > 0 ? round($detail['storage_quota_bytes'] / 1048576, 0) : 0; $storagePct = $quotaMb > 0 ? min(100, round(($storageMb / $quotaMb) * 100)) : 0; -$tierLabels = [ - 'free' => 'Gratis', - 'plus' => 'Plus', - 'pro' => 'Pro Familie', - 'caveau' => 'CaveauAI', -]; +$tierLabels = array_map(static fn(array $plan): string => (string)$plan['name'], PricingCatalog::plans()); +$tierLabels['caveau'] = 'CaveauAI'; // Recent usage $db = dbnmDb(); @@ -125,7 +122,7 @@ $status = (string)($_GET['status'] ?? '');

Tilgjengelige kreditter

- månedlige · bonus + månedlige · forhåndsbetalte

diff --git a/dashboard.php b/dashboard.php index 59e9c21..87f8af9 100644 --- a/dashboard.php +++ b/dashboard.php @@ -60,7 +60,7 @@ window.DBN_TOOLS_LANG = ;

-

· ·

+

· ·

diff --git a/includes/FreeTier.php b/includes/FreeTier.php index efdb2fa..52b16ba 100644 --- a/includes/FreeTier.php +++ b/includes/FreeTier.php @@ -1,102 +1,48 @@ 0, - 'search' => 0, - 'ask' => 1, - 'extract' => 1, - 'timeline' => 2, - 'redact' => 2, - 'barnevernet' => 3, - 'advocate' => 3, - 'deep-research' => 5, - 'transcribe' => 2, - 'discrepancy' => 4, - 'korrespond' => 3, - 'translate' => 2, - ]; - - /** Monthly credit allowance per tier. */ - private const MONTHLY_ALLOWANCE = [ - 'free' => 30, - 'plus' => 250, - 'pro' => 1000, - ]; - - /** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */ - private const HOURLY_CAP = [ - 'free' => 10, - 'plus' => 20, - 'pro' => 40, - ]; - - /** Per-user case-storage quota in bytes. */ - private const STORAGE_QUOTA = [ - 'free' => 0, - 'plus' => 524288000, // 500 MB - 'pro' => 5368709120, // 5 GB - ]; - - /** Credit cost for a given tool slug. Returns 1 for unknown tools. */ public static function cost(string $tool): int { - return self::COSTS[$tool] ?? 1; + return PricingCatalog::toolCost($tool); } public static function monthlyAllowance(string $tier): int { - return self::MONTHLY_ALLOWANCE[$tier] ?? self::MONTHLY_ALLOWANCE['free']; + return PricingCatalog::monthlyAllowance($tier); } public static function hourlyCap(string $tier): int { - return self::HOURLY_CAP[$tier] ?? self::HOURLY_CAP['free']; + return PricingCatalog::hourlyCap($tier); } public static function storageQuota(string $tier): int { - return self::STORAGE_QUOTA[$tier] ?? 0; + return PricingCatalog::storageQuota($tier); } - /** Fetch a user's tier (defaults to 'free' if no row). */ public static function tier(int $userId): string { $row = self::row($userId); return $row['tier'] ?? 'free'; } - /** Tiers that have access to "My Case" features (upload, save analyses, use case context). */ public static function isPaidTier(string $tier): bool { - return in_array($tier, ['plus', 'pro'], true); + return PricingCatalog::isPaidTier($tier); } - /** True when the user is currently in a Stripe trial (tier='plus', trial_expires_at in future). */ public static function isTrialActive(int $userId): bool { $row = self::row($userId); @@ -104,13 +50,9 @@ final class FreeTier return false; } $expires = $row['trial_expires_at'] ?? null; - if (!$expires) { - return false; - } - return strtotime((string)$expires) > time(); + return $expires ? strtotime((string)$expires) > time() : false; } - /** Days remaining in the active trial (0 if no trial / expired). */ public static function trialDaysRemaining(int $userId): int { if (!self::isTrialActive($userId)) { @@ -121,18 +63,25 @@ final class FreeTier return max(0, (int)ceil(($expires - time()) / 86400)); } - /** Fetch the full credits row, applying lazy monthly reset. */ public static function row(int $userId): ?array { $db = dbnmDb(); - // Auto-refill monthly balance based on tier-specific allowance, only if a new calendar month has begun. + $freeAllowance = self::monthlyAllowance('free'); + $plusAllowance = self::monthlyAllowance('plus'); + $proAllowance = self::monthlyAllowance('pro'); + $db->prepare( "UPDATE user_tool_credits SET balance = CASE tier - WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . " - WHEN 'plus' THEN " . self::MONTHLY_ALLOWANCE['plus'] . " - WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . " + WHEN 'free' THEN {$freeAllowance} + WHEN 'plus' THEN {$plusAllowance} + WHEN 'pro' THEN {$proAllowance} ELSE balance END, + allowance = CASE tier + WHEN 'free' THEN {$freeAllowance} + WHEN 'plus' THEN {$plusAllowance} + WHEN 'pro' THEN {$proAllowance} + ELSE allowance END, last_reset = CURDATE() WHERE user_id = ? AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))" @@ -144,38 +93,39 @@ final class FreeTier return is_array($row) ? $row : null; } - /** - * Check whether the user may proceed with a tool call. - * - * Returns: - * ['ok' => true, 'balance' => int, 'bonus_balance' => int, 'tier' => string] - * ['ok' => false, 'balance' => int, 'bonus_balance' => int, 'tier' => string, - * 'reason' => 'no_credits'|'rate_limit'] - */ public static function check(int $userId, string $tool): array + { + return self::checkAmount($userId, $tool, self::cost($tool)); + } + + public static function checkAmount(int $userId, string $tool, int $credits): array { $db = dbnmDb(); - $cost = self::cost($tool); $row = self::row($userId); + $credits = max(0, $credits); if ($row === null) { return [ - 'ok' => false, 'balance' => 0, 'bonus_balance' => 0, - 'tier' => 'free', 'reason' => 'no_credits', + 'ok' => false, + 'balance' => 0, + 'bonus_balance' => 0, + 'tier' => 'free', + 'reason' => 'no_credits', + 'cost' => $credits, ]; } $balance = (int)$row['balance']; - $bonus = (int)$row['bonus_balance']; - $tier = (string)$row['tier']; + $bonus = (int)$row['bonus_balance']; + $tier = (string)$row['tier']; $base = [ 'balance' => $balance, 'bonus_balance' => $bonus, 'tier' => $tier, + 'cost' => $credits, ]; - // Hourly rate limit (always applies) $stmt = $db->prepare( 'SELECT COUNT(*) FROM user_tool_usage_log WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0' @@ -186,67 +136,85 @@ final class FreeTier return $base + ['ok' => false, 'reason' => 'rate_limit']; } - // Free tool (cost=0) always passes credit check - if ($cost === 0) { + if ($credits === 0) { return $base + ['ok' => true]; } - if (($balance + $bonus) < $cost) { + if (($balance + $bonus) < $credits) { return $base + ['ok' => false, 'reason' => 'no_credits']; } return $base + ['ok' => true]; } - /** - * Deduct credits for a completed tool call and log the usage. - * Spends from `balance` first, then `bonus_balance`. - * - * Returns the new effective balance (balance + bonus_balance). - */ public static function deduct(int $userId, string $tool): int { - $db = dbnmDb(); - $cost = self::cost($tool); - $row = self::row($userId); + return self::deductAmount($userId, $tool, self::cost($tool)); + } - if ($cost > 0 && $row !== null) { - $balance = (int)$row['balance']; - $bonus = (int)$row['bonus_balance']; + public static function deductAmount(int $userId, string $tool, int $credits, array $metadata = []): int + { + $db = dbnmDb(); + $credits = max(0, $credits); + $row = self::row($userId); + $beforeMonthly = $row ? (int)$row['balance'] : 0; + $beforePrepaid = $row ? (int)$row['bonus_balance'] : 0; + $fromMonthly = 0; + $fromPrepaid = 0; - $fromBalance = min($cost, $balance); - $fromBonus = $cost - $fromBalance; + if ($credits > 0 && $row !== null) { + $fromMonthly = min($credits, $beforeMonthly); + $fromPrepaid = $credits - $fromMonthly; - $db->prepare( + $stmt = $db->prepare( 'UPDATE user_tool_credits SET balance = GREATEST(0, balance - ?), bonus_balance = GREATEST(0, bonus_balance - ?) - WHERE user_id = ?' - )->execute([$fromBalance, $fromBonus, $userId]); + WHERE user_id = ? + AND (balance + bonus_balance) >= ?' + ); + $stmt->execute([$fromMonthly, $fromPrepaid, $userId, $credits]); + + if ($stmt->rowCount() < 1) { + $check = self::checkAmount($userId, $tool, $credits); + if (empty($check['ok'])) { + throw new RuntimeException('Insufficient credits while settling tool charge.'); + } + } } $db->prepare( 'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)' - )->execute([$userId, $tool, $cost]); + )->execute([$userId, $tool, $credits]); $latest = self::row($userId); - return $latest ? ((int)$latest['balance'] + (int)$latest['bonus_balance']) : 0; + $afterMonthly = $latest ? (int)$latest['balance'] : 0; + $afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0; + self::recordLedger($userId, 'tool_charge', $tool, -$credits, [ + 'from_monthly' => $fromMonthly, + 'from_prepaid' => $fromPrepaid, + 'before_monthly' => $beforeMonthly, + 'before_prepaid' => $beforePrepaid, + 'after_monthly' => $afterMonthly, + 'after_prepaid' => $afterPrepaid, + ] + $metadata); + + return $afterMonthly + $afterPrepaid; } - /** Effective balance (monthly + bonus). */ public static function balance(int $userId): int { $row = self::row($userId); return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0; } - /** Detailed balance breakdown for UI rendering. */ public static function balanceDetail(int $userId): array { $row = self::row($userId); if (!$row) { return ['balance' => 0, 'bonus_balance' => 0, 'tier' => 'free']; } + return [ 'balance' => (int)$row['balance'], 'bonus_balance' => (int)$row['bonus_balance'], @@ -262,10 +230,6 @@ final class FreeTier ]; } - /** - * Award one-time bonus credits (survey reward, Stripe topup, manual grant). - * Source is logged via user_tool_usage_log with a negative credits_used value. - */ public static function awardBonus(int $userId, int $credits, string $source): int { if ($credits <= 0) { @@ -273,20 +237,29 @@ final class FreeTier } $db = dbnmDb(); self::ensureRow($userId); + $row = self::row($userId); + $beforeMonthly = $row ? (int)$row['balance'] : 0; + $beforePrepaid = $row ? (int)$row['bonus_balance'] : 0; + $db->prepare('UPDATE user_tool_credits SET bonus_balance = bonus_balance + ? WHERE user_id = ?') ->execute([$credits, $userId]); $db->prepare('INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)') ->execute([$userId, 'bonus:' . substr($source, 0, 40), -$credits]); - return self::balance($userId); + + $latest = self::row($userId); + $afterMonthly = $latest ? (int)$latest['balance'] : 0; + $afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0; + self::recordLedger($userId, 'credit_grant', 'bonus:' . substr($source, 0, 40), $credits, [ + 'source' => $source, + 'before_monthly' => $beforeMonthly, + 'before_prepaid' => $beforePrepaid, + 'after_monthly' => $afterMonthly, + 'after_prepaid' => $afterPrepaid, + ]); + + return $afterMonthly + $afterPrepaid; } - /** - * Set or upgrade a user's tier (called by Stripe subscription webhook). - * Refills monthly balance to the new tier's allowance. - * - * When $trialEndIso is non-null, also writes trial_started_at (preserving original on updates) - * and trial_expires_at — used when subscription.status='trialing'. - */ public static function setTier( int $userId, string $tier, @@ -325,12 +298,16 @@ final class FreeTier WHERE user_id = ?' )->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]); } + + self::recordLedger($userId, 'subscription_refill', 'subscription:' . $tier, $allowance, [ + 'tier' => $tier, + 'subscription_id' => $subscriptionId, + 'stripe_customer_id' => $stripeCustomerId, + 'period_end' => $periodEndIso, + 'trial_end' => $trialEndIso, + ]); } - /** - * Refill monthly balance at subscription renewal (invoice.paid). - * Does not touch bonus_balance. - */ public static function refillForRenewal(int $userId, string $tier, ?string $periodEndIso): void { $db = dbnmDb(); @@ -338,20 +315,22 @@ final class FreeTier $db->prepare( 'UPDATE user_tool_credits SET balance = ?, + allowance = ?, subscription_period_end = ?, last_reset = CURDATE() WHERE user_id = ?' - )->execute([$allowance, $periodEndIso, $userId]); + )->execute([$allowance, $allowance, $periodEndIso, $userId]); + + self::recordLedger($userId, 'subscription_refill', 'invoice:' . $tier, $allowance, [ + 'tier' => $tier, + 'period_end' => $periodEndIso, + ]); } - /** - * Revert a user to free tier (subscription canceled, trial ended without conversion). - * Preserves bonus_balance and case_documents (handled by 60-day cron). - * Stamps trial_downgraded_at if a trial was active. - */ public static function clearTier(int $userId): void { $db = dbnmDb(); + $allowance = self::monthlyAllowance('free'); $db->prepare( "UPDATE user_tool_credits SET tier = 'free', @@ -366,10 +345,11 @@ final class FreeTier END, trial_expires_at = NULL WHERE user_id = ?" - )->execute([self::monthlyAllowance('free'), self::monthlyAllowance('free'), $userId]); + )->execute([$allowance, $allowance, $userId]); + + self::recordLedger($userId, 'tier_change', 'subscription:clear', 0, ['tier' => 'free']); } - /** Mark survey as completed so the bonus can only be claimed once per account. */ public static function markSurveyCompleted(int $userId): void { $db = dbnmDb(); @@ -384,13 +364,118 @@ final class FreeTier return !empty($row['survey_completed_at']); } - /** Create the user_tool_credits row if missing (idempotent). */ public static function ensureRow(int $userId): void { $db = dbnmDb(); + $allowance = self::monthlyAllowance('free'); $db->prepare( 'INSERT IGNORE INTO user_tool_credits (user_id, balance, allowance, tier, last_reset, created_at) VALUES (?, ?, ?, ?, CURDATE(), NOW())' - )->execute([$userId, self::monthlyAllowance('free'), self::monthlyAllowance('free'), 'free']); + )->execute([$userId, $allowance, $allowance, 'free']); + } + + public static function transcribeCreditsForSeconds(float $seconds): int + { + return PricingCatalog::transcribeCreditsForSeconds($seconds); + } + + public static function estimateTranscribeCreditsFromBytes(int $bytes): int + { + return PricingCatalog::estimateTranscribeCreditsFromBytes($bytes); + } + + public static function estimateTranscribeCreditsFromFile(string $filename, int $bytes): int + { + return PricingCatalog::estimateTranscribeCreditsFromFile($filename, $bytes); + } + + public static function createReservation(int $userId, string $tool, int $credits, array $metadata = []): int + { + try { + $db = dbnmDb(); + $db->prepare( + 'INSERT INTO user_tool_credit_reservations + (user_id, tool, reserved_credits, status, metadata_json, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 2 HOUR))' + )->execute([ + $userId, + substr($tool, 0, 40), + max(0, $credits), + 'reserved', + json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + return (int)$db->lastInsertId(); + } catch (Throwable $e) { + return 0; + } + } + + public static function settleReservation(int $reservationId, int $credits, string $provider, float $durationSeconds, array $metadata = []): void + { + if ($reservationId <= 0) { + return; + } + try { + $db = dbnmDb(); + $db->prepare( + "UPDATE user_tool_credit_reservations + SET status = 'settled', + settled_credits = ?, + provider = ?, + duration_seconds = ?, + metadata_json = ?, + settled_at = NOW() + WHERE id = ? AND status = 'reserved'" + )->execute([ + max(0, $credits), + substr($provider, 0, 40), + round($durationSeconds, 2), + json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $reservationId, + ]); + } catch (Throwable $e) { + // Non-fatal audit path. + } + } + + public static function releaseReservation(int $reservationId, array $metadata = []): void + { + if ($reservationId <= 0) { + return; + } + try { + $db = dbnmDb(); + $db->prepare( + "UPDATE user_tool_credit_reservations + SET status = 'released', + metadata_json = ? + WHERE id = ? AND status = 'reserved'" + )->execute([ + json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $reservationId, + ]); + } catch (Throwable $e) { + // Non-fatal audit path. + } + } + + private static function recordLedger(int $userId, string $eventType, string $source, int $creditsDelta, array $metadata = []): void + { + try { + $db = dbnmDb(); + $db->prepare( + 'INSERT INTO user_tool_credit_ledger + (user_id, event_type, source, credits_delta, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?, NOW())' + )->execute([ + $userId, + substr($eventType, 0, 40), + substr($source, 0, 100), + $creditsDelta, + json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } catch (Throwable $e) { + // Ledger is additive and should not block core credit behavior before migration is applied. + } } } diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php index 1662ee7..a99954f 100644 --- a/includes/KorrespondAgent.php +++ b/includes/KorrespondAgent.php @@ -234,8 +234,9 @@ PROMPT; * * @return array Final result payload (matches NDJSON 'final' event shape). */ - public function generate(array $intake, array $classify, ?callable $emit = null): array + public function generate(array $intake, array $classify, ?callable $emit = null, string $engine = 'azure_mini'): array { + $draftDeployment = ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini'; $body = $intake['recipient_body'] ?? 'other'; $outputType = $intake['output_type'] ?? 'email'; $tone = $intake['tone'] ?? 'neutral'; @@ -256,7 +257,7 @@ PROMPT; // ── Draft in Norwegian bokmål ─────────────────────────────────────────── if ($emit) { $emit('progress', ['detail' => self::L('drafting_no', $userLang)]); } $draftNo = $this->draftNorwegian( - $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal + $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $draftDeployment ); // ── Self-check: verify citations, deadline, goal, tone ────────────────── @@ -528,7 +529,7 @@ PROMPT; private function draftNorwegian( array $intake, array $classify, array $sources, string $bodyLabel, - string $outputType, string $tone, string $goal + string $outputType, string $tone, string $goal, string $draftDeployment = self::DRAFT_DEPLOYMENT ): string { $context = $this->buildContextBlob($intake); $toneLabel = $this->toneLabelNorsk($tone); @@ -576,7 +577,7 @@ Skriv kun utkastet. Ingen forklaring eller preamble. Bruk linjeskift som passer PROMPT; try { - return $this->azure->withDeployment(self::DRAFT_DEPLOYMENT)->chatText([ + return $this->azure->withDeployment($draftDeployment)->chatText([ ['role' => 'system', 'content' => 'Du er en erfaren norsk juridisk forfatter som skriver presist og faktabasert.'], ['role' => 'user', 'content' => $prompt], ], [ diff --git a/includes/LegalTools.php b/includes/LegalTools.php index 6023245..f19e483 100644 --- a/includes/LegalTools.php +++ b/includes/LegalTools.php @@ -186,8 +186,9 @@ final class DbnLegalToolsService ]; } - public function ask(string $question, string $language = 'en'): array + public function ask(string $question, string $language = 'en', string $engine = 'azure_mini'): array { + $engine = in_array($engine, ['azure_mini', 'azure_full'], true) ? $engine : 'azure_mini'; $search = $this->search($question, $language, 7); $hits = $search['hits']; $trace = $search['trace']; @@ -240,7 +241,8 @@ Return JSON only with these keys: PROMPT; $system = $this->legalJsonSystemPrompt($language); - $raw = $this->azure->chatText([ + $askDeployment = ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini'; + $raw = $this->azure->withDeployment($askDeployment)->chatText([ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $prompt], ], [ @@ -278,7 +280,7 @@ PROMPT; 'trace_metadata' => [ 'chunk_count' => count($hits), 'source_count' => count($hits), - 'deployment' => $this->azure->chatDeployment(), + 'deployment' => $askDeployment, 'citation_confidence' => $search['trace_metadata']['citation_confidence'] ?? 'medium', ], 'disclaimer' => dbnToolsDisclaimer($language), diff --git a/includes/PricingCatalog.php b/includes/PricingCatalog.php new file mode 100644 index 0000000..60bd70b --- /dev/null +++ b/includes/PricingCatalog.php @@ -0,0 +1,297 @@ +> */ + public static function plans(): array + { + return [ + 'free' => [ + 'sku' => 'free', + 'tier' => 'free', + 'name' => 'Gratis', + 'price_nok' => 0, + 'unit_amount' => 0, + 'currency' => 'nok', + 'monthly_credits' => 30, + 'effective_credit_cost' => null, + 'storage_bytes' => 0, + 'seats' => 1, + 'hourly_cap' => 10, + 'trial_days' => 0, + 'stripe_price_key' => null, + 'features' => [ + 'Pasted-text tools', + 'Norwegian legal corpus search', + 'No My Case storage', + ], + ], + 'plus' => [ + 'sku' => 'plus', + 'tier' => 'plus', + 'name' => 'Pluss', + 'price_nok' => 149, + 'unit_amount' => 14900, + 'currency' => 'nok', + 'monthly_credits' => 250, + 'effective_credit_cost' => 0.60, + 'storage_bytes' => 524288000, + 'seats' => 1, + 'hourly_cap' => 20, + 'trial_days' => 14, + 'stripe_price_key' => 'STRIPE_PRICE_PLUS_NOK', + 'features' => [ + '500 MB My Case storage', + 'Use private case context in tools', + 'Saved analyses', + '14-day trial', + ], + ], + 'pro' => [ + 'sku' => 'pro', + 'tier' => 'pro', + 'name' => 'Pro Familie', + 'price_nok' => 399, + 'unit_amount' => 39900, + 'currency' => 'nok', + 'monthly_credits' => 900, + 'effective_credit_cost' => 0.44, + 'storage_bytes' => 5368709120, + 'seats' => 3, + 'hourly_cap' => 40, + 'trial_days' => 0, + 'stripe_price_key' => 'STRIPE_PRICE_PRO_NOK', + 'features' => [ + '5 GB shared My Case storage', + '3 users on one case', + 'Full Azure model route', + 'Priority for complex analysis', + ], + ], + ]; + } + + /** @return array> */ + public static function topups(): array + { + return [ + 'topup_100' => [ + 'sku' => 'topup_100', + 'name' => 'Ekstra 100', + 'price_nok' => 129, + 'unit_amount' => 12900, + 'currency' => 'nok', + 'credits' => 100, + 'cost_per_credit' => 1.29, + 'stripe_price_key' => 'STRIPE_PRICE_TOPUP_100_NOK', + 'aliases' => ['topup_s'], + ], + 'topup_300' => [ + 'sku' => 'topup_300', + 'name' => 'Saks-pakke 300', + 'price_nok' => 299, + 'unit_amount' => 29900, + 'currency' => 'nok', + 'credits' => 300, + 'cost_per_credit' => 1.00, + 'stripe_price_key' => 'STRIPE_PRICE_TOPUP_300_NOK', + 'aliases' => ['topup_m'], + ], + 'topup_1000' => [ + 'sku' => 'topup_1000', + 'name' => 'Stor pakke 1000', + 'price_nok' => 849, + 'unit_amount' => 84900, + 'currency' => 'nok', + 'credits' => 1000, + 'cost_per_credit' => 0.85, + 'stripe_price_key' => 'STRIPE_PRICE_TOPUP_1000_NOK', + 'aliases' => ['topup_l'], + ], + ]; + } + + /** @return array */ + public static function toolCosts(): array + { + return [ + 'search' => 0, + 'corpus-search' => 0, + 'ask' => 1, + 'extract' => 1, + 'summarize' => 1, + 'translate' => 1, + 'korrespond_refine' => 1, + 'timeline' => 2, + 'redact' => 2, + 'barnevernet' => 3, + 'advocate' => 3, + 'korrespond' => 3, + 'legal-analysis' => 3, + 'discrepancy' => 4, + 'deep-research' => 6, + 'transcribe' => 5, + ]; + } + + /** @return array */ + public static function stt(): array + { + return [ + 'minimum_credits' => 5, + 'credits_per_started_minute' => 1, + 'reservation_bytes_per_credit' => 300000, + ]; + } + + /** @return array|null */ + public static function plan(string $sku): ?array + { + $plans = self::plans(); + return $plans[$sku] ?? null; + } + + /** @return array|null */ + public static function topup(string $sku): ?array + { + $canonical = self::canonicalSku($sku); + $topups = self::topups(); + return $topups[$canonical] ?? null; + } + + public static function canonicalSku(string $sku): string + { + $sku = trim($sku); + if ($sku === 'light') { + return 'plus'; + } + if ($sku === 'pro-plus') { + return 'pro'; + } + foreach (self::topups() as $canonical => $topup) { + if ($sku === $canonical || in_array($sku, $topup['aliases'] ?? [], true)) { + return $canonical; + } + } + return $sku; + } + + public static function isSubscriptionSku(string $sku): bool + { + $sku = self::canonicalSku($sku); + return in_array($sku, ['plus', 'pro'], true); + } + + public static function isTopupSku(string $sku): bool + { + return self::topup($sku) !== null; + } + + /** @return list */ + public static function subscriptionSkus(): array + { + return ['plus', 'pro']; + } + + /** @return list */ + public static function topupSkus(): array + { + return array_keys(self::topups()); + } + + public static function stripePriceKey(string $sku): ?string + { + $sku = self::canonicalSku($sku); + if (self::isSubscriptionSku($sku)) { + return self::plans()[$sku]['stripe_price_key'] ?? null; + } + $topup = self::topup($sku); + return $topup['stripe_price_key'] ?? null; + } + + public static function monthlyAllowance(string $tier): int + { + return (int)(self::plans()[$tier]['monthly_credits'] ?? self::plans()['free']['monthly_credits']); + } + + public static function hourlyCap(string $tier): int + { + return (int)(self::plans()[$tier]['hourly_cap'] ?? self::plans()['free']['hourly_cap']); + } + + public static function storageQuota(string $tier): int + { + return (int)(self::plans()[$tier]['storage_bytes'] ?? 0); + } + + public static function isPaidTier(string $tier): bool + { + return in_array($tier, ['plus', 'pro'], true); + } + + public static function toolCost(string $tool): int + { + return self::toolCosts()[$tool] ?? 1; + } + + public static function topupCredits(string $sku): int + { + $topup = self::topup($sku); + return $topup ? (int)$topup['credits'] : 0; + } + + public static function planTrialDays(string $sku): int + { + $sku = self::canonicalSku($sku); + return (int)(self::plans()[$sku]['trial_days'] ?? 0); + } + + public static function transcribeCreditsForSeconds(float $seconds): int + { + $stt = self::stt(); + $perMinute = max(1, (int)$stt['credits_per_started_minute']); + $minimum = max(1, (int)$stt['minimum_credits']); + $minutes = max(1, (int)ceil(max(0.0, $seconds) / 60)); + return max($minimum, $minutes * $perMinute); + } + + public static function estimateTranscribeCreditsFromBytes(int $bytes): int + { + $stt = self::stt(); + $bytesPerCredit = max(1, (int)$stt['reservation_bytes_per_credit']); + return max((int)$stt['minimum_credits'], (int)ceil(max(1, $bytes) / $bytesPerCredit)); + } + + public static function estimateTranscribeCreditsFromFile(string $filename, int $bytes): int + { + $stt = self::stt(); + $minimum = (int)$stt['minimum_credits']; + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + $bytesPerCredit = match ($ext) { + 'wav', 'flac' => 2000000, + 'mp3', 'm4a', 'mp4', 'aac', 'ogg', 'oga', 'webm' => (int)$stt['reservation_bytes_per_credit'], + default => 960000, + }; + return max($minimum, (int)ceil(max(1, $bytes) / max(1, $bytesPerCredit))); + } + + public static function formatNok(int $nok): string + { + return 'NOK ' . number_format($nok, 0, ',', ' '); + } + + public static function formatCredits(int $credits): string + { + return number_format($credits, 0, ',', ' '); + } +} diff --git a/includes/StripeClient.php b/includes/StripeClient.php index e5e7b42..1b71d92 100644 --- a/includes/StripeClient.php +++ b/includes/StripeClient.php @@ -1,16 +1,23 @@ */ + public static function subscriptionSkus(): array + { + return PricingCatalog::subscriptionSkus(); + } + + /** @return list */ + public static function topupSkus(): array + { + return PricingCatalog::topupSkus(); + } + public static function priceId(string $sku): string { - static $map = null; - if ($map === null) { - $map = [ - 'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'), - 'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'), - 'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'), - 'plus' => self::config('STRIPE_PRICE_PLUS_NOK'), - 'pro' => self::config('STRIPE_PRICE_PRO_NOK'), - ]; - } - $id = $map[$sku] ?? ''; + $canonical = PricingCatalog::canonicalSku($sku); + $key = PricingCatalog::stripePriceKey($canonical); + $id = $key ? self::config($key) : ''; + if ($id === '') { - throw new InvalidArgumentException("Unknown Stripe SKU: {$sku}"); + $legacy = [ + 'topup_100' => 'STRIPE_PRICE_TOPUP_S', + 'topup_300' => 'STRIPE_PRICE_TOPUP_M', + 'topup_1000' => 'STRIPE_PRICE_TOPUP_L', + ]; + $legacyKey = $legacy[$canonical] ?? null; + $id = $legacyKey ? self::config($legacyKey) : ''; + } + + if ($id === '') { + throw new InvalidArgumentException("Unknown Stripe SKU or missing price ID: {$sku}"); } return $id; } - /** Topup credit grants — must match values shown on pricing.php. */ public static function topupCredits(string $sku): int { - return match ($sku) { - 'topup_s' => 30, - 'topup_m' => 100, - 'topup_l' => 300, - default => 0, - }; + return PricingCatalog::topupCredits($sku); } - /** Map a Stripe price ID back to the internal subscription tier (plus/pro). */ public static function tierForPrice(string $priceId): ?string { - $map = [ - 'plus' => self::config('STRIPE_PRICE_PLUS_NOK'), - 'pro' => self::config('STRIPE_PRICE_PRO_NOK'), - ]; - foreach ($map as $tier => $configuredPriceId) { + foreach (PricingCatalog::subscriptionSkus() as $tier) { + $configuredPriceId = ''; + $key = PricingCatalog::stripePriceKey($tier); + if ($key !== null) { + $configuredPriceId = self::config($key); + } if ($configuredPriceId !== '' && hash_equals($configuredPriceId, $priceId)) { - return (string)$tier; + return $tier; } } return null; } - /** - * Create a Checkout Session. - * - * @param array $params Stripe parameters (flat form-encoded — see request() docs). - */ public function createCheckoutSession(array $params): array { return $this->request('POST', '/checkout/sessions', $params); } - /** Create a Customer Portal session for self-serve subscription management. */ public function createPortalSession(string $customerId, string $returnUrl): array { return $this->request('POST', '/billing_portal/sessions', [ @@ -111,13 +134,11 @@ final class StripeClient ]); } - /** Retrieve a subscription. */ public function getSubscription(string $subscriptionId): array { return $this->request('GET', '/subscriptions/' . urlencode($subscriptionId)); } - /** Find-or-create a Stripe customer for a given email. */ public function ensureCustomer(string $email, ?int $userId = null): string { $found = $this->request('GET', '/customers', ['email' => $email, 'limit' => 1]); @@ -132,10 +153,6 @@ final class StripeClient return (string)($created['id'] ?? ''); } - /** - * Verify a Stripe webhook signature. - * Stripe-Signature header format: t=,v1=[,v1=...] - */ public static function verifyWebhookSignature(string $payload, string $sigHeader, string $secret, int $toleranceSeconds = 300): bool { if ($secret === '' || $sigHeader === '') { @@ -166,10 +183,6 @@ final class StripeClient return false; } - /** - * Low-level HTTP request to Stripe API. Returns decoded JSON or throws on error. - * Stripe uses form-encoded bodies even for nested params (foo[bar]=baz). - */ public function request(string $method, string $path, array $params = []): array { $url = self::API_BASE . $path; @@ -218,7 +231,6 @@ final class StripeClient return $decoded; } - /** Flatten nested arrays into Stripe's form-encoding scheme (foo[bar]=baz). */ private static function flattenFormParams(array $params, string $prefix = ''): string { $pairs = []; diff --git a/includes/bootstrap.php b/includes/bootstrap.php index dffff74..6a38e14 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -332,6 +332,61 @@ function dbnToolsWithTelemetry(string $tool, string $language, callable $handler } } +function dbnToolsWithChargedTelemetry(string $tool, string $language, int $creditUserId, callable $handler, ?int $credits = null, array $creditMetadata = []): void +{ + $start = microtime(true); + + try { + $payload = $handler(); + if ($creditUserId > 0) { + $balance = $credits === null + ? dbnToolsFreeTierDeduct($creditUserId, $tool) + : dbnToolsFreeTierDeductAmount($creditUserId, $tool, $credits, $creditMetadata); + if ($balance >= 0 && !headers_sent()) { + header('X-Credits-Remaining: ' . $balance); + } + $payload['balance'] = $balance; + } + + $latency = (int)round((microtime(true) - $start) * 1000); + $payload['ok'] = $payload['ok'] ?? true; + $payload['latency_ms'] = $latency; + + dbnToolsLogMetadata([ + 'tool' => $tool, + 'language' => $language, + 'ok' => true, + 'latency_ms' => $latency, + 'chunk_count' => (int)($payload['trace_metadata']['chunk_count'] ?? 0), + 'source_count' => (int)($payload['trace_metadata']['source_count'] ?? 0), + 'deployment' => $payload['trace_metadata']['deployment'] ?? null, + ]); + + dbnToolsRespond($payload); + } catch (DbnToolsHttpException $e) { + $latency = (int)round((microtime(true) - $start) * 1000); + dbnToolsLogMetadata([ + 'tool' => $tool, + 'language' => $language, + 'ok' => false, + 'latency_ms' => $latency, + 'error_code' => $e->errorCode, + ]); + dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra); + } catch (Throwable $e) { + $latency = (int)round((microtime(true) - $start) * 1000); + dbnToolsLogMetadata([ + 'tool' => $tool, + 'language' => $language, + 'ok' => false, + 'latency_ms' => $latency, + 'error_code' => 'internal_error', + ]); + error_log('DBN tools error: ' . $e->getMessage()); + dbnToolsError('The tool could not complete this request.', 500, 'internal_error'); + } +} + function dbnToolsAiPortalRoot(): string { $root = dbnToolsEnv('DBN_AI_PORTAL_ROOT'); @@ -516,6 +571,40 @@ function dbnToolsFreeTierCheck(string $tool): int return $uid; } +function dbnToolsFreeTierCheckAmount(string $tool, int $credits): int +{ + if (!dbnToolsIsFreeTier()) { + return 0; + } + + require_once __DIR__ . '/FreeTier.php'; + $uid = (int)$_SESSION['dbn_tools_sso_uid']; + $result = FreeTier::checkAmount($uid, $tool, max(0, $credits)); + + if (!$result['ok']) { + $isRateLimit = ($result['reason'] ?? '') === 'rate_limit'; + $tier = (string)($result['tier'] ?? 'free'); + $cap = FreeTier::hourlyCap($tier); + http_response_code($isRateLimit ? 429 : 402); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + echo json_encode([ + 'ok' => false, + 'error' => ['code' => $result['reason'], 'message' => $isRateLimit + ? "Rate limit reached - your tier ({$tier}) allows {$cap} requests per hour." + : 'No credits remaining. See /pricing.php to top up or upgrade.', + ], + 'balance' => $result['balance'], + 'bonus_balance' => $result['bonus_balance'] ?? 0, + 'tier' => $tier, + 'cost' => $result['cost'] ?? $credits, + ], JSON_UNESCAPED_UNICODE); + exit; + } + + return $uid; +} + /** Return the current SSO user's tier, or 'free' if not SSO / no row. */ function dbnToolsCurrentTier(): string { @@ -619,6 +708,15 @@ function dbnToolsFreeTierDeduct(int $uid, string $tool): int return FreeTier::deduct($uid, $tool); } +function dbnToolsFreeTierDeductAmount(int $uid, string $tool, int $credits, array $metadata = []): int +{ + if ($uid === 0) { + return -1; + } + require_once __DIR__ . '/FreeTier.php'; + return FreeTier::deductAmount($uid, $tool, $credits, $metadata); +} + function dbnToolsClientSlug(): string { return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter'; diff --git a/includes/i18n.php b/includes/i18n.php index 8e2f780..acb97eb 100644 --- a/includes/i18n.php +++ b/includes/i18n.php @@ -233,7 +233,7 @@ function dbnToolsTranslations(): array 'nav_logout' => 'Log out', 'credits_available' => 'Available credits', 'credits_monthly' => 'monthly', - 'credits_bonus' => 'bonus', + 'credits_bonus' => 'prepaid', 'details_link' => 'Details', 'my_case' => 'My case', 'build_your_case' => 'Build your own case', @@ -678,7 +678,7 @@ function dbnToolsTranslations(): array 'nav_logout' => 'Logg ut', 'credits_available' => 'Tilgjengelige kreditter', 'credits_monthly' => 'månedlige', - 'credits_bonus' => 'bonus', + 'credits_bonus' => 'forhåndsbetalte', 'details_link' => 'Detaljer', 'my_case' => 'Min sak', 'build_your_case' => 'Bygg din egen sak', diff --git a/index.php b/index.php index 0974041..e33fe5b 100644 --- a/index.php +++ b/index.php @@ -2,6 +2,7 @@ declare(strict_types=1); require_once __DIR__ . '/includes/bootstrap.php'; +require_once __DIR__ . '/includes/PricingCatalog.php'; function dbnToolsSafeReturn(mixed $value, string $default = '/'): string { @@ -257,13 +258,13 @@ window.DBN_TOOLS_LANG = ;

'Gratis', 'uk' => 'Безкоштовно', 'pl' => 'Bezpłatnie', default => 'Free' }; ?>
- €0 + · - €9 Light + Pluss · - €29 Pro + Pro Familie · - €79 Pro+ +
diff --git a/pricing.php b/pricing.php index 61a9c21..4c6b09a 100644 --- a/pricing.php +++ b/pricing.php @@ -3,8 +3,10 @@ declare(strict_types=1); require_once __DIR__ . '/includes/bootstrap.php'; require_once __DIR__ . '/includes/FreeTier.php'; +require_once __DIR__ . '/includes/PricingCatalog.php'; $uiLang = dbnToolsCurrentLanguage(); +$isNorwegian = $uiLang === 'no'; $isAuthed = dbnToolsIsAuthenticated(); $currentTier = $isAuthed ? dbnToolsCurrentTier() : 'free'; $surveyDone = false; @@ -15,167 +17,114 @@ if ($isAuthed && dbnToolsIsFreeTier()) { $status = (string)($_GET['status'] ?? ''); $loginUrl = 'https://dobetternorge.no/tools-login.php?return=' . urlencode('/pricing.php'); $surveyUrl = 'https://dobetternorge.no/survey.php'; +$orgUrl = 'mailto:support@dobetternorge.no?subject=DBN%20Tools%20Organisasjon'; -function pt(string $key, string $lang): string { - return htmlspecialchars(dbnToolsT($key, $lang)); +function h(mixed $value): string +{ + return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } -$creditsPerMonth = $uiLang === 'no' ? 'kreditter / mnd' : ($uiLang === 'uk' ? 'кредитів / міс' : ($uiLang === 'pl' ? 'kredytów / mies' : 'credits / mo')); -$perMonth = $uiLang === 'no' ? '/ måned' : ($uiLang === 'uk' ? '/ міс' : ($uiLang === 'pl' ? '/ mies' : '/ month')); -$capSuffix = $uiLang === 'no' ? '/ time' : ($uiLang === 'uk' ? '/ год' : ($uiLang === 'pl' ? '/ godz' : '/ hour')); -$freeName = $uiLang === 'no' ? 'Gratis' : ($uiLang === 'uk' ? 'Безкоштовно' : ($uiLang === 'pl' ? 'Bezpłatnie' : 'Free')); -$unlimited = $uiLang === 'no' ? 'Ubegrenset' : ($uiLang === 'uk' ? 'Необмежено' : ($uiLang === 'pl' ? 'Nieograniczone' : 'Unlimited')); -$noStorage = $uiLang === 'no' ? 'Ingen saksoppbevaring' : ($uiLang === 'uk' ? 'Без сховища справ' : ($uiLang === 'pl' ? 'Brak przechowywania spraw' : 'No case storage')); +function nok(int $amount): string +{ + return PricingCatalog::formatNok($amount); +} -$tiers = [ - [ - 'sku' => 'free', - 'name' => $freeName, - 'price' => '€0', - 'period' => $uiLang === 'no' ? 'alltid' : ($uiLang === 'uk' ? 'завжди' : ($uiLang === 'pl' ? 'zawsze' : 'always')), - 'credits' => '30 ' . $creditsPerMonth, - 'storage' => $noStorage, - 'seats' => $uiLang === 'no' ? '1 bruker' : ($uiLang === 'uk' ? '1 користувач' : ($uiLang === 'pl' ? '1 użytkownik' : '1 user')), - 'cap' => '10 ' . $capSuffix, - 'features' => $uiLang === 'no' ? [ - 'Alle 13 verktøy på innlimt tekst', - 'Norsk juridisk korpus (~220K passasjer)', - 'EU-vert (Tyskland / Finland / Norge)', - ] : ($uiLang === 'uk' ? [ - 'Усі 13 інструментів на вставленому тексті', - 'Норвезький правовий корпус (~220K уривків)', - 'Розміщено в ЄС (Німеччина / Фінляндія / Норвегія)', - ] : ($uiLang === 'pl' ? [ - 'Wszystkie 13 narzędzi na wklejonym tekście', - 'Norweski korpus prawny (~220K fragmentów)', - 'Hostowane w UE (Niemcy / Finlandia / Norwegia)', - ] : [ - 'All 13 tools on pasted text', - 'Norwegian legal corpus (~220K passages)', - 'EU-hosted (Germany / Finland / Norway)', - ])), - 'cta' => $isAuthed ? null : dbnToolsT('pricing_cta_login', $uiLang), - 'highlight' => false, - ], - [ - 'sku' => 'light', - 'name' => 'Light', - 'price' => '€9', - 'period' => $perMonth, - 'credits' => '100 ' . $creditsPerMonth, - 'storage' => '500 MB', - 'seats' => $uiLang === 'no' ? '1 bruker' : ($uiLang === 'uk' ? '1 користувач' : ($uiLang === 'pl' ? '1 użytkownik' : '1 user')), - 'cap' => '20 ' . $capSuffix, - 'features' => $uiLang === 'no' ? [ - 'Min Sak — last opp dokumenter med OCR', - 'Bruk min sak som kontekst i alle verktøy', - 'Lagrede analyser — alle resultater samlet', - '14 dagers gratis prøveperiode (kort kreves)', - ] : ($uiLang === 'uk' ? [ - 'Моя справа — завантаження документів з OCR', - 'Використання справи як контексту в усіх інструментах', - 'Збережені аналізи — всі результати в одному місці', - '14-денна безкоштовна пробна версія (потрібна картка)', - ] : ($uiLang === 'pl' ? [ - 'Moja sprawa — przesyłanie dokumentów z OCR', - 'Używanie sprawy jako kontekstu w każdym narzędziu', - 'Zapisane analizy — wszystkie wyniki w jednym miejscu', - '14-dniowy bezpłatny okres próbny (wymagana karta)', - ] : [ - 'My Case — upload documents with OCR', - 'Use my case as context in every tool', - 'Saved analyses — every run kept and searchable', - '14-day free trial (card required)', - ])), - 'highlight' => false, - 'badge' => null, - ], - [ - 'sku' => 'pro', - 'name' => 'Pro', - 'price' => '€29', - 'period' => $perMonth, - 'credits' => '500 ' . $creditsPerMonth, - 'storage' => '5 GB', - 'seats' => $uiLang === 'no' ? '3 brukere · delt sak' : ($uiLang === 'uk' ? '3 користувачі · спільна справа' : ($uiLang === 'pl' ? '3 użytkowników · wspólna sprawa' : '3 users · shared case')), - 'cap' => '40 ' . $capSuffix, - 'features' => $uiLang === 'no' ? [ - 'Alt i Light, med mer plass og raskere modeller', - 'Familie-sete: 3 innlogginger på samme sak', - 'Prioritert GPT-4o for kompleks analyse', - 'Audit-logg for hvem som kjørte hva', - ] : ($uiLang === 'uk' ? [ - 'Все з Light, більше місця та швидші моделі', - 'Сімейні місця: 3 входи до однієї справи', - 'Пріоритетний GPT-4o для складного аналізу', - 'Журнал аудиту — хто що запускав', - ] : ($uiLang === 'pl' ? [ - 'Wszystko z Light, więcej miejsca i szybsze modele', - 'Miejsca rodzinne: 3 logowania do jednej sprawy', - 'Priorytetowy GPT-4o do złożonych analiz', - 'Dziennik audytu — kto co uruchamiał', - ] : [ - 'Everything in Light, with more space and faster models', - 'Family seats: 3 logins sharing one case', - 'Priority GPT-4o for complex analysis', - 'Audit log of who ran what', - ])), - 'highlight' => true, - 'badge' => $uiLang === 'no' ? 'Mest populær' : ($uiLang === 'uk' ? 'Найпопулярніший' : ($uiLang === 'pl' ? 'Najpopularniejszy' : 'Most popular')), - ], - [ - 'sku' => 'pro-plus', - 'name' => 'Pro+', - 'price' => '€79', - 'period' => $perMonth, - 'credits' => $unlimited, - 'storage' => '25 GB', - 'seats' => $uiLang === 'no' ? '10 brukere · delt sak' : ($uiLang === 'uk' ? '10 користувачів · спільна справа' : ($uiLang === 'pl' ? '10 użytkowników · wspólna sprawa' : '10 users · shared case')), - 'cap' => '80 ' . $capSuffix, - 'features' => $uiLang === 'no' ? [ - 'Alt i Pro, ubegrenset kreditter', - '25 GB delt sakslagring', - 'Dedikert prosesserings-kø (ingen ventetid)', - 'Personlig onboarding-samtale', - ] : ($uiLang === 'uk' ? [ - 'Все з Pro, необмежені кредити', - '25 GB спільного сховища справ', - 'Виділена черга обробки (без очікування)', - 'Особистий вступний дзвінок', - ] : ($uiLang === 'pl' ? [ - 'Wszystko z Pro, nieograniczone kredyty', - '25 GB wspólnego przechowywania spraw', - 'Dedykowana kolejka przetwarzania (bez czekania)', - 'Osobista rozmowa onboardingowa', - ] : [ - 'Everything in Pro, unlimited credits', - '25 GB shared case storage', - 'Dedicated processing queue (no waiting)', - 'Personal onboarding call', - ])), - 'highlight' => false, - 'badge' => $uiLang === 'no' ? 'For organisasjoner' : ($uiLang === 'uk' ? 'Для організацій' : ($uiLang === 'pl' ? 'Dla organizacji' : 'For organisations')), - ], +function credits(int $amount): string +{ + return PricingCatalog::formatCredits($amount); +} + +$copy = $isNorwegian ? [ + 'title' => 'Priser - DBN Tools', + 'description' => 'NOK-priser, kreditter og abonnement for tools.dobetternorge.no.', + 'eyebrow' => 'NOK-priser for DBN Tools', + 'headline' => 'Kreditter som gir mening', + 'subhead' => 'Månedlige kreditter brukes først. Forhåndsbetalte kreditter legges på toppen og utløper ikke.', + 'trial' => 'Pluss har 14 dagers prøveperiode. Kort kreves, og du kan kansellere når som helst.', + 'survey_title' => 'Få 25 ekstra kreditter', + 'survey_text' => 'Svar på fem korte spørsmål om hvordan du bruker verktøyene.', + 'survey_cta' => 'Ta undersøkelsen', + 'current' => 'Din plan', + 'choose' => 'Velg', + 'login' => 'Logg inn for å velge', + 'available' => 'Tilgjengelig', + 'topups_title' => 'Ekstra kreditter', + 'topups_lead' => 'Top-ups er engangskjøp. De utløper ikke og brukes etter månedlige kreditter.', + 'buy' => 'Kjøp', + 'login_buy' => 'Logg inn for å kjøpe', + 'tool_costs' => 'Verktøykostnader', + 'tool_costs_lead' => 'Kreditter trekkes bare når verktøyet fullfører med et gyldig resultat.', + 'organisation' => 'Organisasjon', + 'organisation_price' => 'Kontakt', + 'organisation_text' => 'For rådgivere, frivillige miljøer og større familieteam som trenger flere brukere, særskilte avtaler eller onboarding.', + 'contact' => 'Snakk med oss', + 'billing_note' => 'Stripe brukes for kortbetaling, abonnement og kvitteringer. Lokale DBN-kreditter er fasiten for tilgang.', + 'status_success' => 'Betalingen er bekreftet. Kontoen oppdateres når Stripe-webhooken er behandlet.', + 'status_canceled' => 'Betalingen ble avbrutt. Ingen endringer er gjort.', + 'connecting' => 'Kobler til Stripe...', + 'checkout_error' => 'Kunne ikke starte betaling. Prøv igjen.', +] : [ + 'title' => 'Pricing - DBN Tools', + 'description' => 'NOK pricing, credits, and subscriptions for tools.dobetternorge.no.', + 'eyebrow' => 'NOK pricing for DBN Tools', + 'headline' => 'Credits that make sense', + 'subhead' => 'Monthly credits are spent first. Prepaid credits sit on top and never expire.', + 'trial' => 'Plus includes a 14-day trial. Card required, cancel anytime.', + 'survey_title' => 'Get 25 extra credits', + 'survey_text' => 'Answer five short questions about how you use the tools.', + 'survey_cta' => 'Take the survey', + 'current' => 'Current plan', + 'choose' => 'Choose', + 'login' => 'Log in to choose', + 'available' => 'Available', + 'topups_title' => 'Extra credits', + 'topups_lead' => 'Top-ups are one-time purchases. They never expire and are spent after monthly credits.', + 'buy' => 'Buy', + 'login_buy' => 'Log in to buy', + 'tool_costs' => 'Tool costs', + 'tool_costs_lead' => 'Credits are charged only when a tool completes with a valid result.', + 'organisation' => 'Organisation', + 'organisation_price' => 'Contact', + 'organisation_text' => 'For advisers, volunteer groups, and larger family teams that need more users, custom terms, or onboarding.', + 'contact' => 'Talk to us', + 'billing_note' => 'Stripe handles cards, subscriptions, and receipts. Local DBN credits remain authoritative for access.', + 'status_success' => 'Payment confirmed. Your account updates when the Stripe webhook is processed.', + 'status_canceled' => 'Payment was canceled. No changes were made.', + 'connecting' => 'Connecting to Stripe...', + 'checkout_error' => 'Could not start checkout. Please try again.', ]; -$topupNotes = [ - 'topup_s' => dbnToolsT('pricing_topup_s_note', $uiLang), - 'topup_m' => dbnToolsT('pricing_topup_m_note', $uiLang), - 'topup_l' => dbnToolsT('pricing_topup_l_note', $uiLang), +$plans = PricingCatalog::plans(); +$topups = PricingCatalog::topups(); +$planFeaturesNo = [ + 'free' => ['30 kreditter per måned', 'Verktøy på innlimt tekst', 'Juridisk korpussøk', 'Ingen Min Sak-lagring'], + 'plus' => ['250 kreditter per måned', '500 MB Min Sak-lagring', '1 bruker', '14 dagers prøveperiode'], + 'pro' => ['900 kreditter per måned', '5 GB Min Sak-lagring', '3 brukere', 'Full Azure-modellrute'], ]; -$topups = [ - ['sku' => 'topup_s', 'price' => 'NOK 49', 'credits' => 30, 'note' => $topupNotes['topup_s']], - ['sku' => 'topup_m', 'price' => 'NOK 149', 'credits' => 100, 'note' => $topupNotes['topup_m']], - ['sku' => 'topup_l', 'price' => 'NOK 399', 'credits' => 300, 'note' => $topupNotes['topup_l']], +$planFeaturesEn = [ + 'free' => ['30 credits per month', 'Tools on pasted text', 'Legal corpus search', 'No My Case storage'], + 'plus' => ['250 credits per month', '500 MB My Case storage', '1 user', '14-day trial'], + 'pro' => ['900 credits per month', '5 GB My Case storage', '3 users', 'Full Azure model route'], +]; +$planFeatures = $isNorwegian ? $planFeaturesNo : $planFeaturesEn; + +$toolCostRows = [ + ['0', 'search, corpus-search, clarify-only gates'], + ['1', 'ask, extract, summarize, translate, korrespond_refine'], + ['2', 'timeline, redact'], + ['3', 'barnevernet, advocate, korrespond, legal-analysis'], + ['4', 'discrepancy'], + ['6', 'deep-research'], + [$isNorwegian ? 'variabel' : 'variable', $isNorwegian ? 'transcribe: 1 kreditt per startet lydminutt, minst 5' : 'transcribe: 1 credit per started audio minute, minimum 5'], ]; ?> - + - <?= pt('pricing_title_meta', $uiLang) ?> - + <?= h($copy['title']) ?> + @@ -184,218 +133,232 @@ $topups = [
-
- - +
+
-

-

-

+
+

+

+

+
+
-

+

-

+

-
- '🎉 Prøv Light gratis i 14 dager — kort kreves, kanseller når som helst, ingen belastning før dag 15.', - 'uk' => '🎉 Спробуйте Light безкоштовно 14 днів — потрібна картка, скасуйте будь-коли, списання лише з 15 дня.', - 'pl' => '🎉 Wypróbuj Light za darmo przez 14 dni — wymagana karta, anuluj w dowolnym momencie, brak opłat przed 15 dniem.', - default => '🎉 Try Light free for 14 days — card required, cancel anytime, no charge until day 15.', - } ?> -
- -
-
-

-

-
- -
+
+
+

+

+
+ +
-
- -
- - - -

-
- - -
-
    -
  • -
  • -
  • -
  • -
-
    - -
  • - -
- - - - - +
+ + +
+ Pro +

+

+ + +

+

+ + + + kr / + +

+
    + +
  • + +
  • +
+ + + + + + + + - + + + + + + + - - - - - - - - - -
+
+ +
+ +

+

+

+
    +
  • +
  • +
  • +
  • +
+ +

+
-
-

-

-
- -
-
-
-
- - - - - +
+
+
+

+

- +
+
+ +
+

+
+
+
kr /
+ + + + + +
+
-
-

-
- -

-
-
- -

-
-
- -

-
-
- -

-
-
- -

-
-
- -

-
+
+
+
+

+

+
+
+
+ + + + + + + + + + + + + + + +
+
+