Files
Greenlens/services/backend/contracts.ts
Timo e3a28b0a1c feat(billing): add weekly_pro subscription plan
Adds a 2.99 EUR/week plan with a 3-day free trial alongside the existing
monthly and yearly subscriptions.

Backend: weekly_pro joins the supported subscription products, the
available product list and the Discord sales label. No schema change --
weekly Pro grants the same 100 credits per calendar month as monthly Pro,
so no column is needed to tell the two apart.

Paywall: weekly and yearly are the two prominent cards, monthly is a
selectable row below them. Weekly is preselected. Cards only render when
their RevenueCat package exists, and the selection falls back to a
visible plan so the CTA can never buy a product that is not loaded.

Trial eligibility: checkTrialOrIntroductoryPriceEligibility now gates the
trial copy. Apple grants one intro offer per subscription group, so with
two trial products a second free-trial promise would otherwise be shown
to users who get charged immediately. Anything but a clear ELIGIBLE is
treated as no trial, as the RevenueCat SDK recommends.

Analytics: trial_started previously fired on every subscription purchase,
including monthly which never had a trial. It now fires only for products
that actually carry one. paywall_viewed distinguishes trial_enabled from
trial_eligible and reports selected_plan.

Tests: 9 new cases covering the entitlement path, credits, renewal period
and trial allowance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:50:38 +02:00

196 lines
4.7 KiB
TypeScript

import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../../types';
export type PlanId = 'free' | 'pro';
export type BillingProvider = 'mock' | 'revenuecat' | 'stripe';
export type PurchaseProductId = 'weekly_pro' | 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large';
export type SimulatedWebhookEvent =
| 'entitlement_granted'
| 'entitlement_revoked'
| 'topup_granted'
| 'credits_depleted';
export interface BackendDatabaseEntry extends IdentificationResult {
imageUri: string;
imageStatus?: 'ok' | 'missing' | 'invalid';
categories: string[];
}
export interface CreditState {
monthlyAllowance: number;
usedThisCycle: number;
topupBalance: number;
available: number;
cycleStartedAt: string;
cycleEndsAt: string;
}
export interface EntitlementState {
plan: PlanId;
provider: BillingProvider;
status: 'active' | 'inactive';
renewsAt: string | null;
}
export interface BillingSummary {
entitlement: EntitlementState;
credits: CreditState;
availableProducts: PurchaseProductId[];
}
export interface RevenueCatEntitlementInfo {
productIdentifier?: string;
expirationDate?: string | null;
expiresDate?: string | null;
periodType?: string | null;
period_type?: string | null;
}
export interface RevenueCatNonSubscriptionTransaction {
productIdentifier?: string;
transactionIdentifier?: string;
transactionId?: string;
purchaseDate?: string | null;
}
export interface RevenueCatCustomerInfo {
appUserId?: string | null;
originalAppUserId?: string | null;
entitlements: {
active: Record<string, RevenueCatEntitlementInfo>;
};
nonSubscriptions?: Record<string, RevenueCatNonSubscriptionTransaction[]>;
nonSubscriptionTransactions?: RevenueCatNonSubscriptionTransaction[];
allPurchasedProductIdentifiers?: string[];
latestExpirationDate?: string | null;
}
export interface ScanPlantRequest {
userId: string;
idempotencyKey: string;
imageUri: string;
language: Language;
}
export interface ScanPlantResponse {
result: IdentificationResult;
creditsCharged: number;
modelPath: string[];
modelUsed?: string | null;
modelFallbackCount?: number;
billing: BillingSummary;
}
export interface SemanticSearchRequest {
userId: string;
idempotencyKey: string;
query: string;
language: Language;
}
export interface SemanticSearchResponse {
status: 'success' | 'no_results';
results: BackendDatabaseEntry[];
creditsCharged: number;
billing: BillingSummary;
}
export interface HealthCheckRequest {
userId: string;
idempotencyKey: string;
imageUri: string;
language: Language;
plantContext?: {
name: string;
botanicalName: string;
careInfo: CareInfo;
description?: string;
};
}
export interface HealthCheckResponse {
healthCheck: PlantHealthCheck;
creditsCharged: number;
modelUsed?: string | null;
modelFallbackCount?: number;
billing: BillingSummary;
}
export interface ServiceHealthResponse {
ok: boolean;
uptimeSec: number;
timestamp: string;
openAiConfigured: boolean;
dbReady?: boolean;
dbPath?: string;
scanModel?: string;
healthModel?: string;
}
export interface SimulatePurchaseRequest {
userId: string;
idempotencyKey: string;
productId: PurchaseProductId;
}
export interface SimulatePurchaseResponse {
appliedProduct: PurchaseProductId;
billing: BillingSummary;
}
export interface SimulateWebhookRequest {
userId: string;
idempotencyKey: string;
event: SimulatedWebhookEvent;
payload?: {
credits?: number;
};
}
export interface SimulateWebhookResponse {
event: SimulatedWebhookEvent;
billing: BillingSummary;
}
export type RevenueCatSyncSource =
| 'app_init'
| 'subscription_purchase'
| 'topup_purchase'
| 'restore';
export interface SyncRevenueCatStateResponse {
billing: BillingSummary;
syncedAt: string;
}
export type BackendErrorCode =
| 'INSUFFICIENT_CREDITS'
| 'UNAUTHORIZED'
| 'TIMEOUT'
| 'NETWORK_ERROR'
| 'PROVIDER_ERROR'
| 'BAD_REQUEST'
| 'NOT_A_PLANT';
export class BackendApiError extends Error {
public readonly code: BackendErrorCode;
public readonly status: number;
public readonly metadata?: Record<string, unknown>;
constructor(
code: BackendErrorCode,
message: string,
status = 500,
metadata?: Record<string, unknown>,
) {
super(message);
this.name = 'BackendApiError';
this.code = code;
this.status = status;
this.metadata = metadata;
}
}
export const isBackendApiError = (error: unknown): error is BackendApiError => {
return error instanceof BackendApiError;
};