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:
+224
-139
@@ -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,38 +93,39 @@ final class FreeTier
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user may proceed with a tool call.
|
||||
*
|
||||
* Returns:
|
||||
* ['ok' => true, 'balance' => int, 'bonus_balance' => int, 'tier' => string]
|
||||
* ['ok' => false, 'balance' => int, 'bonus_balance' => int, 'tier' => string,
|
||||
* 'reason' => 'no_credits'|'rate_limit']
|
||||
*/
|
||||
public static function check(int $userId, string $tool): array
|
||||
{
|
||||
return self::checkAmount($userId, $tool, self::cost($tool));
|
||||
}
|
||||
|
||||
public static function checkAmount(int $userId, string $tool, int $credits): array
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$cost = self::cost($tool);
|
||||
$row = self::row($userId);
|
||||
$credits = max(0, $credits);
|
||||
|
||||
if ($row === null) {
|
||||
return [
|
||||
'ok' => false, 'balance' => 0, 'bonus_balance' => 0,
|
||||
'tier' => 'free', 'reason' => 'no_credits',
|
||||
'ok' => false,
|
||||
'balance' => 0,
|
||||
'bonus_balance' => 0,
|
||||
'tier' => 'free',
|
||||
'reason' => 'no_credits',
|
||||
'cost' => $credits,
|
||||
];
|
||||
}
|
||||
|
||||
$balance = (int)$row['balance'];
|
||||
$bonus = (int)$row['bonus_balance'];
|
||||
$tier = (string)$row['tier'];
|
||||
$bonus = (int)$row['bonus_balance'];
|
||||
$tier = (string)$row['tier'];
|
||||
|
||||
$base = [
|
||||
'balance' => $balance,
|
||||
'bonus_balance' => $bonus,
|
||||
'tier' => $tier,
|
||||
'cost' => $credits,
|
||||
];
|
||||
|
||||
// Hourly rate limit (always applies)
|
||||
$stmt = $db->prepare(
|
||||
'SELECT COUNT(*) FROM user_tool_usage_log
|
||||
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
|
||||
@@ -186,67 +136,85 @@ final class FreeTier
|
||||
return $base + ['ok' => false, 'reason' => 'rate_limit'];
|
||||
}
|
||||
|
||||
// Free tool (cost=0) always passes credit check
|
||||
if ($cost === 0) {
|
||||
if ($credits === 0) {
|
||||
return $base + ['ok' => true];
|
||||
}
|
||||
|
||||
if (($balance + $bonus) < $cost) {
|
||||
if (($balance + $bonus) < $credits) {
|
||||
return $base + ['ok' => false, 'reason' => 'no_credits'];
|
||||
}
|
||||
|
||||
return $base + ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct credits for a completed tool call and log the usage.
|
||||
* Spends from `balance` first, then `bonus_balance`.
|
||||
*
|
||||
* Returns the new effective balance (balance + bonus_balance).
|
||||
*/
|
||||
public static function deduct(int $userId, string $tool): int
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$cost = self::cost($tool);
|
||||
$row = self::row($userId);
|
||||
return self::deductAmount($userId, $tool, self::cost($tool));
|
||||
}
|
||||
|
||||
if ($cost > 0 && $row !== null) {
|
||||
$balance = (int)$row['balance'];
|
||||
$bonus = (int)$row['bonus_balance'];
|
||||
public static function deductAmount(int $userId, string $tool, int $credits, array $metadata = []): int
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$credits = max(0, $credits);
|
||||
$row = self::row($userId);
|
||||
$beforeMonthly = $row ? (int)$row['balance'] : 0;
|
||||
$beforePrepaid = $row ? (int)$row['bonus_balance'] : 0;
|
||||
$fromMonthly = 0;
|
||||
$fromPrepaid = 0;
|
||||
|
||||
$fromBalance = min($cost, $balance);
|
||||
$fromBonus = $cost - $fromBalance;
|
||||
if ($credits > 0 && $row !== null) {
|
||||
$fromMonthly = min($credits, $beforeMonthly);
|
||||
$fromPrepaid = $credits - $fromMonthly;
|
||||
|
||||
$db->prepare(
|
||||
$stmt = $db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET balance = GREATEST(0, balance - ?),
|
||||
bonus_balance = GREATEST(0, bonus_balance - ?)
|
||||
WHERE user_id = ?'
|
||||
)->execute([$fromBalance, $fromBonus, $userId]);
|
||||
WHERE user_id = ?
|
||||
AND (balance + bonus_balance) >= ?'
|
||||
);
|
||||
$stmt->execute([$fromMonthly, $fromPrepaid, $userId, $credits]);
|
||||
|
||||
if ($stmt->rowCount() < 1) {
|
||||
$check = self::checkAmount($userId, $tool, $credits);
|
||||
if (empty($check['ok'])) {
|
||||
throw new RuntimeException('Insufficient credits while settling tool charge.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db->prepare(
|
||||
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
|
||||
)->execute([$userId, $tool, $cost]);
|
||||
)->execute([$userId, $tool, $credits]);
|
||||
|
||||
$latest = self::row($userId);
|
||||
return $latest ? ((int)$latest['balance'] + (int)$latest['bonus_balance']) : 0;
|
||||
$afterMonthly = $latest ? (int)$latest['balance'] : 0;
|
||||
$afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0;
|
||||
self::recordLedger($userId, 'tool_charge', $tool, -$credits, [
|
||||
'from_monthly' => $fromMonthly,
|
||||
'from_prepaid' => $fromPrepaid,
|
||||
'before_monthly' => $beforeMonthly,
|
||||
'before_prepaid' => $beforePrepaid,
|
||||
'after_monthly' => $afterMonthly,
|
||||
'after_prepaid' => $afterPrepaid,
|
||||
] + $metadata);
|
||||
|
||||
return $afterMonthly + $afterPrepaid;
|
||||
}
|
||||
|
||||
/** Effective balance (monthly + bonus). */
|
||||
public static function balance(int $userId): int
|
||||
{
|
||||
$row = self::row($userId);
|
||||
return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0;
|
||||
}
|
||||
|
||||
/** Detailed balance breakdown for UI rendering. */
|
||||
public static function balanceDetail(int $userId): array
|
||||
{
|
||||
$row = self::row($userId);
|
||||
if (!$row) {
|
||||
return ['balance' => 0, 'bonus_balance' => 0, 'tier' => 'free'];
|
||||
}
|
||||
|
||||
return [
|
||||
'balance' => (int)$row['balance'],
|
||||
'bonus_balance' => (int)$row['bonus_balance'],
|
||||
@@ -262,10 +230,6 @@ final class FreeTier
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Award one-time bonus credits (survey reward, Stripe topup, manual grant).
|
||||
* Source is logged via user_tool_usage_log with a negative credits_used value.
|
||||
*/
|
||||
public static function awardBonus(int $userId, int $credits, string $source): int
|
||||
{
|
||||
if ($credits <= 0) {
|
||||
@@ -273,20 +237,29 @@ final class FreeTier
|
||||
}
|
||||
$db = dbnmDb();
|
||||
self::ensureRow($userId);
|
||||
$row = self::row($userId);
|
||||
$beforeMonthly = $row ? (int)$row['balance'] : 0;
|
||||
$beforePrepaid = $row ? (int)$row['bonus_balance'] : 0;
|
||||
|
||||
$db->prepare('UPDATE user_tool_credits SET bonus_balance = bonus_balance + ? WHERE user_id = ?')
|
||||
->execute([$credits, $userId]);
|
||||
$db->prepare('INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)')
|
||||
->execute([$userId, 'bonus:' . substr($source, 0, 40), -$credits]);
|
||||
return self::balance($userId);
|
||||
|
||||
$latest = self::row($userId);
|
||||
$afterMonthly = $latest ? (int)$latest['balance'] : 0;
|
||||
$afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0;
|
||||
self::recordLedger($userId, 'credit_grant', 'bonus:' . substr($source, 0, 40), $credits, [
|
||||
'source' => $source,
|
||||
'before_monthly' => $beforeMonthly,
|
||||
'before_prepaid' => $beforePrepaid,
|
||||
'after_monthly' => $afterMonthly,
|
||||
'after_prepaid' => $afterPrepaid,
|
||||
]);
|
||||
|
||||
return $afterMonthly + $afterPrepaid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or upgrade a user's tier (called by Stripe subscription webhook).
|
||||
* Refills monthly balance to the new tier's allowance.
|
||||
*
|
||||
* When $trialEndIso is non-null, also writes trial_started_at (preserving original on updates)
|
||||
* and trial_expires_at — used when subscription.status='trialing'.
|
||||
*/
|
||||
public static function setTier(
|
||||
int $userId,
|
||||
string $tier,
|
||||
@@ -325,12 +298,16 @@ final class FreeTier
|
||||
WHERE user_id = ?'
|
||||
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
|
||||
}
|
||||
|
||||
self::recordLedger($userId, 'subscription_refill', 'subscription:' . $tier, $allowance, [
|
||||
'tier' => $tier,
|
||||
'subscription_id' => $subscriptionId,
|
||||
'stripe_customer_id' => $stripeCustomerId,
|
||||
'period_end' => $periodEndIso,
|
||||
'trial_end' => $trialEndIso,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refill monthly balance at subscription renewal (invoice.paid).
|
||||
* Does not touch bonus_balance.
|
||||
*/
|
||||
public static function refillForRenewal(int $userId, string $tier, ?string $periodEndIso): void
|
||||
{
|
||||
$db = dbnmDb();
|
||||
@@ -338,20 +315,22 @@ final class FreeTier
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET balance = ?,
|
||||
allowance = ?,
|
||||
subscription_period_end = ?,
|
||||
last_reset = CURDATE()
|
||||
WHERE user_id = ?'
|
||||
)->execute([$allowance, $periodEndIso, $userId]);
|
||||
)->execute([$allowance, $allowance, $periodEndIso, $userId]);
|
||||
|
||||
self::recordLedger($userId, 'subscription_refill', 'invoice:' . $tier, $allowance, [
|
||||
'tier' => $tier,
|
||||
'period_end' => $periodEndIso,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a user to free tier (subscription canceled, trial ended without conversion).
|
||||
* Preserves bonus_balance and case_documents (handled by 60-day cron).
|
||||
* Stamps trial_downgraded_at if a trial was active.
|
||||
*/
|
||||
public static function clearTier(int $userId): void
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$allowance = self::monthlyAllowance('free');
|
||||
$db->prepare(
|
||||
"UPDATE user_tool_credits
|
||||
SET tier = 'free',
|
||||
@@ -366,10 +345,11 @@ final class FreeTier
|
||||
END,
|
||||
trial_expires_at = NULL
|
||||
WHERE user_id = ?"
|
||||
)->execute([self::monthlyAllowance('free'), self::monthlyAllowance('free'), $userId]);
|
||||
)->execute([$allowance, $allowance, $userId]);
|
||||
|
||||
self::recordLedger($userId, 'tier_change', 'subscription:clear', 0, ['tier' => 'free']);
|
||||
}
|
||||
|
||||
/** Mark survey as completed so the bonus can only be claimed once per account. */
|
||||
public static function markSurveyCompleted(int $userId): void
|
||||
{
|
||||
$db = dbnmDb();
|
||||
@@ -384,13 +364,118 @@ final class FreeTier
|
||||
return !empty($row['survey_completed_at']);
|
||||
}
|
||||
|
||||
/** Create the user_tool_credits row if missing (idempotent). */
|
||||
public static function ensureRow(int $userId): void
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$allowance = self::monthlyAllowance('free');
|
||||
$db->prepare(
|
||||
'INSERT IGNORE INTO user_tool_credits (user_id, balance, allowance, tier, last_reset, created_at)
|
||||
VALUES (?, ?, ?, ?, CURDATE(), NOW())'
|
||||
)->execute([$userId, self::monthlyAllowance('free'), self::monthlyAllowance('free'), 'free']);
|
||||
)->execute([$userId, $allowance, $allowance, 'free']);
|
||||
}
|
||||
|
||||
public static function transcribeCreditsForSeconds(float $seconds): int
|
||||
{
|
||||
return PricingCatalog::transcribeCreditsForSeconds($seconds);
|
||||
}
|
||||
|
||||
public static function estimateTranscribeCreditsFromBytes(int $bytes): int
|
||||
{
|
||||
return PricingCatalog::estimateTranscribeCreditsFromBytes($bytes);
|
||||
}
|
||||
|
||||
public static function estimateTranscribeCreditsFromFile(string $filename, int $bytes): int
|
||||
{
|
||||
return PricingCatalog::estimateTranscribeCreditsFromFile($filename, $bytes);
|
||||
}
|
||||
|
||||
public static function createReservation(int $userId, string $tool, int $credits, array $metadata = []): int
|
||||
{
|
||||
try {
|
||||
$db = dbnmDb();
|
||||
$db->prepare(
|
||||
'INSERT INTO user_tool_credit_reservations
|
||||
(user_id, tool, reserved_credits, status, metadata_json, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 2 HOUR))'
|
||||
)->execute([
|
||||
$userId,
|
||||
substr($tool, 0, 40),
|
||||
max(0, $credits),
|
||||
'reserved',
|
||||
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
} catch (Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static function settleReservation(int $reservationId, int $credits, string $provider, float $durationSeconds, array $metadata = []): void
|
||||
{
|
||||
if ($reservationId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$db = dbnmDb();
|
||||
$db->prepare(
|
||||
"UPDATE user_tool_credit_reservations
|
||||
SET status = 'settled',
|
||||
settled_credits = ?,
|
||||
provider = ?,
|
||||
duration_seconds = ?,
|
||||
metadata_json = ?,
|
||||
settled_at = NOW()
|
||||
WHERE id = ? AND status = 'reserved'"
|
||||
)->execute([
|
||||
max(0, $credits),
|
||||
substr($provider, 0, 40),
|
||||
round($durationSeconds, 2),
|
||||
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$reservationId,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Non-fatal audit path.
|
||||
}
|
||||
}
|
||||
|
||||
public static function releaseReservation(int $reservationId, array $metadata = []): void
|
||||
{
|
||||
if ($reservationId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$db = dbnmDb();
|
||||
$db->prepare(
|
||||
"UPDATE user_tool_credit_reservations
|
||||
SET status = 'released',
|
||||
metadata_json = ?
|
||||
WHERE id = ? AND status = 'reserved'"
|
||||
)->execute([
|
||||
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$reservationId,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Non-fatal audit path.
|
||||
}
|
||||
}
|
||||
|
||||
private static function recordLedger(int $userId, string $eventType, string $source, int $creditsDelta, array $metadata = []): void
|
||||
{
|
||||
try {
|
||||
$db = dbnmDb();
|
||||
$db->prepare(
|
||||
'INSERT INTO user_tool_credit_ledger
|
||||
(user_id, event_type, source, credits_delta, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())'
|
||||
)->execute([
|
||||
$userId,
|
||||
substr($eventType, 0, 40),
|
||||
substr($source, 0, 100),
|
||||
$creditsDelta,
|
||||
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Ledger is additive and should not block core credit behavior before migration is applied.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user