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>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
<?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, ',', ' ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user