From ba9cddf9a1aaef32e65efee722ef5021dbd0c6d7 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Wed, 20 May 2026 20:52:54 +0200 Subject: [PATCH] Add monetization spine + Build Your Own Case (Min Sak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stripe: StripeClient.php, checkout/portal/webhook endpoints, idempotent event handling - FreeTier: tier-aware credits (free/light/pro/pro_plus), bonus_balance, hourly caps per tier - pricing.php + billing.php: 4-tier cards, 3 topups, Customer Portal, balance breakdown - Min Sak: CaseStore.php, AzureDocIntelligence.php, AzureSearchAdmin.php — per-user hybrid RAG - api/case/: upload, list, delete, ingest-callback (HMAC-auth'd from n8n) - award-survey-credits: inter-site HMAC endpoint for dobetternorge.no survey bonus - dashboard.php: tier badge, balance breakdown card, Min Sak CTA, survey CTA - KorrespondAgent + all 3 other agents: use_my_case toggle wired to dbnToolsCaseContext() - bootstrap.php: dbnToolsCaseContext(), dbnToolsIntersiteSecret(), dbnToolsCurrentTier() Co-Authored-By: Claude Opus 4.7 --- advocate.php | 5 +- api/award-survey-credits.php | 104 ++++++++++ api/case/delete.php | 26 +++ api/case/ingest-callback.php | 73 +++++++ api/case/list.php | 18 ++ api/case/upload.php | 50 +++++ api/korrespond.php | 1 + api/stripe-checkout.php | 77 ++++++++ api/stripe-portal.php | 36 ++++ api/stripe-webhook.php | 224 +++++++++++++++++++++ assets/js/korrespond.js | 4 +- assets/js/workbench.js | 113 +++++++++++ barnevernet.php | 3 +- billing.php | 210 ++++++++++++++++++++ dashboard.php | 58 ++++++ deep-research.php | 3 +- discrepancy.php | 3 +- includes/AzureDocIntelligence.php | 119 +++++++++++ includes/AzureSearchAdmin.php | 204 +++++++++++++++++++ includes/BvjAnalyzerAgent.php | 21 +- includes/CaseStore.php | 285 +++++++++++++++++++++++++++ includes/DeepResearchAgent.php | 15 +- includes/DiscrepancyAgent.php | 12 +- includes/FreeTier.php | 314 ++++++++++++++++++++++++------ includes/KorrespondAgent.php | 12 ++ includes/StripeClient.php | 234 ++++++++++++++++++++++ includes/bootstrap.php | 124 ++++++++++-- min-sak.php | 228 ++++++++++++++++++++++ pricing.php | 308 +++++++++++++++++++++++++++++ workbench.php | 53 ++--- 30 files changed, 2804 insertions(+), 133 deletions(-) create mode 100644 api/award-survey-credits.php create mode 100644 api/case/delete.php create mode 100644 api/case/ingest-callback.php create mode 100644 api/case/list.php create mode 100644 api/case/upload.php create mode 100644 api/stripe-checkout.php create mode 100644 api/stripe-portal.php create mode 100644 api/stripe-webhook.php create mode 100644 billing.php create mode 100644 includes/AzureDocIntelligence.php create mode 100644 includes/AzureSearchAdmin.php create mode 100644 includes/CaseStore.php create mode 100644 includes/StripeClient.php create mode 100644 min-sak.php create mode 100644 pricing.php diff --git a/advocate.php b/advocate.php index 91a93c1..39dcce1 100644 --- a/advocate.php +++ b/advocate.php @@ -55,9 +55,10 @@ require_once __DIR__ . '/includes/layout.php'; - + + -

Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist uses a fine-tuned Norwegian law model for the synthesis — best for Barneloven, barnevern, and ECHR family law cases.

+

Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist v3 is a Qwen2.5 fine-tune trained on barnevernsloven, ECHR, and forvaltningsloven — highest precision for § 4-25, Strand Lobben, and procedural red flags.

Corpus slices

diff --git a/api/award-survey-credits.php b/api/award-survey-credits.php new file mode 100644 index 0000000..6ac6e91 --- /dev/null +++ b/api/award-survey-credits.php @@ -0,0 +1,104 @@ + 300) { + dbnToolsError('Stale intersite timestamp.', 401, 'stale_timestamp'); +} + +$raw = file_get_contents('php://input'); +if ($raw === false || $raw === '') { + dbnToolsError('Empty body.', 400, 'empty_body'); +} + +$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret); +if (!hash_equals($expected, $sig)) { + dbnToolsError('Bad intersite signature.', 401, 'bad_signature'); +} + +$data = json_decode($raw, true); +if (!is_array($data)) { + dbnToolsError('Invalid JSON body.', 400, 'invalid_json'); +} + +$email = strtolower(trim((string)($data['email'] ?? ''))); +$userIdHint = isset($data['user_id']) ? (int)$data['user_id'] : 0; +$credits = (int)($data['credits'] ?? 25); +$source = (string)($data['source'] ?? 'survey'); + +if ($credits < 1 || $credits > 100) { + dbnToolsError('Credits out of range.', 400, 'bad_credits'); +} +if ($email === '' && $userIdHint <= 0) { + dbnToolsError('Need email or user_id.', 400, 'missing_user'); +} + +$db = dbnmDb(); + +// Resolve user_id from hint or email +$userId = $userIdHint; +if ($userId <= 0 && $email !== '') { + $stmt = $db->prepare('SELECT id FROM users WHERE LOWER(email) = ? LIMIT 1'); + $stmt->execute([$email]); + $userId = (int)($stmt->fetchColumn() ?: 0); +} + +if ($userId <= 0) { + // No account yet — that's fine, dobetternorge.no will magic-link them; we'll award on next login. + dbnToolsRespond([ + 'ok' => true, + 'awarded' => false, + 'pending' => true, + 'message' => 'User not yet registered — will award on first login.', + ]); +} + +// Idempotency: don't double-award +if (FreeTier::hasCompletedSurvey($userId)) { + dbnToolsRespond([ + 'ok' => true, + 'awarded' => false, + 'reason' => 'already_completed', + 'balance' => FreeTier::balance($userId), + ]); +} + +FreeTier::ensureRow($userId); +$newBalance = FreeTier::awardBonus($userId, $credits, $source); +FreeTier::markSurveyCompleted($userId); + +dbnToolsRespond([ + 'ok' => true, + 'awarded' => true, + 'credits' => $credits, + 'balance' => $newBalance, + 'user_id' => $userId, +]); diff --git a/api/case/delete.php b/api/case/delete.php new file mode 100644 index 0000000..606c873 --- /dev/null +++ b/api/case/delete.php @@ -0,0 +1,26 @@ + true, 'doc_id' => $docId]); diff --git a/api/case/ingest-callback.php b/api/case/ingest-callback.php new file mode 100644 index 0000000..ee7a6fd --- /dev/null +++ b/api/case/ingest-callback.php @@ -0,0 +1,73 @@ + 300) { + dbnToolsError('Bad intersite auth.', 401, 'bad_auth'); +} + +$raw = file_get_contents('php://input'); +if (!is_string($raw) || $raw === '') { + dbnToolsError('Empty body.', 400, 'empty'); +} +if (!hash_equals(hash_hmac('sha256', $ts . '.' . $raw, $secret), $sig)) { + dbnToolsError('Bad signature.', 401, 'bad_sig'); +} + +$data = json_decode($raw, true); +if (!is_array($data)) { + dbnToolsError('Invalid JSON.', 400, 'invalid_json'); +} + +$docId = (int)($data['doc_id'] ?? 0); +$status = (string)($data['status'] ?? 'failed'); +$pageCount = isset($data['page_count']) ? (int)$data['page_count'] : null; +$docType = isset($data['doc_type']) ? (string)$data['doc_type'] : null; +$detectedDate = isset($data['detected_date']) ? (string)$data['detected_date'] : null; +$parties = $data['parties'] ?? null; +$errorMsg = isset($data['error_msg']) ? (string)$data['error_msg'] : null; + +if ($docId <= 0) { + dbnToolsError('doc_id required.', 400, 'bad_input'); +} + +$db = dbnmDb(); +$db->prepare( + 'UPDATE case_documents + SET ocr_status = ?, + page_count = COALESCE(?, page_count), + doc_type = COALESCE(?, doc_type), + detected_date = COALESCE(?, detected_date), + parties = COALESCE(?, parties), + ocr_error = COALESCE(?, ocr_error), + indexed_at = CASE WHEN ? = "ready" THEN NOW() ELSE indexed_at END + WHERE id = ?' +)->execute([ + $status, + $pageCount, + $docType, + $detectedDate, + $parties !== null ? json_encode($parties, JSON_UNESCAPED_UNICODE) : null, + $errorMsg, + $status, + $docId, +]); + +dbnToolsRespond(['ok' => true]); diff --git a/api/case/list.php b/api/case/list.php new file mode 100644 index 0000000..65e19be --- /dev/null +++ b/api/case/list.php @@ -0,0 +1,18 @@ + true, + 'docs' => CaseStore::listDocs($userId), +]); diff --git a/api/case/upload.php b/api/case/upload.php new file mode 100644 index 0000000..463d436 --- /dev/null +++ b/api/case/upload.php @@ -0,0 +1,50 @@ + 25 * 1024 * 1024) { + dbnToolsError('Filen må være mellom 1 byte og 25 MB.', 413, 'bad_size'); +} + +// Validate it's actually a PDF (magic number check) +$fh = @fopen($tmp, 'rb'); +if ($fh === false) { + dbnToolsError('Kunne ikke lese filen.', 500, 'read_fail'); +} +$head = (string)fread($fh, 5); +fclose($fh); +if (strncmp($head, '%PDF-', 5) !== 0) { + dbnToolsError('Filen er ikke en gyldig PDF.', 415, 'not_pdf'); +} + +try { + $doc = CaseStore::registerUpload($userId, $name, $tmp, $size); + CaseStore::caseEnqueueIngest((int)$doc['doc_id'], $userId); + dbnToolsRespond([ + 'ok' => true, + 'doc_id' => $doc['doc_id'], + 'filename' => $doc['filename'], + ]); +} catch (Throwable $e) { + dbnToolsError($e->getMessage(), 400, 'upload_failed'); +} diff --git a/api/korrespond.php b/api/korrespond.php index 1d3a81f..056235f 100644 --- a/api/korrespond.php +++ b/api/korrespond.php @@ -78,6 +78,7 @@ try { $input['deadlines'] ))), 0, 6) : [], 'clarifications' => is_array($input['clarifications'] ?? null) ? $input['clarifications'] : [], + 'use_my_case' => !empty($input['use_my_case']), ]; $forceDraft = !empty($input['force_draft']); diff --git a/api/stripe-checkout.php b/api/stripe-checkout.php new file mode 100644 index 0000000..a00d6ff --- /dev/null +++ b/api/stripe-checkout.php @@ -0,0 +1,77 @@ +ensureCustomer($email, $userId); + + $baseUrl = (dbnToolsIsHttps() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'tools.dobetternorge.no'); + $successUrl = $baseUrl . '/billing.php?status=success&session_id={CHECKOUT_SESSION_ID}'; + $cancelUrl = $baseUrl . '/pricing.php?status=canceled'; + + $isSub = in_array($sku, $validSubscriptions, true); + + $params = [ + 'mode' => $isSub ? 'subscription' : 'payment', + 'customer' => $customerId, + 'success_url' => $successUrl, + 'cancel_url' => $cancelUrl, + 'line_items' => [[ + 'price' => StripeClient::priceId($sku), + 'quantity' => 1, + ]], + 'metadata' => [ + 'user_id' => (string)$userId, + 'sku' => $sku, + ], + 'allow_promotion_codes' => true, + 'billing_address_collection' => 'auto', + 'locale' => 'auto', + 'automatic_tax' => ['enabled' => false], + ]; + + if ($isSub) { + $params['subscription_data'] = [ + 'metadata' => ['user_id' => (string)$userId, 'tier' => $sku], + ]; + } else { + $params['payment_intent_data'] = [ + 'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)], + ]; + } + + $session = $stripe->createCheckoutSession($params); + $url = (string)($session['url'] ?? ''); + if ($url === '') { + dbnToolsError('Stripe did not return a checkout URL.', 502, 'stripe_no_url'); + } + + dbnToolsRespond(['ok' => true, 'url' => $url, 'session_id' => (string)($session['id'] ?? '')]); +} catch (Throwable $e) { + error_log('[stripe-checkout] ' . $e->getMessage()); + dbnToolsError('Could not start checkout: ' . $e->getMessage(), 500, 'stripe_failed'); +} diff --git a/api/stripe-portal.php b/api/stripe-portal.php new file mode 100644 index 0000000..d9a5e29 --- /dev/null +++ b/api/stripe-portal.php @@ -0,0 +1,36 @@ +createPortalSession($customerId, $baseUrl . '/billing.php'); + $url = (string)($session['url'] ?? ''); + if ($url === '') { + dbnToolsError('Stripe did not return a portal URL.', 502, 'stripe_no_url'); + } + dbnToolsRespond(['ok' => true, 'url' => $url]); +} catch (Throwable $e) { + error_log('[stripe-portal] ' . $e->getMessage()); + dbnToolsError('Could not open billing portal: ' . $e->getMessage(), 500, 'stripe_failed'); +} diff --git a/api/stripe-webhook.php b/api/stripe-webhook.php new file mode 100644 index 0000000..65bf210 --- /dev/null +++ b/api/stripe-webhook.php @@ -0,0 +1,224 @@ +prepare('SELECT 1 FROM stripe_events WHERE stripe_event_id = ? LIMIT 1'); +$check->execute([$eventId]); +if ($check->fetchColumn()) { + http_response_code(200); + echo 'Already processed'; + exit; +} + +try { + $db->prepare('INSERT INTO stripe_events (stripe_event_id, type, payload, processed_at) VALUES (?, ?, ?, NOW())') + ->execute([$eventId, $type, json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]); + + switch ($type) { + case 'checkout.session.completed': + handleCheckoutCompleted($db, $object); + break; + + case 'customer.subscription.created': + case 'customer.subscription.updated': + handleSubscriptionChange($db, $object); + break; + + case 'customer.subscription.deleted': + handleSubscriptionDeleted($db, $object); + break; + + case 'invoice.paid': + handleInvoicePaid($db, $object); + break; + + case 'invoice.payment_failed': + handleInvoiceFailed($db, $object); + break; + + default: + // Unhandled event types are fine — Stripe just needs a 2xx response. + break; + } + http_response_code(200); + echo 'OK'; +} catch (Throwable $e) { + error_log('[stripe-webhook] handler error: ' . $e->getMessage()); + // Return 500 so Stripe retries — but the stripe_events row already exists, so a retry will be idempotent-skipped. + // To re-process: delete the row before retry. + http_response_code(500); + echo 'Handler failure'; +} + +// ── Handlers ──────────────────────────────────────────────────────────────── + +function handleCheckoutCompleted(PDO $db, array $session): void +{ + $mode = (string)($session['mode'] ?? ''); + $metadata = (array)($session['metadata'] ?? []); + $userId = (int)($metadata['user_id'] ?? 0); + if ($userId <= 0) { + error_log('[stripe-webhook] checkout.session.completed missing user_id metadata'); + return; + } + // Store Stripe customer ID for portal access later. + $customerId = (string)($session['customer'] ?? ''); + if ($customerId !== '') { + $db->prepare('UPDATE user_tool_credits SET stripe_customer_id = ? WHERE user_id = ? AND (stripe_customer_id IS NULL OR stripe_customer_id = "")') + ->execute([$customerId, $userId]); + } + + if ($mode === 'payment') { + // One-time topup — grant credits immediately. + $sku = (string)($metadata['sku'] ?? ''); + $credits = StripeClient::topupCredits($sku); + if ($credits > 0) { + FreeTier::awardBonus($userId, $credits, 'topup:' . $sku); + } + } + // For mode='subscription', subscription.created will fire and update the tier. +} + +function handleSubscriptionChange(PDO $db, array $sub): void +{ + $subId = (string)($sub['id'] ?? ''); + $status = (string)($sub['status'] ?? ''); + $customerId = (string)($sub['customer'] ?? ''); + $items = (array)($sub['items']['data'] ?? []); + $priceId = (string)($items[0]['price']['id'] ?? ''); + $tier = StripeClient::tierForPrice($priceId); + if ($tier === null) { + error_log('[stripe-webhook] unknown price_id on subscription: ' . $priceId); + return; + } + $metadata = (array)($sub['metadata'] ?? []); + $userId = (int)($metadata['user_id'] ?? 0); + if ($userId <= 0) { + // Fallback: look up by stripe_customer_id + $stmt = $db->prepare('SELECT user_id FROM user_tool_credits WHERE stripe_customer_id = ? LIMIT 1'); + $stmt->execute([$customerId]); + $userId = (int)($stmt->fetchColumn() ?: 0); + } + if ($userId <= 0) { + error_log('[stripe-webhook] subscription.* could not resolve user_id (sub=' . $subId . ', customer=' . $customerId . ')'); + return; + } + + $periodEndTs = (int)($sub['current_period_end'] ?? 0); + $periodStartTs = (int)($sub['current_period_start'] ?? 0); + $periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null; + $periodStartIso = $periodStartTs > 0 ? gmdate('Y-m-d H:i:s', $periodStartTs) : null; + + // Upsert subscription ledger + $db->prepare( + 'INSERT INTO user_subscriptions + (user_id, stripe_customer_id, stripe_subscription_id, tier, status, current_period_start, current_period_end) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + tier = VALUES(tier), + current_period_start = VALUES(current_period_start), + current_period_end = VALUES(current_period_end), + stripe_customer_id = VALUES(stripe_customer_id)' + )->execute([$userId, $customerId, $subId, $tier, $status, $periodStartIso, $periodEndIso]); + + // Only flip the live tier flag if subscription is active/trialing. + if (in_array($status, ['active', 'trialing'], true)) { + FreeTier::setTier($userId, $tier, $customerId, $subId, $periodEndIso); + } elseif (in_array($status, ['canceled', 'unpaid', 'incomplete_expired'], true)) { + FreeTier::clearTier($userId); + } +} + +function handleSubscriptionDeleted(PDO $db, array $sub): void +{ + $subId = (string)($sub['id'] ?? ''); + $customerId = (string)($sub['customer'] ?? ''); + $stmt = $db->prepare('SELECT user_id FROM user_subscriptions WHERE stripe_subscription_id = ? LIMIT 1'); + $stmt->execute([$subId]); + $userId = (int)($stmt->fetchColumn() ?: 0); + if ($userId <= 0 && $customerId !== '') { + $stmt = $db->prepare('SELECT user_id FROM user_tool_credits WHERE stripe_customer_id = ? LIMIT 1'); + $stmt->execute([$customerId]); + $userId = (int)($stmt->fetchColumn() ?: 0); + } + if ($userId > 0) { + $db->prepare('UPDATE user_subscriptions SET status = ? WHERE stripe_subscription_id = ?') + ->execute(['canceled', $subId]); + FreeTier::clearTier($userId); + } +} + +function handleInvoicePaid(PDO $db, array $invoice): void +{ + $subId = (string)($invoice['subscription'] ?? ''); + if ($subId === '') return; + $stmt = $db->prepare('SELECT user_id, tier FROM user_subscriptions WHERE stripe_subscription_id = ? LIMIT 1'); + $stmt->execute([$subId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) return; + + $userId = (int)$row['user_id']; + $tier = (string)$row['tier']; + $periodEndTs = (int)($invoice['lines']['data'][0]['period']['end'] ?? 0); + $periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null; + + FreeTier::refillForRenewal($userId, $tier, $periodEndIso); +} + +function handleInvoiceFailed(PDO $db, array $invoice): void +{ + $subId = (string)($invoice['subscription'] ?? ''); + if ($subId === '') return; + $db->prepare('UPDATE user_subscriptions SET status = ? WHERE stripe_subscription_id = ?') + ->execute(['past_due', $subId]); + // Don't downgrade yet — Stripe will retry. subscription.updated will fire if it fully fails. +} diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js index 80b9a1f..166d6e0 100644 --- a/assets/js/korrespond.js +++ b/assets/js/korrespond.js @@ -576,7 +576,7 @@ ${(data.legal_check && data.legal_check.length) ? `