Files
dobetternorge-tools/includes/PricingCatalog.php
T
daveadmin b21bfb2f1d 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>
2026-05-24 13:42:27 +02:00

298 lines
9.2 KiB
PHP

<?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, ',', ' ');
}
}