b21bfb2f1d
- 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>
482 lines
17 KiB
PHP
482 lines
17 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/PricingCatalog.php';
|
|
|
|
/**
|
|
* Credit + tier system for SSO users of tools.dobetternorge.no.
|
|
*
|
|
* balance = monthly credits, reset/refilled by tier.
|
|
* bonus_balance = prepaid/top-up credits, never expires.
|
|
* Spend order: monthly first, prepaid second.
|
|
*/
|
|
final class FreeTier
|
|
{
|
|
public static function cost(string $tool): int
|
|
{
|
|
return PricingCatalog::toolCost($tool);
|
|
}
|
|
|
|
public static function monthlyAllowance(string $tier): int
|
|
{
|
|
return PricingCatalog::monthlyAllowance($tier);
|
|
}
|
|
|
|
public static function hourlyCap(string $tier): int
|
|
{
|
|
return PricingCatalog::hourlyCap($tier);
|
|
}
|
|
|
|
public static function storageQuota(string $tier): int
|
|
{
|
|
return PricingCatalog::storageQuota($tier);
|
|
}
|
|
|
|
public static function tier(int $userId): string
|
|
{
|
|
$row = self::row($userId);
|
|
return $row['tier'] ?? 'free';
|
|
}
|
|
|
|
public static function isPaidTier(string $tier): bool
|
|
{
|
|
return PricingCatalog::isPaidTier($tier);
|
|
}
|
|
|
|
public static function isTrialActive(int $userId): bool
|
|
{
|
|
$row = self::row($userId);
|
|
if (!$row || ($row['tier'] ?? '') !== 'plus') {
|
|
return false;
|
|
}
|
|
$expires = $row['trial_expires_at'] ?? null;
|
|
return $expires ? strtotime((string)$expires) > time() : false;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
public static function row(int $userId): ?array
|
|
{
|
|
$db = dbnmDb();
|
|
$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 {$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()))"
|
|
)->execute([$userId]);
|
|
|
|
$stmt = $db->prepare('SELECT * FROM user_tool_credits WHERE user_id = ? LIMIT 1');
|
|
$stmt->execute([$userId]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
return is_array($row) ? $row : null;
|
|
}
|
|
|
|
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();
|
|
$row = self::row($userId);
|
|
$credits = max(0, $credits);
|
|
|
|
if ($row === null) {
|
|
return [
|
|
'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'];
|
|
|
|
$base = [
|
|
'balance' => $balance,
|
|
'bonus_balance' => $bonus,
|
|
'tier' => $tier,
|
|
'cost' => $credits,
|
|
];
|
|
|
|
$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'
|
|
);
|
|
$stmt->execute([$userId]);
|
|
$hourly = (int)$stmt->fetchColumn();
|
|
if ($hourly >= self::hourlyCap($tier)) {
|
|
return $base + ['ok' => false, 'reason' => 'rate_limit'];
|
|
}
|
|
|
|
if ($credits === 0) {
|
|
return $base + ['ok' => true];
|
|
}
|
|
|
|
if (($balance + $bonus) < $credits) {
|
|
return $base + ['ok' => false, 'reason' => 'no_credits'];
|
|
}
|
|
|
|
return $base + ['ok' => true];
|
|
}
|
|
|
|
public static function deduct(int $userId, string $tool): int
|
|
{
|
|
return self::deductAmount($userId, $tool, self::cost($tool));
|
|
}
|
|
|
|
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;
|
|
|
|
if ($credits > 0 && $row !== null) {
|
|
$fromMonthly = min($credits, $beforeMonthly);
|
|
$fromPrepaid = $credits - $fromMonthly;
|
|
|
|
$stmt = $db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET balance = GREATEST(0, balance - ?),
|
|
bonus_balance = GREATEST(0, bonus_balance - ?)
|
|
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, $credits]);
|
|
|
|
$latest = self::row($userId);
|
|
$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;
|
|
}
|
|
|
|
public static function balance(int $userId): int
|
|
{
|
|
$row = self::row($userId);
|
|
return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0;
|
|
}
|
|
|
|
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'],
|
|
'tier' => (string)$row['tier'],
|
|
'storage_used_bytes' => (int)($row['storage_used_bytes'] ?? 0),
|
|
'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),
|
|
];
|
|
}
|
|
|
|
public static function awardBonus(int $userId, int $credits, string $source): int
|
|
{
|
|
if ($credits <= 0) {
|
|
return self::balance($userId);
|
|
}
|
|
$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]);
|
|
|
|
$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;
|
|
}
|
|
|
|
public static function setTier(
|
|
int $userId,
|
|
string $tier,
|
|
?string $stripeCustomerId,
|
|
?string $subscriptionId,
|
|
?string $periodEndIso,
|
|
?string $trialEndIso = null
|
|
): void {
|
|
$db = dbnmDb();
|
|
self::ensureRow($userId);
|
|
$allowance = self::monthlyAllowance($tier);
|
|
|
|
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]);
|
|
}
|
|
|
|
self::recordLedger($userId, 'subscription_refill', 'subscription:' . $tier, $allowance, [
|
|
'tier' => $tier,
|
|
'subscription_id' => $subscriptionId,
|
|
'stripe_customer_id' => $stripeCustomerId,
|
|
'period_end' => $periodEndIso,
|
|
'trial_end' => $trialEndIso,
|
|
]);
|
|
}
|
|
|
|
public static function refillForRenewal(int $userId, string $tier, ?string $periodEndIso): void
|
|
{
|
|
$db = dbnmDb();
|
|
$allowance = self::monthlyAllowance($tier);
|
|
$db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET balance = ?,
|
|
allowance = ?,
|
|
subscription_period_end = ?,
|
|
last_reset = CURDATE()
|
|
WHERE user_id = ?'
|
|
)->execute([$allowance, $allowance, $periodEndIso, $userId]);
|
|
|
|
self::recordLedger($userId, 'subscription_refill', 'invoice:' . $tier, $allowance, [
|
|
'tier' => $tier,
|
|
'period_end' => $periodEndIso,
|
|
]);
|
|
}
|
|
|
|
public static function clearTier(int $userId): void
|
|
{
|
|
$db = dbnmDb();
|
|
$allowance = self::monthlyAllowance('free');
|
|
$db->prepare(
|
|
"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([$allowance, $allowance, $userId]);
|
|
|
|
self::recordLedger($userId, 'tier_change', 'subscription:clear', 0, ['tier' => 'free']);
|
|
}
|
|
|
|
public static function markSurveyCompleted(int $userId): void
|
|
{
|
|
$db = dbnmDb();
|
|
self::ensureRow($userId);
|
|
$db->prepare('UPDATE user_tool_credits SET survey_completed_at = NOW() WHERE user_id = ?')
|
|
->execute([$userId]);
|
|
}
|
|
|
|
public static function hasCompletedSurvey(int $userId): bool
|
|
{
|
|
$row = self::row($userId);
|
|
return !empty($row['survey_completed_at']);
|
|
}
|
|
|
|
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, $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.
|
|
}
|
|
}
|
|
}
|