Add NOK pricing catalog, credit ledger, success-based charging, and tier-gated model routing

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 13:42:27 +02:00
parent bffc714541
commit b21bfb2f1d
30 changed files with 1171 additions and 586 deletions
+6 -4
View File
@@ -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,10 +21,11 @@ $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'],
'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 = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
</p>
<p class="account-credits-sub">
<?= (int)$detail['balance'] ?> <?= htmlspecialchars(dbnToolsT('credits_monthly', $uiLang)) ?>
· <?= (int)$detail['bonus_balance'] ?> <?= htmlspecialchars(dbnToolsT('credits_bonus', $uiLang)) ?>
· <?= (int)$detail['bonus_balance'] ?> <?= $uiLang === 'no' ? 'forhåndsbetalte' : 'prepaid' ?>
</p>
<?php if ($renewalLabel): ?>
+4 -4
View File
@@ -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); }
$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);
});
+4 -2
View File
@@ -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]);
+5 -2
View File
@@ -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);
+4 -4
View File
@@ -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',
+4 -2
View File
@@ -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',
]);
+6 -4
View File
@@ -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',
+1 -3
View File
@@ -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');
+20 -14
View File
@@ -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,
];
}
+1 -1
View File
@@ -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);
+1 -3
View File
@@ -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');
+44 -4
View File
@@ -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,
]);
+5 -4
View File
@@ -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) {
+3
View File
@@ -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 || '?'}`,
+3
View File
@@ -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 || {};
+3
View File
@@ -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`,
+6
View File
@@ -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);
}
+16
View File
@@ -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 =
'<div class="la-step done"><strong>' + escapeHtml(_laT('pass1')) + '</strong> — ' + escapeHtml(_laT('pass1Found', { n: (res.issues || []).length })) + '</div>'
+ '<div class="la-step done"><strong>' + escapeHtml(_laT('pass2')) + '</strong> — ' + escapeHtml(_laT('pass2Answered', { n: (res.issues || []).length })) + '</div>'
@@ -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) {
<button class="credit-modal__dismiss" onclick="this.closest('.credit-modal-overlay').remove()">Close</button>
</div>
</div>`;
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.<br>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(); });
}
+4 -7
View File
@@ -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'] ?? '');
<h2>Tilgjengelige kreditter</h2>
<p class="balance-big"><?= number_format($effective, 0, ',', ' ') ?></p>
<p class="balance-break">
<?= (int)$detail['balance'] ?> månedlige · <?= (int)$detail['bonus_balance'] ?> bonus
<?= (int)$detail['balance'] ?> månedlige · <?= (int)$detail['bonus_balance'] ?> forhåndsbetalte
</p>
</div>
+1 -1
View File
@@ -60,7 +60,7 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
<?php $eff = (int)$dashDetail['balance'] + (int)$dashDetail['bonus_balance']; ?>
<?= number_format($eff, 0, ',', ' ') ?>
</p>
<p style="margin:0; color:#6b7280; font-size:0.85rem;"><?= (int)$dashDetail['balance'] ?> <?= htmlspecialchars(dbnToolsT('credits_monthly', $uiLang)) ?> · <?= (int)$dashDetail['bonus_balance'] ?> <?= htmlspecialchars(dbnToolsT('credits_bonus', $uiLang)) ?> · <a href="/billing.php"><?= htmlspecialchars(dbnToolsT('details_link', $uiLang)) ?></a></p>
<p style="margin:0; color:#6b7280; font-size:0.85rem;"><?= (int)$dashDetail['balance'] ?> <?= htmlspecialchars(dbnToolsT('credits_monthly', $uiLang)) ?> · <?= (int)$dashDetail['bonus_balance'] ?> <?= $uiLang === 'no' ? 'forhåndsbetalte' : 'prepaid' ?> · <a href="/billing.php"><?= htmlspecialchars(dbnToolsT('details_link', $uiLang)) ?></a></p>
</div>
<?php if (in_array($dashTier, ['plus','pro'], true)): ?>
<a class="status-card" href="/min-sak.php" style="background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none; color:inherit;">
+221 -136
View File
@@ -1,102 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/PricingCatalog.php';
/**
* Credit + tier system for users of tools.dobetternorge.no.
* Credit + tier system for SSO users of tools.dobetternorge.no.
*
* Three tiers: free, plus, pro (NOK pricing, see pricing.php).
*
* Tables:
* user_tool_credits balance (monthly, resets), bonus_balance (never expires), tier,
* Stripe links, trial_started_at / trial_expires_at / trial_downgraded_at
* user_tool_usage_log every tool call with credits_used
* user_subscriptions Stripe subscription ledger
*
* Effective balance = balance + bonus_balance.
* Spend order: deduct from balance first, overflow to bonus_balance.
* Pro tier has the largest monthly allowance (still subject to hourly cap).
*
* Trial is Stripe-driven: when subscription.status='trialing', tier='plus' and
* trial_expires_at mirrors Stripe's trial_end. No homegrown trial cron the
* Stripe webhook flips tier='free' on subscription.deleted.
*
* CaveauAI client sessions bypass all credit checks.
* Only SSO sessions are subject to limits.
* balance = monthly credits, reset/refilled by tier.
* bonus_balance = prepaid/top-up credits, never expires.
* Spend order: monthly first, prepaid second.
*/
final class FreeTier
{
private const COSTS = [
'corpus-search' => 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,24 +93,25 @@ 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,
];
}
@@ -173,9 +123,9 @@ final class FreeTier
'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
{
return self::deductAmount($userId, $tool, self::cost($tool));
}
public static function deductAmount(int $userId, string $tool, int $credits, array $metadata = []): int
{
$db = dbnmDb();
$cost = self::cost($tool);
$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;
if ($cost > 0 && $row !== null) {
$balance = (int)$row['balance'];
$bonus = (int)$row['bonus_balance'];
if ($credits > 0 && $row !== null) {
$fromMonthly = min($credits, $beforeMonthly);
$fromPrepaid = $credits - $fromMonthly;
$fromBalance = min($cost, $balance);
$fromBonus = $cost - $fromBalance;
$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.
}
}
}
+5 -4
View File
@@ -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],
], [
+5 -3
View File
@@ -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),
+297
View File
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
/**
* Authoritative pricing, entitlement, and credit catalog for DBN Tools.
*
* Current database columns are intentionally preserved:
* - user_tool_credits.balance = monthly credits
* - user_tool_credits.bonus_balance = prepaid/top-up credits
*/
final class PricingCatalog
{
public const VERSION = 'dbn-tools-nok-2026-05-v1';
/** @return array<string,array<string,mixed>> */
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<string,array<string,mixed>> */
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<string,int> */
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<string,mixed> */
public static function stt(): array
{
return [
'minimum_credits' => 5,
'credits_per_started_minute' => 1,
'reservation_bytes_per_credit' => 300000,
];
}
/** @return array<string,mixed>|null */
public static function plan(string $sku): ?array
{
$plans = self::plans();
return $plans[$sku] ?? null;
}
/** @return array<string,mixed>|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<string> */
public static function subscriptionSkus(): array
{
return ['plus', 'pro'];
}
/** @return list<string> */
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, ',', ' ');
}
}
+63 -51
View File
@@ -1,16 +1,23 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/PricingCatalog.php';
/**
* Thin Stripe API wrapper no SDK, pure curl + HMAC.
* Thin Stripe API wrapper: no SDK, pure curl.
*
* Configuration is loaded from /etc/bnl/stripe.php (production) or env vars (local).
* Required keys:
* STRIPE_SECRET_KEY sk_live_... or sk_test_...
* STRIPE_PUBLISHABLE_KEY pk_live_... or pk_test_...
* STRIPE_WEBHOOK_SECRET whsec_...
* STRIPE_PRICE_TOPUP_S / _M / _L
* STRIPE_PRICE_PLUS_NOK / STRIPE_PRICE_PRO_NOK
* STRIPE_SECRET_KEY
* STRIPE_PUBLISHABLE_KEY
* STRIPE_WEBHOOK_SECRET
* STRIPE_PRICE_PLUS_NOK
* STRIPE_PRICE_PRO_NOK
* STRIPE_PRICE_TOPUP_100_NOK
* STRIPE_PRICE_TOPUP_300_NOK
* STRIPE_PRICE_TOPUP_1000_NOK
*
* Legacy STRIPE_PRICE_TOPUP_S/M/L are accepted temporarily for old config files.
*/
final class StripeClient
{
@@ -27,7 +34,6 @@ final class StripeClient
}
}
/** Load a Stripe config value from /etc/bnl/stripe.php OR env. */
public static function config(string $key): string
{
static $fileConfig = null;
@@ -46,63 +52,80 @@ final class StripeClient
return (string)($fileConfig[$key] ?? '');
}
/** Map an internal SKU to a Stripe price ID. */
public static function canonicalSku(string $sku): string
{
return PricingCatalog::canonicalSku($sku);
}
public static function isSubscriptionSku(string $sku): bool
{
return PricingCatalog::isSubscriptionSku($sku);
}
public static function isTopupSku(string $sku): bool
{
return PricingCatalog::isTopupSku($sku);
}
/** @return list<string> */
public static function subscriptionSkus(): array
{
return PricingCatalog::subscriptionSkus();
}
/** @return list<string> */
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=<timestamp>,v1=<signature>[,v1=<signature>...]
*/
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 = [];
+98
View File
@@ -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';
+2 -2
View File
@@ -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',
+5 -4
View File
@@ -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 = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
<p class="lt-pricing-strip__sub"><?= htmlspecialchars(dbnToolsT('pricing_strip_sub', $uiLang)) ?></p>
<?php $freeName = match($uiLang) { 'no' => 'Gratis', 'uk' => 'Безкоштовно', 'pl' => 'Bezpłatnie', default => 'Free' }; ?>
<div class="lt-pricing-strip__tiers">
<span class="lt-pricing-strip__tier">€0 <?= htmlspecialchars($freeName) ?></span>
<span class="lt-pricing-strip__tier"><?= htmlspecialchars(PricingCatalog::formatNok(0)) ?> <?= htmlspecialchars($freeName) ?></span>
<span class="lt-pricing-strip__sep">·</span>
<span class="lt-pricing-strip__tier">€9 Light</span>
<span class="lt-pricing-strip__tier"><?= htmlspecialchars(PricingCatalog::formatNok(149)) ?> Pluss</span>
<span class="lt-pricing-strip__sep">·</span>
<span class="lt-pricing-strip__tier lt-pricing-strip__tier--pop">€29 Pro</span>
<span class="lt-pricing-strip__tier lt-pricing-strip__tier--pop"><?= htmlspecialchars(PricingCatalog::formatNok(399)) ?> Pro Familie</span>
<span class="lt-pricing-strip__sep">·</span>
<span class="lt-pricing-strip__tier">€79 Pro+</span>
<span class="lt-pricing-strip__tier"><?= $uiLang === 'no' ? 'Organisasjon: kontakt' : 'Organisation: contact' ?></span>
</div>
<a href="/pricing.php<?= $uiLang !== 'en' ? '?lang=' . urlencode($uiLang) : '' ?>" class="lt-pricing-strip__cta"><?= htmlspecialchars(dbnToolsT('pricing_strip_cta', $uiLang)) ?></a>
</div>
+267 -304
View File
@@ -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'],
];
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<html lang="<?= h($uiLang) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= pt('pricing_title_meta', $uiLang) ?></title>
<meta name="description" content="<?= pt('pricing_desc_meta', $uiLang) ?>">
<title><?= h($copy['title']) ?></title>
<meta name="description" content="<?= h($copy['description']) ?>">
<link rel="canonical" href="https://tools.dobetternorge.no/pricing.php">
<meta name="theme-color" content="#00205B">
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -184,218 +133,232 @@ $topups = [
<link rel="stylesheet" href="assets/css/tools.css">
<link rel="stylesheet" href="assets/css/dbn-tools-redesign.css">
<style>
.pricing-shell { max-width: 1200px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
.pricing-hero { text-align: center; margin-bottom: 3rem; }
.pricing-hero h1 { font-family: 'Crimson Pro', serif; font-size: 2.5rem; margin: 0 0 0.75rem; }
.pricing-hero p { color: #4b5563; font-size: 1.1rem; max-width: 640px; margin: 0 auto; }
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.25rem; margin-bottom: 3rem; }
.pricing-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 1.75rem 1.5rem; display: flex; flex-direction: column; position: relative; }
.pricing-card.is-highlight { border-color: #00205B; border-width: 2px; box-shadow: 0 8px 24px rgba(0,32,91,0.08); }
.pricing-card .pricing-badge { position: absolute; top: -10px; right: 14px; background: #00205B; color: #fff; padding: 4px 10px; font-size: 0.72rem; border-radius: 999px; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 600; }
.pricing-card h2 { margin: 0 0 0.25rem; font-size: 1.4rem; font-family: 'Crimson Pro', serif; }
.pricing-price { display: flex; align-items: baseline; gap: 0.4rem; margin: 0.5rem 0 1rem; }
.pricing-price .amount { font-size: 2.2rem; font-weight: 700; color: #00205B; }
.pricing-price .period { color: #6b7280; font-size: 0.95rem; }
.pricing-meta { margin: 0 0 1.25rem; padding: 0; list-style: none; font-size: 0.92rem; color: #374151; }
.pricing-meta li { padding: 6px 0; border-bottom: 1px dashed #f3f4f6; }
.pricing-meta li:last-child { border-bottom: none; }
.pricing-features { list-style: none; padding: 0; margin: 0 0 1.5rem; flex: 1; }
.pricing-features li { padding: 5px 0 5px 1.4rem; position: relative; font-size: 0.92rem; color: #1f2937; }
.pricing-features li::before { content: ""; position: absolute; left: 0; color: #059669; font-weight: 700; }
.pricing-cta { display: block; text-align: center; padding: 0.75rem 1rem; border-radius: 8px; font-weight: 600; text-decoration: none; transition: all 0.15s; cursor: pointer; border: none; font-size: 0.95rem; }
.pricing-cta.primary { background: #00205B; color: #fff; }
.pricing-cta.primary:hover { background: #001740; }
.pricing-cta.secondary { background: #f3f4f6; color: #1f2937; }
.pricing-cta.secondary:hover { background: #e5e7eb; }
.pricing-cta.current { background: #d1fae5; color: #065f46; cursor: default; }
.pricing-topups { margin-top: 2rem; padding: 2rem 1.5rem; background: #f9fafb; border-radius: 12px; }
.pricing-topups h2 { font-family: 'Crimson Pro', serif; margin: 0 0 0.5rem; font-size: 1.6rem; }
.pricing-topups p.lead { color: #6b7280; margin: 0 0 1.5rem; }
.topup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.topup-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 1.25rem; text-align: center; }
.topup-card .price { font-size: 1.6rem; font-weight: 700; color: #00205B; }
.topup-card .credits { color: #374151; font-size: 0.95rem; margin: 0.25rem 0 0.5rem; }
.topup-card .note { color: #6b7280; font-size: 0.82rem; margin-bottom: 0.75rem; }
.survey-banner { background: linear-gradient(135deg, #00205B, #003478); color: #fff; padding: 1.75rem 1.5rem; border-radius: 12px; margin-bottom: 2rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; }
.survey-banner .copy { flex: 1; min-width: 260px; }
.survey-banner h3 { margin: 0 0 0.35rem; font-size: 1.3rem; font-family: 'Crimson Pro', serif; }
.survey-banner p { margin: 0; opacity: 0.9; font-size: 0.95rem; }
.survey-banner a { background: #ffd166; color: #00205B; padding: 0.7rem 1.4rem; border-radius: 8px; font-weight: 700; text-decoration: none; white-space: nowrap; }
.pricing-faq { margin-top: 3rem; }
.pricing-faq details { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 0.6rem; }
.pricing-faq summary { font-weight: 600; cursor: pointer; }
.pricing-faq p { color: #4b5563; margin: 0.75rem 0 0; font-size: 0.92rem; }
.status-pill-info { display: inline-block; margin-bottom: 1.5rem; padding: 6px 12px; background: #fef3c7; color: #92400e; border-radius: 6px; font-size: 0.9rem; }
.status-pill-success { background: #d1fae5; color: #065f46; }
.status-pill-error { background: #fee2e2; color: #991b1b; }
.lang-bar { text-align: right; margin-bottom: 1rem; font-size: 0.85rem; }
.lang-bar a { margin-left: 0.5rem; color: #6b7280; text-decoration: none; padding: 2px 6px; border-radius: 4px; }
.lang-bar a.is-active { background: #00205B; color: #fff; }
:root { --dbn-navy:#00205B; --dbn-red:#BA0C2F; --dbn-ink:#111827; --dbn-muted:#5b6472; --dbn-line:#d9dee8; --dbn-soft:#f7f9fc; --dbn-green:#0f766e; }
body { margin:0; background:#fbfcfe; color:var(--dbn-ink); font-family:'IBM Plex Sans', system-ui, sans-serif; }
.pricing-shell { max-width:1180px; margin:0 auto; padding:28px 20px 64px; }
.lang-bar { display:flex; justify-content:flex-end; gap:8px; font-size:0.86rem; margin-bottom:18px; }
.lang-bar a { color:var(--dbn-muted); text-decoration:none; padding:4px 8px; border-radius:6px; }
.lang-bar a.is-active { background:var(--dbn-navy); color:#fff; }
.pricing-hero { display:grid; grid-template-columns:minmax(0, 1.2fr) minmax(280px, .8fr); gap:28px; align-items:end; padding:28px 0 34px; border-bottom:1px solid var(--dbn-line); }
.eyebrow { margin:0 0 8px; color:var(--dbn-red); font-size:.82rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; }
h1 { margin:0; font-family:'Crimson Pro', serif; font-size:clamp(2.2rem, 4vw, 4rem); line-height:1; letter-spacing:0; }
.hero-copy { margin:14px 0 0; color:var(--dbn-muted); font-size:1.05rem; max-width:680px; }
.hero-note { background:#fff; border:1px solid var(--dbn-line); border-left:5px solid var(--dbn-red); border-radius:8px; padding:18px 18px; color:#263244; }
.status-pill { display:inline-flex; margin:22px 0 0; padding:9px 13px; border-radius:8px; font-size:.92rem; background:#fff7ed; color:#9a3412; border:1px solid #fed7aa; }
.status-pill.success { background:#ecfdf5; color:#065f46; border-color:#a7f3d0; }
.survey-banner { margin:26px 0 0; display:flex; align-items:center; justify-content:space-between; gap:16px; background:var(--dbn-navy); color:#fff; border-radius:8px; padding:18px 20px; }
.survey-banner h2 { margin:0 0 4px; font-size:1.1rem; }
.survey-banner p { margin:0; color:rgba(255,255,255,.82); }
.btn, .pricing-cta { border:0; display:inline-flex; align-items:center; justify-content:center; min-height:42px; padding:0 15px; border-radius:8px; font-weight:700; text-decoration:none; cursor:pointer; line-height:1.1; }
.btn-primary { background:var(--dbn-navy); color:#fff; }
.btn-primary:hover { background:#001740; }
.btn-light { background:#fff; color:var(--dbn-navy); }
.btn-muted { background:#edf1f7; color:#263244; }
.btn-current { background:#dcfce7; color:#166534; cursor:default; }
.plans-grid { display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:16px; margin:28px 0; }
.plan-card, .topup-card, .cost-panel { background:#fff; border:1px solid var(--dbn-line); border-radius:8px; padding:20px; }
.plan-card { display:flex; flex-direction:column; min-height:390px; position:relative; }
.plan-card.highlight { border-color:var(--dbn-navy); box-shadow:0 10px 30px rgba(0,32,91,.10); }
.plan-badge { position:absolute; top:14px; right:14px; background:#e8eef8; color:var(--dbn-navy); border-radius:999px; padding:4px 9px; font-size:.74rem; font-weight:800; }
.plan-name { margin:0; font-size:1.35rem; font-family:'Crimson Pro', serif; }
.plan-price { margin:16px 0 4px; display:flex; align-items:baseline; gap:6px; }
.plan-price strong { font-size:2rem; color:var(--dbn-navy); }
.plan-meta { margin:0 0 16px; color:var(--dbn-muted); font-size:.92rem; }
.plan-list { margin:0 0 22px; padding:0; list-style:none; flex:1; }
.plan-list li { padding:8px 0; border-bottom:1px dashed #eef1f6; font-size:.94rem; }
.fine-print { margin:10px 0 0; color:var(--dbn-muted); font-size:.84rem; }
.section-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin:34px 0 14px; }
.section-head h2 { margin:0; font-family:'Crimson Pro', serif; font-size:1.9rem; }
.section-head p { margin:0; color:var(--dbn-muted); max-width:640px; }
.topup-grid { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:16px; }
.topup-card { display:grid; gap:10px; }
.topup-price { color:var(--dbn-navy); font-size:1.8rem; font-weight:800; }
.topup-credits { font-weight:700; }
.topup-rate { color:var(--dbn-muted); font-size:.9rem; }
.cost-panel { margin-top:16px; overflow:auto; }
.cost-table { width:100%; border-collapse:collapse; min-width:620px; }
.cost-table th, .cost-table td { text-align:left; padding:11px 10px; border-bottom:1px solid #edf1f7; }
.cost-table th { color:var(--dbn-muted); font-size:.84rem; text-transform:uppercase; letter-spacing:.06em; }
.billing-note { margin-top:18px; color:var(--dbn-muted); font-size:.92rem; }
@media (max-width:980px) { .pricing-hero { grid-template-columns:1fr; } .plans-grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
@media (max-width:680px) { .pricing-shell { padding-inline:14px; } .plans-grid, .topup-grid { grid-template-columns:1fr; } .survey-banner, .section-head { align-items:flex-start; flex-direction:column; } .plan-card { min-height:0; } }
</style>
</head>
<body>
<main class="pricing-shell">
<div class="lang-bar">
<?php foreach (['no', 'en', 'uk', 'pl'] as $lc): ?>
<a href="?lang=<?= $lc ?>" class="<?= $lc === $uiLang ? 'is-active' : '' ?>"><?= htmlspecialchars(dbnToolsLanguageLabel($lc)) ?></a>
<nav class="lang-bar" aria-label="Language">
<?php foreach (['no', 'en'] as $lc): ?>
<a href="?lang=<?= h($lc) ?>" class="<?= $lc === $uiLang ? 'is-active' : '' ?>"><?= h(dbnToolsLanguageLabel($lc)) ?></a>
<?php endforeach; ?>
</div>
</nav>
<header class="pricing-hero">
<p style="margin:0 0 0.5rem; text-transform:uppercase; letter-spacing:0.08em; color:#6b7280; font-size:0.85rem;"><?= pt('pricing_eyebrow', $uiLang) ?></p>
<h1><?= pt('pricing_hero_title', $uiLang) ?></h1>
<p><?= pt('pricing_hero_sub', $uiLang) ?></p>
<div>
<p class="eyebrow"><?= h($copy['eyebrow']) ?></p>
<h1><?= h($copy['headline']) ?></h1>
<p class="hero-copy"><?= h($copy['subhead']) ?></p>
</div>
<aside class="hero-note">
<?= h($copy['trial']) ?>
</aside>
</header>
<?php if ($status === 'success'): ?>
<p class="status-pill-info status-pill-success"><?= pt('pricing_status_success', $uiLang) ?></p>
<p class="status-pill success"><?= h($copy['status_success']) ?></p>
<?php elseif ($status === 'canceled'): ?>
<p class="status-pill-info"><?= pt('pricing_status_canceled', $uiLang) ?></p>
<p class="status-pill"><?= h($copy['status_canceled']) ?></p>
<?php endif; ?>
<div style="background:linear-gradient(135deg,#fef3c7,#fcd34d);color:#78350f;padding:1rem 1.5rem;border-radius:10px;margin-bottom:2rem;text-align:center;font-weight:600;">
<?= match($uiLang) {
'no' => '🎉 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.',
} ?>
</div>
<?php if ($isAuthed && !$surveyDone): ?>
<div class="survey-banner">
<div class="copy">
<h3><?= pt('pricing_survey_title', $uiLang) ?></h3>
<p><?= pt('pricing_survey_text', $uiLang) ?></p>
</div>
<a href="<?= htmlspecialchars($surveyUrl) ?>"><?= pt('pricing_survey_cta', $uiLang) ?></a>
<section class="survey-banner">
<div>
<h2><?= h($copy['survey_title']) ?></h2>
<p><?= h($copy['survey_text']) ?></p>
</div>
<a class="btn btn-light" href="<?= h($surveyUrl) ?>"><?= h($copy['survey_cta']) ?></a>
</section>
<?php endif; ?>
<section class="pricing-grid" aria-label="<?= pt('pricing_faq_title', $uiLang) ?>">
<?php foreach ($tiers as $tier): ?>
<article class="pricing-card<?= !empty($tier['highlight']) ? ' is-highlight' : '' ?>">
<?php if (!empty($tier['badge'])): ?>
<span class="pricing-badge"><?= htmlspecialchars($tier['badge']) ?></span>
<section class="plans-grid" aria-label="Plans">
<?php foreach (['free', 'plus', 'pro'] as $sku): ?>
<?php $plan = $plans[$sku]; ?>
<article class="plan-card<?= $sku === 'pro' ? ' highlight' : '' ?>">
<?php if ($sku === 'pro'): ?><span class="plan-badge">Pro</span><?php endif; ?>
<h2 class="plan-name"><?= h($plan['name']) ?></h2>
<p class="plan-price">
<strong><?= h(nok((int)$plan['price_nok'])) ?></strong>
<span><?= $sku === 'free' ? '' : ($isNorwegian ? '/ mnd' : '/ mo') ?></span>
</p>
<p class="plan-meta">
<?php if ($sku === 'free'): ?>
<?= $isNorwegian ? 'Startnivå' : 'Starter tier' ?>
<?php else: ?>
<?= h(sprintf('%.2f', (float)$plan['effective_credit_cost'])) ?> kr / <?= $isNorwegian ? 'kreditt' : 'credit' ?>
<?php endif; ?>
<h2><?= htmlspecialchars($tier['name']) ?></h2>
<div class="pricing-price">
<span class="amount"><?= htmlspecialchars($tier['price']) ?></span>
<span class="period"><?= htmlspecialchars($tier['period']) ?></span>
</div>
<ul class="pricing-meta">
<li><?= htmlspecialchars($tier['credits']) ?></li>
<li><?= htmlspecialchars($tier['storage']) ?></li>
<li><?= htmlspecialchars($tier['seats']) ?></li>
<li><?= htmlspecialchars($tier['cap']) ?></li>
</ul>
<ul class="pricing-features">
<?php foreach ($tier['features'] as $feature): ?>
<li><?= htmlspecialchars($feature) ?></li>
</p>
<ul class="plan-list">
<?php foreach ($planFeatures[$sku] as $feature): ?>
<li><?= h($feature) ?></li>
<?php endforeach; ?>
<li><?= (int)$plan['hourly_cap'] ?> <?= $isNorwegian ? 'betalte kjøringer per time' : 'paid runs per hour' ?></li>
</ul>
<?php if ($tier['sku'] === 'free'): ?>
<?php if ($sku === 'free'): ?>
<?php if (!$isAuthed): ?>
<a class="pricing-cta primary" href="<?= htmlspecialchars($loginUrl) ?>"><?= htmlspecialchars($tier['cta'] ?? dbnToolsT('pricing_cta_login', $uiLang)) ?></a>
<a class="pricing-cta btn-primary" href="<?= h($loginUrl) ?>"><?= h($copy['login']) ?></a>
<?php elseif ($currentTier === 'free'): ?>
<span class="pricing-cta current"><?= pt('pricing_cta_current', $uiLang) ?></span>
<span class="pricing-cta btn-current"><?= h($copy['current']) ?></span>
<?php else: ?>
<span class="pricing-cta secondary"><?= pt('pricing_cta_available', $uiLang) ?></span>
<span class="pricing-cta btn-muted"><?= h($copy['available']) ?></span>
<?php endif; ?>
<?php else: ?>
<?php if (!$isAuthed): ?>
<a class="pricing-cta primary" href="<?= htmlspecialchars($loginUrl) ?>"><?= pt('pricing_cta_subscribe', $uiLang) ?></a>
<?php elseif ($currentTier === $tier['sku']): ?>
<span class="pricing-cta current"><?= pt('pricing_cta_current', $uiLang) ?></span>
<a class="pricing-cta btn-primary" href="<?= h($loginUrl) ?>"><?= h($copy['login']) ?></a>
<?php elseif ($currentTier === $sku): ?>
<span class="pricing-cta btn-current"><?= h($copy['current']) ?></span>
<?php else: ?>
<button type="button" class="pricing-cta primary" data-sku="<?= htmlspecialchars($tier['sku']) ?>" data-checkout="subscription">
<?= pt('pricing_cta_choose', $uiLang) ?> <?= htmlspecialchars($tier['name']) ?>
</button>
<button type="button" class="pricing-cta btn-primary" data-sku="<?= h($sku) ?>"><?= h($copy['choose'] . ' ' . $plan['name']) ?></button>
<?php endif; ?>
<?php endif; ?>
</article>
<?php endforeach; ?>
<article class="plan-card">
<span class="plan-badge"><?= h($copy['organisation']) ?></span>
<h2 class="plan-name"><?= h($copy['organisation']) ?></h2>
<p class="plan-price"><strong><?= h($copy['organisation_price']) ?></strong></p>
<p class="plan-meta"><?= $isNorwegian ? 'Tilpasset avtale' : 'Custom terms' ?></p>
<ul class="plan-list">
<li><?= $isNorwegian ? 'Flere brukere' : 'More users' ?></li>
<li><?= $isNorwegian ? 'Tilpassede kreditter' : 'Custom credits' ?></li>
<li><?= $isNorwegian ? 'Onboarding og støtte' : 'Onboarding and support' ?></li>
<li><?= $isNorwegian ? 'Avtales direkte' : 'Agreed directly' ?></li>
</ul>
<a class="pricing-cta btn-muted" href="<?= h($orgUrl) ?>"><?= h($copy['contact']) ?></a>
<p class="fine-print"><?= h($copy['organisation_text']) ?></p>
</article>
</section>
<section class="pricing-topups" aria-label="<?= pt('pricing_topup_title', $uiLang) ?>">
<h2><?= pt('pricing_topup_title', $uiLang) ?></h2>
<p class="lead"><?= pt('pricing_topup_lead', $uiLang) ?></p>
<section>
<div class="section-head">
<div>
<h2><?= h($copy['topups_title']) ?></h2>
<p><?= h($copy['topups_lead']) ?></p>
</div>
</div>
<div class="topup-grid">
<?php foreach ($topups as $topup): ?>
<div class="topup-card">
<div class="price"><?= htmlspecialchars($topup['price']) ?></div>
<div class="credits"><?= (int)$topup['credits'] ?> <?= pt('pricing_credits_label', $uiLang) ?></div>
<div class="note"><?= htmlspecialchars($topup['note']) ?></div>
<article class="topup-card">
<h3 class="plan-name"><?= h($topup['name']) ?></h3>
<div class="topup-price"><?= h(nok((int)$topup['price_nok'])) ?></div>
<div class="topup-credits"><?= h(credits((int)$topup['credits'])) ?> <?= $isNorwegian ? 'kreditter' : 'credits' ?></div>
<div class="topup-rate"><?= h(sprintf('%.2f', (float)$topup['cost_per_credit'])) ?> kr / <?= $isNorwegian ? 'kreditt' : 'credit' ?></div>
<?php if ($isAuthed): ?>
<button type="button" class="pricing-cta primary" data-sku="<?= htmlspecialchars($topup['sku']) ?>" data-checkout="topup"><?= pt('pricing_topup_buy', $uiLang) ?></button>
<button type="button" class="pricing-cta btn-primary" data-sku="<?= h($topup['sku']) ?>"><?= h($copy['buy']) ?></button>
<?php else: ?>
<a class="pricing-cta primary" href="<?= htmlspecialchars($loginUrl) ?>"><?= pt('pricing_login_first', $uiLang) ?></a>
<a class="pricing-cta btn-primary" href="<?= h($loginUrl) ?>"><?= h($copy['login_buy']) ?></a>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
</section>
<section class="pricing-faq" aria-label="<?= pt('pricing_faq_title', $uiLang) ?>">
<h2 style="font-family:'Crimson Pro', serif; margin-bottom:1rem;"><?= pt('pricing_faq_title', $uiLang) ?></h2>
<details>
<summary><?= pt('pricing_faq1_q', $uiLang) ?></summary>
<p><?= pt('pricing_faq1_a', $uiLang) ?></p>
</details>
<details>
<summary><?= pt('pricing_faq2_q', $uiLang) ?></summary>
<p><?= pt('pricing_faq2_a', $uiLang) ?></p>
</details>
<details>
<summary><?= pt('pricing_faq3_q', $uiLang) ?></summary>
<p><?= pt('pricing_faq3_a', $uiLang) ?></p>
</details>
<details>
<summary><?= pt('pricing_faq4_q', $uiLang) ?></summary>
<p><?= pt('pricing_faq4_a', $uiLang) ?></p>
</details>
<details>
<summary><?= pt('pricing_faq5_q', $uiLang) ?></summary>
<p><?= pt('pricing_faq5_a', $uiLang) ?></p>
</details>
<details>
<summary><?= pt('pricing_faq6_q', $uiLang) ?></summary>
<p><?= pt('pricing_faq6_a', $uiLang) ?></p>
</details>
<section>
<div class="section-head">
<div>
<h2><?= h($copy['tool_costs']) ?></h2>
<p><?= h($copy['tool_costs_lead']) ?></p>
</div>
</div>
<div class="cost-panel">
<table class="cost-table">
<thead>
<tr>
<th><?= $isNorwegian ? 'Kostnad' : 'Cost' ?></th>
<th><?= $isNorwegian ? 'Verktøy' : 'Tools' ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($toolCostRows as $row): ?>
<tr>
<td><?= h($row[0]) ?></td>
<td><?= h($row[1]) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="billing-note"><?= h($copy['billing_note']) ?></p>
</section>
</main>
<script>
(function() {
const connecting = <?= json_encode(dbnToolsT('pricing_connecting', $uiLang)) ?>;
const errorRetry = <?= json_encode(dbnToolsT('pricing_error_retry', $uiLang)) ?>;
const errorMsg = <?= json_encode(dbnToolsT('pricing_error_checkout', $uiLang)) ?>;
const buttons = document.querySelectorAll('button[data-checkout]');
buttons.forEach(btn => {
btn.addEventListener('click', async () => {
const sku = btn.getAttribute('data-sku');
btn.disabled = true;
const original = btn.textContent;
btn.textContent = connecting;
const connecting = <?= json_encode($copy['connecting'], JSON_UNESCAPED_UNICODE) ?>;
const checkoutError = <?= json_encode($copy['checkout_error'], JSON_UNESCAPED_UNICODE) ?>;
document.querySelectorAll('button[data-sku]').forEach((button) => {
button.addEventListener('click', async () => {
const sku = button.getAttribute('data-sku');
const original = button.textContent;
button.disabled = true;
button.textContent = connecting;
try {
const res = await fetch('/api/stripe-checkout.php', {
const response = await fetch('/api/stripe-checkout.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ sku })
});
const data = await res.json();
const data = await response.json();
if (data.ok && data.url) {
window.location.href = data.url;
} else {
btn.textContent = errorRetry;
alert(data.error?.message || errorMsg);
return;
}
} catch (e) {
btn.textContent = original;
alert(e.message);
alert(data.error?.message || checkoutError);
} catch (error) {
alert(error.message || checkoutError);
} finally {
setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 1500);
button.disabled = false;
button.textContent = original;
}
});
});
@@ -0,0 +1,44 @@
-- Migration 003: DBN Tools NOK pricing catalog support
-- Run against dobetternorge_maindb:
-- mysql -u root dobetternorge_maindb < scripts/sql/003_pricing_credit_catalog.sql
--
-- Existing user_tool_credits columns are preserved:
-- balance = monthly credits
-- bonus_balance = prepaid/top-up credits
START TRANSACTION;
CREATE TABLE IF NOT EXISTS user_tool_credit_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
event_type VARCHAR(40) NOT NULL,
source VARCHAR(100) NOT NULL,
credits_delta INT NOT NULL,
metadata_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_user_created (user_id, created_at DESC),
KEY idx_event_type (event_type, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Auditable DBN Tools credit grants, charges, and subscription refills';
CREATE TABLE IF NOT EXISTS user_tool_credit_reservations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
tool VARCHAR(40) NOT NULL,
reserved_credits INT UNSIGNED NOT NULL,
settled_credits INT UNSIGNED NULL,
status ENUM('reserved','settled','released','expired') NOT NULL DEFAULT 'reserved',
provider VARCHAR(40) NULL,
duration_seconds DECIMAL(10,2) NULL,
metadata_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
settled_at DATETIME NULL,
expires_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_user_status (user_id, status, created_at DESC),
KEY idx_expires (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='STT reservation audit trail; v1 gates by estimate and settles on success';
COMMIT;