feat: implement billing system with credit tracking and RevenueCat integration

This commit is contained in:
2026-04-04 12:15:16 +02:00
parent 439f5a44c9
commit 363f5f60d1
3 changed files with 1051 additions and 883 deletions

View File

@@ -69,7 +69,7 @@ const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
const DEFAULT_BOOTSTRAP_PLANTS = [
const DEFAULT_BOOTSTRAP_PLANTS = [
{
id: '1',
name: 'Monstera Deliciosa',
@@ -97,10 +97,18 @@ const DEFAULT_BOOTSTRAP_PLANTS = [
temp: '15-30C',
light: 'Low to full light',
},
},
];
let db;
},
];
const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [
path.join(__dirname, 'data', 'plants_dump_utf8.json'),
path.join(__dirname, '..', 'plants_dump_utf8.json'),
];
const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [
path.join(__dirname, 'public', 'plants', 'manifest.json'),
];
let db;
const parseBoolean = (value, fallbackValue) => {
if (typeof value !== 'string') return fallbackValue;
@@ -270,23 +278,161 @@ const ensureRequestAuth = (request) => {
const isGuest = (userId) => userId === 'guest';
const ensureNonEmptyString = (value, fieldName) => {
if (typeof value === 'string' && value.trim()) return value.trim();
const error = new Error(`${fieldName} is required.`);
error.code = 'BAD_REQUEST';
throw error;
};
const seedBootstrapCatalogIfNeeded = async () => {
const existing = await getPlants(db, { limit: 1 });
if (existing.length > 0) return;
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
source: 'bootstrap',
preserveExistingIds: false,
enforceUniqueImages: false,
});
};
const ensureNonEmptyString = (value, fieldName) => {
if (typeof value === 'string' && value.trim()) return value.trim();
const error = new Error(`${fieldName} is required.`);
error.code = 'BAD_REQUEST';
throw error;
};
const readJsonFromCandidates = (filePaths) => {
for (const filePath of filePaths) {
if (!fs.existsSync(filePath)) continue;
try {
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
return {
parsed: JSON.parse(raw),
sourcePath: filePath,
};
} catch (error) {
console.warn('Failed to parse bootstrap JSON file.', {
filePath,
error: error instanceof Error ? error.message : String(error),
});
}
}
return null;
};
const buildEntriesFromManifest = (manifest) => {
const items = Array.isArray(manifest?.items) ? manifest.items : [];
return items
.filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string')
.map((item) => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
name: item.name.trim(),
botanicalName: item.botanicalName.trim(),
imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim()
? item.localImageUri.trim()
: (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''),
imageStatus: item.status === 'missing' ? 'missing' : 'ok',
description: '',
categories: [],
confidence: 1,
careInfo: {
waterIntervalDays: 7,
light: 'Unknown',
temp: 'Unknown',
},
}))
.filter((entry) => entry.imageUri);
};
const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => {
const mergedByBotanical = new Map();
primaryEntries.forEach((entry) => {
const botanicalKey = typeof entry?.botanicalName === 'string'
? entry.botanicalName.trim().toLowerCase()
: '';
if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return;
mergedByBotanical.set(botanicalKey, { ...entry });
});
secondaryEntries.forEach((entry) => {
const botanicalKey = typeof entry?.botanicalName === 'string'
? entry.botanicalName.trim().toLowerCase()
: '';
if (!botanicalKey) return;
const existing = mergedByBotanical.get(botanicalKey);
if (!existing) {
mergedByBotanical.set(botanicalKey, { ...entry });
return;
}
const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/');
mergedByBotanical.set(botanicalKey, {
...existing,
imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri,
imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus,
id: existing.id || entry.id,
name: existing.name || entry.name,
botanicalName: existing.botanicalName || entry.botanicalName,
});
});
return Array.from(mergedByBotanical.values());
};
const loadFullBootstrapCatalog = () => {
const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES);
const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES);
const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : [];
const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : [];
const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries);
if (mergedEntries.length === 0) return null;
return {
entries: mergedEntries,
sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '),
};
};
const isMinimalBootstrapCatalog = (entries) => {
if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) {
return false;
}
const botanicalNames = new Set(
entries
.map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''))
.filter(Boolean),
);
return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase()));
};
const seedBootstrapCatalogIfNeeded = async () => {
const fullCatalog = loadFullBootstrapCatalog();
const diagnostics = await getPlantDiagnostics(db);
if (diagnostics.totalCount > 0) {
if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) {
const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 });
if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) {
await rebuildPlantsCatalog(db, fullCatalog.entries, {
source: 'bootstrap_upgrade_from_minimal_catalog',
preserveExistingIds: false,
enforceUniqueImages: false,
});
console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`);
}
}
return;
}
if (fullCatalog) {
await rebuildPlantsCatalog(db, fullCatalog.entries, {
source: 'bootstrap_full_catalog',
preserveExistingIds: false,
enforceUniqueImages: false,
});
console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`);
return;
}
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
source: 'bootstrap_minimal_catalog',
preserveExistingIds: false,
enforceUniqueImages: false,
});
console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.');
};
app.use(cors());
app.use('/plants', express.static(plantsPublicDir));

View File

@@ -403,6 +403,19 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
);
}
// Fallback: also check active entitlements for topup products.
// This handles cases where a topup product is misconfigured in RevenueCat
// to grant an entitlement instead of being treated as a consumable.
const rawActiveEntitlements = Object.values(customerInfo?.entitlements?.active || {});
for (const entitlement of rawActiveEntitlements) {
const productId = entitlement?.productIdentifier;
if (isSupportedTopupProduct(productId)) {
const purchaseDate = entitlement?.latestPurchaseDate || entitlement?.originalPurchaseDate;
const txId = purchaseDate ? `entitlement:${productId}:${purchaseDate}` : null;
await grantRevenueCatTopupIfNeeded(tx, account, txId, productId);
}
}
account.updatedAt = nowIso();
await upsertAccount(tx, account);
return {