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
+98
View File
@@ -332,6 +332,61 @@ function dbnToolsWithTelemetry(string $tool, string $language, callable $handler
}
}
function dbnToolsWithChargedTelemetry(string $tool, string $language, int $creditUserId, callable $handler, ?int $credits = null, array $creditMetadata = []): void
{
$start = microtime(true);
try {
$payload = $handler();
if ($creditUserId > 0) {
$balance = $credits === null
? dbnToolsFreeTierDeduct($creditUserId, $tool)
: dbnToolsFreeTierDeductAmount($creditUserId, $tool, $credits, $creditMetadata);
if ($balance >= 0 && !headers_sent()) {
header('X-Credits-Remaining: ' . $balance);
}
$payload['balance'] = $balance;
}
$latency = (int)round((microtime(true) - $start) * 1000);
$payload['ok'] = $payload['ok'] ?? true;
$payload['latency_ms'] = $latency;
dbnToolsLogMetadata([
'tool' => $tool,
'language' => $language,
'ok' => true,
'latency_ms' => $latency,
'chunk_count' => (int)($payload['trace_metadata']['chunk_count'] ?? 0),
'source_count' => (int)($payload['trace_metadata']['source_count'] ?? 0),
'deployment' => $payload['trace_metadata']['deployment'] ?? null,
]);
dbnToolsRespond($payload);
} catch (DbnToolsHttpException $e) {
$latency = (int)round((microtime(true) - $start) * 1000);
dbnToolsLogMetadata([
'tool' => $tool,
'language' => $language,
'ok' => false,
'latency_ms' => $latency,
'error_code' => $e->errorCode,
]);
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
$latency = (int)round((microtime(true) - $start) * 1000);
dbnToolsLogMetadata([
'tool' => $tool,
'language' => $language,
'ok' => false,
'latency_ms' => $latency,
'error_code' => 'internal_error',
]);
error_log('DBN tools error: ' . $e->getMessage());
dbnToolsError('The tool could not complete this request.', 500, 'internal_error');
}
}
function dbnToolsAiPortalRoot(): string
{
$root = dbnToolsEnv('DBN_AI_PORTAL_ROOT');
@@ -516,6 +571,40 @@ function dbnToolsFreeTierCheck(string $tool): int
return $uid;
}
function dbnToolsFreeTierCheckAmount(string $tool, int $credits): int
{
if (!dbnToolsIsFreeTier()) {
return 0;
}
require_once __DIR__ . '/FreeTier.php';
$uid = (int)$_SESSION['dbn_tools_sso_uid'];
$result = FreeTier::checkAmount($uid, $tool, max(0, $credits));
if (!$result['ok']) {
$isRateLimit = ($result['reason'] ?? '') === 'rate_limit';
$tier = (string)($result['tier'] ?? 'free');
$cap = FreeTier::hourlyCap($tier);
http_response_code($isRateLimit ? 429 : 402);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'ok' => false,
'error' => ['code' => $result['reason'], 'message' => $isRateLimit
? "Rate limit reached - your tier ({$tier}) allows {$cap} requests per hour."
: 'No credits remaining. See /pricing.php to top up or upgrade.',
],
'balance' => $result['balance'],
'bonus_balance' => $result['bonus_balance'] ?? 0,
'tier' => $tier,
'cost' => $result['cost'] ?? $credits,
], JSON_UNESCAPED_UNICODE);
exit;
}
return $uid;
}
/** Return the current SSO user's tier, or 'free' if not SSO / no row. */
function dbnToolsCurrentTier(): string
{
@@ -619,6 +708,15 @@ function dbnToolsFreeTierDeduct(int $uid, string $tool): int
return FreeTier::deduct($uid, $tool);
}
function dbnToolsFreeTierDeductAmount(int $uid, string $tool, int $credits, array $metadata = []): int
{
if ($uid === 0) {
return -1;
}
require_once __DIR__ . '/FreeTier.php';
return FreeTier::deductAmount($uid, $tool, $credits, $metadata);
}
function dbnToolsClientSlug(): string
{
return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter';