Share funktion

This commit is contained in:
2026-07-16 17:13:37 +02:00
parent 52c9dfb472
commit c7c1864fc6
9 changed files with 311 additions and 27 deletions

View File

@@ -46,6 +46,7 @@ const {
const {
chargeKey,
consumeCreditsWithIdempotency,
refundCreditsWithIdempotency,
endpointKey,
ensureBillingSchema,
getAccountSnapshot,
@@ -757,9 +758,11 @@ app.post('/v1/billing/sync-revenuecat', async (request, response) => {
app.post('/v1/scan', async (request, response) => {
let userId = 'unknown';
let idempotencyKey = null;
let creditsCharged = 0;
try {
userId = ensureRequestAuth(request);
const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri');
const language = normalizeLanguage(request.body?.language);
const endpointId = endpointKey('scan', userId, idempotencyKey);
@@ -770,7 +773,6 @@ app.post('/v1/scan', async (request, response) => {
return;
}
let creditsCharged = 0;
const modelPath = [];
let modelUsed = null;
let modelFallbackCount = 0;
@@ -936,6 +938,19 @@ app.post('/v1/scan', async (request, response) => {
response.status(200).json(payload);
} catch (error) {
console.error(`Scan error for user ${userId}:`, error);
if (error?.code === 'NOT_A_PLANT' && creditsCharged > 0 && idempotencyKey) {
try {
const refunded = await refundCreditsWithIdempotency(
db,
userId,
chargeKey('scan-refund', userId, idempotencyKey),
creditsCharged,
);
console.log(`Refunded ${refunded} credit(s) for user ${userId} (NOT_A_PLANT)`);
} catch (refundError) {
console.error(`Failed to refund credits for user ${userId} (NOT_A_PLANT):`, refundError);
}
}
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}

View File

@@ -623,6 +623,37 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
});
};
const refundCredits = (account, amount) => {
if (amount <= 0) return 0;
// Spiegelbildlich zu consumeCredits: zuerst das Monatskontingent entlasten,
// ein etwaiger Rest wandert auf das Topup-Guthaben zurück.
let remaining = amount;
const monthlyRefund = Math.min(account.usedThisCycle, remaining);
account.usedThisCycle -= monthlyRefund;
remaining -= monthlyRefund;
if (remaining > 0) {
account.topupBalance += remaining;
}
return amount;
};
const refundCreditsWithIdempotency = async (db, userId, key, amount) => {
return runInTransaction(db, async (tx) => {
const existing = await readIdempotentValue(tx, key);
if (existing && typeof existing.refunded === 'number') return existing.refunded;
const account = await getOrCreateAccount(tx, userId);
const refunded = refundCredits(account, amount);
account.updatedAt = nowIso();
await upsertAccount(tx, account);
await writeIdempotentValue(tx, key, { refunded });
return refunded;
});
};
const getBillingSummary = async (db, userId) => {
if (userId === 'guest') {
return {
@@ -820,6 +851,7 @@ module.exports = {
chargeKey,
claimNotificationOnce,
consumeCreditsWithIdempotency,
refundCreditsWithIdempotency,
endpointKey,
ensureBillingSchema,
getAccountSnapshot,