Slefhostet und postgres

This commit is contained in:
2026-04-02 11:39:57 +02:00
parent b1c99893a6
commit 08483c7075
215 changed files with 4584 additions and 5190 deletions

View File

@@ -5,6 +5,7 @@ import {
HealthCheckResponse,
PurchaseProductId,
RevenueCatCustomerInfo,
RevenueCatSyncSource,
ScanPlantResponse,
SemanticSearchResponse,
ServiceHealthResponse,
@@ -13,12 +14,12 @@ import {
SimulateWebhookResponse,
SyncRevenueCatStateResponse,
} from './contracts';
import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim();
const REQUEST_TIMEOUT_MS = 15000;
import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 15000;
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
if (status === 400) return 'BAD_REQUEST';
@@ -28,10 +29,12 @@ const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
return 'PROVIDER_ERROR';
};
const buildBackendUrl = (path: string): string => {
const base = BACKEND_BASE_URL.replace(/\/$/, '');
return `${base}${path}`;
};
const buildBackendUrl = (path: string): string => {
const backendBaseUrl = getConfiguredBackendRootUrl();
if (!backendBaseUrl) return path;
const base = backendBaseUrl.replace(/\/$/, '');
return `${base}${path}`;
};
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
if (!value) return null;
@@ -104,9 +107,9 @@ const makeRequest = async <T,>(
};
export const backendApiClient = {
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!BACKEND_BASE_URL) {
return {
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) {
return {
ok: true,
uptimeSec: 0,
timestamp: new Date().toISOString(),
@@ -127,10 +130,10 @@ export const backendApiClient = {
},
getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.getBillingSummary(token);
}
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.getBillingSummary(token);
}
return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET',
@@ -140,13 +143,15 @@ export const backendApiClient = {
syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.syncRevenueCatState({
userId: token,
customerInfo: params.customerInfo,
});
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.syncRevenueCatState({
userId: token,
customerInfo: params.customerInfo,
source: params.source,
});
}
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
@@ -154,6 +159,7 @@ export const backendApiClient = {
token,
body: {
customerInfo: params.customerInfo,
source: params.source,
},
});
},
@@ -162,10 +168,10 @@ export const backendApiClient = {
idempotencyKey: string;
imageUri: string;
language: Language;
}): Promise<ScanPlantResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.scanPlant({
}): Promise<ScanPlantResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.scanPlant({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
@@ -188,10 +194,10 @@ export const backendApiClient = {
idempotencyKey: string;
query: string;
language: Language;
}): Promise<SemanticSearchResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.semanticSearch({
}): Promise<SemanticSearchResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.semanticSearch({
userId: token,
idempotencyKey: params.idempotencyKey,
query: params.query,
@@ -220,10 +226,10 @@ export const backendApiClient = {
careInfo: CareInfo;
description?: string;
};
}): Promise<HealthCheckResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.healthCheck({
}): Promise<HealthCheckResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.healthCheck({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
@@ -247,10 +253,10 @@ export const backendApiClient = {
simulatePurchase: async (params: {
idempotencyKey: string;
productId: PurchaseProductId;
}): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.simulatePurchase({
}): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulatePurchase({
userId: token,
idempotencyKey: params.idempotencyKey,
productId: params.productId,
@@ -271,10 +277,10 @@ export const backendApiClient = {
idempotencyKey: string;
event: SimulatedWebhookEvent;
payload?: { credits?: number };
}): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.simulateWebhook({
}): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulateWebhook({
userId: token,
idempotencyKey: params.idempotencyKey,
event: params.event,

View File

@@ -149,6 +149,12 @@ export interface SimulateWebhookResponse {
billing: BillingSummary;
}
export type RevenueCatSyncSource =
| 'app_init'
| 'subscription_purchase'
| 'topup_purchase'
| 'restore';
export interface SyncRevenueCatStateResponse {
billing: BillingSummary;
syncedAt: string;

View File

@@ -2,13 +2,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import {
BackendApiError,
BillingProvider,
BillingSummary,
BillingSummary,
HealthCheckRequest,
HealthCheckResponse,
PlanId,
PurchaseProductId,
RevenueCatCustomerInfo,
RevenueCatEntitlementInfo,
RevenueCatNonSubscriptionTransaction,
RevenueCatSyncSource,
ScanPlantRequest,
ScanPlantResponse,
SemanticSearchRequest,
@@ -46,11 +48,12 @@ const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
monthly_pro: 0,
yearly_pro: 0,
topup_small: 25,
topup_medium: 75,
topup_large: 200,
topup_medium: 120,
topup_large: 300,
};
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']);
interface MockAccountRecord {
userId: string;
@@ -243,6 +246,42 @@ const normalizeRevenueCatTransactions = (
const nonSubscriptions = customerInfo?.nonSubscriptions || {};
return Object.values(nonSubscriptions).flatMap((entries) => Array.isArray(entries) ? entries : []);
};
const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => {
const activeEntitlements = customerInfo?.entitlements?.active || {};
return {
appUserId: customerInfo?.appUserId ?? null,
originalAppUserId: customerInfo?.originalAppUserId ?? null,
activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({
id,
productIdentifier: entitlement?.productIdentifier ?? null,
expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null,
})),
allPurchasedProductIdentifiers: customerInfo?.allPurchasedProductIdentifiers ?? [],
nonSubscriptionTransactions: normalizeRevenueCatTransactions(customerInfo).map((transaction) => ({
productIdentifier: transaction?.productIdentifier ?? null,
transactionIdentifier: transaction?.transactionIdentifier || transaction?.transactionId || null,
})),
};
};
const getValidProEntitlement = (customerInfo: RevenueCatCustomerInfo): RevenueCatEntitlementInfo | null => {
const activeEntitlements = customerInfo?.entitlements?.active || {};
const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
if (!proEntitlement) {
return null;
}
if (
proEntitlement.productIdentifier
&& SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS.has(proEntitlement.productIdentifier as PurchaseProductId)
) {
return proEntitlement;
}
console.warn('[Billing][Mock] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
return null;
};
const readIdempotentResponse = <T,>(store: IdempotencyStore, key: string): T | null => {
const record = store[key];
@@ -652,17 +691,25 @@ export const mockBackendService = {
syncRevenueCatState: async (request: {
userId: string;
customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => {
return withUserLock(request.userId, async () => {
const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId);
const activeEntitlements = request.customerInfo?.entitlements?.active || {};
const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
const proEntitlement = getValidProEntitlement(request.customerInfo);
const source = request.source || 'app_init';
account.plan = proEntitlement ? 'pro' : 'free';
account.provider = 'revenuecat';
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId);
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
console.log('[Billing][Mock] Syncing RevenueCat customer info', {
source,
customerInfo: summarizeRevenueCatCustomerInfo(request.customerInfo),
});
if (source !== 'topup_purchase') {
account.plan = proEntitlement ? 'pro' : 'free';
account.provider = 'revenuecat';
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId);
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
}
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
const productId = transaction.productIdentifier as PurchaseProductId | undefined;