From b21bfb2f1db8fbef10ef097bd0f152bb41c5b214 Mon Sep 17 00:00:00 2001
From: davegilligan
Date: Sun, 24 May 2026 13:42:27 +0200
Subject: [PATCH] Add NOK pricing catalog, credit ledger, success-based
charging, and tier-gated model routing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- PricingCatalog.php: single source of truth for plans (free/plus/pro), top-ups,
Stripe price env keys, tool costs (0–6 credits), STT variable billing, feature limits
- FreeTier.php: monthly-first credit deduction, ledger (user_tool_credit_ledger),
STT reservation/settle/release, monthly reset, trial logic
- StripeClient.php: canonical SKUs (plus/pro/topup_100/300/1000), legacy aliases kept
- stripe-checkout.php: subscription vs payment mode, trial gating, catalog metadata
- stripe-webhook.php: idempotent via stripe_events, handles subscription lifecycle +
invoice.paid renewal + one-time topup credit grants
- All API tools: success-based credit deduction (check before, charge after)
- transcribe.php: file-size heuristic reservation, settle from actual provider duration
- ask.php + LegalTools.php: ToolModels engine resolution — Pro gets gpt-4o
- KorrespondAgent.php + korrespond.php: tier-gated draft deployment —
Free/Plus gets gpt-4o-mini, Pro gets gpt-4o
- pricing.php: NOK-only, plan cards, top-up packs, Organisation contact card,
tool cost table, separate monthly/prepaid balance display
- 003_pricing_credit_catalog.sql: ledger and STT reservation tables
Co-Authored-By: Claude Sonnet 4.6
---
account.php | 12 +-
api/ask.php | 12 +-
api/discrepancy.php | 6 +-
api/extract.php | 7 +-
api/korrespond-refine.php | 8 +-
api/korrespond.php | 6 +-
api/legal-analysis.php | 10 +-
api/redact.php | 4 +-
api/stripe-checkout.php | 34 +-
api/stripe-webhook.php | 2 +-
api/timeline.php | 4 +-
api/transcribe.php | 48 +-
api/translate.php | 9 +-
assets/js/barnevernet.js | 3 +
assets/js/deep-research.js | 3 +
assets/js/discrepancy.js | 3 +
assets/js/korrespond.js | 6 +
assets/js/tools.js | 16 +
billing.php | 11 +-
dashboard.php | 2 +-
includes/FreeTier.php | 363 ++++++++-----
includes/KorrespondAgent.php | 9 +-
includes/LegalTools.php | 8 +-
includes/PricingCatalog.php | 297 ++++++++++
includes/StripeClient.php | 114 ++--
includes/bootstrap.php | 98 ++++
includes/i18n.php | 4 +-
index.php | 9 +-
pricing.php | 605 ++++++++++-----------
scripts/sql/003_pricing_credit_catalog.sql | 44 ++
30 files changed, 1171 insertions(+), 586 deletions(-)
create mode 100644 includes/PricingCatalog.php
create mode 100644 scripts/sql/003_pricing_credit_catalog.sql
diff --git a/account.php b/account.php
index 6f4da68..5e775b8 100644
--- a/account.php
+++ b/account.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
+require_once __DIR__ . '/includes/PricingCatalog.php';
if (!dbnToolsIsAuthenticated()) {
header('Location: /?return=' . urlencode($_SERVER['REQUEST_URI'] ?? '/account.php'));
@@ -20,11 +21,12 @@ $role = (string)($authUser['role'] ?? '');
$detail = $isSso ? FreeTier::balanceDetail((int)$_SESSION['dbn_tools_sso_uid']) : null;
$tier = $detail ? (string)$detail['tier'] : ($isSso ? 'free' : 'caveau');
+$catalogPlans = PricingCatalog::plans();
$tierLabels = [
- 'free' => ['Free', '#f3f4f6', '#374151'],
- 'plus' => ['Plus', '#ddd6fe', '#5b21b6'],
- 'pro' => ['Pro', '#bfdbfe', '#1e40af'],
- 'caveau' => ['CaveauAI', '#d1fae5', '#065f46'],
+ 'free' => [$catalogPlans['free']['name'], '#f3f4f6', '#374151'],
+ 'plus' => [$catalogPlans['plus']['name'], '#ddd6fe', '#5b21b6'],
+ 'pro' => [$catalogPlans['pro']['name'], '#bfdbfe', '#1e40af'],
+ 'caveau' => ['CaveauAI', '#d1fae5', '#065f46'],
];
$tierLabel = $tierLabels[$tier] ?? $tierLabels['free'];
@@ -106,7 +108,7 @@ window.DBN_TOOLS_LANG = = json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
= (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' ?>
diff --git a/api/ask.php b/api/ask.php
index f1207ae..31f3d0f 100644
--- a/api/ask.php
+++ b/api/ask.php
@@ -2,19 +2,19 @@
declare(strict_types=1);
require_once __DIR__ . '/../includes/LegalTools.php';
+require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
-$ftUid = dbnToolsFreeTierCheck('ask');
-$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'ask');
-if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
-$input = dbnToolsJsonInput(25000);
+$ftUid = dbnToolsFreeTierCheck('ask');
+$engine = ToolModels::engineForUser($ftUid, 'azure_mini');
+$input = dbnToolsJsonInput(25000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
-dbnToolsWithTelemetry('ask', $language, function () use ($input, $language): array {
+dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input, $language, $engine): array {
$question = dbnToolsInjectDocContent($input, dbnToolsString($input, 'question', 4000, false));
if (mb_strlen(trim($question), 'UTF-8') < 5) {
dbnToolsAbort('Enter a question or select a document before running.', 422, 'empty_text');
}
- return (new DbnLegalToolsService())->ask($question, $language);
+ return (new DbnLegalToolsService())->ask($question, $language, $engine);
});
diff --git a/api/discrepancy.php b/api/discrepancy.php
index 135e517..3e182fb 100644
--- a/api/discrepancy.php
+++ b/api/discrepancy.php
@@ -8,7 +8,6 @@ require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('discrepancy');
-$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy');
@ini_set('output_buffering', '0');
@ini_set('zlib.output_compression', '0');
@@ -19,7 +18,6 @@ ob_implicit_flush(true);
header('Content-Type: application/x-ndjson; charset=utf-8');
header('Cache-Control: no-store');
header('X-Accel-Buffering: no');
-if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$language = 'en';
$startTime = microtime(true);
@@ -146,6 +144,10 @@ try {
'deployment' => $result['trace_metadata']['deployment'] ?? null,
]);
+ $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy');
+ if ($ftRemaining >= 0) {
+ $result['balance'] = $ftRemaining;
+ }
$emit('final', ['result' => $result]);
diff --git a/api/extract.php b/api/extract.php
index 5ba70d7..598751c 100644
--- a/api/extract.php
+++ b/api/extract.php
@@ -6,8 +6,6 @@ require_once __DIR__ . '/../includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('extract');
-$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract');
-if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
try {
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
@@ -15,6 +13,11 @@ try {
}
$result = dbnToolsExtractUploadedFile($_FILES['file']);
+ $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract');
+ if ($ftRemaining >= 0) {
+ header('X-Credits-Remaining: ' . $ftRemaining);
+ $result['balance'] = $ftRemaining;
+ }
dbnToolsRespond($result);
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
diff --git a/api/korrespond-refine.php b/api/korrespond-refine.php
index 4a3cdc1..80c5946 100644
--- a/api/korrespond-refine.php
+++ b/api/korrespond-refine.php
@@ -68,10 +68,6 @@ try {
// Credit gate (refine is a paid pass)
$ftUid = dbnToolsFreeTierCheck('korrespond_refine');
- $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond_refine');
- if ($ftRemaining >= 0) {
- header('X-Credits-Remaining: ' . $ftRemaining);
- }
$emit('start', [
'jurisdiction' => $jurisdiction,
@@ -81,8 +77,12 @@ try {
$agent = new DbnKorrespondAgent();
$result = $agent->refine($intake, $classify, $originalDraftNo, $jurisdiction, $emit);
+ $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond_refine');
$result['ok'] = true;
$result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000);
+ if ($ftRemaining >= 0) {
+ $result['balance'] = $ftRemaining;
+ }
dbnToolsLogMetadata([
'tool' => 'korrespond_refine',
diff --git a/api/korrespond.php b/api/korrespond.php
index 1b91d6c..e3ff488 100644
--- a/api/korrespond.php
+++ b/api/korrespond.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/KorrespondAgent.php';
+require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@@ -165,11 +166,12 @@ try {
// ── Deduct credit now (Pass 2 starts) ───────────────────────────────────────
$ftUid = dbnToolsFreeTierCheck('korrespond');
+ $engine = ToolModels::engineForUser($ftUid, 'azure_mini');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond');
$creditDeducted = true;
// ── Pass 2: retrieve law → draft → self-check → translate ──────────────────
- $result = $agent->generate($intake, $classify, $emit);
+ $result = $agent->generate($intake, $classify, $emit, $engine);
$result['ok'] = true;
$result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000);
if ($ftRemaining >= 0) {
@@ -182,7 +184,7 @@ try {
'ok' => true,
'latency_ms' => $result['latency_ms'],
'source_count' => is_array($result['cited_law'] ?? null) ? count($result['cited_law']) : 0,
- 'deployment' => 'gpt-4o',
+ 'deployment' => ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini',
]);
diff --git a/api/legal-analysis.php b/api/legal-analysis.php
index 5e21df7..c8f00ff 100644
--- a/api/legal-analysis.php
+++ b/api/legal-analysis.php
@@ -20,6 +20,8 @@ header('X-Accel-Buffering: no');
$startTime = microtime(true);
$language = 'en';
$creditDeducted = false;
+$ftUid = 0;
+$ftRemaining = -1;
$emit = function (string $event, array $payload = []) use ($startTime): void {
$payload['event'] = $event;
@@ -44,6 +46,7 @@ try {
422, 'empty_text'
);
}
+ $ftUid = dbnToolsFreeTierCheck('legal-analysis');
$emit('start', [
'mode' => 'legal-analysis',
@@ -75,12 +78,8 @@ try {
}
// Deduct credit (gated until extract succeeds and at least one issue exists)
- $ftUid = dbnToolsFreeTierCheck('legal-analysis');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'legal-analysis');
$creditDeducted = true;
- if ($ftRemaining >= 0) {
- header('X-Credits-Remaining: ' . $ftRemaining);
- }
$emit('issues_extracted', [
'count' => count($issues),
@@ -129,6 +128,9 @@ try {
'model' => 'dbn-legal-agent-v3',
'latency_ms' => (int)round((microtime(true) - $startTime) * 1000),
];
+ if ($ftRemaining >= 0) {
+ $result['balance'] = $ftRemaining;
+ }
dbnToolsLogMetadata([
'tool' => 'legal-analysis',
diff --git a/api/redact.php b/api/redact.php
index e9efa22..ae6acd5 100644
--- a/api/redact.php
+++ b/api/redact.php
@@ -6,11 +6,9 @@ require_once __DIR__ . '/../includes/LegalTools.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('redact');
-$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'redact');
-if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$input = dbnToolsJsonInput(400000);
-dbnToolsWithTelemetry('redact', '', function () use ($input): array {
+dbnToolsWithChargedTelemetry('redact', '', $ftUid, function () use ($input): array {
$text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false));
if (mb_strlen(trim($text), 'UTF-8') < 10) {
dbnToolsAbort('Paste text, upload a file, or select a document before running.', 422, 'empty_text');
diff --git a/api/stripe-checkout.php b/api/stripe-checkout.php
index 4f02329..ab55c4c 100644
--- a/api/stripe-checkout.php
+++ b/api/stripe-checkout.php
@@ -16,12 +16,9 @@ if ($userId <= 0 || $email === '') {
}
$input = dbnToolsJsonInput(2000);
-$sku = (string)($input['sku'] ?? '');
+$sku = StripeClient::canonicalSku((string)($input['sku'] ?? ''));
-$validSubscriptions = ['plus', 'pro'];
-$validTopups = ['topup_s', 'topup_m', 'topup_l'];
-
-if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
+if (!StripeClient::isSubscriptionSku($sku) && !StripeClient::isTopupSku($sku)) {
dbnToolsError('Unknown SKU.', 400, 'unknown_sku');
}
@@ -33,7 +30,18 @@ try {
$successUrl = $baseUrl . '/billing.php?status=success&session_id={CHECKOUT_SESSION_ID}';
$cancelUrl = $baseUrl . '/pricing.php?status=canceled';
- $isSub = in_array($sku, $validSubscriptions, true);
+ $isSub = StripeClient::isSubscriptionSku($sku);
+ $credits = $isSub ? 0 : StripeClient::topupCredits($sku);
+ $metadata = [
+ 'user_id' => (string)$userId,
+ 'sku' => $sku,
+ 'catalog_version' => PricingCatalog::VERSION,
+ ];
+ if ($isSub) {
+ $metadata['tier'] = $sku;
+ } else {
+ $metadata['credits'] = (string)$credits;
+ }
$params = [
'mode' => $isSub ? 'subscription' : 'payment',
@@ -44,10 +52,7 @@ try {
'price' => StripeClient::priceId($sku),
'quantity' => 1,
]],
- 'metadata' => [
- 'user_id' => (string)$userId,
- 'sku' => $sku,
- ],
+ 'metadata' => $metadata,
'allow_promotion_codes' => true,
'billing_address_collection' => 'auto',
'locale' => 'auto',
@@ -58,10 +63,11 @@ try {
FreeTier::ensureRow($userId);
$detail = FreeTier::balanceDetail($userId);
$params['subscription_data'] = [
- 'metadata' => ['user_id' => (string)$userId, 'tier' => $sku],
+ 'metadata' => $metadata,
];
- if ($sku === 'plus' && empty($detail['trial_started_at'])) {
- $params['subscription_data']['trial_period_days'] = 14;
+ $trialDays = PricingCatalog::planTrialDays($sku);
+ if ($trialDays > 0 && empty($detail['trial_started_at'])) {
+ $params['subscription_data']['trial_period_days'] = $trialDays;
$params['subscription_data']['trial_settings'] = [
'end_behavior' => ['missing_payment_method' => 'cancel'],
];
@@ -69,7 +75,7 @@ try {
$params['payment_method_collection'] = 'always';
} else {
$params['payment_intent_data'] = [
- 'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
+ 'metadata' => $metadata,
];
}
diff --git a/api/stripe-webhook.php b/api/stripe-webhook.php
index bd26bbf..4034ee7 100644
--- a/api/stripe-webhook.php
+++ b/api/stripe-webhook.php
@@ -122,7 +122,7 @@ function handleCheckoutCompleted(PDO $db, array $session): void
if ($mode === 'payment') {
// One-time topup — grant credits immediately.
- $sku = (string)($metadata['sku'] ?? '');
+ $sku = StripeClient::canonicalSku((string)($metadata['sku'] ?? ''));
$credits = StripeClient::topupCredits($sku);
if ($credits > 0) {
FreeTier::awardBonus($userId, $credits, 'topup:' . $sku);
diff --git a/api/timeline.php b/api/timeline.php
index 041da65..79b5a7a 100644
--- a/api/timeline.php
+++ b/api/timeline.php
@@ -7,12 +7,10 @@ require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('timeline');
-$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'timeline');
-if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$input = dbnToolsJsonInput(400000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
-dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language, $ftUid): array {
+dbnToolsWithChargedTelemetry('timeline', $language, $ftUid, function () use ($input, $language, $ftUid): array {
$text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false));
if (mb_strlen(trim($text), 'UTF-8') < 10) {
dbnToolsAbort('Paste text, upload a file, or select a document before running.', 422, 'empty_text');
diff --git a/api/transcribe.php b/api/transcribe.php
index 7b4b721..62161b5 100644
--- a/api/transcribe.php
+++ b/api/transcribe.php
@@ -2,12 +2,14 @@
declare(strict_types=1);
require_once __DIR__ . '/../includes/LegalTools.php';
+require_once __DIR__ . '/../includes/FreeTier.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
-$ftUid = dbnToolsFreeTierCheck('transcribe');
-$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'transcribe');
-if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
+$ftUid = 0;
+$ftRemaining = -1;
+$sttReservationId = 0;
+$sttSettled = false;
set_time_limit(0);
ignore_user_abort(true);
@@ -92,6 +94,23 @@ $detectedMime = mime_content_type($file['tmp_name']) ?: 'application/octet-strea
$timeOffset = max(0.0, (float)($_POST['time_offset'] ?? 0));
$t0 = microtime(true);
+$estimatedCredits = FreeTier::estimateTranscribeCreditsFromFile((string)($file['name'] ?? ''), (int)($file['size'] ?? 0));
+$ftUid = dbnToolsFreeTierCheckAmount('transcribe', $estimatedCredits);
+if ($ftUid > 0) {
+ $sttReservationId = FreeTier::createReservation($ftUid, 'transcribe', $estimatedCredits, [
+ 'filename' => (string)($file['name'] ?? ''),
+ 'bytes' => (int)($file['size'] ?? 0),
+ 'mime' => $detectedMime,
+ 'language' => $language,
+ 'diarize' => $diarize,
+ ]);
+ register_shutdown_function(static function () use (&$sttSettled, $sttReservationId): void {
+ if (!$sttSettled && $sttReservationId > 0) {
+ FreeTier::releaseReservation($sttReservationId, ['reason' => 'request_ended_before_settlement']);
+ }
+ });
+}
+
// ── Auto-cascade: Azure → GCP → Whisper GPU ───────────────────────────────────
$result = null;
@@ -186,6 +205,25 @@ $engineLabel = match($engineUsed) {
// ── Log + respond ─────────────────────────────────────────────────────────────
+$durationSec = round((float)($result['duration_seconds'] ?? $result['duration'] ?? 0), 2);
+$creditsUsed = FreeTier::transcribeCreditsForSeconds($durationSec);
+$ftRemaining = dbnToolsFreeTierDeductAmount($ftUid, 'transcribe', $creditsUsed, [
+ 'engine' => $engineUsed,
+ 'duration_sec' => $durationSec,
+ 'estimated_credits' => $estimatedCredits,
+ 'reservation_id' => $sttReservationId,
+]);
+if ($ftRemaining >= 0) {
+ header('X-Credits-Remaining: ' . $ftRemaining);
+}
+if ($sttReservationId > 0) {
+ FreeTier::settleReservation($sttReservationId, $creditsUsed, $engineUsed, $durationSec, [
+ 'estimated_credits' => $estimatedCredits,
+ 'balance_after' => $ftRemaining,
+ ]);
+ $sttSettled = true;
+}
+
dbnToolsLogMetadata([
'tool' => 'transcribe',
'engine' => $engineUsed,
@@ -203,12 +241,14 @@ dbnToolsRespond([
'speaker_roles' => $speakerRoles,
'num_speakers' => $numDetected,
'language' => (string)($result['language'] ?? $language),
- 'duration_sec' => round((float)($result['duration_seconds'] ?? $result['duration'] ?? 0), 2),
+ 'duration_sec' => $durationSec,
'processing_sec'=> round((float)($result['processing_seconds'] ?? 0), 2),
'model' => $engineLabel,
'engine' => $engineUsed,
'latency_ms' => $latencyMs,
'cleaned_by' => $cleanedBy,
+ 'credits_used' => $creditsUsed,
+ 'balance' => $ftRemaining,
]);
diff --git a/api/translate.php b/api/translate.php
index 1f0effb..02dd7d1 100644
--- a/api/translate.php
+++ b/api/translate.php
@@ -55,10 +55,6 @@ try {
}
$ftUid = dbnToolsFreeTierCheck('translate');
- $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'translate');
- if ($ftRemaining >= 0) {
- header('X-Credits-Remaining: ' . $ftRemaining);
- }
$emit('start', [
'mode' => 'translate',
@@ -150,6 +146,11 @@ PROMPT;
'deployment' => 'gpt-4o-mini',
]);
+ $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'translate');
+ if ($ftRemaining >= 0) {
+ $result['balance'] = $ftRemaining;
+ }
+
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
diff --git a/assets/js/barnevernet.js b/assets/js/barnevernet.js
index 076f357..1331e2a 100644
--- a/assets/js/barnevernet.js
+++ b/assets/js/barnevernet.js
@@ -564,6 +564,9 @@
}
lastResult = finalResult;
+ if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(finalResult.balance);
+ }
const meta = finalResult.trace_metadata || {};
setStatus(
`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${meta.source_count || 0} sources · confidence ${meta.citation_confidence || '?'}`,
diff --git a/assets/js/deep-research.js b/assets/js/deep-research.js
index f40ec9a..dce5e4c 100644
--- a/assets/js/deep-research.js
+++ b/assets/js/deep-research.js
@@ -432,6 +432,9 @@
}
finalResult.query = query;
+ if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(finalResult.balance);
+ }
lastResult = finalResult;
const meta = finalResult.trace_metadata || {};
const rc = meta.retrieval_counts || {};
diff --git a/assets/js/discrepancy.js b/assets/js/discrepancy.js
index b40ec85..e77a8d6 100644
--- a/assets/js/discrepancy.js
+++ b/assets/js/discrepancy.js
@@ -354,6 +354,9 @@
}
lastResult = finalResult;
+ if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(finalResult.balance);
+ }
const meta = finalResult.trace_metadata || {};
setStatus(
`Done · ${meta.conflict_count || 0} contradictions · ${meta.deleted_count || 0} deletions · ${meta.added_count || 0} additions · ${meta.source_count || 0} sources`,
diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js
index 6712abb..16b8800 100644
--- a/assets/js/korrespond.js
+++ b/assets/js/korrespond.js
@@ -382,6 +382,9 @@
}
setStatus(t('done_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length }), 'ok');
+ if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(finalResult.balance);
+ }
lastFinal = finalResult;
renderFinal(finalResult);
pendingClarifications = {}; // reset for next run
@@ -487,6 +490,9 @@
}
setStatus(t('refined_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length, jur: jurLabel }), 'ok');
+ if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(finalResult.balance);
+ }
renderRefined(finalResult);
}
diff --git a/assets/js/tools.js b/assets/js/tools.js
index f9e9d61..7fed5a7 100644
--- a/assets/js/tools.js
+++ b/assets/js/tools.js
@@ -1882,6 +1882,9 @@ async function runTranscribe() {
const resp = await fetch('api/transcribe.php', { method: 'POST', credentials: 'same-origin', body: fd });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Transcription failed.');
+ if (typeof data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(data.balance);
+ }
if (typeof renderTranscribeResult === 'function') renderTranscribeResult(data);
else renderResults(data);
showSaveResultButton('transcribe', { audio_doc_id: storedAudioDocId }, data, {
@@ -1968,6 +1971,9 @@ async function runTranscribe() {
if (!resp.ok || !data.ok) {
throw new Error(data.error?.message || currentUiT('transcribeFailed', resp.status));
}
+ if (typeof data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(data.balance);
+ }
clearInterval(timer);
item.status = 'done';
@@ -2713,6 +2719,9 @@ function laAddonEvent(data, listEl, pipelineEl, cards) {
card.insertAdjacentHTML('beforeend', html);
} else if (data.event === 'final' && data.result) {
const res = data.result;
+ if (typeof res.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
+ window.dbnUpdateCredits(res.balance);
+ }
pipelineEl.innerHTML =
'' + escapeHtml(_laT('pass1')) + ' — ' + escapeHtml(_laT('pass1Found', { n: (res.issues || []).length })) + '
'
+ '' + escapeHtml(_laT('pass2')) + ' — ' + escapeHtml(_laT('pass2Answered', { n: (res.issues || []).length })) + '
'
@@ -2792,6 +2801,7 @@ function dbnFreeTierError(status, data) {
const toast = document.createElement('div');
toast.className = 'credit-toast';
toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.';
+ toast.textContent = data?.error?.message || 'Rate limit reached. Try again shortly or see pricing for higher caps.';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
return;
@@ -2810,6 +2820,12 @@ function dbnFreeTierError(status, data) {
Close
`;
+ overlay.querySelector('.credit-modal__icon').textContent = 'DBN';
+ overlay.querySelector('#cmTitle').textContent = 'No credits remaining';
+ overlay.querySelector('p').innerHTML = 'Your monthly and prepaid credits have been used. Monthly credits reset next month, and prepaid top-ups never expire.';
+ const pricingLink = overlay.querySelector('.credit-modal__cta');
+ pricingLink.setAttribute('href', '/pricing.php');
+ pricingLink.textContent = 'See plans and top-ups';
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
diff --git a/billing.php b/billing.php
index 9ab75f1..f1dc944 100644
--- a/billing.php
+++ b/billing.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
+require_once __DIR__ . '/includes/PricingCatalog.php';
if (!dbnToolsIsAuthenticated()) {
header('Location: /?return=' . urlencode('/billing.php'));
@@ -28,12 +29,8 @@ $storageMb = round($detail['storage_used_bytes'] / 1048576, 1);
$quotaMb = $detail['storage_quota_bytes'] > 0 ? round($detail['storage_quota_bytes'] / 1048576, 0) : 0;
$storagePct = $quotaMb > 0 ? min(100, round(($storageMb / $quotaMb) * 100)) : 0;
-$tierLabels = [
- 'free' => 'Gratis',
- 'plus' => 'Plus',
- 'pro' => 'Pro Familie',
- 'caveau' => 'CaveauAI',
-];
+$tierLabels = array_map(static fn(array $plan): string => (string)$plan['name'], PricingCatalog::plans());
+$tierLabels['caveau'] = 'CaveauAI';
// Recent usage
$db = dbnmDb();
@@ -125,7 +122,7 @@ $status = (string)($_GET['status'] ?? '');
Tilgjengelige kreditter
= number_format($effective, 0, ',', ' ') ?>
- = (int)$detail['balance'] ?> månedlige · = (int)$detail['bonus_balance'] ?> bonus
+ = (int)$detail['balance'] ?> månedlige · = (int)$detail['bonus_balance'] ?> forhåndsbetalte
diff --git a/dashboard.php b/dashboard.php
index 59e9c21..87f8af9 100644
--- a/dashboard.php
+++ b/dashboard.php
@@ -60,7 +60,7 @@ window.DBN_TOOLS_LANG = = json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
= number_format($eff, 0, ',', ' ') ?>
- = (int)$dashDetail['balance'] ?> = htmlspecialchars(dbnToolsT('credits_monthly', $uiLang)) ?> · = (int)$dashDetail['bonus_balance'] ?> = htmlspecialchars(dbnToolsT('credits_bonus', $uiLang)) ?> · = htmlspecialchars(dbnToolsT('details_link', $uiLang)) ?>
+ = (int)$dashDetail['balance'] ?> = htmlspecialchars(dbnToolsT('credits_monthly', $uiLang)) ?> · = (int)$dashDetail['bonus_balance'] ?> = $uiLang === 'no' ? 'forhåndsbetalte' : 'prepaid' ?> · = htmlspecialchars(dbnToolsT('details_link', $uiLang)) ?>
diff --git a/includes/FreeTier.php b/includes/FreeTier.php
index efdb2fa..52b16ba 100644
--- a/includes/FreeTier.php
+++ b/includes/FreeTier.php
@@ -1,102 +1,48 @@
0,
- 'search' => 0,
- 'ask' => 1,
- 'extract' => 1,
- 'timeline' => 2,
- 'redact' => 2,
- 'barnevernet' => 3,
- 'advocate' => 3,
- 'deep-research' => 5,
- 'transcribe' => 2,
- 'discrepancy' => 4,
- 'korrespond' => 3,
- 'translate' => 2,
- ];
-
- /** Monthly credit allowance per tier. */
- private const MONTHLY_ALLOWANCE = [
- 'free' => 30,
- 'plus' => 250,
- 'pro' => 1000,
- ];
-
- /** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
- private const HOURLY_CAP = [
- 'free' => 10,
- 'plus' => 20,
- 'pro' => 40,
- ];
-
- /** Per-user case-storage quota in bytes. */
- private const STORAGE_QUOTA = [
- 'free' => 0,
- 'plus' => 524288000, // 500 MB
- 'pro' => 5368709120, // 5 GB
- ];
-
- /** Credit cost for a given tool slug. Returns 1 for unknown tools. */
public static function cost(string $tool): int
{
- return self::COSTS[$tool] ?? 1;
+ return PricingCatalog::toolCost($tool);
}
public static function monthlyAllowance(string $tier): int
{
- return self::MONTHLY_ALLOWANCE[$tier] ?? self::MONTHLY_ALLOWANCE['free'];
+ return PricingCatalog::monthlyAllowance($tier);
}
public static function hourlyCap(string $tier): int
{
- return self::HOURLY_CAP[$tier] ?? self::HOURLY_CAP['free'];
+ return PricingCatalog::hourlyCap($tier);
}
public static function storageQuota(string $tier): int
{
- return self::STORAGE_QUOTA[$tier] ?? 0;
+ return PricingCatalog::storageQuota($tier);
}
- /** Fetch a user's tier (defaults to 'free' if no row). */
public static function tier(int $userId): string
{
$row = self::row($userId);
return $row['tier'] ?? 'free';
}
- /** Tiers that have access to "My Case" features (upload, save analyses, use case context). */
public static function isPaidTier(string $tier): bool
{
- return in_array($tier, ['plus', 'pro'], true);
+ return PricingCatalog::isPaidTier($tier);
}
- /** True when the user is currently in a Stripe trial (tier='plus', trial_expires_at in future). */
public static function isTrialActive(int $userId): bool
{
$row = self::row($userId);
@@ -104,13 +50,9 @@ final class FreeTier
return false;
}
$expires = $row['trial_expires_at'] ?? null;
- if (!$expires) {
- return false;
- }
- return strtotime((string)$expires) > time();
+ return $expires ? strtotime((string)$expires) > time() : false;
}
- /** Days remaining in the active trial (0 if no trial / expired). */
public static function trialDaysRemaining(int $userId): int
{
if (!self::isTrialActive($userId)) {
@@ -121,18 +63,25 @@ final class FreeTier
return max(0, (int)ceil(($expires - time()) / 86400));
}
- /** Fetch the full credits row, applying lazy monthly reset. */
public static function row(int $userId): ?array
{
$db = dbnmDb();
- // Auto-refill monthly balance based on tier-specific allowance, only if a new calendar month has begun.
+ $freeAllowance = self::monthlyAllowance('free');
+ $plusAllowance = self::monthlyAllowance('plus');
+ $proAllowance = self::monthlyAllowance('pro');
+
$db->prepare(
"UPDATE user_tool_credits
SET balance = CASE tier
- WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
- WHEN 'plus' THEN " . self::MONTHLY_ALLOWANCE['plus'] . "
- WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
+ WHEN 'free' THEN {$freeAllowance}
+ WHEN 'plus' THEN {$plusAllowance}
+ WHEN 'pro' THEN {$proAllowance}
ELSE balance END,
+ allowance = CASE tier
+ WHEN 'free' THEN {$freeAllowance}
+ WHEN 'plus' THEN {$plusAllowance}
+ WHEN 'pro' THEN {$proAllowance}
+ ELSE allowance END,
last_reset = CURDATE()
WHERE user_id = ?
AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))"
@@ -144,38 +93,39 @@ final class FreeTier
return is_array($row) ? $row : null;
}
- /**
- * Check whether the user may proceed with a tool call.
- *
- * Returns:
- * ['ok' => true, 'balance' => int, 'bonus_balance' => int, 'tier' => string]
- * ['ok' => false, 'balance' => int, 'bonus_balance' => int, 'tier' => string,
- * 'reason' => 'no_credits'|'rate_limit']
- */
public static function check(int $userId, string $tool): array
+ {
+ return self::checkAmount($userId, $tool, self::cost($tool));
+ }
+
+ public static function checkAmount(int $userId, string $tool, int $credits): array
{
$db = dbnmDb();
- $cost = self::cost($tool);
$row = self::row($userId);
+ $credits = max(0, $credits);
if ($row === null) {
return [
- 'ok' => false, 'balance' => 0, 'bonus_balance' => 0,
- 'tier' => 'free', 'reason' => 'no_credits',
+ 'ok' => false,
+ 'balance' => 0,
+ 'bonus_balance' => 0,
+ 'tier' => 'free',
+ 'reason' => 'no_credits',
+ 'cost' => $credits,
];
}
$balance = (int)$row['balance'];
- $bonus = (int)$row['bonus_balance'];
- $tier = (string)$row['tier'];
+ $bonus = (int)$row['bonus_balance'];
+ $tier = (string)$row['tier'];
$base = [
'balance' => $balance,
'bonus_balance' => $bonus,
'tier' => $tier,
+ 'cost' => $credits,
];
- // Hourly rate limit (always applies)
$stmt = $db->prepare(
'SELECT COUNT(*) FROM user_tool_usage_log
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
@@ -186,67 +136,85 @@ final class FreeTier
return $base + ['ok' => false, 'reason' => 'rate_limit'];
}
- // Free tool (cost=0) always passes credit check
- if ($cost === 0) {
+ if ($credits === 0) {
return $base + ['ok' => true];
}
- if (($balance + $bonus) < $cost) {
+ if (($balance + $bonus) < $credits) {
return $base + ['ok' => false, 'reason' => 'no_credits'];
}
return $base + ['ok' => true];
}
- /**
- * Deduct credits for a completed tool call and log the usage.
- * Spends from `balance` first, then `bonus_balance`.
- *
- * Returns the new effective balance (balance + bonus_balance).
- */
public static function deduct(int $userId, string $tool): int
{
- $db = dbnmDb();
- $cost = self::cost($tool);
- $row = self::row($userId);
+ return self::deductAmount($userId, $tool, self::cost($tool));
+ }
- if ($cost > 0 && $row !== null) {
- $balance = (int)$row['balance'];
- $bonus = (int)$row['bonus_balance'];
+ public static function deductAmount(int $userId, string $tool, int $credits, array $metadata = []): int
+ {
+ $db = dbnmDb();
+ $credits = max(0, $credits);
+ $row = self::row($userId);
+ $beforeMonthly = $row ? (int)$row['balance'] : 0;
+ $beforePrepaid = $row ? (int)$row['bonus_balance'] : 0;
+ $fromMonthly = 0;
+ $fromPrepaid = 0;
- $fromBalance = min($cost, $balance);
- $fromBonus = $cost - $fromBalance;
+ if ($credits > 0 && $row !== null) {
+ $fromMonthly = min($credits, $beforeMonthly);
+ $fromPrepaid = $credits - $fromMonthly;
- $db->prepare(
+ $stmt = $db->prepare(
'UPDATE user_tool_credits
SET balance = GREATEST(0, balance - ?),
bonus_balance = GREATEST(0, bonus_balance - ?)
- WHERE user_id = ?'
- )->execute([$fromBalance, $fromBonus, $userId]);
+ WHERE user_id = ?
+ AND (balance + bonus_balance) >= ?'
+ );
+ $stmt->execute([$fromMonthly, $fromPrepaid, $userId, $credits]);
+
+ if ($stmt->rowCount() < 1) {
+ $check = self::checkAmount($userId, $tool, $credits);
+ if (empty($check['ok'])) {
+ throw new RuntimeException('Insufficient credits while settling tool charge.');
+ }
+ }
}
$db->prepare(
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
- )->execute([$userId, $tool, $cost]);
+ )->execute([$userId, $tool, $credits]);
$latest = self::row($userId);
- return $latest ? ((int)$latest['balance'] + (int)$latest['bonus_balance']) : 0;
+ $afterMonthly = $latest ? (int)$latest['balance'] : 0;
+ $afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0;
+ self::recordLedger($userId, 'tool_charge', $tool, -$credits, [
+ 'from_monthly' => $fromMonthly,
+ 'from_prepaid' => $fromPrepaid,
+ 'before_monthly' => $beforeMonthly,
+ 'before_prepaid' => $beforePrepaid,
+ 'after_monthly' => $afterMonthly,
+ 'after_prepaid' => $afterPrepaid,
+ ] + $metadata);
+
+ return $afterMonthly + $afterPrepaid;
}
- /** Effective balance (monthly + bonus). */
public static function balance(int $userId): int
{
$row = self::row($userId);
return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0;
}
- /** Detailed balance breakdown for UI rendering. */
public static function balanceDetail(int $userId): array
{
$row = self::row($userId);
if (!$row) {
return ['balance' => 0, 'bonus_balance' => 0, 'tier' => 'free'];
}
+
return [
'balance' => (int)$row['balance'],
'bonus_balance' => (int)$row['bonus_balance'],
@@ -262,10 +230,6 @@ final class FreeTier
];
}
- /**
- * Award one-time bonus credits (survey reward, Stripe topup, manual grant).
- * Source is logged via user_tool_usage_log with a negative credits_used value.
- */
public static function awardBonus(int $userId, int $credits, string $source): int
{
if ($credits <= 0) {
@@ -273,20 +237,29 @@ final class FreeTier
}
$db = dbnmDb();
self::ensureRow($userId);
+ $row = self::row($userId);
+ $beforeMonthly = $row ? (int)$row['balance'] : 0;
+ $beforePrepaid = $row ? (int)$row['bonus_balance'] : 0;
+
$db->prepare('UPDATE user_tool_credits SET bonus_balance = bonus_balance + ? WHERE user_id = ?')
->execute([$credits, $userId]);
$db->prepare('INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)')
->execute([$userId, 'bonus:' . substr($source, 0, 40), -$credits]);
- return self::balance($userId);
+
+ $latest = self::row($userId);
+ $afterMonthly = $latest ? (int)$latest['balance'] : 0;
+ $afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0;
+ self::recordLedger($userId, 'credit_grant', 'bonus:' . substr($source, 0, 40), $credits, [
+ 'source' => $source,
+ 'before_monthly' => $beforeMonthly,
+ 'before_prepaid' => $beforePrepaid,
+ 'after_monthly' => $afterMonthly,
+ 'after_prepaid' => $afterPrepaid,
+ ]);
+
+ return $afterMonthly + $afterPrepaid;
}
- /**
- * Set or upgrade a user's tier (called by Stripe subscription webhook).
- * Refills monthly balance to the new tier's allowance.
- *
- * When $trialEndIso is non-null, also writes trial_started_at (preserving original on updates)
- * and trial_expires_at — used when subscription.status='trialing'.
- */
public static function setTier(
int $userId,
string $tier,
@@ -325,12 +298,16 @@ final class FreeTier
WHERE user_id = ?'
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
}
+
+ self::recordLedger($userId, 'subscription_refill', 'subscription:' . $tier, $allowance, [
+ 'tier' => $tier,
+ 'subscription_id' => $subscriptionId,
+ 'stripe_customer_id' => $stripeCustomerId,
+ 'period_end' => $periodEndIso,
+ 'trial_end' => $trialEndIso,
+ ]);
}
- /**
- * Refill monthly balance at subscription renewal (invoice.paid).
- * Does not touch bonus_balance.
- */
public static function refillForRenewal(int $userId, string $tier, ?string $periodEndIso): void
{
$db = dbnmDb();
@@ -338,20 +315,22 @@ final class FreeTier
$db->prepare(
'UPDATE user_tool_credits
SET balance = ?,
+ allowance = ?,
subscription_period_end = ?,
last_reset = CURDATE()
WHERE user_id = ?'
- )->execute([$allowance, $periodEndIso, $userId]);
+ )->execute([$allowance, $allowance, $periodEndIso, $userId]);
+
+ self::recordLedger($userId, 'subscription_refill', 'invoice:' . $tier, $allowance, [
+ 'tier' => $tier,
+ 'period_end' => $periodEndIso,
+ ]);
}
- /**
- * Revert a user to free tier (subscription canceled, trial ended without conversion).
- * Preserves bonus_balance and case_documents (handled by 60-day cron).
- * Stamps trial_downgraded_at if a trial was active.
- */
public static function clearTier(int $userId): void
{
$db = dbnmDb();
+ $allowance = self::monthlyAllowance('free');
$db->prepare(
"UPDATE user_tool_credits
SET tier = 'free',
@@ -366,10 +345,11 @@ final class FreeTier
END,
trial_expires_at = NULL
WHERE user_id = ?"
- )->execute([self::monthlyAllowance('free'), self::monthlyAllowance('free'), $userId]);
+ )->execute([$allowance, $allowance, $userId]);
+
+ self::recordLedger($userId, 'tier_change', 'subscription:clear', 0, ['tier' => 'free']);
}
- /** Mark survey as completed so the bonus can only be claimed once per account. */
public static function markSurveyCompleted(int $userId): void
{
$db = dbnmDb();
@@ -384,13 +364,118 @@ final class FreeTier
return !empty($row['survey_completed_at']);
}
- /** Create the user_tool_credits row if missing (idempotent). */
public static function ensureRow(int $userId): void
{
$db = dbnmDb();
+ $allowance = self::monthlyAllowance('free');
$db->prepare(
'INSERT IGNORE INTO user_tool_credits (user_id, balance, allowance, tier, last_reset, created_at)
VALUES (?, ?, ?, ?, CURDATE(), NOW())'
- )->execute([$userId, self::monthlyAllowance('free'), self::monthlyAllowance('free'), 'free']);
+ )->execute([$userId, $allowance, $allowance, 'free']);
+ }
+
+ public static function transcribeCreditsForSeconds(float $seconds): int
+ {
+ return PricingCatalog::transcribeCreditsForSeconds($seconds);
+ }
+
+ public static function estimateTranscribeCreditsFromBytes(int $bytes): int
+ {
+ return PricingCatalog::estimateTranscribeCreditsFromBytes($bytes);
+ }
+
+ public static function estimateTranscribeCreditsFromFile(string $filename, int $bytes): int
+ {
+ return PricingCatalog::estimateTranscribeCreditsFromFile($filename, $bytes);
+ }
+
+ public static function createReservation(int $userId, string $tool, int $credits, array $metadata = []): int
+ {
+ try {
+ $db = dbnmDb();
+ $db->prepare(
+ 'INSERT INTO user_tool_credit_reservations
+ (user_id, tool, reserved_credits, status, metadata_json, created_at, expires_at)
+ VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 2 HOUR))'
+ )->execute([
+ $userId,
+ substr($tool, 0, 40),
+ max(0, $credits),
+ 'reserved',
+ json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ ]);
+ return (int)$db->lastInsertId();
+ } catch (Throwable $e) {
+ return 0;
+ }
+ }
+
+ public static function settleReservation(int $reservationId, int $credits, string $provider, float $durationSeconds, array $metadata = []): void
+ {
+ if ($reservationId <= 0) {
+ return;
+ }
+ try {
+ $db = dbnmDb();
+ $db->prepare(
+ "UPDATE user_tool_credit_reservations
+ SET status = 'settled',
+ settled_credits = ?,
+ provider = ?,
+ duration_seconds = ?,
+ metadata_json = ?,
+ settled_at = NOW()
+ WHERE id = ? AND status = 'reserved'"
+ )->execute([
+ max(0, $credits),
+ substr($provider, 0, 40),
+ round($durationSeconds, 2),
+ json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ $reservationId,
+ ]);
+ } catch (Throwable $e) {
+ // Non-fatal audit path.
+ }
+ }
+
+ public static function releaseReservation(int $reservationId, array $metadata = []): void
+ {
+ if ($reservationId <= 0) {
+ return;
+ }
+ try {
+ $db = dbnmDb();
+ $db->prepare(
+ "UPDATE user_tool_credit_reservations
+ SET status = 'released',
+ metadata_json = ?
+ WHERE id = ? AND status = 'reserved'"
+ )->execute([
+ json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ $reservationId,
+ ]);
+ } catch (Throwable $e) {
+ // Non-fatal audit path.
+ }
+ }
+
+ private static function recordLedger(int $userId, string $eventType, string $source, int $creditsDelta, array $metadata = []): void
+ {
+ try {
+ $db = dbnmDb();
+ $db->prepare(
+ 'INSERT INTO user_tool_credit_ledger
+ (user_id, event_type, source, credits_delta, metadata_json, created_at)
+ VALUES (?, ?, ?, ?, ?, NOW())'
+ )->execute([
+ $userId,
+ substr($eventType, 0, 40),
+ substr($source, 0, 100),
+ $creditsDelta,
+ json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ ]);
+ } catch (Throwable $e) {
+ // Ledger is additive and should not block core credit behavior before migration is applied.
+ }
}
}
diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php
index 1662ee7..a99954f 100644
--- a/includes/KorrespondAgent.php
+++ b/includes/KorrespondAgent.php
@@ -234,8 +234,9 @@ PROMPT;
*
* @return array Final result payload (matches NDJSON 'final' event shape).
*/
- public function generate(array $intake, array $classify, ?callable $emit = null): array
+ public function generate(array $intake, array $classify, ?callable $emit = null, string $engine = 'azure_mini'): array
{
+ $draftDeployment = ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini';
$body = $intake['recipient_body'] ?? 'other';
$outputType = $intake['output_type'] ?? 'email';
$tone = $intake['tone'] ?? 'neutral';
@@ -256,7 +257,7 @@ PROMPT;
// ── Draft in Norwegian bokmål ───────────────────────────────────────────
if ($emit) { $emit('progress', ['detail' => self::L('drafting_no', $userLang)]); }
$draftNo = $this->draftNorwegian(
- $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal
+ $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $draftDeployment
);
// ── Self-check: verify citations, deadline, goal, tone ──────────────────
@@ -528,7 +529,7 @@ PROMPT;
private function draftNorwegian(
array $intake, array $classify, array $sources, string $bodyLabel,
- string $outputType, string $tone, string $goal
+ string $outputType, string $tone, string $goal, string $draftDeployment = self::DRAFT_DEPLOYMENT
): string {
$context = $this->buildContextBlob($intake);
$toneLabel = $this->toneLabelNorsk($tone);
@@ -576,7 +577,7 @@ Skriv kun utkastet. Ingen forklaring eller preamble. Bruk linjeskift som passer
PROMPT;
try {
- return $this->azure->withDeployment(self::DRAFT_DEPLOYMENT)->chatText([
+ return $this->azure->withDeployment($draftDeployment)->chatText([
['role' => 'system', 'content' => 'Du er en erfaren norsk juridisk forfatter som skriver presist og faktabasert.'],
['role' => 'user', 'content' => $prompt],
], [
diff --git a/includes/LegalTools.php b/includes/LegalTools.php
index 6023245..f19e483 100644
--- a/includes/LegalTools.php
+++ b/includes/LegalTools.php
@@ -186,8 +186,9 @@ final class DbnLegalToolsService
];
}
- public function ask(string $question, string $language = 'en'): array
+ public function ask(string $question, string $language = 'en', string $engine = 'azure_mini'): array
{
+ $engine = in_array($engine, ['azure_mini', 'azure_full'], true) ? $engine : 'azure_mini';
$search = $this->search($question, $language, 7);
$hits = $search['hits'];
$trace = $search['trace'];
@@ -240,7 +241,8 @@ Return JSON only with these keys:
PROMPT;
$system = $this->legalJsonSystemPrompt($language);
- $raw = $this->azure->chatText([
+ $askDeployment = ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini';
+ $raw = $this->azure->withDeployment($askDeployment)->chatText([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $prompt],
], [
@@ -278,7 +280,7 @@ PROMPT;
'trace_metadata' => [
'chunk_count' => count($hits),
'source_count' => count($hits),
- 'deployment' => $this->azure->chatDeployment(),
+ 'deployment' => $askDeployment,
'citation_confidence' => $search['trace_metadata']['citation_confidence'] ?? 'medium',
],
'disclaimer' => dbnToolsDisclaimer($language),
diff --git a/includes/PricingCatalog.php b/includes/PricingCatalog.php
new file mode 100644
index 0000000..60bd70b
--- /dev/null
+++ b/includes/PricingCatalog.php
@@ -0,0 +1,297 @@
+> */
+ public static function plans(): array
+ {
+ return [
+ 'free' => [
+ 'sku' => 'free',
+ 'tier' => 'free',
+ 'name' => 'Gratis',
+ 'price_nok' => 0,
+ 'unit_amount' => 0,
+ 'currency' => 'nok',
+ 'monthly_credits' => 30,
+ 'effective_credit_cost' => null,
+ 'storage_bytes' => 0,
+ 'seats' => 1,
+ 'hourly_cap' => 10,
+ 'trial_days' => 0,
+ 'stripe_price_key' => null,
+ 'features' => [
+ 'Pasted-text tools',
+ 'Norwegian legal corpus search',
+ 'No My Case storage',
+ ],
+ ],
+ 'plus' => [
+ 'sku' => 'plus',
+ 'tier' => 'plus',
+ 'name' => 'Pluss',
+ 'price_nok' => 149,
+ 'unit_amount' => 14900,
+ 'currency' => 'nok',
+ 'monthly_credits' => 250,
+ 'effective_credit_cost' => 0.60,
+ 'storage_bytes' => 524288000,
+ 'seats' => 1,
+ 'hourly_cap' => 20,
+ 'trial_days' => 14,
+ 'stripe_price_key' => 'STRIPE_PRICE_PLUS_NOK',
+ 'features' => [
+ '500 MB My Case storage',
+ 'Use private case context in tools',
+ 'Saved analyses',
+ '14-day trial',
+ ],
+ ],
+ 'pro' => [
+ 'sku' => 'pro',
+ 'tier' => 'pro',
+ 'name' => 'Pro Familie',
+ 'price_nok' => 399,
+ 'unit_amount' => 39900,
+ 'currency' => 'nok',
+ 'monthly_credits' => 900,
+ 'effective_credit_cost' => 0.44,
+ 'storage_bytes' => 5368709120,
+ 'seats' => 3,
+ 'hourly_cap' => 40,
+ 'trial_days' => 0,
+ 'stripe_price_key' => 'STRIPE_PRICE_PRO_NOK',
+ 'features' => [
+ '5 GB shared My Case storage',
+ '3 users on one case',
+ 'Full Azure model route',
+ 'Priority for complex analysis',
+ ],
+ ],
+ ];
+ }
+
+ /** @return array> */
+ public static function topups(): array
+ {
+ return [
+ 'topup_100' => [
+ 'sku' => 'topup_100',
+ 'name' => 'Ekstra 100',
+ 'price_nok' => 129,
+ 'unit_amount' => 12900,
+ 'currency' => 'nok',
+ 'credits' => 100,
+ 'cost_per_credit' => 1.29,
+ 'stripe_price_key' => 'STRIPE_PRICE_TOPUP_100_NOK',
+ 'aliases' => ['topup_s'],
+ ],
+ 'topup_300' => [
+ 'sku' => 'topup_300',
+ 'name' => 'Saks-pakke 300',
+ 'price_nok' => 299,
+ 'unit_amount' => 29900,
+ 'currency' => 'nok',
+ 'credits' => 300,
+ 'cost_per_credit' => 1.00,
+ 'stripe_price_key' => 'STRIPE_PRICE_TOPUP_300_NOK',
+ 'aliases' => ['topup_m'],
+ ],
+ 'topup_1000' => [
+ 'sku' => 'topup_1000',
+ 'name' => 'Stor pakke 1000',
+ 'price_nok' => 849,
+ 'unit_amount' => 84900,
+ 'currency' => 'nok',
+ 'credits' => 1000,
+ 'cost_per_credit' => 0.85,
+ 'stripe_price_key' => 'STRIPE_PRICE_TOPUP_1000_NOK',
+ 'aliases' => ['topup_l'],
+ ],
+ ];
+ }
+
+ /** @return array */
+ public static function toolCosts(): array
+ {
+ return [
+ 'search' => 0,
+ 'corpus-search' => 0,
+ 'ask' => 1,
+ 'extract' => 1,
+ 'summarize' => 1,
+ 'translate' => 1,
+ 'korrespond_refine' => 1,
+ 'timeline' => 2,
+ 'redact' => 2,
+ 'barnevernet' => 3,
+ 'advocate' => 3,
+ 'korrespond' => 3,
+ 'legal-analysis' => 3,
+ 'discrepancy' => 4,
+ 'deep-research' => 6,
+ 'transcribe' => 5,
+ ];
+ }
+
+ /** @return array */
+ public static function stt(): array
+ {
+ return [
+ 'minimum_credits' => 5,
+ 'credits_per_started_minute' => 1,
+ 'reservation_bytes_per_credit' => 300000,
+ ];
+ }
+
+ /** @return array|null */
+ public static function plan(string $sku): ?array
+ {
+ $plans = self::plans();
+ return $plans[$sku] ?? null;
+ }
+
+ /** @return array|null */
+ public static function topup(string $sku): ?array
+ {
+ $canonical = self::canonicalSku($sku);
+ $topups = self::topups();
+ return $topups[$canonical] ?? null;
+ }
+
+ public static function canonicalSku(string $sku): string
+ {
+ $sku = trim($sku);
+ if ($sku === 'light') {
+ return 'plus';
+ }
+ if ($sku === 'pro-plus') {
+ return 'pro';
+ }
+ foreach (self::topups() as $canonical => $topup) {
+ if ($sku === $canonical || in_array($sku, $topup['aliases'] ?? [], true)) {
+ return $canonical;
+ }
+ }
+ return $sku;
+ }
+
+ public static function isSubscriptionSku(string $sku): bool
+ {
+ $sku = self::canonicalSku($sku);
+ return in_array($sku, ['plus', 'pro'], true);
+ }
+
+ public static function isTopupSku(string $sku): bool
+ {
+ return self::topup($sku) !== null;
+ }
+
+ /** @return list */
+ public static function subscriptionSkus(): array
+ {
+ return ['plus', 'pro'];
+ }
+
+ /** @return list */
+ public static function topupSkus(): array
+ {
+ return array_keys(self::topups());
+ }
+
+ public static function stripePriceKey(string $sku): ?string
+ {
+ $sku = self::canonicalSku($sku);
+ if (self::isSubscriptionSku($sku)) {
+ return self::plans()[$sku]['stripe_price_key'] ?? null;
+ }
+ $topup = self::topup($sku);
+ return $topup['stripe_price_key'] ?? null;
+ }
+
+ public static function monthlyAllowance(string $tier): int
+ {
+ return (int)(self::plans()[$tier]['monthly_credits'] ?? self::plans()['free']['monthly_credits']);
+ }
+
+ public static function hourlyCap(string $tier): int
+ {
+ return (int)(self::plans()[$tier]['hourly_cap'] ?? self::plans()['free']['hourly_cap']);
+ }
+
+ public static function storageQuota(string $tier): int
+ {
+ return (int)(self::plans()[$tier]['storage_bytes'] ?? 0);
+ }
+
+ public static function isPaidTier(string $tier): bool
+ {
+ return in_array($tier, ['plus', 'pro'], true);
+ }
+
+ public static function toolCost(string $tool): int
+ {
+ return self::toolCosts()[$tool] ?? 1;
+ }
+
+ public static function topupCredits(string $sku): int
+ {
+ $topup = self::topup($sku);
+ return $topup ? (int)$topup['credits'] : 0;
+ }
+
+ public static function planTrialDays(string $sku): int
+ {
+ $sku = self::canonicalSku($sku);
+ return (int)(self::plans()[$sku]['trial_days'] ?? 0);
+ }
+
+ public static function transcribeCreditsForSeconds(float $seconds): int
+ {
+ $stt = self::stt();
+ $perMinute = max(1, (int)$stt['credits_per_started_minute']);
+ $minimum = max(1, (int)$stt['minimum_credits']);
+ $minutes = max(1, (int)ceil(max(0.0, $seconds) / 60));
+ return max($minimum, $minutes * $perMinute);
+ }
+
+ public static function estimateTranscribeCreditsFromBytes(int $bytes): int
+ {
+ $stt = self::stt();
+ $bytesPerCredit = max(1, (int)$stt['reservation_bytes_per_credit']);
+ return max((int)$stt['minimum_credits'], (int)ceil(max(1, $bytes) / $bytesPerCredit));
+ }
+
+ public static function estimateTranscribeCreditsFromFile(string $filename, int $bytes): int
+ {
+ $stt = self::stt();
+ $minimum = (int)$stt['minimum_credits'];
+ $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+ $bytesPerCredit = match ($ext) {
+ 'wav', 'flac' => 2000000,
+ 'mp3', 'm4a', 'mp4', 'aac', 'ogg', 'oga', 'webm' => (int)$stt['reservation_bytes_per_credit'],
+ default => 960000,
+ };
+ return max($minimum, (int)ceil(max(1, $bytes) / max(1, $bytesPerCredit)));
+ }
+
+ public static function formatNok(int $nok): string
+ {
+ return 'NOK ' . number_format($nok, 0, ',', ' ');
+ }
+
+ public static function formatCredits(int $credits): string
+ {
+ return number_format($credits, 0, ',', ' ');
+ }
+}
diff --git a/includes/StripeClient.php b/includes/StripeClient.php
index e5e7b42..1b71d92 100644
--- a/includes/StripeClient.php
+++ b/includes/StripeClient.php
@@ -1,16 +1,23 @@
*/
+ public static function subscriptionSkus(): array
+ {
+ return PricingCatalog::subscriptionSkus();
+ }
+
+ /** @return list */
+ public static function topupSkus(): array
+ {
+ return PricingCatalog::topupSkus();
+ }
+
public static function priceId(string $sku): string
{
- static $map = null;
- if ($map === null) {
- $map = [
- 'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'),
- 'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'),
- 'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'),
- 'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
- 'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
- ];
- }
- $id = $map[$sku] ?? '';
+ $canonical = PricingCatalog::canonicalSku($sku);
+ $key = PricingCatalog::stripePriceKey($canonical);
+ $id = $key ? self::config($key) : '';
+
if ($id === '') {
- throw new InvalidArgumentException("Unknown Stripe SKU: {$sku}");
+ $legacy = [
+ 'topup_100' => 'STRIPE_PRICE_TOPUP_S',
+ 'topup_300' => 'STRIPE_PRICE_TOPUP_M',
+ 'topup_1000' => 'STRIPE_PRICE_TOPUP_L',
+ ];
+ $legacyKey = $legacy[$canonical] ?? null;
+ $id = $legacyKey ? self::config($legacyKey) : '';
+ }
+
+ if ($id === '') {
+ throw new InvalidArgumentException("Unknown Stripe SKU or missing price ID: {$sku}");
}
return $id;
}
- /** Topup credit grants — must match values shown on pricing.php. */
public static function topupCredits(string $sku): int
{
- return match ($sku) {
- 'topup_s' => 30,
- 'topup_m' => 100,
- 'topup_l' => 300,
- default => 0,
- };
+ return PricingCatalog::topupCredits($sku);
}
- /** Map a Stripe price ID back to the internal subscription tier (plus/pro). */
public static function tierForPrice(string $priceId): ?string
{
- $map = [
- 'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
- 'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
- ];
- foreach ($map as $tier => $configuredPriceId) {
+ foreach (PricingCatalog::subscriptionSkus() as $tier) {
+ $configuredPriceId = '';
+ $key = PricingCatalog::stripePriceKey($tier);
+ if ($key !== null) {
+ $configuredPriceId = self::config($key);
+ }
if ($configuredPriceId !== '' && hash_equals($configuredPriceId, $priceId)) {
- return (string)$tier;
+ return $tier;
}
}
return null;
}
- /**
- * Create a Checkout Session.
- *
- * @param array $params Stripe parameters (flat form-encoded — see request() docs).
- */
public function createCheckoutSession(array $params): array
{
return $this->request('POST', '/checkout/sessions', $params);
}
- /** Create a Customer Portal session for self-serve subscription management. */
public function createPortalSession(string $customerId, string $returnUrl): array
{
return $this->request('POST', '/billing_portal/sessions', [
@@ -111,13 +134,11 @@ final class StripeClient
]);
}
- /** Retrieve a subscription. */
public function getSubscription(string $subscriptionId): array
{
return $this->request('GET', '/subscriptions/' . urlencode($subscriptionId));
}
- /** Find-or-create a Stripe customer for a given email. */
public function ensureCustomer(string $email, ?int $userId = null): string
{
$found = $this->request('GET', '/customers', ['email' => $email, 'limit' => 1]);
@@ -132,10 +153,6 @@ final class StripeClient
return (string)($created['id'] ?? '');
}
- /**
- * Verify a Stripe webhook signature.
- * Stripe-Signature header format: t=,v1=[,v1=...]
- */
public static function verifyWebhookSignature(string $payload, string $sigHeader, string $secret, int $toleranceSeconds = 300): bool
{
if ($secret === '' || $sigHeader === '') {
@@ -166,10 +183,6 @@ final class StripeClient
return false;
}
- /**
- * Low-level HTTP request to Stripe API. Returns decoded JSON or throws on error.
- * Stripe uses form-encoded bodies even for nested params (foo[bar]=baz).
- */
public function request(string $method, string $path, array $params = []): array
{
$url = self::API_BASE . $path;
@@ -218,7 +231,6 @@ final class StripeClient
return $decoded;
}
- /** Flatten nested arrays into Stripe's form-encoding scheme (foo[bar]=baz). */
private static function flattenFormParams(array $params, string $prefix = ''): string
{
$pairs = [];
diff --git a/includes/bootstrap.php b/includes/bootstrap.php
index dffff74..6a38e14 100644
--- a/includes/bootstrap.php
+++ b/includes/bootstrap.php
@@ -332,6 +332,61 @@ function dbnToolsWithTelemetry(string $tool, string $language, callable $handler
}
}
+function dbnToolsWithChargedTelemetry(string $tool, string $language, int $creditUserId, callable $handler, ?int $credits = null, array $creditMetadata = []): void
+{
+ $start = microtime(true);
+
+ try {
+ $payload = $handler();
+ if ($creditUserId > 0) {
+ $balance = $credits === null
+ ? dbnToolsFreeTierDeduct($creditUserId, $tool)
+ : dbnToolsFreeTierDeductAmount($creditUserId, $tool, $credits, $creditMetadata);
+ if ($balance >= 0 && !headers_sent()) {
+ header('X-Credits-Remaining: ' . $balance);
+ }
+ $payload['balance'] = $balance;
+ }
+
+ $latency = (int)round((microtime(true) - $start) * 1000);
+ $payload['ok'] = $payload['ok'] ?? true;
+ $payload['latency_ms'] = $latency;
+
+ dbnToolsLogMetadata([
+ 'tool' => $tool,
+ 'language' => $language,
+ 'ok' => true,
+ 'latency_ms' => $latency,
+ 'chunk_count' => (int)($payload['trace_metadata']['chunk_count'] ?? 0),
+ 'source_count' => (int)($payload['trace_metadata']['source_count'] ?? 0),
+ 'deployment' => $payload['trace_metadata']['deployment'] ?? null,
+ ]);
+
+ dbnToolsRespond($payload);
+ } catch (DbnToolsHttpException $e) {
+ $latency = (int)round((microtime(true) - $start) * 1000);
+ dbnToolsLogMetadata([
+ 'tool' => $tool,
+ 'language' => $language,
+ 'ok' => false,
+ 'latency_ms' => $latency,
+ 'error_code' => $e->errorCode,
+ ]);
+ dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
+ } catch (Throwable $e) {
+ $latency = (int)round((microtime(true) - $start) * 1000);
+ dbnToolsLogMetadata([
+ 'tool' => $tool,
+ 'language' => $language,
+ 'ok' => false,
+ 'latency_ms' => $latency,
+ 'error_code' => 'internal_error',
+ ]);
+ error_log('DBN tools error: ' . $e->getMessage());
+ dbnToolsError('The tool could not complete this request.', 500, 'internal_error');
+ }
+}
+
function dbnToolsAiPortalRoot(): string
{
$root = dbnToolsEnv('DBN_AI_PORTAL_ROOT');
@@ -516,6 +571,40 @@ function dbnToolsFreeTierCheck(string $tool): int
return $uid;
}
+function dbnToolsFreeTierCheckAmount(string $tool, int $credits): int
+{
+ if (!dbnToolsIsFreeTier()) {
+ return 0;
+ }
+
+ require_once __DIR__ . '/FreeTier.php';
+ $uid = (int)$_SESSION['dbn_tools_sso_uid'];
+ $result = FreeTier::checkAmount($uid, $tool, max(0, $credits));
+
+ if (!$result['ok']) {
+ $isRateLimit = ($result['reason'] ?? '') === 'rate_limit';
+ $tier = (string)($result['tier'] ?? 'free');
+ $cap = FreeTier::hourlyCap($tier);
+ http_response_code($isRateLimit ? 429 : 402);
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store');
+ echo json_encode([
+ 'ok' => false,
+ 'error' => ['code' => $result['reason'], 'message' => $isRateLimit
+ ? "Rate limit reached - your tier ({$tier}) allows {$cap} requests per hour."
+ : 'No credits remaining. See /pricing.php to top up or upgrade.',
+ ],
+ 'balance' => $result['balance'],
+ 'bonus_balance' => $result['bonus_balance'] ?? 0,
+ 'tier' => $tier,
+ 'cost' => $result['cost'] ?? $credits,
+ ], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ return $uid;
+}
+
/** Return the current SSO user's tier, or 'free' if not SSO / no row. */
function dbnToolsCurrentTier(): string
{
@@ -619,6 +708,15 @@ function dbnToolsFreeTierDeduct(int $uid, string $tool): int
return FreeTier::deduct($uid, $tool);
}
+function dbnToolsFreeTierDeductAmount(int $uid, string $tool, int $credits, array $metadata = []): int
+{
+ if ($uid === 0) {
+ return -1;
+ }
+ require_once __DIR__ . '/FreeTier.php';
+ return FreeTier::deductAmount($uid, $tool, $credits, $metadata);
+}
+
function dbnToolsClientSlug(): string
{
return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter';
diff --git a/includes/i18n.php b/includes/i18n.php
index 8e2f780..acb97eb 100644
--- a/includes/i18n.php
+++ b/includes/i18n.php
@@ -233,7 +233,7 @@ function dbnToolsTranslations(): array
'nav_logout' => 'Log out',
'credits_available' => 'Available credits',
'credits_monthly' => 'monthly',
- 'credits_bonus' => 'bonus',
+ 'credits_bonus' => 'prepaid',
'details_link' => 'Details',
'my_case' => 'My case',
'build_your_case' => 'Build your own case',
@@ -678,7 +678,7 @@ function dbnToolsTranslations(): array
'nav_logout' => 'Logg ut',
'credits_available' => 'Tilgjengelige kreditter',
'credits_monthly' => 'månedlige',
- 'credits_bonus' => 'bonus',
+ 'credits_bonus' => 'forhåndsbetalte',
'details_link' => 'Detaljer',
'my_case' => 'Min sak',
'build_your_case' => 'Bygg din egen sak',
diff --git a/index.php b/index.php
index 0974041..e33fe5b 100644
--- a/index.php
+++ b/index.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
+require_once __DIR__ . '/includes/PricingCatalog.php';
function dbnToolsSafeReturn(mixed $value, string $default = '/'): string
{
@@ -257,13 +258,13 @@ window.DBN_TOOLS_LANG = = json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
= htmlspecialchars(dbnToolsT('pricing_strip_sub', $uiLang)) ?>
'Gratis', 'uk' => 'Безкоштовно', 'pl' => 'Bezpłatnie', default => 'Free' }; ?>
- €0 = htmlspecialchars($freeName) ?>
+ = htmlspecialchars(PricingCatalog::formatNok(0)) ?> = htmlspecialchars($freeName) ?>
·
- €9 Light
+ = htmlspecialchars(PricingCatalog::formatNok(149)) ?> Pluss
·
- €29 Pro
+ = htmlspecialchars(PricingCatalog::formatNok(399)) ?> Pro Familie
·
- €79 Pro+
+ = $uiLang === 'no' ? 'Organisasjon: kontakt' : 'Organisation: contact' ?>
= htmlspecialchars(dbnToolsT('pricing_strip_cta', $uiLang)) ?>
diff --git a/pricing.php b/pricing.php
index 61a9c21..4c6b09a 100644
--- a/pricing.php
+++ b/pricing.php
@@ -3,8 +3,10 @@ declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
+require_once __DIR__ . '/includes/PricingCatalog.php';
$uiLang = dbnToolsCurrentLanguage();
+$isNorwegian = $uiLang === 'no';
$isAuthed = dbnToolsIsAuthenticated();
$currentTier = $isAuthed ? dbnToolsCurrentTier() : 'free';
$surveyDone = false;
@@ -15,167 +17,114 @@ if ($isAuthed && dbnToolsIsFreeTier()) {
$status = (string)($_GET['status'] ?? '');
$loginUrl = 'https://dobetternorge.no/tools-login.php?return=' . urlencode('/pricing.php');
$surveyUrl = 'https://dobetternorge.no/survey.php';
+$orgUrl = 'mailto:support@dobetternorge.no?subject=DBN%20Tools%20Organisasjon';
-function pt(string $key, string $lang): string {
- return htmlspecialchars(dbnToolsT($key, $lang));
+function h(mixed $value): string
+{
+ return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
-$creditsPerMonth = $uiLang === 'no' ? 'kreditter / mnd' : ($uiLang === 'uk' ? 'кредитів / міс' : ($uiLang === 'pl' ? 'kredytów / mies' : 'credits / mo'));
-$perMonth = $uiLang === 'no' ? '/ måned' : ($uiLang === 'uk' ? '/ міс' : ($uiLang === 'pl' ? '/ mies' : '/ month'));
-$capSuffix = $uiLang === 'no' ? '/ time' : ($uiLang === 'uk' ? '/ год' : ($uiLang === 'pl' ? '/ godz' : '/ hour'));
-$freeName = $uiLang === 'no' ? 'Gratis' : ($uiLang === 'uk' ? 'Безкоштовно' : ($uiLang === 'pl' ? 'Bezpłatnie' : 'Free'));
-$unlimited = $uiLang === 'no' ? 'Ubegrenset' : ($uiLang === 'uk' ? 'Необмежено' : ($uiLang === 'pl' ? 'Nieograniczone' : 'Unlimited'));
-$noStorage = $uiLang === 'no' ? 'Ingen saksoppbevaring' : ($uiLang === 'uk' ? 'Без сховища справ' : ($uiLang === 'pl' ? 'Brak przechowywania spraw' : 'No case storage'));
+function nok(int $amount): string
+{
+ return PricingCatalog::formatNok($amount);
+}
-$tiers = [
- [
- 'sku' => 'free',
- 'name' => $freeName,
- 'price' => '€0',
- 'period' => $uiLang === 'no' ? 'alltid' : ($uiLang === 'uk' ? 'завжди' : ($uiLang === 'pl' ? 'zawsze' : 'always')),
- 'credits' => '30 ' . $creditsPerMonth,
- 'storage' => $noStorage,
- 'seats' => $uiLang === 'no' ? '1 bruker' : ($uiLang === 'uk' ? '1 користувач' : ($uiLang === 'pl' ? '1 użytkownik' : '1 user')),
- 'cap' => '10 ' . $capSuffix,
- 'features' => $uiLang === 'no' ? [
- 'Alle 13 verktøy på innlimt tekst',
- 'Norsk juridisk korpus (~220K passasjer)',
- 'EU-vert (Tyskland / Finland / Norge)',
- ] : ($uiLang === 'uk' ? [
- 'Усі 13 інструментів на вставленому тексті',
- 'Норвезький правовий корпус (~220K уривків)',
- 'Розміщено в ЄС (Німеччина / Фінляндія / Норвегія)',
- ] : ($uiLang === 'pl' ? [
- 'Wszystkie 13 narzędzi na wklejonym tekście',
- 'Norweski korpus prawny (~220K fragmentów)',
- 'Hostowane w UE (Niemcy / Finlandia / Norwegia)',
- ] : [
- 'All 13 tools on pasted text',
- 'Norwegian legal corpus (~220K passages)',
- 'EU-hosted (Germany / Finland / Norway)',
- ])),
- 'cta' => $isAuthed ? null : dbnToolsT('pricing_cta_login', $uiLang),
- 'highlight' => false,
- ],
- [
- 'sku' => 'light',
- 'name' => 'Light',
- 'price' => '€9',
- 'period' => $perMonth,
- 'credits' => '100 ' . $creditsPerMonth,
- 'storage' => '500 MB',
- 'seats' => $uiLang === 'no' ? '1 bruker' : ($uiLang === 'uk' ? '1 користувач' : ($uiLang === 'pl' ? '1 użytkownik' : '1 user')),
- 'cap' => '20 ' . $capSuffix,
- 'features' => $uiLang === 'no' ? [
- 'Min Sak — last opp dokumenter med OCR',
- 'Bruk min sak som kontekst i alle verktøy',
- 'Lagrede analyser — alle resultater samlet',
- '14 dagers gratis prøveperiode (kort kreves)',
- ] : ($uiLang === 'uk' ? [
- 'Моя справа — завантаження документів з OCR',
- 'Використання справи як контексту в усіх інструментах',
- 'Збережені аналізи — всі результати в одному місці',
- '14-денна безкоштовна пробна версія (потрібна картка)',
- ] : ($uiLang === 'pl' ? [
- 'Moja sprawa — przesyłanie dokumentów z OCR',
- 'Używanie sprawy jako kontekstu w każdym narzędziu',
- 'Zapisane analizy — wszystkie wyniki w jednym miejscu',
- '14-dniowy bezpłatny okres próbny (wymagana karta)',
- ] : [
- 'My Case — upload documents with OCR',
- 'Use my case as context in every tool',
- 'Saved analyses — every run kept and searchable',
- '14-day free trial (card required)',
- ])),
- 'highlight' => false,
- 'badge' => null,
- ],
- [
- 'sku' => 'pro',
- 'name' => 'Pro',
- 'price' => '€29',
- 'period' => $perMonth,
- 'credits' => '500 ' . $creditsPerMonth,
- 'storage' => '5 GB',
- 'seats' => $uiLang === 'no' ? '3 brukere · delt sak' : ($uiLang === 'uk' ? '3 користувачі · спільна справа' : ($uiLang === 'pl' ? '3 użytkowników · wspólna sprawa' : '3 users · shared case')),
- 'cap' => '40 ' . $capSuffix,
- 'features' => $uiLang === 'no' ? [
- 'Alt i Light, med mer plass og raskere modeller',
- 'Familie-sete: 3 innlogginger på samme sak',
- 'Prioritert GPT-4o for kompleks analyse',
- 'Audit-logg for hvem som kjørte hva',
- ] : ($uiLang === 'uk' ? [
- 'Все з Light, більше місця та швидші моделі',
- 'Сімейні місця: 3 входи до однієї справи',
- 'Пріоритетний GPT-4o для складного аналізу',
- 'Журнал аудиту — хто що запускав',
- ] : ($uiLang === 'pl' ? [
- 'Wszystko z Light, więcej miejsca i szybsze modele',
- 'Miejsca rodzinne: 3 logowania do jednej sprawy',
- 'Priorytetowy GPT-4o do złożonych analiz',
- 'Dziennik audytu — kto co uruchamiał',
- ] : [
- 'Everything in Light, with more space and faster models',
- 'Family seats: 3 logins sharing one case',
- 'Priority GPT-4o for complex analysis',
- 'Audit log of who ran what',
- ])),
- 'highlight' => true,
- 'badge' => $uiLang === 'no' ? 'Mest populær' : ($uiLang === 'uk' ? 'Найпопулярніший' : ($uiLang === 'pl' ? 'Najpopularniejszy' : 'Most popular')),
- ],
- [
- 'sku' => 'pro-plus',
- 'name' => 'Pro+',
- 'price' => '€79',
- 'period' => $perMonth,
- 'credits' => $unlimited,
- 'storage' => '25 GB',
- 'seats' => $uiLang === 'no' ? '10 brukere · delt sak' : ($uiLang === 'uk' ? '10 користувачів · спільна справа' : ($uiLang === 'pl' ? '10 użytkowników · wspólna sprawa' : '10 users · shared case')),
- 'cap' => '80 ' . $capSuffix,
- 'features' => $uiLang === 'no' ? [
- 'Alt i Pro, ubegrenset kreditter',
- '25 GB delt sakslagring',
- 'Dedikert prosesserings-kø (ingen ventetid)',
- 'Personlig onboarding-samtale',
- ] : ($uiLang === 'uk' ? [
- 'Все з Pro, необмежені кредити',
- '25 GB спільного сховища справ',
- 'Виділена черга обробки (без очікування)',
- 'Особистий вступний дзвінок',
- ] : ($uiLang === 'pl' ? [
- 'Wszystko z Pro, nieograniczone kredyty',
- '25 GB wspólnego przechowywania spraw',
- 'Dedykowana kolejka przetwarzania (bez czekania)',
- 'Osobista rozmowa onboardingowa',
- ] : [
- 'Everything in Pro, unlimited credits',
- '25 GB shared case storage',
- 'Dedicated processing queue (no waiting)',
- 'Personal onboarding call',
- ])),
- 'highlight' => false,
- 'badge' => $uiLang === 'no' ? 'For organisasjoner' : ($uiLang === 'uk' ? 'Для організацій' : ($uiLang === 'pl' ? 'Dla organizacji' : 'For organisations')),
- ],
+function credits(int $amount): string
+{
+ return PricingCatalog::formatCredits($amount);
+}
+
+$copy = $isNorwegian ? [
+ 'title' => 'Priser - DBN Tools',
+ 'description' => 'NOK-priser, kreditter og abonnement for tools.dobetternorge.no.',
+ 'eyebrow' => 'NOK-priser for DBN Tools',
+ 'headline' => 'Kreditter som gir mening',
+ 'subhead' => 'Månedlige kreditter brukes først. Forhåndsbetalte kreditter legges på toppen og utløper ikke.',
+ 'trial' => 'Pluss har 14 dagers prøveperiode. Kort kreves, og du kan kansellere når som helst.',
+ 'survey_title' => 'Få 25 ekstra kreditter',
+ 'survey_text' => 'Svar på fem korte spørsmål om hvordan du bruker verktøyene.',
+ 'survey_cta' => 'Ta undersøkelsen',
+ 'current' => 'Din plan',
+ 'choose' => 'Velg',
+ 'login' => 'Logg inn for å velge',
+ 'available' => 'Tilgjengelig',
+ 'topups_title' => 'Ekstra kreditter',
+ 'topups_lead' => 'Top-ups er engangskjøp. De utløper ikke og brukes etter månedlige kreditter.',
+ 'buy' => 'Kjøp',
+ 'login_buy' => 'Logg inn for å kjøpe',
+ 'tool_costs' => 'Verktøykostnader',
+ 'tool_costs_lead' => 'Kreditter trekkes bare når verktøyet fullfører med et gyldig resultat.',
+ 'organisation' => 'Organisasjon',
+ 'organisation_price' => 'Kontakt',
+ 'organisation_text' => 'For rådgivere, frivillige miljøer og større familieteam som trenger flere brukere, særskilte avtaler eller onboarding.',
+ 'contact' => 'Snakk med oss',
+ 'billing_note' => 'Stripe brukes for kortbetaling, abonnement og kvitteringer. Lokale DBN-kreditter er fasiten for tilgang.',
+ 'status_success' => 'Betalingen er bekreftet. Kontoen oppdateres når Stripe-webhooken er behandlet.',
+ 'status_canceled' => 'Betalingen ble avbrutt. Ingen endringer er gjort.',
+ 'connecting' => 'Kobler til Stripe...',
+ 'checkout_error' => 'Kunne ikke starte betaling. Prøv igjen.',
+] : [
+ 'title' => 'Pricing - DBN Tools',
+ 'description' => 'NOK pricing, credits, and subscriptions for tools.dobetternorge.no.',
+ 'eyebrow' => 'NOK pricing for DBN Tools',
+ 'headline' => 'Credits that make sense',
+ 'subhead' => 'Monthly credits are spent first. Prepaid credits sit on top and never expire.',
+ 'trial' => 'Plus includes a 14-day trial. Card required, cancel anytime.',
+ 'survey_title' => 'Get 25 extra credits',
+ 'survey_text' => 'Answer five short questions about how you use the tools.',
+ 'survey_cta' => 'Take the survey',
+ 'current' => 'Current plan',
+ 'choose' => 'Choose',
+ 'login' => 'Log in to choose',
+ 'available' => 'Available',
+ 'topups_title' => 'Extra credits',
+ 'topups_lead' => 'Top-ups are one-time purchases. They never expire and are spent after monthly credits.',
+ 'buy' => 'Buy',
+ 'login_buy' => 'Log in to buy',
+ 'tool_costs' => 'Tool costs',
+ 'tool_costs_lead' => 'Credits are charged only when a tool completes with a valid result.',
+ 'organisation' => 'Organisation',
+ 'organisation_price' => 'Contact',
+ 'organisation_text' => 'For advisers, volunteer groups, and larger family teams that need more users, custom terms, or onboarding.',
+ 'contact' => 'Talk to us',
+ 'billing_note' => 'Stripe handles cards, subscriptions, and receipts. Local DBN credits remain authoritative for access.',
+ 'status_success' => 'Payment confirmed. Your account updates when the Stripe webhook is processed.',
+ 'status_canceled' => 'Payment was canceled. No changes were made.',
+ 'connecting' => 'Connecting to Stripe...',
+ 'checkout_error' => 'Could not start checkout. Please try again.',
];
-$topupNotes = [
- 'topup_s' => dbnToolsT('pricing_topup_s_note', $uiLang),
- 'topup_m' => dbnToolsT('pricing_topup_m_note', $uiLang),
- 'topup_l' => dbnToolsT('pricing_topup_l_note', $uiLang),
+$plans = PricingCatalog::plans();
+$topups = PricingCatalog::topups();
+$planFeaturesNo = [
+ 'free' => ['30 kreditter per måned', 'Verktøy på innlimt tekst', 'Juridisk korpussøk', 'Ingen Min Sak-lagring'],
+ 'plus' => ['250 kreditter per måned', '500 MB Min Sak-lagring', '1 bruker', '14 dagers prøveperiode'],
+ 'pro' => ['900 kreditter per måned', '5 GB Min Sak-lagring', '3 brukere', 'Full Azure-modellrute'],
];
-$topups = [
- ['sku' => 'topup_s', 'price' => 'NOK 49', 'credits' => 30, 'note' => $topupNotes['topup_s']],
- ['sku' => 'topup_m', 'price' => 'NOK 149', 'credits' => 100, 'note' => $topupNotes['topup_m']],
- ['sku' => 'topup_l', 'price' => 'NOK 399', 'credits' => 300, 'note' => $topupNotes['topup_l']],
+$planFeaturesEn = [
+ 'free' => ['30 credits per month', 'Tools on pasted text', 'Legal corpus search', 'No My Case storage'],
+ 'plus' => ['250 credits per month', '500 MB My Case storage', '1 user', '14-day trial'],
+ 'pro' => ['900 credits per month', '5 GB My Case storage', '3 users', 'Full Azure model route'],
+];
+$planFeatures = $isNorwegian ? $planFeaturesNo : $planFeaturesEn;
+
+$toolCostRows = [
+ ['0', 'search, corpus-search, clarify-only gates'],
+ ['1', 'ask, extract, summarize, translate, korrespond_refine'],
+ ['2', 'timeline, redact'],
+ ['3', 'barnevernet, advocate, korrespond, legal-analysis'],
+ ['4', 'discrepancy'],
+ ['6', 'deep-research'],
+ [$isNorwegian ? 'variabel' : 'variable', $isNorwegian ? 'transcribe: 1 kreditt per startet lydminutt, minst 5' : 'transcribe: 1 credit per started audio minute, minimum 5'],
];
?>
-
+
- = pt('pricing_title_meta', $uiLang) ?>
-
+ = h($copy['title']) ?>
+
@@ -184,218 +133,232 @@ $topups = [
-
+
- = pt('pricing_eyebrow', $uiLang) ?>
- = pt('pricing_hero_title', $uiLang) ?>
- = pt('pricing_hero_sub', $uiLang) ?>
+
+
= h($copy['eyebrow']) ?>
+
= h($copy['headline']) ?>
+
= h($copy['subhead']) ?>
+
+
+ = h($copy['trial']) ?>
+
- = pt('pricing_status_success', $uiLang) ?>
+ = h($copy['status_success']) ?>
- = pt('pricing_status_canceled', $uiLang) ?>
+ = h($copy['status_canceled']) ?>
-
- = 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.',
- } ?>
-
-
-
+
-
-
-
-
- = htmlspecialchars($tier['badge']) ?>
-
- = htmlspecialchars($tier['name']) ?>
-
- = htmlspecialchars($tier['price']) ?>
- = htmlspecialchars($tier['period']) ?>
-
-
-
-
- = htmlspecialchars($feature) ?>
-
-
-
-
- = htmlspecialchars($tier['cta'] ?? dbnToolsT('pricing_cta_login', $uiLang)) ?>
-
- = pt('pricing_cta_current', $uiLang) ?>
+
+
+
+
+ Pro
+ = h($plan['name']) ?>
+
+ = h(nok((int)$plan['price_nok'])) ?>
+ = $sku === 'free' ? '' : ($isNorwegian ? '/ mnd' : '/ mo') ?>
+
+
+
+ = $isNorwegian ? 'Startnivå' : 'Starter tier' ?>
+
+ = h(sprintf('%.2f', (float)$plan['effective_credit_cost'])) ?> kr / = $isNorwegian ? 'kreditt' : 'credit' ?>
+
+
+
+
+ = h($feature) ?>
+
+ = (int)$plan['hourly_cap'] ?> = $isNorwegian ? 'betalte kjøringer per time' : 'paid runs per hour' ?>
+
+
+
+ = h($copy['login']) ?>
+
+ = h($copy['current']) ?>
+
+ = h($copy['available']) ?>
+
- = pt('pricing_cta_available', $uiLang) ?>
+
+ = h($copy['login']) ?>
+
+ = h($copy['current']) ?>
+
+ = h($copy['choose'] . ' ' . $plan['name']) ?>
+
-
-
- = pt('pricing_cta_subscribe', $uiLang) ?>
-
- = pt('pricing_cta_current', $uiLang) ?>
-
-
- = pt('pricing_cta_choose', $uiLang) ?> = htmlspecialchars($tier['name']) ?>
-
-
-
-
+
+
+
+ = h($copy['organisation']) ?>
+ = h($copy['organisation']) ?>
+ = h($copy['organisation_price']) ?>
+ = $isNorwegian ? 'Tilpasset avtale' : 'Custom terms' ?>
+
+ = $isNorwegian ? 'Flere brukere' : 'More users' ?>
+ = $isNorwegian ? 'Tilpassede kreditter' : 'Custom credits' ?>
+ = $isNorwegian ? 'Onboarding og støtte' : 'Onboarding and support' ?>
+ = $isNorwegian ? 'Avtales direkte' : 'Agreed directly' ?>
+
+ = h($copy['contact']) ?>
+ = h($copy['organisation_text']) ?>
+
-
- = pt('pricing_topup_title', $uiLang) ?>
- = pt('pricing_topup_lead', $uiLang) ?>
-
-
-
-
= htmlspecialchars($topup['price']) ?>
-
= (int)$topup['credits'] ?> = pt('pricing_credits_label', $uiLang) ?>
-
= htmlspecialchars($topup['note']) ?>
-
-
= pt('pricing_topup_buy', $uiLang) ?>
-
-
= pt('pricing_login_first', $uiLang) ?>
-
+
+
+
+
= h($copy['topups_title']) ?>
+
= h($copy['topups_lead']) ?>
-
+
+
+
+
+ = h($topup['name']) ?>
+ = h(nok((int)$topup['price_nok'])) ?>
+ = h(credits((int)$topup['credits'])) ?> = $isNorwegian ? 'kreditter' : 'credits' ?>
+ = h(sprintf('%.2f', (float)$topup['cost_per_credit'])) ?> kr / = $isNorwegian ? 'kreditt' : 'credit' ?>
+
+ = h($copy['buy']) ?>
+
+ = h($copy['login_buy']) ?>
+
+
+
-
- = pt('pricing_faq_title', $uiLang) ?>
-
- = pt('pricing_faq1_q', $uiLang) ?>
- = pt('pricing_faq1_a', $uiLang) ?>
-
-
- = pt('pricing_faq2_q', $uiLang) ?>
- = pt('pricing_faq2_a', $uiLang) ?>
-
-
- = pt('pricing_faq3_q', $uiLang) ?>
- = pt('pricing_faq3_a', $uiLang) ?>
-
-
- = pt('pricing_faq4_q', $uiLang) ?>
- = pt('pricing_faq4_a', $uiLang) ?>
-
-
- = pt('pricing_faq5_q', $uiLang) ?>
- = pt('pricing_faq5_a', $uiLang) ?>
-
-
- = pt('pricing_faq6_q', $uiLang) ?>
- = pt('pricing_faq6_a', $uiLang) ?>
-
+
+
+
+
= h($copy['tool_costs']) ?>
+
= h($copy['tool_costs_lead']) ?>
+
+
+
+
+
+
+ = $isNorwegian ? 'Kostnad' : 'Cost' ?>
+ = $isNorwegian ? 'Verktøy' : 'Tools' ?>
+
+
+
+
+
+ = h($row[0]) ?>
+ = h($row[1]) ?>
+
+
+
+
+
+ = h($copy['billing_note']) ?>