feat: implement billing system with credit tracking and RevenueCat integration
This commit is contained in:
@@ -335,6 +335,15 @@ export default function BillingScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already
|
||||
// owns this subscription on a different GreenLens account. Silently dismiss;
|
||||
// the current account stays free. The user can restore via "Käufe wiederherstellen".
|
||||
const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record<string, unknown>).code : undefined;
|
||||
if (rcErrorCode === 7) {
|
||||
setSubModalVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Payment failed', e);
|
||||
Alert.alert('Unerwarteter Fehler', msg);
|
||||
} finally {
|
||||
|
||||
156
server/index.js
156
server/index.js
@@ -100,6 +100,14 @@ const DEFAULT_BOOTSTRAP_PLANTS = [
|
||||
},
|
||||
];
|
||||
|
||||
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) => {
|
||||
@@ -277,15 +285,153 @@ const ensureNonEmptyString = (value, fieldName) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const seedBootstrapCatalogIfNeeded = async () => {
|
||||
const existing = await getPlants(db, { limit: 1 });
|
||||
if (existing.length > 0) return;
|
||||
const readJsonFromCandidates = (filePaths) => {
|
||||
for (const filePath of filePaths) {
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
|
||||
source: 'bootstrap',
|
||||
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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user