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
-6
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user