Add premium My Case MVP
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
require_once __DIR__ . '/CaseStore.php';
|
||||
|
||||
/**
|
||||
* Persistent tool-run results per case (premium "Saved analyses" feature).
|
||||
*
|
||||
* Every successful Korrespond / Advocate / BVJ / Deep Research / Discrepancy / Timeline run
|
||||
* for a paid (Plus or Pro) user — including active trial — is written to `case_tool_results`.
|
||||
* Free users do not persist results; save() silently no-ops for them.
|
||||
*
|
||||
* Storage layout:
|
||||
* user_id — session user (may be a family member)
|
||||
* owner_user_id — CaseStore::caseResolveClientId result; whose corpus the run touched
|
||||
* tool — tool slug
|
||||
* input_payload — the entire request body (used by Re-run)
|
||||
* output_payload — the entire tool response
|
||||
* used_case_context + case_doc_ids — true when the user toggled "Bruk min sak"
|
||||
*/
|
||||
final class CaseResults
|
||||
{
|
||||
/** Tools that participate in the saved-results system. Other tools (search, corpus, etc.) are not persisted. */
|
||||
public const ELIGIBLE_TOOLS = [
|
||||
'korrespond',
|
||||
'advocate',
|
||||
'barnevernet',
|
||||
'deep-research',
|
||||
'discrepancy',
|
||||
'timeline',
|
||||
];
|
||||
|
||||
/** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */
|
||||
public static function isEnabled(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$tier = FreeTier::tier($userId);
|
||||
return FreeTier::isPaidTier($tier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a completed tool run. Returns the new row id, or 0 if the user isn't eligible
|
||||
* (so callers can wrap unconditionally without if-branches).
|
||||
*
|
||||
* @param array $meta {
|
||||
* used_case_context: 0|1,
|
||||
* case_doc_ids: int[],
|
||||
* model: string|null,
|
||||
* latency_ms: int|null,
|
||||
* credits_charged: int,
|
||||
* }
|
||||
*/
|
||||
public static function save(
|
||||
int $userId,
|
||||
int $ownerUserId,
|
||||
string $tool,
|
||||
array $input,
|
||||
array $output,
|
||||
array $meta = []
|
||||
): int {
|
||||
if (!self::isEnabled($userId)) {
|
||||
return 0;
|
||||
}
|
||||
if (!in_array($tool, self::ELIGIBLE_TOOLS, true)) {
|
||||
return 0;
|
||||
}
|
||||
if (!self::tableReady()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Title default: first 80 chars of the most descriptive input field.
|
||||
$title = self::deriveTitle($tool, $input);
|
||||
|
||||
$caseDocIds = $meta['case_doc_ids'] ?? [];
|
||||
if (!is_array($caseDocIds)) {
|
||||
$caseDocIds = [];
|
||||
}
|
||||
$caseDocIds = array_values(array_unique(array_map('intval', $caseDocIds)));
|
||||
|
||||
$db = dbnmDb();
|
||||
$db->prepare(
|
||||
'INSERT INTO case_tool_results
|
||||
(user_id, owner_user_id, tool, title, used_case_context, case_doc_ids,
|
||||
input_payload, output_payload, model, latency_ms, credits_charged, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'
|
||||
)->execute([
|
||||
$userId,
|
||||
$ownerUserId > 0 ? $ownerUserId : $userId,
|
||||
$tool,
|
||||
$title,
|
||||
!empty($meta['used_case_context']) ? 1 : 0,
|
||||
json_encode($caseDocIds, JSON_UNESCAPED_UNICODE),
|
||||
json_encode($input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
json_encode($output, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$meta['model'] ?? null,
|
||||
isset($meta['latency_ms']) ? (int)$meta['latency_ms'] : null,
|
||||
(int)($meta['credits_charged'] ?? 0),
|
||||
]);
|
||||
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
/** List a user's saved results (also visible across family seats). */
|
||||
public static function listForUser(int $userId, int $limit = 50): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
if (!self::tableReady()) {
|
||||
return [];
|
||||
}
|
||||
$ownerId = CaseStore::caseResolveClientId($userId);
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'SELECT id, user_id, owner_user_id, tool, title, used_case_context,
|
||||
model, latency_ms, credits_charged, pinned, created_at
|
||||
FROM case_tool_results
|
||||
WHERE owner_user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY pinned DESC, created_at DESC
|
||||
LIMIT ' . max(1, min(200, $limit))
|
||||
);
|
||||
$stmt->execute([$ownerId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
/** Single result row with full payloads, ownership-checked. */
|
||||
public static function get(int $userId, int $resultId): ?array
|
||||
{
|
||||
if ($userId <= 0 || $resultId <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (!self::tableReady()) {
|
||||
return null;
|
||||
}
|
||||
$ownerId = CaseStore::caseResolveClientId($userId);
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'SELECT * FROM case_tool_results
|
||||
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$resultId, $ownerId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$row['case_doc_ids'] = json_decode((string)$row['case_doc_ids'], true) ?: [];
|
||||
$row['input_payload'] = json_decode((string)$row['input_payload'], true) ?: [];
|
||||
$row['output_payload'] = json_decode((string)$row['output_payload'], true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Soft-delete a result. Returns true if the row was found and deleted. */
|
||||
public static function softDelete(int $userId, int $resultId): bool
|
||||
{
|
||||
if ($userId <= 0 || $resultId <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (!self::tableReady()) {
|
||||
return false;
|
||||
}
|
||||
$ownerId = CaseStore::caseResolveClientId($userId);
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'UPDATE case_tool_results
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$resultId, $ownerId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Toggle the pinned flag. Returns the new pinned state, or null if not found. */
|
||||
public static function togglePin(int $userId, int $resultId): ?bool
|
||||
{
|
||||
if ($userId <= 0 || $resultId <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (!self::tableReady()) {
|
||||
return null;
|
||||
}
|
||||
$ownerId = CaseStore::caseResolveClientId($userId);
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'UPDATE case_tool_results
|
||||
SET pinned = 1 - pinned
|
||||
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$resultId, $ownerId]);
|
||||
if ($stmt->rowCount() === 0) {
|
||||
return null;
|
||||
}
|
||||
$check = $db->prepare('SELECT pinned FROM case_tool_results WHERE id = ?');
|
||||
$check->execute([$resultId]);
|
||||
return (bool)$check->fetchColumn();
|
||||
}
|
||||
|
||||
/** Update the user-editable title. Returns true on success. */
|
||||
public static function updateTitle(int $userId, int $resultId, string $title): bool
|
||||
{
|
||||
$title = mb_substr(trim($title), 0, 200, 'UTF-8');
|
||||
if ($title === '') {
|
||||
return false;
|
||||
}
|
||||
if (!self::tableReady()) {
|
||||
return false;
|
||||
}
|
||||
$ownerId = CaseStore::caseResolveClientId($userId);
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'UPDATE case_tool_results
|
||||
SET title = ?
|
||||
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$title, $resultId, $ownerId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Human-readable Norwegian title hint per tool. */
|
||||
public static function toolLabel(string $tool): string
|
||||
{
|
||||
return [
|
||||
'korrespond' => 'Korrespondanse',
|
||||
'advocate' => 'Advokatutkast',
|
||||
'barnevernet' => 'BVJ-analyse',
|
||||
'deep-research' => 'Dyp analyse',
|
||||
'discrepancy' => 'Motstrid',
|
||||
'timeline' => 'Tidslinje',
|
||||
][$tool] ?? ucfirst($tool);
|
||||
}
|
||||
|
||||
/** Tool icon (emoji for now, can swap to SVG later). */
|
||||
public static function toolIcon(string $tool): string
|
||||
{
|
||||
return [
|
||||
'korrespond' => '✉️',
|
||||
'advocate' => '⚖️',
|
||||
'barnevernet' => '🛡️',
|
||||
'deep-research' => '🔬',
|
||||
'discrepancy' => '🔍',
|
||||
'timeline' => '📅',
|
||||
][$tool] ?? '📄';
|
||||
}
|
||||
|
||||
/** Derive a default title from the input payload (best-effort per tool). */
|
||||
private static function deriveTitle(string $tool, array $input): string
|
||||
{
|
||||
$candidates = match ($tool) {
|
||||
'korrespond' => [$input['goal'] ?? null, $input['narrative'] ?? null, $input['case_ref'] ?? null],
|
||||
'advocate' => [$input['question'] ?? null, $input['facts'] ?? null, $input['topic'] ?? null],
|
||||
'barnevernet' => [$input['document_type'] ?? null, $input['summary'] ?? null, $input['text'] ?? null],
|
||||
'deep-research' => [$input['question'] ?? null, $input['query'] ?? null, $input['topic'] ?? null],
|
||||
'discrepancy' => [$input['focus'] ?? null, $input['context'] ?? null],
|
||||
'timeline' => [$input['context'] ?? null, $input['text'] ?? null],
|
||||
default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null],
|
||||
};
|
||||
foreach ($candidates as $c) {
|
||||
$c = is_string($c) ? trim($c) : '';
|
||||
if ($c !== '') {
|
||||
return mb_substr($c, 0, 80, 'UTF-8');
|
||||
}
|
||||
}
|
||||
return self::toolLabel($tool) . ' — ' . date('j. M Y H:i');
|
||||
}
|
||||
|
||||
/** Guard against deployment order: code may arrive just before the DB migration. */
|
||||
private static function tableReady(): bool
|
||||
{
|
||||
static $ready = null;
|
||||
if ($ready !== null) {
|
||||
return $ready;
|
||||
}
|
||||
try {
|
||||
$stmt = dbnmDb()->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?'
|
||||
);
|
||||
$stmt->execute(['case_tool_results']);
|
||||
$ready = ((int)$stmt->fetchColumn()) > 0;
|
||||
} catch (Throwable $e) {
|
||||
error_log('[CaseResults] table readiness check failed: ' . $e->getMessage());
|
||||
$ready = false;
|
||||
}
|
||||
return $ready;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ final class CaseStore
|
||||
$quota = (int)$detail['storage_quota_bytes'];
|
||||
$used = (int)$detail['storage_used_bytes'];
|
||||
if ($quota === 0) {
|
||||
throw new RuntimeException('Min Sak er ikke tilgjengelig på gratis-nivå. Oppgrader for å laste opp dokumenter.');
|
||||
throw new RuntimeException('Min Sak er ikke tilgjengelig på Gratis-nivå. Oppgrader til Plus eller Pro for å laste opp dokumenter.');
|
||||
}
|
||||
if ($used + $sizeBytes > $quota) {
|
||||
$remainMb = max(0, ($quota - $used) / 1048576);
|
||||
|
||||
+109
-45
@@ -4,16 +4,23 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Credit + tier system for 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
|
||||
* 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_plus tier bypasses balance checks (still subject to hourly cap).
|
||||
* Pro tier has the largest monthly allowance (still subject to hourly cap).
|
||||
*
|
||||
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
|
||||
* 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.
|
||||
*/
|
||||
final class FreeTier
|
||||
@@ -33,28 +40,25 @@ final class FreeTier
|
||||
'korrespond' => 3,
|
||||
];
|
||||
|
||||
/** Monthly credit allowance per tier. pro_plus is "effectively unlimited" but hourly-capped. */
|
||||
/** Monthly credit allowance per tier. */
|
||||
private const MONTHLY_ALLOWANCE = [
|
||||
'free' => 30,
|
||||
'light' => 120,
|
||||
'pro' => 500,
|
||||
'pro_plus' => 999999,
|
||||
'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,
|
||||
'light' => 15,
|
||||
'pro' => 30,
|
||||
'pro_plus' => 50,
|
||||
'free' => 10,
|
||||
'plus' => 20,
|
||||
'pro' => 40,
|
||||
];
|
||||
|
||||
/** Per-user case-storage quota in bytes. */
|
||||
private const STORAGE_QUOTA = [
|
||||
'free' => 0,
|
||||
'light' => 104857600, // 100 MB
|
||||
'pro' => 1073741824, // 1 GB
|
||||
'pro_plus' => 10737418240, // 10 GB
|
||||
'free' => 0,
|
||||
'plus' => 524288000, // 500 MB
|
||||
'pro' => 5368709120, // 5 GB
|
||||
];
|
||||
|
||||
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
|
||||
@@ -85,6 +89,37 @@ final class FreeTier
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!$row || ($row['tier'] ?? '') !== 'plus') {
|
||||
return false;
|
||||
}
|
||||
$expires = $row['trial_expires_at'] ?? null;
|
||||
if (!$expires) {
|
||||
return false;
|
||||
}
|
||||
return strtotime((string)$expires) > time();
|
||||
}
|
||||
|
||||
/** Days remaining in the active trial (0 if no trial / expired). */
|
||||
public static function trialDaysRemaining(int $userId): int
|
||||
{
|
||||
if (!self::isTrialActive($userId)) {
|
||||
return 0;
|
||||
}
|
||||
$row = self::row($userId);
|
||||
$expires = strtotime((string)$row['trial_expires_at']);
|
||||
return max(0, (int)ceil(($expires - time()) / 86400));
|
||||
}
|
||||
|
||||
/** Fetch the full credits row, applying lazy monthly reset. */
|
||||
public static function row(int $userId): ?array
|
||||
{
|
||||
@@ -93,10 +128,9 @@ final class FreeTier
|
||||
$db->prepare(
|
||||
"UPDATE user_tool_credits
|
||||
SET balance = CASE tier
|
||||
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
|
||||
WHEN 'light' THEN " . self::MONTHLY_ALLOWANCE['light'] . "
|
||||
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
|
||||
WHEN 'pro_plus' THEN " . self::MONTHLY_ALLOWANCE['pro_plus'] . "
|
||||
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
|
||||
WHEN 'plus' THEN " . self::MONTHLY_ALLOWANCE['plus'] . "
|
||||
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
|
||||
ELSE balance END,
|
||||
last_reset = CURDATE()
|
||||
WHERE user_id = ?
|
||||
@@ -140,7 +174,7 @@ final class FreeTier
|
||||
'tier' => $tier,
|
||||
];
|
||||
|
||||
// Hourly rate limit (always applies, even to pro_plus)
|
||||
// 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'
|
||||
@@ -156,11 +190,6 @@ final class FreeTier
|
||||
return $base + ['ok' => true];
|
||||
}
|
||||
|
||||
// pro_plus bypasses credit check
|
||||
if ($tier === 'pro_plus') {
|
||||
return $base + ['ok' => true];
|
||||
}
|
||||
|
||||
if (($balance + $bonus) < $cost) {
|
||||
return $base + ['ok' => false, 'reason' => 'no_credits'];
|
||||
}
|
||||
@@ -171,7 +200,6 @@ final class FreeTier
|
||||
/**
|
||||
* Deduct credits for a completed tool call and log the usage.
|
||||
* Spends from `balance` first, then `bonus_balance`.
|
||||
* pro_plus tier logs the call but does not deduct.
|
||||
*
|
||||
* Returns the new effective balance (balance + bonus_balance).
|
||||
*/
|
||||
@@ -180,9 +208,8 @@ final class FreeTier
|
||||
$db = dbnmDb();
|
||||
$cost = self::cost($tool);
|
||||
$row = self::row($userId);
|
||||
$tier = $row['tier'] ?? 'free';
|
||||
|
||||
if ($cost > 0 && $tier !== 'pro_plus' && $row !== null) {
|
||||
if ($cost > 0 && $row !== null) {
|
||||
$balance = (int)$row['balance'];
|
||||
$bonus = (int)$row['bonus_balance'];
|
||||
|
||||
@@ -227,6 +254,10 @@ final class FreeTier
|
||||
'storage_quota_bytes' => self::storageQuota((string)$row['tier']),
|
||||
'survey_completed_at' => $row['survey_completed_at'] ?? null,
|
||||
'subscription_period_end' => $row['subscription_period_end'] ?? null,
|
||||
'trial_started_at' => $row['trial_started_at'] ?? null,
|
||||
'trial_expires_at' => $row['trial_expires_at'] ?? null,
|
||||
'trial_active' => self::isTrialActive($userId),
|
||||
'trial_days_remaining' => self::trialDaysRemaining($userId),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -251,26 +282,48 @@ final class FreeTier
|
||||
/**
|
||||
* 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,
|
||||
?string $stripeCustomerId,
|
||||
?string $subscriptionId,
|
||||
?string $periodEndIso
|
||||
?string $periodEndIso,
|
||||
?string $trialEndIso = null
|
||||
): void {
|
||||
$db = dbnmDb();
|
||||
self::ensureRow($userId);
|
||||
$allowance = self::monthlyAllowance($tier);
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET tier = ?, balance = ?, allowance = ?,
|
||||
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
||||
subscription_id = ?,
|
||||
subscription_period_end = ?,
|
||||
last_reset = CURDATE()
|
||||
WHERE user_id = ?'
|
||||
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
|
||||
|
||||
if ($trialEndIso !== null) {
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET tier = ?, balance = ?, allowance = ?,
|
||||
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
||||
subscription_id = ?,
|
||||
subscription_period_end = ?,
|
||||
trial_started_at = COALESCE(trial_started_at, NOW()),
|
||||
trial_expires_at = ?,
|
||||
trial_downgraded_at = NULL,
|
||||
last_reset = CURDATE()
|
||||
WHERE user_id = ?'
|
||||
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $trialEndIso, $userId]);
|
||||
} else {
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET tier = ?, balance = ?, allowance = ?,
|
||||
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
||||
subscription_id = ?,
|
||||
subscription_period_end = ?,
|
||||
trial_expires_at = NULL,
|
||||
trial_downgraded_at = NULL,
|
||||
last_reset = CURDATE()
|
||||
WHERE user_id = ?'
|
||||
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,17 +344,28 @@ final class FreeTier
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a user to free tier (subscription canceled or fully ended).
|
||||
* Preserves bonus_balance and case_documents (handled by 90-day cron).
|
||||
* 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();
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET tier = ?, allowance = ?, subscription_id = NULL, subscription_period_end = NULL
|
||||
WHERE user_id = ?'
|
||||
)->execute(['free', self::monthlyAllowance('free'), $userId]);
|
||||
"UPDATE user_tool_credits
|
||||
SET tier = 'free',
|
||||
allowance = ?,
|
||||
balance = ?,
|
||||
subscription_id = NULL,
|
||||
subscription_period_end = NULL,
|
||||
trial_downgraded_at = CASE
|
||||
WHEN trial_expires_at IS NOT NULL AND trial_downgraded_at IS NULL
|
||||
THEN NOW()
|
||||
ELSE trial_downgraded_at
|
||||
END,
|
||||
trial_expires_at = NULL
|
||||
WHERE user_id = ?"
|
||||
)->execute([self::monthlyAllowance('free'), self::monthlyAllowance('free'), $userId]);
|
||||
}
|
||||
|
||||
/** Mark survey as completed so the bonus can only be claimed once per account. */
|
||||
|
||||
@@ -10,7 +10,7 @@ declare(strict_types=1);
|
||||
* STRIPE_PUBLISHABLE_KEY pk_live_... or pk_test_...
|
||||
* STRIPE_WEBHOOK_SECRET whsec_...
|
||||
* STRIPE_PRICE_TOPUP_S / _M / _L
|
||||
* STRIPE_PRICE_LIGHT / _PRO / _PRO_PLUS
|
||||
* STRIPE_PRICE_PLUS_NOK / STRIPE_PRICE_PRO_NOK
|
||||
*/
|
||||
final class StripeClient
|
||||
{
|
||||
@@ -55,9 +55,8 @@ final class StripeClient
|
||||
'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'),
|
||||
'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'),
|
||||
'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'),
|
||||
'light' => self::config('STRIPE_PRICE_LIGHT'),
|
||||
'pro' => self::config('STRIPE_PRICE_PRO'),
|
||||
'pro_plus' => self::config('STRIPE_PRICE_PRO_PLUS'),
|
||||
'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
|
||||
'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
|
||||
];
|
||||
}
|
||||
$id = $map[$sku] ?? '';
|
||||
@@ -78,12 +77,16 @@ final class StripeClient
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a Stripe price ID back to the internal subscription tier (light/pro/pro_plus). */
|
||||
/** Map a Stripe price ID back to the internal subscription tier (plus/pro). */
|
||||
public static function tierForPrice(string $priceId): ?string
|
||||
{
|
||||
foreach (['light', 'pro', 'pro_plus'] as $tier) {
|
||||
if (self::config('STRIPE_PRICE_' . strtoupper($tier)) === $priceId) {
|
||||
return $tier;
|
||||
$map = [
|
||||
'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
|
||||
'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
|
||||
];
|
||||
foreach ($map as $tier => $configuredPriceId) {
|
||||
if ($configuredPriceId !== '' && hash_equals($configuredPriceId, $priceId)) {
|
||||
return (string)$tier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -173,7 +176,7 @@ final class StripeClient
|
||||
$method = strtoupper($method);
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $this->secretKey,
|
||||
'Stripe-Version: 2024-10-28.acacia',
|
||||
'Stripe-Version: 2026-02-25.clover',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
|
||||
/**
|
||||
* Tier-aware model routing for tools that expose the existing engine selector.
|
||||
*
|
||||
* Plus/trial users get the cost-controlled Azure mini path. Pro users get the
|
||||
* full Azure path. CaveauAI sessions keep the engine requested by their UI.
|
||||
*/
|
||||
final class ToolModels
|
||||
{
|
||||
public static function engineForUser(int $userId, string $requestedEngine): string
|
||||
{
|
||||
$valid = ['azure_mini', 'azure_full', 'gpu', 'regex'];
|
||||
$requestedEngine = in_array($requestedEngine, $valid, true) ? $requestedEngine : 'azure_mini';
|
||||
|
||||
if ($userId <= 0) {
|
||||
return $requestedEngine;
|
||||
}
|
||||
|
||||
return FreeTier::tier($userId) === 'pro' ? 'azure_full' : 'azure_mini';
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -421,7 +421,7 @@ function dbnmDb(): PDO
|
||||
|
||||
/**
|
||||
* True when the current session belongs to an SSO user (Google login).
|
||||
* All SSO sessions go through the credit + tier system (free, light, pro, pro_plus).
|
||||
* All SSO sessions go through the credit + tier system (free, plus, pro).
|
||||
* False for CaveauAI client sessions, which bypass all credit checks.
|
||||
*
|
||||
* Note: name is historical — paid SSO users are also subject to the credit gate.
|
||||
@@ -497,18 +497,22 @@ function dbnToolsCurrentTier(): string
|
||||
*/
|
||||
function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
|
||||
{
|
||||
if (!$useMyCase) return '';
|
||||
if (!dbnToolsIsFreeTier()) return '';
|
||||
if (!$useMyCase) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||
if (!dbnToolsIsFreeTier()) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||
if ($userId <= 0) return '';
|
||||
if ($userId <= 0) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
$tier = FreeTier::tier($userId);
|
||||
if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) return '';
|
||||
if (!FreeTier::isPaidTier($tier)) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||
|
||||
require_once __DIR__ . '/CaseStore.php';
|
||||
$effective = CaseStore::caseResolveClientId($userId);
|
||||
$chunks = CaseStore::caseHybridSearch($effective, $query, $k);
|
||||
$GLOBALS['dbn_last_case_doc_ids'] = array_values(array_unique(array_map(
|
||||
static fn($c) => (int)($c['doc_id'] ?? 0),
|
||||
$chunks
|
||||
)));
|
||||
|
||||
// Audit log: who ran what against whose case
|
||||
try {
|
||||
@@ -528,6 +532,12 @@ function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
|
||||
return CaseStore::formatChunksForPrompt($chunks);
|
||||
}
|
||||
|
||||
/** Returns the case_document ids retrieved by the most recent dbnToolsCaseContext() call in this request. */
|
||||
function dbnToolsLastCaseDocIds(): array
|
||||
{
|
||||
return is_array($GLOBALS['dbn_last_case_doc_ids'] ?? null) ? $GLOBALS['dbn_last_case_doc_ids'] : [];
|
||||
}
|
||||
|
||||
/** Read /etc/bnl/intersite.php for the HMAC secret shared between dobetternorge.no and tools.dobetternorge.no. */
|
||||
function dbnToolsIntersiteSecret(): string
|
||||
{
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared "Bruk min sak som kontekst" toggle for tool forms.
|
||||
*
|
||||
* Renders nothing for free / unauthenticated / CaveauAI sessions.
|
||||
* For paid (Plus / Pro / active trial) users, renders a checkbox that defaults
|
||||
* to checked when they have any indexed case documents.
|
||||
*
|
||||
* The companion JS exposes `window.dbnGetUseMyCase()` for each tool's JS to call
|
||||
* when assembling its request payload — no per-tool plumbing beyond one read.
|
||||
*
|
||||
* Usage:
|
||||
* <?php require_once __DIR__ . '/includes/case_toggle.php'; ?>
|
||||
* (Place inside the tool form, before the submit button.)
|
||||
*
|
||||
* Endpoint side: read `$input['use_my_case']` — already supported across the
|
||||
* five wired tools (korrespond, advocate/deep-research, barnevernet, discrepancy, timeline).
|
||||
*/
|
||||
|
||||
if (!function_exists('dbnToolsIsAuthenticated')) {
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
}
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
require_once __DIR__ . '/CaseStore.php';
|
||||
|
||||
$__caseToggleUserId = 0;
|
||||
$__caseToggleEnabled = false;
|
||||
$__caseToggleDocCount = 0;
|
||||
|
||||
if (dbnToolsIsAuthenticated() && dbnToolsIsFreeTier()) {
|
||||
$__caseToggleUserId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||
if ($__caseToggleUserId > 0) {
|
||||
$__caseToggleTier = FreeTier::tier($__caseToggleUserId);
|
||||
if (FreeTier::isPaidTier($__caseToggleTier)) {
|
||||
$__caseToggleEnabled = true;
|
||||
try {
|
||||
$__caseToggleOwnerId = CaseStore::caseResolveClientId($__caseToggleUserId);
|
||||
$__caseToggleDocCount = count(CaseStore::listDocs($__caseToggleOwnerId));
|
||||
} catch (Throwable $e) {
|
||||
$__caseToggleDocCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$__caseToggleEnabled) {
|
||||
// Free / CaveauAI / unauthenticated — emit a no-op JS shim so tool code can call it safely.
|
||||
echo '<script>window.dbnGetUseMyCase = function () { return false; };</script>';
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultChecked = $__caseToggleDocCount > 0 ? 'checked' : '';
|
||||
$docCountLabel = $__caseToggleDocCount === 1
|
||||
? '1 dokument'
|
||||
: ($__caseToggleDocCount . ' dokumenter');
|
||||
?>
|
||||
<div class="control-row case-context-toggle" id="caseContextRow"
|
||||
style="margin: 0.75rem 0; padding: 0.85rem 1rem; background: #f0f7ff;
|
||||
border-left: 3px solid #00205B; border-radius: 6px; display: flex;
|
||||
align-items: center; gap: 0.7rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.55rem; cursor: pointer; flex: 1; margin: 0;">
|
||||
<input type="checkbox" id="useMyCaseToggle" name="use_my_case" value="1" <?= $defaultChecked ?>
|
||||
style="width: 18px; height: 18px; accent-color: #00205B;">
|
||||
<span style="font-weight: 600; color: #00205B;">Bruk min sak som kontekst</span>
|
||||
<span style="color: #6b7280; font-size: 0.85rem;">
|
||||
(<?= htmlspecialchars($docCountLabel) ?>)
|
||||
</span>
|
||||
</label>
|
||||
<a href="/min-sak.php" style="color: #00205B; font-size: 0.85rem; text-decoration: none; white-space: nowrap;">
|
||||
Min sak →
|
||||
</a>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var el = document.getElementById('useMyCaseToggle');
|
||||
window.dbnGetUseMyCase = function () {
|
||||
return el ? !!el.checked : false;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
unset($__caseToggleUserId, $__caseToggleEnabled, $__caseToggleDocCount, $__caseToggleTier, $__caseToggleOwnerId);
|
||||
?>
|
||||
Reference in New Issue
Block a user