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

- PricingCatalog.php: single source of truth for plans (free/plus/pro), top-ups,
  Stripe price env keys, tool costs (0–6 credits), STT variable billing, feature limits
- FreeTier.php: monthly-first credit deduction, ledger (user_tool_credit_ledger),
  STT reservation/settle/release, monthly reset, trial logic
- StripeClient.php: canonical SKUs (plus/pro/topup_100/300/1000), legacy aliases kept
- stripe-checkout.php: subscription vs payment mode, trial gating, catalog metadata
- stripe-webhook.php: idempotent via stripe_events, handles subscription lifecycle +
  invoice.paid renewal + one-time topup credit grants
- All API tools: success-based credit deduction (check before, charge after)
- transcribe.php: file-size heuristic reservation, settle from actual provider duration
- ask.php + LegalTools.php: ToolModels engine resolution — Pro gets gpt-4o
- KorrespondAgent.php + korrespond.php: tier-gated draft deployment —
  Free/Plus gets gpt-4o-mini, Pro gets gpt-4o
- pricing.php: NOK-only, plan cards, top-up packs, Organisation contact card,
  tool cost table, separate monthly/prepaid balance display
- 003_pricing_credit_catalog.sql: ledger and STT reservation tables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 13:42:27 +02:00
parent bffc714541
commit b21bfb2f1d
30 changed files with 1171 additions and 586 deletions
+6 -6
View File
@@ -2,19 +2,19 @@
declare(strict_types=1);
require_once __DIR__ . '/../includes/LegalTools.php';
require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('ask');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'ask');
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$input = dbnToolsJsonInput(25000);
$ftUid = dbnToolsFreeTierCheck('ask');
$engine = ToolModels::engineForUser($ftUid, 'azure_mini');
$input = dbnToolsJsonInput(25000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
dbnToolsWithTelemetry('ask', $language, function () use ($input, $language): array {
dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input, $language, $engine): array {
$question = dbnToolsInjectDocContent($input, dbnToolsString($input, 'question', 4000, false));
if (mb_strlen(trim($question), 'UTF-8') < 5) {
dbnToolsAbort('Enter a question or select a document before running.', 422, 'empty_text');
}
return (new DbnLegalToolsService())->ask($question, $language);
return (new DbnLegalToolsService())->ask($question, $language, $engine);
});
+4 -2
View File
@@ -8,7 +8,6 @@ require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('discrepancy');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy');
@ini_set('output_buffering', '0');
@ini_set('zlib.output_compression', '0');
@@ -19,7 +18,6 @@ ob_implicit_flush(true);
header('Content-Type: application/x-ndjson; charset=utf-8');
header('Cache-Control: no-store');
header('X-Accel-Buffering: no');
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$language = 'en';
$startTime = microtime(true);
@@ -146,6 +144,10 @@ try {
'deployment' => $result['trace_metadata']['deployment'] ?? null,
]);
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy');
if ($ftRemaining >= 0) {
$result['balance'] = $ftRemaining;
}
$emit('final', ['result' => $result]);
+5 -2
View File
@@ -6,8 +6,6 @@ require_once __DIR__ . '/../includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('extract');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract');
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
try {
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
@@ -15,6 +13,11 @@ try {
}
$result = dbnToolsExtractUploadedFile($_FILES['file']);
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract');
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
$result['balance'] = $ftRemaining;
}
dbnToolsRespond($result);
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
+4 -4
View File
@@ -68,10 +68,6 @@ try {
// Credit gate (refine is a paid pass)
$ftUid = dbnToolsFreeTierCheck('korrespond_refine');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond_refine');
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
}
$emit('start', [
'jurisdiction' => $jurisdiction,
@@ -81,8 +77,12 @@ try {
$agent = new DbnKorrespondAgent();
$result = $agent->refine($intake, $classify, $originalDraftNo, $jurisdiction, $emit);
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond_refine');
$result['ok'] = true;
$result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000);
if ($ftRemaining >= 0) {
$result['balance'] = $ftRemaining;
}
dbnToolsLogMetadata([
'tool' => 'korrespond_refine',
+4 -2
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/KorrespondAgent.php';
require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@@ -165,11 +166,12 @@ try {
// ── Deduct credit now (Pass 2 starts) ───────────────────────────────────────
$ftUid = dbnToolsFreeTierCheck('korrespond');
$engine = ToolModels::engineForUser($ftUid, 'azure_mini');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond');
$creditDeducted = true;
// ── Pass 2: retrieve law → draft → self-check → translate ──────────────────
$result = $agent->generate($intake, $classify, $emit);
$result = $agent->generate($intake, $classify, $emit, $engine);
$result['ok'] = true;
$result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000);
if ($ftRemaining >= 0) {
@@ -182,7 +184,7 @@ try {
'ok' => true,
'latency_ms' => $result['latency_ms'],
'source_count' => is_array($result['cited_law'] ?? null) ? count($result['cited_law']) : 0,
'deployment' => 'gpt-4o',
'deployment' => ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini',
]);
+6 -4
View File
@@ -20,6 +20,8 @@ header('X-Accel-Buffering: no');
$startTime = microtime(true);
$language = 'en';
$creditDeducted = false;
$ftUid = 0;
$ftRemaining = -1;
$emit = function (string $event, array $payload = []) use ($startTime): void {
$payload['event'] = $event;
@@ -44,6 +46,7 @@ try {
422, 'empty_text'
);
}
$ftUid = dbnToolsFreeTierCheck('legal-analysis');
$emit('start', [
'mode' => 'legal-analysis',
@@ -75,12 +78,8 @@ try {
}
// Deduct credit (gated until extract succeeds and at least one issue exists)
$ftUid = dbnToolsFreeTierCheck('legal-analysis');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'legal-analysis');
$creditDeducted = true;
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
}
$emit('issues_extracted', [
'count' => count($issues),
@@ -129,6 +128,9 @@ try {
'model' => 'dbn-legal-agent-v3',
'latency_ms' => (int)round((microtime(true) - $startTime) * 1000),
];
if ($ftRemaining >= 0) {
$result['balance'] = $ftRemaining;
}
dbnToolsLogMetadata([
'tool' => 'legal-analysis',
+1 -3
View File
@@ -6,11 +6,9 @@ require_once __DIR__ . '/../includes/LegalTools.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('redact');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'redact');
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$input = dbnToolsJsonInput(400000);
dbnToolsWithTelemetry('redact', '', function () use ($input): array {
dbnToolsWithChargedTelemetry('redact', '', $ftUid, function () use ($input): array {
$text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false));
if (mb_strlen(trim($text), 'UTF-8') < 10) {
dbnToolsAbort('Paste text, upload a file, or select a document before running.', 422, 'empty_text');
+20 -14
View File
@@ -16,12 +16,9 @@ if ($userId <= 0 || $email === '') {
}
$input = dbnToolsJsonInput(2000);
$sku = (string)($input['sku'] ?? '');
$sku = StripeClient::canonicalSku((string)($input['sku'] ?? ''));
$validSubscriptions = ['plus', 'pro'];
$validTopups = ['topup_s', 'topup_m', 'topup_l'];
if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
if (!StripeClient::isSubscriptionSku($sku) && !StripeClient::isTopupSku($sku)) {
dbnToolsError('Unknown SKU.', 400, 'unknown_sku');
}
@@ -33,7 +30,18 @@ try {
$successUrl = $baseUrl . '/billing.php?status=success&session_id={CHECKOUT_SESSION_ID}';
$cancelUrl = $baseUrl . '/pricing.php?status=canceled';
$isSub = in_array($sku, $validSubscriptions, true);
$isSub = StripeClient::isSubscriptionSku($sku);
$credits = $isSub ? 0 : StripeClient::topupCredits($sku);
$metadata = [
'user_id' => (string)$userId,
'sku' => $sku,
'catalog_version' => PricingCatalog::VERSION,
];
if ($isSub) {
$metadata['tier'] = $sku;
} else {
$metadata['credits'] = (string)$credits;
}
$params = [
'mode' => $isSub ? 'subscription' : 'payment',
@@ -44,10 +52,7 @@ try {
'price' => StripeClient::priceId($sku),
'quantity' => 1,
]],
'metadata' => [
'user_id' => (string)$userId,
'sku' => $sku,
],
'metadata' => $metadata,
'allow_promotion_codes' => true,
'billing_address_collection' => 'auto',
'locale' => 'auto',
@@ -58,10 +63,11 @@ try {
FreeTier::ensureRow($userId);
$detail = FreeTier::balanceDetail($userId);
$params['subscription_data'] = [
'metadata' => ['user_id' => (string)$userId, 'tier' => $sku],
'metadata' => $metadata,
];
if ($sku === 'plus' && empty($detail['trial_started_at'])) {
$params['subscription_data']['trial_period_days'] = 14;
$trialDays = PricingCatalog::planTrialDays($sku);
if ($trialDays > 0 && empty($detail['trial_started_at'])) {
$params['subscription_data']['trial_period_days'] = $trialDays;
$params['subscription_data']['trial_settings'] = [
'end_behavior' => ['missing_payment_method' => 'cancel'],
];
@@ -69,7 +75,7 @@ try {
$params['payment_method_collection'] = 'always';
} else {
$params['payment_intent_data'] = [
'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
'metadata' => $metadata,
];
}
+1 -1
View File
@@ -122,7 +122,7 @@ function handleCheckoutCompleted(PDO $db, array $session): void
if ($mode === 'payment') {
// One-time topup — grant credits immediately.
$sku = (string)($metadata['sku'] ?? '');
$sku = StripeClient::canonicalSku((string)($metadata['sku'] ?? ''));
$credits = StripeClient::topupCredits($sku);
if ($credits > 0) {
FreeTier::awardBonus($userId, $credits, 'topup:' . $sku);
+1 -3
View File
@@ -7,12 +7,10 @@ require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('timeline');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'timeline');
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$input = dbnToolsJsonInput(400000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language, $ftUid): array {
dbnToolsWithChargedTelemetry('timeline', $language, $ftUid, function () use ($input, $language, $ftUid): array {
$text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false));
if (mb_strlen(trim($text), 'UTF-8') < 10) {
dbnToolsAbort('Paste text, upload a file, or select a document before running.', 422, 'empty_text');
+44 -4
View File
@@ -2,12 +2,14 @@
declare(strict_types=1);
require_once __DIR__ . '/../includes/LegalTools.php';
require_once __DIR__ . '/../includes/FreeTier.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$ftUid = dbnToolsFreeTierCheck('transcribe');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'transcribe');
if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$ftUid = 0;
$ftRemaining = -1;
$sttReservationId = 0;
$sttSettled = false;
set_time_limit(0);
ignore_user_abort(true);
@@ -92,6 +94,23 @@ $detectedMime = mime_content_type($file['tmp_name']) ?: 'application/octet-strea
$timeOffset = max(0.0, (float)($_POST['time_offset'] ?? 0));
$t0 = microtime(true);
$estimatedCredits = FreeTier::estimateTranscribeCreditsFromFile((string)($file['name'] ?? ''), (int)($file['size'] ?? 0));
$ftUid = dbnToolsFreeTierCheckAmount('transcribe', $estimatedCredits);
if ($ftUid > 0) {
$sttReservationId = FreeTier::createReservation($ftUid, 'transcribe', $estimatedCredits, [
'filename' => (string)($file['name'] ?? ''),
'bytes' => (int)($file['size'] ?? 0),
'mime' => $detectedMime,
'language' => $language,
'diarize' => $diarize,
]);
register_shutdown_function(static function () use (&$sttSettled, $sttReservationId): void {
if (!$sttSettled && $sttReservationId > 0) {
FreeTier::releaseReservation($sttReservationId, ['reason' => 'request_ended_before_settlement']);
}
});
}
// ── Auto-cascade: Azure → GCP → Whisper GPU ───────────────────────────────────
$result = null;
@@ -186,6 +205,25 @@ $engineLabel = match($engineUsed) {
// ── Log + respond ─────────────────────────────────────────────────────────────
$durationSec = round((float)($result['duration_seconds'] ?? $result['duration'] ?? 0), 2);
$creditsUsed = FreeTier::transcribeCreditsForSeconds($durationSec);
$ftRemaining = dbnToolsFreeTierDeductAmount($ftUid, 'transcribe', $creditsUsed, [
'engine' => $engineUsed,
'duration_sec' => $durationSec,
'estimated_credits' => $estimatedCredits,
'reservation_id' => $sttReservationId,
]);
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
}
if ($sttReservationId > 0) {
FreeTier::settleReservation($sttReservationId, $creditsUsed, $engineUsed, $durationSec, [
'estimated_credits' => $estimatedCredits,
'balance_after' => $ftRemaining,
]);
$sttSettled = true;
}
dbnToolsLogMetadata([
'tool' => 'transcribe',
'engine' => $engineUsed,
@@ -203,12 +241,14 @@ dbnToolsRespond([
'speaker_roles' => $speakerRoles,
'num_speakers' => $numDetected,
'language' => (string)($result['language'] ?? $language),
'duration_sec' => round((float)($result['duration_seconds'] ?? $result['duration'] ?? 0), 2),
'duration_sec' => $durationSec,
'processing_sec'=> round((float)($result['processing_seconds'] ?? 0), 2),
'model' => $engineLabel,
'engine' => $engineUsed,
'latency_ms' => $latencyMs,
'cleaned_by' => $cleanedBy,
'credits_used' => $creditsUsed,
'balance' => $ftRemaining,
]);
+5 -4
View File
@@ -55,10 +55,6 @@ try {
}
$ftUid = dbnToolsFreeTierCheck('translate');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'translate');
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
}
$emit('start', [
'mode' => 'translate',
@@ -150,6 +146,11 @@ PROMPT;
'deployment' => 'gpt-4o-mini',
]);
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'translate');
if ($ftRemaining >= 0) {
$result['balance'] = $ftRemaining;
}
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {