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:
+6
-4
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 || '?'}`,
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
], [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user