From 55b03b50398155b01c7f35a0a9807e95ec390b3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:13:58 +0000 Subject: [PATCH] feat(kvot): debitera AI-skanning PER BILD + fixa dubbeldebitering Johans beslut: en skanning debiteras per bild (6 foton = 6 skanningar), streckkod gratis. consumeAiScan tar nu units=imageCount och kollar att hela ryms. Refunden ger tillbaka lika manga (antal bilder) vid misslyckat jobb. Appen kapar antalet foton till kvarvarande kvot och gar till paywall om 0 (servern ar dock den slutgiltiga grinden). Hittade + fixade en PRE-EXISTING bugg pa vagen: kvoten drogs bade vid skapande (consumeAiScan) OCH vid slutford analys (recordAiUsage aiScans:1) -> varje LYCKAD skanning kostade 2 enheter, dolt av att appen bara uppdaterade raknaren vid start. Nu dras kvoten exakt en gang, vid skapandet. Enbildsanvandare far alltsa nu sina fulla 5 gratis-skanningar (var tidigare ~2,5). --- apps/api/src/lib/entitlements.ts | 19 +++-- apps/api/src/routes/scans.ts | 5 +- apps/mobile/src/app/(tabs)/scan.tsx | 42 ++++++++---- apps/worker/src/processors/quota-refund.ts | 80 ++++++++++++++-------- apps/worker/src/processors/scans.ts | 6 +- 5 files changed, 101 insertions(+), 51 deletions(-) diff --git a/apps/api/src/lib/entitlements.ts b/apps/api/src/lib/entitlements.ts index d340ff4..8450a20 100644 --- a/apps/api/src/lib/entitlements.ts +++ b/apps/api/src/lib/entitlements.ts @@ -87,21 +87,30 @@ export async function loadEntitlementsWithToken( } /** Kräv AI-kvot och räkna upp användningen atomiskt. */ -export async function consumeAiScan(db: Database, userId: string): Promise { +/** + * Dra AI-skanningskvot. `units` = antal bilder i skanningen (spec §45–46): + * en skanning debiteras per bild, så 6 foton = 6 enheter. Blockerar om hela + * skanningen inte får plats i kvarvarande kvot (allt-eller-inget – appen kapar + * antalet foton till kvarvarande kvot så det aldrig blockeras mitt i flödet). + */ +export async function consumeAiScan(db: Database, userId: string, units = 1): Promise { + const count = Math.max(1, Math.floor(units)); const billingUserId = await billingUserFor(db, userId); const ent = await loadEntitlements(db, billingUserId); - if (ent.aiScansUsedThisMonth >= ent.aiScansPerMonth) { + if (ent.aiScansUsedThisMonth + count > ent.aiScansPerMonth) { + const kvar = Math.max(0, ent.aiScansPerMonth - ent.aiScansUsedThisMonth); throw errors.quotaExceeded( - `Månadens AI-skanningar är slut (${ent.aiScansPerMonth} st). Uppgradera för fler.`, + `Månadens AI-skanningar räcker inte (${kvar} kvar av ${ent.aiScansPerMonth}, den här kräver ${count}). Uppgradera för fler.`, ); } const month = currentMonth(); + const { sql } = await import("drizzle-orm"); await db .insert(schema.aiUsageCounters) - .values({ userId: billingUserId, month, aiScans: 1 }) + .values({ userId: billingUserId, month, aiScans: count }) .onConflictDoUpdate({ target: [schema.aiUsageCounters.userId, schema.aiUsageCounters.month], - set: { aiScans: (await import("drizzle-orm")).sql`${schema.aiUsageCounters.aiScans} + 1` }, + set: { aiScans: sql`${schema.aiUsageCounters.aiScans} + ${count}` }, }); } diff --git a/apps/api/src/routes/scans.ts b/apps/api/src/routes/scans.ts index 59b7b37..ec6d28f 100644 --- a/apps/api/src/routes/scans.ts +++ b/apps/api/src/routes/scans.ts @@ -75,8 +75,9 @@ export async function scanRoutes(app: FastifyInstance) { return reply.status(201).send({ scan: job, product }); } - // AI-skanning: kvotkontroll (fair use, spec §45–46) och presignade URL:er. - await consumeAiScan(app.db, req.userId); + // AI-skanning: kvotkontroll (fair use, spec §45–46). Debiteras per bild – + // 6 foton = 6 skanningar. Streckkod (ovan) är gratis och drar inget. + await consumeAiScan(app.db, req.userId, Math.max(1, input.imageCount)); const prefix = `${S3_PREFIX[input.scanType] ?? "temporary"}/${householdId}`; const uploads = []; diff --git a/apps/mobile/src/app/(tabs)/scan.tsx b/apps/mobile/src/app/(tabs)/scan.tsx index c3b9e52..36bb053 100644 --- a/apps/mobile/src/app/(tabs)/scan.tsx +++ b/apps/mobile/src/app/(tabs)/scan.tsx @@ -44,17 +44,17 @@ const MULTI_PHOTO_TYPES = new Set(["fridge", "freezer", "pantry", "ingredients"] const MAX_PHOTOS = 6; // matchar createScanInputSchema.imageCount.max /** Fråga om användaren vill ta fler bilder i samma skanning. */ -function askAddMore(count: number): Promise { +function askAddMore(count: number, maxCount: number): Promise { return new Promise((resolve) => { - if (count >= MAX_PHOTOS) { - Alert.alert("Klart", `Max ${MAX_PHOTOS} bilder – analyserar.`, [ + if (count >= maxCount) { + Alert.alert("Klart", `Max ${maxCount} bilder den här skanningen – analyserar.`, [ { text: "OK", onPress: () => resolve(false) }, ]); return; } Alert.alert( `Bild ${count} tagen`, - "Ta gärna fler vinklar av samma plats – appen listar varje vara en gång.", + "Ta gärna fler vinklar av samma plats – appen listar varje vara en gång. Varje bild räknas som en skanning.", [ { text: "Ta en till", onPress: () => resolve(true) }, { text: `Analysera (${count})`, style: "default", onPress: () => resolve(false) }, @@ -63,13 +63,17 @@ function askAddMore(count: number): Promise { }); } -/** Fånga en eller flera bilder. Kamera → loop med "ta en till"; galleri → flerval. */ -async function capturePhotos(multi: boolean): Promise { +/** + * Fånga en eller flera bilder. Kamera → loop med "ta en till"; galleri → flerval. + * maxCount kapar antalet till kvarvarande kvot (varje bild = en skanning). + */ +async function capturePhotos(multi: boolean, maxCount: number): Promise { const permission = await ImagePicker.requestCameraPermissionsAsync(); const useCamera = permission.granted; const uris: string[] = []; + const cap = Math.max(1, maxCount); - if (!multi) { + if (!multi || cap === 1) { const picked = useCamera ? await ImagePicker.launchCameraAsync({ quality: 0.7 }) : await ImagePicker.launchImageLibraryAsync({ quality: 0.7 }); @@ -83,19 +87,19 @@ async function capturePhotos(multi: boolean): Promise { const picked = await ImagePicker.launchImageLibraryAsync({ quality: 0.7, allowsMultipleSelection: true, - selectionLimit: MAX_PHOTOS, + selectionLimit: cap, }); - if (!picked.canceled) for (const a of picked.assets ?? []) uris.push(a.uri); + if (!picked.canceled) for (const a of (picked.assets ?? []).slice(0, cap)) uris.push(a.uri); return uris; } - // Kamera: ta en bild i taget, fråga om fler mellan varje. - while (uris.length < MAX_PHOTOS) { + // Kamera: ta en bild i taget, fråga om fler mellan varje (upp till kvot-taket). + while (uris.length < cap) { const picked = await ImagePicker.launchCameraAsync({ quality: 0.7 }); const asset = picked.assets?.[0]; if (picked.canceled || !asset) break; uris.push(asset.uri); - const more = await askAddMore(uris.length); + const more = await askAddMore(uris.length, cap); if (!more) break; } return uris; @@ -131,9 +135,21 @@ export default function ScanScreen() { return; } try { + // Varje bild = en skanning. Kapa antalet foton till kvarvarande kvot, och + // skicka till paywall direkt om kvoten redan är slut (servern är dock den + // som slutgiltigt räknar – appens tak är bara för att slippa blockering mitt i). + const remaining = entitlements.data + ? entitlements.data.aiScansPerMonth - entitlements.data.aiScansUsedThisMonth + : null; + if (remaining != null && remaining <= 0) { + router.push("/paywall"); + return; + } + const cap = remaining != null ? Math.min(MAX_PHOTOS, remaining) : MAX_PHOTOS; + // 1. Ta bild(er) – flera vinklar tillåtna för kyl/frys/skafferi/ingredienser. const multi = MULTI_PHOTO_TYPES.has(scanType); - const uris = await capturePhotos(multi); + const uris = await capturePhotos(multi, cap); if (uris.length === 0) return; // avbruten // Visa laddning först nu (kamera/dialoger är egna vyer). diff --git a/apps/worker/src/processors/quota-refund.ts b/apps/worker/src/processors/quota-refund.ts index 208a387..1e754bf 100644 --- a/apps/worker/src/processors/quota-refund.ts +++ b/apps/worker/src/processors/quota-refund.ts @@ -4,36 +4,58 @@ import type { WorkerContext } from "../context.js"; /** * Återbetalar AI-skanningskvot när ett skann-jobb dör definitivt. - * Speglar consumeAiScan (api): kvot debiteras på household-ägaren (billingUser). - * Barcode-skanningar drar ingen kvot och hoppas över. Golvat på 0. + * Speglar consumeAiScan (api): kvot debiteras på household-ägaren (billingUser) + * och PER BILD, så återbetalningen ger tillbaka lika många enheter som drogs + * (antal bilder i jobbet). Barcode-skanningar drar ingen kvot och hoppas över. + * Golvat på 0. */ -export async function refundAiScanForFailedJob(ctx: WorkerContext, scanJobId: string): Promise { - const [job] = await ctx.db - .select({ userId: schema.scanJobs.userId, scanType: schema.scanJobs.scanType }) - .from(schema.scanJobs) - .where(eq(schema.scanJobs.id, scanJobId)) - .limit(1); - if (!job || job.scanType === "barcode") return; +export async function refundAiScanForFailedJob( + ctx: WorkerContext, + scanJobId: string, +): Promise { + const [job] = await ctx.db + .select({ + userId: schema.scanJobs.userId, + scanType: schema.scanJobs.scanType, + s3Keys: schema.scanJobs.s3Keys, + }) + .from(schema.scanJobs) + .where(eq(schema.scanJobs.id, scanJobId)) + .limit(1); + if (!job || job.scanType === "barcode") return; - let billingUserId = job.userId; - const [member] = await ctx.db - .select({ householdId: schema.householdMembers.householdId }) - .from(schema.householdMembers) - .where(eq(schema.householdMembers.userId, job.userId)) - .orderBy(schema.householdMembers.joinedAt) - .limit(1); - if (member?.householdId) { - const [owner] = await ctx.db - .select({ userId: schema.householdMembers.userId }) - .from(schema.householdMembers) - .where(and(eq(schema.householdMembers.householdId, member.householdId), eq(schema.householdMembers.role, "owner"))) - .limit(1); - if (owner?.userId) billingUserId = owner.userId; - } + // Debiteras per bild – återbetala lika många enheter som drogs (en per bild). + const units = Math.max(1, Array.isArray(job.s3Keys) ? job.s3Keys.length : 1); - const month = new Date().toISOString().slice(0, 7); - await ctx.db - .update(schema.aiUsageCounters) - .set({ aiScans: sql`GREATEST(${schema.aiUsageCounters.aiScans} - 1, 0)` }) - .where(and(eq(schema.aiUsageCounters.userId, billingUserId), eq(schema.aiUsageCounters.month, month))); + let billingUserId = job.userId; + const [member] = await ctx.db + .select({ householdId: schema.householdMembers.householdId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.userId, job.userId)) + .orderBy(schema.householdMembers.joinedAt) + .limit(1); + if (member?.householdId) { + const [owner] = await ctx.db + .select({ userId: schema.householdMembers.userId }) + .from(schema.householdMembers) + .where( + and( + eq(schema.householdMembers.householdId, member.householdId), + eq(schema.householdMembers.role, "owner"), + ), + ) + .limit(1); + if (owner?.userId) billingUserId = owner.userId; + } + + const month = new Date().toISOString().slice(0, 7); + await ctx.db + .update(schema.aiUsageCounters) + .set({ aiScans: sql`GREATEST(${schema.aiUsageCounters.aiScans} - ${units}, 0)` }) + .where( + and( + eq(schema.aiUsageCounters.userId, billingUserId), + eq(schema.aiUsageCounters.month, month), + ), + ); } diff --git a/apps/worker/src/processors/scans.ts b/apps/worker/src/processors/scans.ts index 58d2ad0..ed38c88 100644 --- a/apps/worker/src/processors/scans.ts +++ b/apps/worker/src/processors/scans.ts @@ -116,13 +116,15 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro } } - // Bokför verklig AI-kostnad/tokens utan PII (spec §45). + // Bokför verklig AI-kostnad/tokens utan PII (spec §45). Kvoten (aiScans) drogs + // redan vid skapandet (consumeAiScan, per bild) – räkna INTE upp den igen här, + // annars dubbeldebiteras varje lyckad skanning. if (result.costUsd != null || result.inputTokens || result.outputTokens) { await recordAiUsage(ctx.db, job.userId, { costUsd: result.costUsd ?? 0, inputTokens: result.inputTokens ?? 0, outputTokens: result.outputTokens ?? 0, - aiScans: 1, + aiScans: 0, }); }