Add premium My Case MVP
This commit is contained in:
+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. */
|
||||
|
||||
Reference in New Issue
Block a user