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>
298 lines
9.2 KiB
PHP
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, ',', ' ');
|
|
}
|
|
}
|