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).
This commit is contained in:
Claude
2026-08-18 21:13:58 +00:00
parent 3bb1d309bc
commit 55b03b5039
5 changed files with 101 additions and 51 deletions
+14 -5
View File
@@ -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<void> {
/**
* Dra AI-skanningskvot. `units` = antal bilder i skanningen (spec §4546):
* 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<void> {
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}` },
});
}
+3 -2
View File
@@ -75,8 +75,9 @@ export async function scanRoutes(app: FastifyInstance) {
return reply.status(201).send({ scan: job, product });
}
// AI-skanning: kvotkontroll (fair use, spec §4546) och presignade URL:er.
await consumeAiScan(app.db, req.userId);
// AI-skanning: kvotkontroll (fair use, spec §4546). 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 = [];
+29 -13
View File
@@ -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<boolean> {
function askAddMore(count: number, maxCount: number): Promise<boolean> {
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<boolean> {
});
}
/** Fånga en eller flera bilder. Kamera → loop med "ta en till"; galleri → flerval. */
async function capturePhotos(multi: boolean): Promise<string[]> {
/**
* 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<string[]> {
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<string[]> {
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).
+51 -29
View File
@@ -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<void> {
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<void> {
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),
),
);
}
+4 -2
View File
@@ -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,
});
}