Hard paywall
This commit is contained in:
160
server/index.js
160
server/index.js
@@ -26,7 +26,14 @@ loadEnvFiles([
|
||||
]);
|
||||
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
|
||||
const {
|
||||
ensureAuthSchema,
|
||||
signUp: authSignUp,
|
||||
login: authLogin,
|
||||
signInWithApple: authSignInWithApple,
|
||||
issueToken,
|
||||
verifyJwt,
|
||||
} = require('./lib/auth');
|
||||
const {
|
||||
PlantImportValidationError,
|
||||
ensurePlantSchema,
|
||||
@@ -168,13 +175,27 @@ const resolveUserId = (request) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveIdempotencyKey = (request) => {
|
||||
const header = request.header('idempotency-key');
|
||||
if (typeof header === 'string' && header.trim()) return header.trim();
|
||||
return '';
|
||||
};
|
||||
|
||||
const toPlantResult = (entry, confidence) => {
|
||||
const resolveIdempotencyKey = (request) => {
|
||||
const header = request.header('idempotency-key');
|
||||
if (typeof header === 'string' && header.trim()) return header.trim();
|
||||
return '';
|
||||
};
|
||||
|
||||
const createHardPaywallError = (requiredCredits) => {
|
||||
const error = new Error('Active Pro or trial entitlement required.');
|
||||
error.code = 'INSUFFICIENT_CREDITS';
|
||||
error.status = 402;
|
||||
error.metadata = { required: requiredCredits, available: 0 };
|
||||
return error;
|
||||
};
|
||||
|
||||
const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => {
|
||||
if (!accountSnapshot || accountSnapshot.plan !== 'pro') {
|
||||
throw createHardPaywallError(requiredCredits);
|
||||
}
|
||||
};
|
||||
|
||||
const toPlantResult = (entry, confidence) => {
|
||||
return {
|
||||
name: entry.name,
|
||||
botanicalName: entry.botanicalName,
|
||||
@@ -235,23 +256,37 @@ const toApiErrorPayload = (error) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
|
||||
return {
|
||||
status: 401,
|
||||
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (isInsufficientCreditsError(error)) {
|
||||
return {
|
||||
status: 402,
|
||||
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
|
||||
return {
|
||||
status: 401,
|
||||
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (isInsufficientCreditsError(error)) {
|
||||
return {
|
||||
status: 402,
|
||||
body: {
|
||||
code: 'INSUFFICIENT_CREDITS',
|
||||
message: error.message || 'Insufficient credits.',
|
||||
details: error.metadata || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
error
|
||||
&& typeof error === 'object'
|
||||
&& Number.isInteger(error.status)
|
||||
&& error.status >= 400
|
||||
&& error.status < 500
|
||||
&& typeof error.code === 'string'
|
||||
) {
|
||||
return {
|
||||
status: error.status,
|
||||
body: { code: error.code, message: error.message || 'Request failed.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
|
||||
return {
|
||||
@@ -487,8 +522,9 @@ app.get('/', (_request, response) => {
|
||||
'GET /health',
|
||||
'GET /api/plants',
|
||||
'POST /api/plants/rebuild',
|
||||
'POST /auth/signup',
|
||||
'POST /auth/login',
|
||||
'POST /auth/signup',
|
||||
'POST /auth/login',
|
||||
'POST /auth/apple',
|
||||
'GET /v1/billing/summary',
|
||||
'POST /v1/billing/sync-revenuecat',
|
||||
'POST /v1/scan',
|
||||
@@ -642,14 +678,17 @@ app.post('/v1/scan', async (request, response) => {
|
||||
let modelUsed = null;
|
||||
let modelFallbackCount = 0;
|
||||
|
||||
const [creditResult, accountSnapshot, catalogEntries] = await Promise.all([
|
||||
isGuest(userId)
|
||||
? Promise.resolve(0)
|
||||
: consumeCreditsWithIdempotency(db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST),
|
||||
getAccountSnapshot(db, userId),
|
||||
getCachedCatalogEntries(db),
|
||||
]);
|
||||
creditsCharged += creditResult;
|
||||
const [accountSnapshot, catalogEntries] = await Promise.all([
|
||||
getAccountSnapshot(db, userId),
|
||||
getCachedCatalogEntries(db),
|
||||
]);
|
||||
ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);
|
||||
creditsCharged += await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('scan-primary', userId, idempotencyKey),
|
||||
SCAN_PRIMARY_COST,
|
||||
);
|
||||
|
||||
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
||||
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
||||
@@ -785,21 +824,24 @@ app.post('/v1/search/semantic', async (request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
const payload = {
|
||||
status: 'no_results',
|
||||
if (!query) {
|
||||
const payload = {
|
||||
status: 'no_results',
|
||||
results: [],
|
||||
creditsCharged: 0,
|
||||
billing: await getBillingSummary(db, userId),
|
||||
};
|
||||
await storeEndpointResponse(db, endpointId, payload);
|
||||
response.status(200).json(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const creditsCharged = await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
return;
|
||||
}
|
||||
|
||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||
ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);
|
||||
|
||||
const creditsCharged = await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('semantic-search', userId, idempotencyKey),
|
||||
SEMANTIC_SEARCH_COST,
|
||||
);
|
||||
@@ -831,11 +873,14 @@ app.post('/v1/health-check', async (request, response) => {
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) {
|
||||
response.status(200).json(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpenAiConfigured()) {
|
||||
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
||||
return;
|
||||
}
|
||||
|
||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||
ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);
|
||||
|
||||
if (!isOpenAiConfigured()) {
|
||||
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
||||
error.code = 'PROVIDER_ERROR';
|
||||
throw error;
|
||||
}
|
||||
@@ -998,9 +1043,9 @@ app.post('/auth/signup', async (request, response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (request, response) => {
|
||||
try {
|
||||
const { email, password } = request.body || {};
|
||||
app.post('/auth/login', async (request, response) => {
|
||||
try {
|
||||
const { email, password } = request.body || {};
|
||||
if (!email || !password) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' });
|
||||
}
|
||||
@@ -1010,8 +1055,23 @@ app.post('/auth/login', async (request, response) => {
|
||||
} catch (error) {
|
||||
const status = error.status || 500;
|
||||
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/apple', async (request, response) => {
|
||||
try {
|
||||
const { identityToken, appleUser, email, name } = request.body || {};
|
||||
if (!identityToken) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
|
||||
}
|
||||
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
|
||||
const token = issueToken(user.id, user.email, user.name);
|
||||
response.status(200).json({ userId: user.id, email: user.email, name: user.name, token });
|
||||
} catch (error) {
|
||||
const status = error.status || 500;
|
||||
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Startup ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
const crypto = require('crypto');
|
||||
const { get, run } = require('./postgres');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
|
||||
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
|
||||
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
|
||||
const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
|
||||
const APPLE_ISSUER = 'https://appleid.apple.com';
|
||||
const APPLE_AUDIENCE = (
|
||||
process.env.APPLE_CLIENT_ID
|
||||
|| process.env.APPLE_BUNDLE_ID
|
||||
|| process.env.EXPO_PUBLIC_APPLE_CLIENT_ID
|
||||
|| process.env.IOS_BUNDLE_ID
|
||||
|| 'com.greenlens.app'
|
||||
).trim();
|
||||
let appleJwksCache = { keys: [], expiresAt: 0 };
|
||||
|
||||
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
|
||||
|
||||
@@ -47,8 +57,100 @@ const issueToken = (userId, email, name) =>
|
||||
|
||||
// ─── Password hashing ──────────────────────────────────────────────────────
|
||||
|
||||
const hashPassword = (password) =>
|
||||
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
|
||||
const hashPassword = (password) =>
|
||||
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
|
||||
|
||||
const parseJwtPart = (value) => JSON.parse(b64urlDecode(value));
|
||||
|
||||
const getAppleJwks = async () => {
|
||||
const now = Date.now();
|
||||
if (appleJwksCache.keys.length > 0 && appleJwksCache.expiresAt > now) {
|
||||
return appleJwksCache.keys;
|
||||
}
|
||||
|
||||
const response = await fetch(APPLE_JWKS_URL);
|
||||
if (!response.ok) {
|
||||
const error = new Error('Could not load Apple public keys.');
|
||||
error.code = 'APPLE_AUTH_UNAVAILABLE';
|
||||
error.status = 503;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
appleJwksCache = {
|
||||
keys: Array.isArray(payload.keys) ? payload.keys : [],
|
||||
expiresAt: now + 6 * 60 * 60 * 1000,
|
||||
};
|
||||
return appleJwksCache.keys;
|
||||
};
|
||||
|
||||
const verifyAppleIdentityToken = async (identityToken) => {
|
||||
if (!identityToken || typeof identityToken !== 'string') {
|
||||
const error = new Error('Apple identityToken is required.');
|
||||
error.code = 'BAD_REQUEST';
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parts = identityToken.split('.');
|
||||
if (parts.length !== 3) {
|
||||
const error = new Error('Apple identityToken is malformed.');
|
||||
error.code = 'APPLE_AUTH_INVALID';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
||||
let header;
|
||||
let claims;
|
||||
try {
|
||||
header = parseJwtPart(encodedHeader);
|
||||
claims = parseJwtPart(encodedPayload);
|
||||
} catch {
|
||||
const error = new Error('Apple identityToken is malformed.');
|
||||
error.code = 'APPLE_AUTH_INVALID';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
if (header.alg !== 'RS256' || !header.kid) {
|
||||
const error = new Error('Apple identityToken has an unsupported signature.');
|
||||
error.code = 'APPLE_AUTH_INVALID';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const keys = await getAppleJwks();
|
||||
const jwk = keys.find((key) => key.kid === header.kid);
|
||||
if (!jwk) {
|
||||
const error = new Error('Apple public key not found.');
|
||||
error.code = 'APPLE_AUTH_INVALID';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(`${encodedHeader}.${encodedPayload}`);
|
||||
verifier.end();
|
||||
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
||||
const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url'));
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const expectedAudiences = new Set([APPLE_AUDIENCE, 'com.greenlens.app'].filter(Boolean));
|
||||
|
||||
if (
|
||||
!validSignature
|
||||
|| claims.iss !== APPLE_ISSUER
|
||||
|| !expectedAudiences.has(claims.aud)
|
||||
|| !claims.sub
|
||||
|| (claims.exp && nowSeconds > Number(claims.exp))
|
||||
) {
|
||||
const error = new Error('Apple identityToken could not be verified.');
|
||||
error.code = 'APPLE_AUTH_INVALID';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return claims;
|
||||
};
|
||||
|
||||
// ─── Schema ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -59,10 +161,22 @@ const ensureAuthSchema = async (db) => {
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
auth_provider TEXT NOT NULL DEFAULT 'email',
|
||||
apple_subject TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
);
|
||||
|
||||
await run(db, "ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'email'");
|
||||
await run(db, 'ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS apple_subject TEXT');
|
||||
await run(db, 'ALTER TABLE auth_users ALTER COLUMN password_hash DROP NOT NULL');
|
||||
await run(
|
||||
db,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_users_apple_subject
|
||||
ON auth_users (apple_subject)
|
||||
WHERE apple_subject IS NOT NULL`,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Operations ───────────────────────────────────────────────────────────
|
||||
@@ -98,14 +212,68 @@ const login = async (db, email, password) => {
|
||||
err.code = 'USER_NOT_FOUND';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (user.password_hash !== hashPassword(password)) {
|
||||
}
|
||||
if (!user.password_hash) {
|
||||
const err = new Error('This account uses Apple Sign-In.');
|
||||
err.code = 'USE_APPLE_LOGIN';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (user.password_hash !== hashPassword(password)) {
|
||||
const err = new Error('Wrong password.');
|
||||
err.code = 'WRONG_PASSWORD';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
};
|
||||
|
||||
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
};
|
||||
|
||||
const signInWithApple = async (db, identityToken, profile = {}) => {
|
||||
const claims = await verifyAppleIdentityToken(identityToken);
|
||||
const appleSubject = String(claims.sub);
|
||||
const emailFromToken = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : '';
|
||||
const emailFromProfile = typeof profile.email === 'string' ? profile.email.trim().toLowerCase() : '';
|
||||
const normalizedEmail = emailFromToken || emailFromProfile;
|
||||
const profileName = typeof profile.name === 'string' ? profile.name.trim() : '';
|
||||
|
||||
const existingByApple = await get(
|
||||
db,
|
||||
'SELECT id, email, name FROM auth_users WHERE apple_subject = $1',
|
||||
[appleSubject],
|
||||
);
|
||||
if (existingByApple) return existingByApple;
|
||||
|
||||
if (!normalizedEmail) {
|
||||
const err = new Error('Apple did not return an email for this account.');
|
||||
err.code = 'APPLE_EMAIL_MISSING';
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existingByEmail = await get(
|
||||
db,
|
||||
'SELECT id, email, name FROM auth_users WHERE LOWER(email) = LOWER($1)',
|
||||
[normalizedEmail],
|
||||
);
|
||||
if (existingByEmail) {
|
||||
const nextName = existingByEmail.name || profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
|
||||
await run(
|
||||
db,
|
||||
'UPDATE auth_users SET apple_subject = $1, auth_provider = $2, name = $3 WHERE id = $4',
|
||||
[appleSubject, 'apple', nextName, existingByEmail.id],
|
||||
);
|
||||
return { ...existingByEmail, name: nextName };
|
||||
}
|
||||
|
||||
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const name = profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO auth_users (id, email, name, password_hash, auth_provider, apple_subject)
|
||||
VALUES ($1, $2, $3, NULL, $4, $5)`,
|
||||
[id, normalizedEmail, name, 'apple', appleSubject],
|
||||
);
|
||||
return { id, email: normalizedEmail, name };
|
||||
};
|
||||
|
||||
module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
const { get, run } = require('./postgres');
|
||||
|
||||
const FREE_MONTHLY_CREDITS = 15;
|
||||
const PRO_MONTHLY_CREDITS = 250;
|
||||
const TOPUP_DEFAULT_CREDITS = 60;
|
||||
const FREE_MONTHLY_CREDITS = 0;
|
||||
const TRIAL_MONTHLY_CREDITS = 30;
|
||||
const PRO_MONTHLY_CREDITS = 100;
|
||||
const TOPUP_DEFAULT_CREDITS = 100;
|
||||
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
||||
const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']);
|
||||
|
||||
const TOPUP_CREDITS_BY_PRODUCT = {
|
||||
monthly_pro: 0,
|
||||
yearly_pro: 0,
|
||||
topup_small: 25,
|
||||
topup_medium: 120,
|
||||
topup_large: 300,
|
||||
topup_small: 30,
|
||||
topup_medium: 100,
|
||||
topup_large: 250,
|
||||
};
|
||||
|
||||
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
|
||||
@@ -64,6 +65,22 @@ const getMonthlyAllowanceForPlan = (plan) => {
|
||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
|
||||
const getRevenueCatPeriodType = (source) => {
|
||||
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
|
||||
};
|
||||
|
||||
const isRevenueCatTrial = (source) => {
|
||||
return getRevenueCatPeriodType(source) === 'trial';
|
||||
};
|
||||
|
||||
const isAllowedMonthlyAllowance = (account) => {
|
||||
if (account.plan === 'pro') {
|
||||
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|
||||
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
|
||||
}
|
||||
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
|
||||
const createInsufficientCreditsError = (required, available) => {
|
||||
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
|
||||
error.code = 'INSUFFICIENT_CREDITS';
|
||||
@@ -130,7 +147,7 @@ const buildDefaultAccount = (userId, now) => {
|
||||
const alignAccountToCurrentCycle = (account, now) => {
|
||||
const next = { ...account };
|
||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
|
||||
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
||||
if (!isAllowedMonthlyAllowance(next)) {
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
|
||||
@@ -238,6 +255,7 @@ const getOrCreateAccount = async (db, userId) => {
|
||||
};
|
||||
|
||||
const getAvailableCredits = (account) => {
|
||||
if (account.plan !== 'pro') return 0;
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||
};
|
||||
@@ -326,14 +344,22 @@ const getValidProEntitlement = (customerInfo) => {
|
||||
|
||||
const applyRevenueCatEntitlementState = (account, options) => {
|
||||
const now = new Date();
|
||||
const previousPlan = account.plan;
|
||||
const previousMonthlyAllowance = account.monthlyAllowance;
|
||||
const nextPlan = options.active ? 'pro' : 'free';
|
||||
const planChanged = account.plan !== nextPlan;
|
||||
const nextMonthlyAllowance = options.active && options.isTrial
|
||||
? TRIAL_MONTHLY_CREDITS
|
||||
: getMonthlyAllowanceForPlan(nextPlan);
|
||||
const planChanged = previousPlan !== nextPlan;
|
||||
const trialConvertedToPaid = previousPlan === 'pro'
|
||||
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
|
||||
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
|
||||
account.plan = nextPlan;
|
||||
account.provider = 'revenuecat';
|
||||
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan);
|
||||
account.monthlyAllowance = nextMonthlyAllowance;
|
||||
account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null;
|
||||
|
||||
if (planChanged) {
|
||||
if (planChanged || trialConvertedToPaid) {
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
@@ -401,6 +427,7 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
|
||||
if (source !== 'topup_purchase') {
|
||||
applyRevenueCatEntitlementState(account, {
|
||||
active: Boolean(proEntitlement),
|
||||
isTrial: isRevenueCatTrial(proEntitlement),
|
||||
renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate),
|
||||
});
|
||||
}
|
||||
@@ -490,6 +517,7 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
|
||||
if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) {
|
||||
applyRevenueCatEntitlementState(account, {
|
||||
active: true,
|
||||
isTrial: isRevenueCatTrial(eventPayload),
|
||||
renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at),
|
||||
});
|
||||
} else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) {
|
||||
@@ -519,6 +547,9 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
|
||||
|
||||
const consumeCredits = (account, cost) => {
|
||||
if (cost <= 0) return 0;
|
||||
if (account.plan !== 'pro') {
|
||||
throw createInsufficientCreditsError(cost, 0);
|
||||
}
|
||||
|
||||
const available = getAvailableCredits(account);
|
||||
if (available < cost) {
|
||||
@@ -567,12 +598,12 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
|
||||
const getBillingSummary = async (db, userId) => {
|
||||
if (userId === 'guest') {
|
||||
return {
|
||||
entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null },
|
||||
entitlement: { plan: 'free', provider: 'mock', status: 'inactive', renewsAt: null },
|
||||
credits: {
|
||||
monthlyAllowance: 5,
|
||||
monthlyAllowance: 0,
|
||||
usedThisCycle: 0,
|
||||
topupBalance: 0,
|
||||
available: 5,
|
||||
available: 0,
|
||||
cycleStartedAt: nowIso(),
|
||||
cycleEndsAt: nowIso(),
|
||||
},
|
||||
@@ -595,7 +626,7 @@ const getAccountSnapshot = async (db, userId) => {
|
||||
provider: 'mock',
|
||||
cycleStartedAt: nowIso(),
|
||||
cycleEndsAt: nowIso(),
|
||||
monthlyAllowance: 5,
|
||||
monthlyAllowance: 0,
|
||||
usedThisCycle: 0,
|
||||
topupBalance: 0,
|
||||
renewsAt: null,
|
||||
|
||||
Reference in New Issue
Block a user