From 8f7413d31be648bff541565eec2d633de03e788f Mon Sep 17 00:00:00 2001 From: devburak Date: Tue, 15 Sep 2026 12:49:56 +0300 Subject: [PATCH 01/12] perf(api): batch API token usage timestamps --- apps/api/src/lib/localRedis.js | 16 ++- apps/api/src/middleware/apiLogger.js | 37 ++++- .../api/src/middleware/apiTokenUsage.test.mjs | 129 ++++++++++++++++++ apps/api/src/middleware/auth.js | 53 ++++--- apps/api/src/routes/publicForms.js | 7 +- apps/api/src/server.js | 10 ++ .../src/services/apiTokenUsageSyncService.js | 56 ++++++++ .../apiTokenUsageSyncService.test.mjs | 53 +++++++ docs/saas/en/api-token-lifecycle.md | 2 + docs/saas/tr/api-token-lifecycle.md | 2 + 10 files changed, 329 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/middleware/apiTokenUsage.test.mjs create mode 100644 apps/api/src/services/apiTokenUsageSyncService.js create mode 100644 apps/api/src/services/apiTokenUsageSyncService.test.mjs diff --git a/apps/api/src/lib/localRedis.js b/apps/api/src/lib/localRedis.js index 863a8ed..e8616f9 100644 --- a/apps/api/src/lib/localRedis.js +++ b/apps/api/src/lib/localRedis.js @@ -4,6 +4,7 @@ const Redis = require('redis'); const DEFAULT_USAGE_KEY_TTL_SECONDS = 45 * 24 * 60 * 60; const DEFAULT_LIMIT_KEY_TTL_SECONDS = 40 * 24 * 60 * 60; +const API_TOKEN_USAGE_KEY = 'usage:api-tokens:last-used'; function getCurrentMonthKey(date = new Date()) { return date.toISOString().slice(0, 7); @@ -103,6 +104,10 @@ class LocalRedisClient extends EventEmitter { return `usage:requests:${tenantId}:${periodKey}`; } + getApiTokenUsageKey() { + return API_TOKEN_USAGE_KEY; + } + async cacheTenantLimits(tenantId, limits, ttl = 86400) { if (!this.isEnabled()) { return false; @@ -304,7 +309,7 @@ class LocalRedisClient extends EventEmitter { } } - async incrementUsageCounter(tenantId, periodKey, ttl = DEFAULT_USAGE_KEY_TTL_SECONDS) { + async incrementUsageCounter(tenantId, periodKey, ttl = DEFAULT_USAGE_KEY_TTL_SECONDS, tokenId = null) { if (!this.isEnabled()) { return null; } @@ -312,14 +317,17 @@ class LocalRedisClient extends EventEmitter { try { const key = this.getUsageCounterKey(tenantId, periodKey); const now = String(Date.now()); - const replies = await this.client.multi() + const transaction = this.client.multi() .hIncrBy(key, 'count', 1) .hSet(key, { periodKey, updatedAt: now, }) - .expire(key, ttl) - .exec(); + .expire(key, ttl); + if (tokenId) { + transaction.zAdd(API_TOKEN_USAGE_KEY, { score: Number(now), value: String(tokenId) }, { comparison: 'GT' }); + } + const replies = await transaction.exec(); return parseInteger(Array.isArray(replies) ? replies[0] : 0); } catch (error) { diff --git a/apps/api/src/middleware/apiLogger.js b/apps/api/src/middleware/apiLogger.js index 98cc697..a775c00 100644 --- a/apps/api/src/middleware/apiLogger.js +++ b/apps/api/src/middleware/apiLogger.js @@ -1,5 +1,25 @@ const localRedisClient = require('../lib/localRedis'); const { getFourHourPeriod, USAGE_KEY_TTL_SECONDS } = require('../services/apiUsageService'); +const { ApiToken } = require('@contexthub/common'); + +const fallbackLastUsed = new Map(); +const FALLBACK_INTERVAL_MS = 5 * 60 * 1000; + +function recordWithoutRedis(tokenId, now) { + if (!tokenId) return; + const lastAttempt = fallbackLastUsed.get(tokenId) || 0; + if (now.getTime() - lastAttempt < FALLBACK_INTERVAL_MS) return; + fallbackLastUsed.set(tokenId, now.getTime()); + if (fallbackLastUsed.size > 10000) { + for (const [id, timestamp] of fallbackLastUsed) { + if (now.getTime() - timestamp >= FALLBACK_INTERVAL_MS) fallbackLastUsed.delete(id); + } + } + ApiToken.updateOne({ _id: tokenId }, { $max: { lastUsedAt: now } }).catch((error) => { + fallbackLastUsed.delete(tokenId); + console.error('[ApiLogger] Failed to record token use without Redis:', error.message); + }); +} async function apiLogger(request) { const skipPaths = ['/health', '/favicon.ico', '/robots.txt']; @@ -11,21 +31,26 @@ async function apiLogger(request) { return; } - if (!localRedisClient.isEnabled()) { - return; - } - try { const tenantId = request.tenantId || request.user?.tenantId || null; if (!tenantId) { return; } - const { periodKey } = getFourHourPeriod(new Date()); + const now = new Date(); + const { periodKey } = getFourHourPeriod(now); + const tokenId = request.apiTokenUsageAuthorized ? request.apiToken?._id?.toString?.() : null; setImmediate(() => { - localRedisClient.incrementUsageCounter(tenantId, periodKey, USAGE_KEY_TTL_SECONDS).catch((error) => { + if (!localRedisClient.isEnabled()) { + recordWithoutRedis(tokenId, now); + return; + } + localRedisClient.incrementUsageCounter(tenantId, periodKey, USAGE_KEY_TTL_SECONDS, tokenId).then((count) => { + if (count === null) recordWithoutRedis(tokenId, now); + }).catch((error) => { console.error('[ApiLogger] Failed to increment usage counter:', error.message); + recordWithoutRedis(tokenId, now); }); }); } catch (error) { diff --git a/apps/api/src/middleware/apiTokenUsage.test.mjs b/apps/api/src/middleware/apiTokenUsage.test.mjs new file mode 100644 index 0000000..3c7e136 --- /dev/null +++ b/apps/api/src/middleware/apiTokenUsage.test.mjs @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { ApiToken, Tenant } = require('@contexthub/common'); +const localRedisClient = require('../lib/localRedis'); +const roleService = require('../services/roleService'); +const { authenticate } = require('./auth'); +const apiLogger = require('./apiLogger'); + +afterEach(() => vi.restoreAllMocks()); + +describe('API token request usage', () => { + it('adds token time to the existing tenant counter transaction', async () => { + const previousClient = localRedisClient.client; + const transaction = { + hIncrBy: vi.fn().mockReturnThis(), + hSet: vi.fn().mockReturnThis(), + expire: vi.fn().mockReturnThis(), + zAdd: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([1, 'OK', 1, 1]), + }; + localRedisClient.client = { multi: vi.fn().mockReturnValue(transaction) }; + vi.spyOn(localRedisClient, 'isEnabled').mockReturnValue(true); + try { + await expect(localRedisClient.incrementUsageCounter('tenant-1', 'period-1', 60, 'token-1')).resolves.toBe(1); + expect(localRedisClient.client.multi).toHaveBeenCalledOnce(); + expect(transaction.zAdd).toHaveBeenCalledWith( + localRedisClient.getApiTokenUsageKey(), + { score: expect.any(Number), value: 'token-1' }, + { comparison: 'GT' } + ); + } finally { + localRedisClient.client = previousClient; + } + }); + + it('authenticates a GET without saving its token and records use after the response', async () => { + const save = vi.fn(); + const token = { + _id: { toString: () => 'token-1' }, + tenantId: { toString: () => 'tenant-1' }, + name: 'reader', + scopes: ['read'], + role: 'viewer', + lastAuditAt: new Date(), + save, + }; + vi.spyOn(ApiToken, 'findOne').mockResolvedValue(token); + vi.spyOn(Tenant, 'findById').mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ status: 'active' }) }), + }); + vi.spyOn(roleService, 'resolveRole').mockResolvedValue(null); + vi.spyOn(localRedisClient, 'isEnabled').mockReturnValue(true); + vi.spyOn(localRedisClient, 'getRequestLimitFlag').mockResolvedValue(null); + const increment = vi.spyOn(localRedisClient, 'incrementUsageCounter').mockResolvedValue(1); + const reply = { code: vi.fn().mockReturnThis(), send: vi.fn().mockReturnThis() }; + const request = { + headers: { authorization: 'Bearer ctx_test' }, + url: '/api/contents', + method: 'GET', + query: {}, + }; + + await authenticate(request, reply); + expect(save).not.toHaveBeenCalled(); + expect(request.apiTokenUsageAuthorized).toBe(true); + expect(increment).not.toHaveBeenCalled(); + + await apiLogger(request); + await new Promise((resolve) => setImmediate(resolve)); + expect(increment).toHaveBeenCalledWith('tenant-1', expect.any(String), expect.any(Number), 'token-1'); + }); + + it('uses a throttled background MongoDB update when Redis is unavailable', async () => { + vi.spyOn(localRedisClient, 'isEnabled').mockReturnValue(false); + const update = vi.spyOn(ApiToken, 'updateOne').mockResolvedValue({ modifiedCount: 1 }); + const request = { + url: '/api/contents', + tenantId: 'tenant-1', + apiTokenUsageAuthorized: true, + apiToken: { _id: { toString: () => 'token-2' } }, + }; + + await apiLogger(request); + expect(update).not.toHaveBeenCalled(); + await new Promise((resolve) => setImmediate(resolve)); + await apiLogger(request); + await new Promise((resolve) => setImmediate(resolve)); + + expect(update).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith({ _id: 'token-2' }, { + $max: { lastUsedAt: expect.any(Date) }, + }); + }); + + it('claims an audit interval with a conditional background update', async () => { + vi.spyOn(ApiToken, 'findOne').mockResolvedValue({ + _id: 'token-3', + tenantId: { toString: () => 'tenant-1' }, + name: 'reader', + scopes: ['read'], + role: 'viewer', + lastAuditAt: new Date(Date.now() - 16 * 60 * 1000), + }); + vi.spyOn(Tenant, 'findById').mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ status: 'active' }) }), + }); + vi.spyOn(roleService, 'resolveRole').mockResolvedValue(null); + vi.spyOn(localRedisClient, 'isEnabled').mockReturnValue(false); + const update = vi.spyOn(ApiToken, 'updateOne').mockResolvedValue({ modifiedCount: 0 }); + const reply = { code: vi.fn().mockReturnThis(), send: vi.fn().mockReturnThis() }; + + await authenticate({ + headers: { authorization: 'Bearer ctx_test' }, + url: '/api/contents', + method: 'GET', + query: {}, + }, reply); + expect(update).not.toHaveBeenCalled(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + _id: 'token-3', + revokedAt: null, + $or: expect.any(Array), + }), { $set: { lastAuditAt: expect.any(Date) } }); + }); +}); diff --git a/apps/api/src/middleware/auth.js b/apps/api/src/middleware/auth.js index 9522ac0..7032d39 100644 --- a/apps/api/src/middleware/auth.js +++ b/apps/api/src/middleware/auth.js @@ -177,16 +177,12 @@ async function authenticateApiToken(request, reply) { }); } - // Last used at'i güncelle. Audit kaydını yüksek trafikte şişirmemek için aynı - // tokenın kullanımını en fazla 15 dakikada bir güvenlik günlüğüne yaz. + // Usage timestamps are recorded after the response and flushed from Redis + // in batches. Claim audit intervals independently so concurrent requests + // cannot all create the same security event. const now = new Date(); const shouldAuditUsage = !apiToken.lastAuditAt || now.getTime() - new Date(apiToken.lastAuditAt).getTime() >= 15 * 60 * 1000; - apiToken.lastUsedAt = now; - if (shouldAuditUsage) { - apiToken.lastAuditAt = now; - } - await apiToken.save(); // Request'e tenant ve token bilgilerini ekle request.tenantId = apiToken.tenantId.toString(); @@ -253,21 +249,36 @@ async function authenticateApiToken(request, reply) { if (!await enforceBillingWriteAccess(request, reply)) return; + request.apiTokenUsageAuthorized = true; if (shouldAuditUsage) { - await logSecurityEvent({ - action: 'api_token.used', - description: `API token kullanıldı: ${apiToken.name}`, - userId: apiToken.createdBy || null, - tenantId: apiToken.tenantId, - metadata: { - tokenId: apiToken._id.toString(), - name: apiToken.name, - scopes: effectiveScopes, - role: tokenRole, - method: request.method, - path: request.url, - }, - request, + setImmediate(() => { + ApiToken.updateOne({ + _id: apiToken._id, + revokedAt: null, + $or: [ + { lastAuditAt: null }, + { lastAuditAt: { $lte: new Date(now.getTime() - 15 * 60 * 1000) } }, + ], + }, { $set: { lastAuditAt: now } }).then((result) => { + if (!result.modifiedCount) return; + return logSecurityEvent({ + action: 'api_token.used', + description: `API token kullanıldı: ${apiToken.name}`, + userId: apiToken.createdBy || null, + tenantId: apiToken.tenantId, + metadata: { + tokenId: apiToken._id.toString(), + name: apiToken.name, + scopes: effectiveScopes, + role: tokenRole, + method: request.method, + path: request.url, + }, + request, + }); + }).catch((error) => { + request.log?.error?.({ err: error }, 'Failed to audit API token use'); + }); }); } diff --git a/apps/api/src/routes/publicForms.js b/apps/api/src/routes/publicForms.js index 9049355..f7ef9d0 100644 --- a/apps/api/src/routes/publicForms.js +++ b/apps/api/src/routes/publicForms.js @@ -354,7 +354,7 @@ async function validateApiKey(request, reply) { // Find API token in database const { ApiToken } = require('@contexthub/common'); - const apiToken = await ApiToken.findOne({ hash }); + const apiToken = await ApiToken.findOne({ hash, revokedAt: null }); if (!apiToken) { return reply.code(401).send({ @@ -383,10 +383,6 @@ async function validateApiKey(request, reply) { }); } - // Update last used timestamp - apiToken.lastUsedAt = new Date(); - await apiToken.save(); - // Set tenant ID from API token request.tenantId = apiToken.tenantId.toString(); request.apiToken = apiToken; @@ -394,6 +390,7 @@ async function validateApiKey(request, reply) { if (await checkRequestLimit(request, reply)) { return; } + request.apiTokenUsageAuthorized = true; } /** diff --git a/apps/api/src/server.js b/apps/api/src/server.js index a22ace9..36ab9d0 100644 --- a/apps/api/src/server.js +++ b/apps/api/src/server.js @@ -427,6 +427,16 @@ async function start() { await localRedisClient.initialize(); + // Keep token inventory timestamps close to current without adding a MongoDB + // write to API-token requests. Redis/Mongo failures leave observations queued. + const { syncApiTokenUsage } = require('./services/apiTokenUsageSyncService'); + const tokenUsageTimer = setInterval(() => { + syncApiTokenUsage().catch((error) => { + console.error('[Server] API token usage sync failed:', error.message); + }); + }, 5 * 60 * 1000); + tokenUsageTimer.unref(); + if (require('./lib/billingConfig').isAccountBillingEnabled()) { const billingLifecycleService = require('./services/billing/billingLifecycleService'); const billingWebhookService = require('./services/billing/billingWebhookService'); diff --git a/apps/api/src/services/apiTokenUsageSyncService.js b/apps/api/src/services/apiTokenUsageSyncService.js new file mode 100644 index 0000000..239dd32 --- /dev/null +++ b/apps/api/src/services/apiTokenUsageSyncService.js @@ -0,0 +1,56 @@ +const { ApiToken } = require('@contexthub/common'); +const localRedisClient = require('../lib/localRedis'); + +const LOCK_KEY = 'usage:api-tokens:sync-lock'; +const BATCH_SIZE = 500; +const MAX_BATCHES = 20; + +// A newer observation must survive a sync that was already reading the old score. +const REMOVE_FLUSHED_SCRIPT = ` +for index = 1, #ARGV, 2 do + local tokenId = ARGV[index] + local observedScore = ARGV[index + 1] + if redis.call('ZSCORE', KEYS[1], tokenId) == observedScore then + redis.call('ZREM', KEYS[1], tokenId) + end +end +return 1 +`; + +async function syncApiTokenUsage(now = new Date()) { + if (!localRedisClient.isEnabled()) return { skipped: 'redis_unavailable', flushed: 0 }; + + const lockToken = await localRedisClient.acquireLock(LOCK_KEY, 15 * 60); + if (!lockToken) return { skipped: 'sync_in_progress', flushed: 0 }; + + let flushed = 0; + try { + const client = localRedisClient.getClient(); + const usageKey = localRedisClient.getApiTokenUsageKey(); + for (let batch = 0; batch < MAX_BATCHES; batch++) { + const entries = await client.zRangeByScoreWithScores(usageKey, '-inf', now.getTime(), { + LIMIT: { offset: 0, count: BATCH_SIZE }, + }); + if (!entries.length) break; + + await ApiToken.bulkWrite(entries.map(({ value, score }) => ({ + updateOne: { + filter: { _id: value }, + update: { $max: { lastUsedAt: new Date(score) } }, + }, + })), { ordered: false }); + + await client.eval(REMOVE_FLUSHED_SCRIPT, { + keys: [usageKey], + arguments: entries.flatMap(({ value, score }) => [value, String(score)]), + }); + flushed += entries.length; + if (entries.length < BATCH_SIZE) break; + } + return { flushed }; + } finally { + await localRedisClient.releaseLock(LOCK_KEY, lockToken); + } +} + +module.exports = { syncApiTokenUsage }; diff --git a/apps/api/src/services/apiTokenUsageSyncService.test.mjs b/apps/api/src/services/apiTokenUsageSyncService.test.mjs new file mode 100644 index 0000000..b6718a3 --- /dev/null +++ b/apps/api/src/services/apiTokenUsageSyncService.test.mjs @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { ApiToken } = require('@contexthub/common'); +const localRedisClient = require('../lib/localRedis'); +const service = require('./apiTokenUsageSyncService'); + +afterEach(() => vi.restoreAllMocks()); + +describe('API token usage sync', () => { + function mockRedis(entries) { + const client = { + zRangeByScoreWithScores: vi.fn().mockResolvedValue(entries), + eval: vi.fn().mockResolvedValue(1), + }; + vi.spyOn(localRedisClient, 'isEnabled').mockReturnValue(true); + vi.spyOn(localRedisClient, 'acquireLock').mockResolvedValue('lock-token'); + vi.spyOn(localRedisClient, 'releaseLock').mockResolvedValue(true); + vi.spyOn(localRedisClient, 'getClient').mockReturnValue(client); + return client; + } + + it('writes the maximum observed time and removes only matching Redis scores', async () => { + const score = Date.parse('2026-09-15T10:00:00.000Z'); + const client = mockRedis([{ value: 'token-1', score }]); + const write = vi.spyOn(ApiToken, 'bulkWrite').mockResolvedValue({}); + + await expect(service.syncApiTokenUsage(new Date(score + 1))).resolves.toEqual({ flushed: 1 }); + + expect(write).toHaveBeenCalledWith([{ + updateOne: { + filter: { _id: 'token-1' }, + update: { $max: { lastUsedAt: new Date(score) } }, + }, + }], { ordered: false }); + expect(client.eval).toHaveBeenCalledWith(expect.stringContaining("redis.call('ZSCORE'"), { + keys: ['usage:api-tokens:last-used'], + arguments: ['token-1', String(score)], + }); + expect(localRedisClient.releaseLock).toHaveBeenCalledWith('usage:api-tokens:sync-lock', 'lock-token'); + }); + + it('keeps the Redis observation when MongoDB fails', async () => { + const client = mockRedis([{ value: 'token-1', score: Date.now() }]); + vi.spyOn(ApiToken, 'bulkWrite').mockRejectedValue(new Error('database unavailable')); + + await expect(service.syncApiTokenUsage()).rejects.toThrow('database unavailable'); + + expect(client.eval).not.toHaveBeenCalled(); + expect(localRedisClient.releaseLock).toHaveBeenCalled(); + }); +}); diff --git a/docs/saas/en/api-token-lifecycle.md b/docs/saas/en/api-token-lifecycle.md index a42c0e9..2f76aba 100644 --- a/docs/saas/en/api-token-lifecycle.md +++ b/docs/saas/en/api-token-lifecycle.md @@ -25,6 +25,8 @@ Store the returned token in a secret manager immediately. ContextHub stores a SH `GET /api/api-tokens` returns token metadata including name, role, scopes, `expiresAt`, `lastUsedAt`, creation time, and creator—never the secret or its hash. Give every workload its own token so usage and revocation remain attributable. +`lastUsedAt` is recorded in Redis and flushed to the database about every five minutes, so the inventory timestamp can lag by a few minutes. Revocation does not depend on this field. + `expiresInDays: 0` creates a non-expiring token. Prefer a finite expiry and alert before it. A token's name and scopes can be updated with `PUT /api/api-tokens/:tokenId`; role and expiry are fixed at creation. ## Rotate without downtime diff --git a/docs/saas/tr/api-token-lifecycle.md b/docs/saas/tr/api-token-lifecycle.md index 10a5081..2e410b8 100644 --- a/docs/saas/tr/api-token-lifecycle.md +++ b/docs/saas/tr/api-token-lifecycle.md @@ -25,6 +25,8 @@ Dönen token'ı hemen secret manager'a kaydedin. ContextHub SHA-256 hash saklar `GET /api/api-tokens`; ad, rol, scope, `expiresAt`, `lastUsedAt`, oluşturma zamanı ve oluşturan kullanıcı bilgisini döndürür; secret veya hash dönmez. Kullanım ve revoke işlemi izlenebilir olsun diye her workload'a ayrı token verin. +`lastUsedAt` kullanım sırasında Redis'te tutulur ve yaklaşık 5 dakikada bir veritabanına aktarılır. Bu nedenle envanterdeki zaman birkaç dakika geriden gelebilir; token iptalinin uygulanması bu alana bağlı değildir. + `expiresInDays: 0` süresiz token oluşturur. Sonlu süre kullanıp sona ermeden alarm üretmeyi tercih edin. Token adı ve scope'ları `PUT /api/api-tokens/:tokenId` ile güncellenebilir; rol ve expiry oluşturma anında sabittir. ## Kesintisiz rotation From dca55013282c62b5266f6d7fe2c11a749fed56cb Mon Sep 17 00:00:00 2001 From: devburak Date: Tue, 15 Sep 2026 15:40:07 +0300 Subject: [PATCH 02/12] fix(billing): preview TR prices after country selection and stop repeated scrolling --- apps/admin/src/lib/api/billing.js | 6 +- apps/admin/src/pages/billing/Billing.jsx | 38 +++++-- .../src/pages/billing/BillingFlow.test.jsx | 101 ++++++++++++++++++ apps/api/src/routes/billing.js | 3 + .../billing/billingCostSummary.test.mjs | 22 ++++ .../src/services/billing/billingService.js | 41 ++++--- 6 files changed, 183 insertions(+), 28 deletions(-) create mode 100644 apps/admin/src/pages/billing/BillingFlow.test.jsx diff --git a/apps/admin/src/lib/api/billing.js b/apps/admin/src/lib/api/billing.js index 5797335..932a90c 100644 --- a/apps/admin/src/lib/api/billing.js +++ b/apps/admin/src/lib/api/billing.js @@ -1,7 +1,9 @@ import { apiClient } from '../api.js' -export async function fetchBillingOverview() { - const response = await apiClient.get('/billing/overview') +export async function fetchBillingOverview({ previewCountry = '', previewPlanSlug = '', previewInterval = 'month' } = {}) { + const response = await apiClient.get('/billing/overview', { + params: { previewCountry, previewPlanSlug, previewInterval }, + }) return response.data } diff --git a/apps/admin/src/pages/billing/Billing.jsx b/apps/admin/src/pages/billing/Billing.jsx index 752aae4..96e9467 100644 --- a/apps/admin/src/pages/billing/Billing.jsx +++ b/apps/admin/src/pages/billing/Billing.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { @@ -118,8 +118,18 @@ export default function Billing() { const [profile, setProfile] = useState(EMPTY_PROFILE) const [fieldErrors, setFieldErrors] = useState({}) const [hostedPaymentContent, setHostedPaymentContent] = useState('') + const profileInitializedForTenant = useRef('') + const scrolledToPlanForTenant = useRef('') const locale = i18n.resolvedLanguage === 'en' ? 'en-US' : 'tr-TR' - const overview = useQuery({ queryKey: ['billing', 'overview', activeTenantId], queryFn: fetchBillingOverview, retry: 1, enabled: canView, refetchInterval: (data) => data?.tenant?.status === 'pending_payment' ? 5000 : false }) + const previewCountry = profile.country + const overview = useQuery({ + queryKey: ['billing', 'overview', activeTenantId, previewCountry, checkoutIntent.planSlug, interval], + queryFn: () => fetchBillingOverview({ previewCountry, previewPlanSlug: checkoutIntent.planSlug, previewInterval: interval }), + retry: 1, + enabled: canView, + keepPreviousData: true, + refetchInterval: (data) => data?.tenant?.status === 'pending_payment' ? 5000 : false, + }) useEffect(() => { if (overview.data?.tenant?.status === 'active' && activeMembership?.tenant?.status === 'pending_payment') { @@ -151,25 +161,31 @@ export default function Billing() { }, [t, toast]) useEffect(() => { + if (!activeTenantId || overview.data?.tenant?.id !== activeTenantId || profileInitializedForTenant.current === activeTenantId) return + profileInitializedForTenant.current = activeTenantId const saved = overview.data?.billingAccount - if (!saved) return setProfile({ ...EMPTY_PROFILE, - ...saved, + ...(saved || {}), taxId: '', - address: { ...EMPTY_PROFILE.address, ...(saved.address || {}) }, + address: { ...EMPTY_PROFILE.address, ...(saved?.address || {}) }, declarationAccepted: false, serviceAgreementAccepted: false, }) - }, [overview.data?.billingAccount]) + }, [activeTenantId, overview.data?.tenant?.id, overview.data?.billingAccount]) useEffect(() => { - if (!overview.data || !checkoutIntent.planSlug) return - document.getElementById(`billing-plan-${checkoutIntent.planSlug}`)?.scrollIntoView({ + if (!overview.data?.plans?.length || !checkoutIntent.planSlug || !activeTenantId) return + const scrollKey = `${activeTenantId}:${checkoutIntent.planSlug}` + if (scrolledToPlanForTenant.current === scrollKey) return + const planElement = document.getElementById(`billing-plan-${checkoutIntent.planSlug}`) + if (!planElement) return + scrolledToPlanForTenant.current = scrollKey + planElement.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center', }) - }, [checkoutIntent.planSlug, overview.data]) + }, [activeTenantId, checkoutIntent.planSlug, overview.data?.plans]) const checkout = useMutation({ mutationFn: createBillingCheckout, @@ -448,7 +464,7 @@ export default function Billing() {

- {t(overview.data.billingAccount?.country === 'TR' + {t((previewCountry || overview.data.billingAccount?.country) === 'TR' ? 'billing.plans.tryCatalogNote' : 'billing.plans.usdCatalogNote')}

@@ -461,7 +477,7 @@ export default function Billing() { const requested = overview.data.tenant.status === 'pending_payment' && overview.data.tenant.requestedPlanSlug === plan.slug const highlighted = checkoutIntent.planSlug === plan.slug const hasSubscription = Boolean(overview.data.subscription && ['active', 'trialing', 'past_due', 'paused'].includes(overview.data.subscription.status)) - const checkoutAvailable = Boolean(overview.data.paymentRouting?.checkoutAvailable) + const checkoutAvailable = Boolean(overview.data.paymentRouting?.checkoutAvailable && profile.country === overview.data.billingAccount?.country) const canCheckout = !enterprise && !current && !hasSubscription && canManage && online && checkoutAvailable && price?.checkoutReady && price?.id const canOpenProfile = !enterprise && !current && !hasSubscription && canManage && online && !overview.data.paymentRouting?.profileComplete const buttonLabel = checkoutButtonLabel(t, { current, enterprise, checkoutAvailable, checkoutReady: price?.checkoutReady, hasProfile: overview.data.paymentRouting?.profileComplete, hasSubscription }) diff --git a/apps/admin/src/pages/billing/BillingFlow.test.jsx b/apps/admin/src/pages/billing/BillingFlow.test.jsx new file mode 100644 index 0000000..9cf2801 --- /dev/null +++ b/apps/admin/src/pages/billing/BillingFlow.test.jsx @@ -0,0 +1,101 @@ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useQuery } from '@tanstack/react-query' +import Billing from './Billing.jsx' + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})) +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key) => key, i18n: { resolvedLanguage: 'tr' } }), +})) +vi.mock('../../contexts/AuthContext.jsx', () => ({ + useAuth: () => ({ hasPermission: () => true, activeTenantId: 'tenant-1', activeMembership: null }), +})) +vi.mock('../../contexts/ToastContext.jsx', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn() }), +})) +vi.mock('../../components/CountryCombobox.jsx', () => ({ + default: ({ value, onChange }) => , +})) + +function overview() { + return { + tenant: { id: 'tenant-1', name: 'Canary', status: 'pending_payment', requestedPlanSlug: 'pro', plan: { slug: 'free', name: 'Free' } }, + account: { name: 'Canary' }, + billingAccount: { legalName: '', country: '', address: {} }, + paymentRouting: { profileComplete: false, checkoutAvailable: false }, + subscription: null, + plans: [{ id: 'pro-id', slug: 'pro', name: 'Pro', prices: [{ interval: 'month', amountMinor: 49900, currency: 'TRY', catalogOnly: true }], capabilities: [] }], + charges: { subscription: { amountMinor: 49900, currency: 'TRY', interval: 'month', isEstimated: true }, usageEstimate: { available: false, lines: [] }, latestInvoice: null }, + quotaAlerts: [], usage: {}, invoices: [], + } +} + +describe('billing checkout intent', () => { + let root + let container + let queryData + let scrollIntoView + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + window.history.pushState({}, '', '/faturalandirma?plan=pro&interval=month') + window.matchMedia = vi.fn(() => ({ matches: true })) + scrollIntoView = vi.fn() + Element.prototype.scrollIntoView = scrollIntoView + queryData = overview() + useQuery.mockImplementation(({ queryKey }) => { + const tr = queryKey[3] === 'TR' + const data = { + ...queryData, + plans: [{ ...queryData.plans[0], prices: [{ ...queryData.plans[0].prices[0], amountMinor: tr ? 49900 : 1200, currency: tr ? 'TRY' : 'USD' }] }], + charges: { ...queryData.charges, subscription: { ...queryData.charges.subscription, amountMinor: tr ? 49900 : 1200, currency: tr ? 'TRY' : 'USD' } }, + } + return { data, isLoading: false, isError: false, refetch: vi.fn() } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + window.history.replaceState({}, '', '/') + delete Element.prototype.scrollIntoView + delete globalThis.IS_REACT_ACT_ENVIRONMENT + vi.clearAllMocks() + }) + + it('shows USD until TR is selected and keeps the form usable through overview refreshes', async () => { + await act(async () => root.render()) + + expect(useQuery.mock.calls.at(-1)[0].queryKey).toContain('') + expect(container.textContent).toContain('$12') + expect(scrollIntoView).toHaveBeenCalledTimes(1) + + const country = container.querySelector('select[aria-label="Country"]') + await act(async () => { + country.value = 'TR' + country.dispatchEvent(new Event('change', { bubbles: true })) + }) + expect(useQuery.mock.calls.at(-1)[0].queryKey).toContain('TR') + expect(container.textContent).toContain('₺499') + + const nameInput = container.querySelector('input[autocomplete="organization"]') + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(nameInput, 'Canary Ltd') + nameInput.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(nameInput.value).toBe('Canary Ltd') + + queryData = overview() + await act(async () => root.render()) + + expect(nameInput.value).toBe('Canary Ltd') + expect(scrollIntoView).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/api/src/routes/billing.js b/apps/api/src/routes/billing.js index a5d2311..cb61467 100644 --- a/apps/api/src/routes/billing.js +++ b/apps/api/src/routes/billing.js @@ -19,6 +19,9 @@ async function billingRoutes(fastify) { try { return reply.send(await billingService.getOverview(request.tenantId, { actorEmail: request.user.email, + previewCountry: request.query?.previewCountry === 'TR' ? 'TR' : request.query?.previewCountry === 'US' ? 'US' : '', + previewPlanSlug: ['pro', 'promax'].includes(request.query?.previewPlanSlug) ? request.query.previewPlanSlug : '', + previewInterval: request.query?.previewInterval === 'year' ? 'year' : 'month', })); } catch (error) { request.log.error({ err: error }, 'Billing overview failed'); diff --git a/apps/api/src/services/billing/billingCostSummary.test.mjs b/apps/api/src/services/billing/billingCostSummary.test.mjs index 286162f..6e0a8f1 100644 --- a/apps/api/src/services/billing/billingCostSummary.test.mjs +++ b/apps/api/src/services/billing/billingCostSummary.test.mjs @@ -152,6 +152,28 @@ describe('owner-visible billing cost summary', () => { expect(result.prices[0]).toMatchObject({ id: 'iyzico-id', currency: 'TRY', checkoutReady: true, catalogOnly: false }); }); + it('previews a Türkiye catalogue price after TR is selected in the unsaved form', () => { + const plan = { _id: 'plan-id', slug: 'pro', name: 'Pro', price: 12 }; + const result = serializeCatalogPlan(plan, [ + { _id: 'paddle-id', provider: 'paddle', interval: 'month', currency: 'USD', amountMinor: 1200, planId: plan }, + { _id: 'iyzico-id', provider: 'iyzico', interval: 'month', currency: 'TRY', amountMinor: 49900, planId: plan }, + ], { displayProvider: 'iyzico' }); + + expect(result.prices[0]).toMatchObject({ id: null, currency: 'TRY', amountMinor: 49900, checkoutReady: false, catalogOnly: true }); + expect(buildChargeSummary({ plan, estimatedPrice: result.prices[0] }).subscription) + .toMatchObject({ amountMinor: 49900, currency: 'TRY', interval: 'month', isEstimated: true }); + }); + + it('keeps an existing subscription amount when a catalogue preview is present', () => { + const summary = buildChargeSummary({ + plan: { slug: 'pro', price: 12 }, + subscription: { amountMinor: 1200, currency: 'USD', interval: 'year' }, + estimatedPrice: { amountMinor: 49900, currency: 'TRY', interval: 'month' }, + }); + + expect(summary.subscription).toMatchObject({ amountMinor: 1200, currency: 'USD', interval: 'year', isEstimated: false }); + }); + it('omits provider and internal identifiers from owner-visible account and subscription data', () => { const billingAccount = serializeBillingAccount({ provider: 'manual', diff --git a/apps/api/src/services/billing/billingService.js b/apps/api/src/services/billing/billingService.js index 13b4130..b696bfc 100644 --- a/apps/api/src/services/billing/billingService.js +++ b/apps/api/src/services/billing/billingService.js @@ -158,14 +158,14 @@ function serializeSubscription(subscription) { }; } -function buildChargeSummary({ plan, subscription, invoices = [], storageBytes = 0, requestCount = 0 }) { +function buildChargeSummary({ plan, subscription, estimatedPrice = null, invoices = [], storageBytes = 0, requestCount = 0 }) { const latestInvoice = invoices[0] ? serializeInvoice(invoices[0]) : null; return { subscription: { - amountMinor: subscription ? subscription.amountMinor : toMinorUnits(plan?.price), - currency: subscription?.currency || CATALOG_CURRENCY, - interval: subscription?.interval || 'month', + amountMinor: subscription ? subscription.amountMinor : (estimatedPrice?.amountMinor ?? toMinorUnits(plan?.price)), + currency: subscription?.currency || estimatedPrice?.currency || CATALOG_CURRENCY, + interval: subscription?.interval || estimatedPrice?.interval || 'month', isEstimated: !subscription, currentPeriodStart: subscription?.currentPeriodStart || null, currentPeriodEnd: subscription?.currentPeriodEnd || null, @@ -244,6 +244,7 @@ function serializePrice(price) { function serializeCatalogPlan(plan, prices = [], { selectedProvider = null, + displayProvider = selectedProvider, providerEnabled = false, reviewCheckoutFallback = false, } = {}) { @@ -254,21 +255,24 @@ function serializeCatalogPlan(plan, prices = [], { const checkoutPrice = selectedProvider ? intervalPrices.find((price) => price.provider === selectedProvider) : null; - const displayPrice = checkoutPrice + const displayPrice = checkoutPrice && selectedProvider === displayProvider ? checkoutPrice : null; + const visiblePrice = displayPrice + || intervalPrices.find((price) => price.provider === displayProvider) || intervalPrices.find((price) => price.provider === 'paddle') || intervalPrices[0]; - if (!displayPrice) return null; + if (!visiblePrice) return null; return { id: checkoutPrice ? String(checkoutPrice._id) : null, interval, - currency: displayPrice.currency, - amountMinor: displayPrice.amountMinor, + currency: visiblePrice.currency, + amountMinor: visiblePrice.amountMinor, checkoutReady: Boolean( providerEnabled && checkoutPrice + && visiblePrice === checkoutPrice && (checkoutPrice.externalPriceId || (selectedProvider === 'iyzico' && reviewCheckoutFallback)) ), - catalogOnly: !checkoutPrice || !providerEnabled, + catalogOnly: !checkoutPrice || !providerEnabled || visiblePrice !== checkoutPrice, }; }).filter(Boolean); @@ -286,12 +290,13 @@ function serializeCatalogPlan(plan, prices = [], { }; } -async function getOverview(tenantId, { actorEmail = '' } = {}) { +async function getOverview(tenantId, { actorEmail = '', previewCountry = '', previewPlanSlug = '', previewInterval = 'month' } = {}) { const { tenant, account } = await getAccountForTenant(tenantId); const effectivePlan = await tenantSubscriptionService.getEffectivePlan(tenant); const billingAccount = await BillingAccount.findOne({ accountId: account._id }).select('+taxId').lean(); const profileValidation = validateBillingProfile(billingAccount || {}); const selectedProvider = billingAccount?.country ? resolveBillingProvider(billingAccount.country) : null; + const displayProvider = previewCountry ? resolveBillingProvider(previewCountry) : selectedProvider; const providerEnabled = selectedProvider ? isBillingProviderEnabled(selectedProvider) && isBillingCheckoutEnabledForTenant(tenant._id) : false; @@ -334,6 +339,15 @@ async function getOverview(tenantId, { actorEmail = '' } = {}) { }; const agreementAccepted = hasCurrentServiceAgreement(billingAccount); + const plans = catalogPlans.map((plan) => serializeCatalogPlan(plan, catalogPrices, { + selectedProvider, + displayProvider, + providerEnabled, + reviewCheckoutFallback, + })); + const estimatedPlanSlug = tenant.status === 'pending_payment' ? tenant.requestedPlanSlug : previewPlanSlug; + const estimatedPrice = plans.find((plan) => plan.slug === estimatedPlanSlug)?.prices + ?.find((price) => price.interval === previewInterval) || null; return { tenant: { id: String(tenant._id), @@ -365,11 +379,7 @@ async function getOverview(tenantId, { actorEmail = '' } = {}) { jurisdictionLocked: Boolean(subscription && ['trialing', 'active', 'past_due', 'paused'].includes(subscription.status)), }, subscription: serializeSubscription(subscription), - plans: catalogPlans.map((plan) => serializeCatalogPlan(plan, catalogPrices, { - selectedProvider, - providerEnabled, - reviewCheckoutFallback, - })), + plans, prices: catalogPrices .filter((price) => selectedProvider && price.provider === selectedProvider) .map(serializePrice), @@ -388,6 +398,7 @@ async function getOverview(tenantId, { actorEmail = '' } = {}) { charges: buildChargeSummary({ plan: effectivePlan, subscription, + estimatedPrice, invoices, storageBytes, requestCount: monthlyRequests, From 964e8c133f55d4cd7a4ea3b735b28c6b15ecbdee Mon Sep 17 00:00:00 2001 From: devburak Date: Tue, 15 Sep 2026 16:56:47 +0300 Subject: [PATCH 03/12] chore(release): v0.1.16 hotfix --- apps/admin/package.json | 2 +- apps/api/package.json | 2 +- package.json | 2 +- packages/common/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 426652b..6e036fb 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@contexthub/admin", - "version": "0.1.15", + "version": "0.1.16", "private": true, "description": "React administration interface for contextHub", "type": "module", diff --git a/apps/api/package.json b/apps/api/package.json index 2b8b89d..3cc3b2e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@contexthub/api", - "version": "0.1.15", + "version": "0.1.16", "private": true, "description": "Fastify back‑end service for contextHub", "main": "src/server.js", diff --git a/package.json b/package.json index a4313bd..5309daa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contexthub", - "version": "0.1.15", + "version": "0.1.16", "private": true, "packageManager": "pnpm@10.13.1", "description": "Multi‑tenant headless CMS and content services platform.", diff --git a/packages/common/package.json b/packages/common/package.json index 89b9605..1ce5a6f 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@contexthub/common", - "version": "0.1.15", + "version": "0.1.16", "private": true, "description": "Shared models, types and utilities for contextHub", "main": "src/index.js", From a2c7fb91417023a9c5f89bb09a1059ad7f486ab2 Mon Sep 17 00:00:00 2001 From: devburak Date: Tue, 15 Sep 2026 19:11:27 +0300 Subject: [PATCH 04/12] fix(billing): stop pending payment polling and report iyzico transport errors --- README.md | 5 + apps/admin/src/pages/billing/Billing.jsx | 10 +- .../src/pages/billing/BillingFlow.test.jsx | 9 +- apps/api/src/routes/billing.js | 2 +- .../services/billing/iyzicoNetwork.test.mjs | 45 +++++++++ .../src/services/billing/iyzicoProvider.js | 30 +++--- package.json | 2 + scripts/reload-api-pm2-with-env.cjs | 99 +++++++++++++++++++ 8 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/services/billing/iyzicoNetwork.test.mjs create mode 100644 scripts/reload-api-pm2-with-env.cjs diff --git a/README.md b/README.md index 68a2ace..37e7773 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,11 @@ core deploy command must not copy private plugin sources into this repository. For more details, see [DEPLOY.md](./DEPLOY.md) or [DEPLOY-QUICK.md](./DEPLOY-QUICK.md). +For an API managed by PM2, run `pnpm api:pm2:env:check` on the server before a +release. It compares each running API process with the local `.env` and reports only +drifting variable names. After changing `.env`, use `pnpm api:pm2:reload` to reload +with the file's values, verify all instances, and save the corrected PM2 state. + ## Versioning and releases The deployable core is released as a **single version**: the root `package.json`, `apps/*` and `packages/common` always carry the same number, and an annotated git tag points at it. `@contexthub/promo-sdk` is excluded — it is published separately and keeps its own version. diff --git a/apps/admin/src/pages/billing/Billing.jsx b/apps/admin/src/pages/billing/Billing.jsx index 96e9467..dbea7ff 100644 --- a/apps/admin/src/pages/billing/Billing.jsx +++ b/apps/admin/src/pages/billing/Billing.jsx @@ -128,7 +128,8 @@ export default function Billing() { retry: 1, enabled: canView, keepPreviousData: true, - refetchInterval: (data) => data?.tenant?.status === 'pending_payment' ? 5000 : false, + staleTime: 60_000, + refetchInterval: false, }) useEffect(() => { @@ -263,7 +264,7 @@ export default function Billing() { return (
- setHostedPaymentContent('')} t={t} /> + { setHostedPaymentContent(''); overview.refetch() }} t={t} />
@@ -294,6 +295,11 @@ export default function Billing() {

{t('billing.active.eyebrow')}

{overview.data.tenant.status === 'pending_payment' ? t('tenant.payment_pending') : overview.data.tenant.plan.name}

{overview.data.tenant.status === 'pending_payment' ? overview.data.plans?.find((plan) => plan.slug === overview.data.tenant.requestedPlanSlug)?.name : activePlanStatus(t, overview.data.tenant.plan, overview.data.subscription)}
+ {overview.data.tenant.status === 'pending_payment' && ( + + )}

{t('billing.active.accountLine', { tenant: overview.data.tenant.name, account: overview.data.account.name })}

diff --git a/apps/admin/src/pages/billing/BillingFlow.test.jsx b/apps/admin/src/pages/billing/BillingFlow.test.jsx index 9cf2801..e37532b 100644 --- a/apps/admin/src/pages/billing/BillingFlow.test.jsx +++ b/apps/admin/src/pages/billing/BillingFlow.test.jsx @@ -39,12 +39,14 @@ describe('billing checkout intent', () => { let container let queryData let scrollIntoView + let refetch beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true window.history.pushState({}, '', '/faturalandirma?plan=pro&interval=month') window.matchMedia = vi.fn(() => ({ matches: true })) scrollIntoView = vi.fn() + refetch = vi.fn() Element.prototype.scrollIntoView = scrollIntoView queryData = overview() useQuery.mockImplementation(({ queryKey }) => { @@ -54,7 +56,7 @@ describe('billing checkout intent', () => { plans: [{ ...queryData.plans[0], prices: [{ ...queryData.plans[0].prices[0], amountMinor: tr ? 49900 : 1200, currency: tr ? 'TRY' : 'USD' }] }], charges: { ...queryData.charges, subscription: { ...queryData.charges.subscription, amountMinor: tr ? 49900 : 1200, currency: tr ? 'TRY' : 'USD' } }, } - return { data, isLoading: false, isError: false, refetch: vi.fn() } + return { data, isLoading: false, isError: false, refetch } }) container = document.createElement('div') document.body.appendChild(container) @@ -74,6 +76,7 @@ describe('billing checkout intent', () => { await act(async () => root.render()) expect(useQuery.mock.calls.at(-1)[0].queryKey).toContain('') + expect(useQuery.mock.calls.at(-1)[0].refetchInterval).toBe(false) expect(container.textContent).toContain('$12') expect(scrollIntoView).toHaveBeenCalledTimes(1) @@ -84,6 +87,10 @@ describe('billing checkout intent', () => { }) expect(useQuery.mock.calls.at(-1)[0].queryKey).toContain('TR') expect(container.textContent).toContain('₺499') + expect(container.textContent).toContain('common.refresh') + const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent.includes('common.refresh')) + await act(async () => refreshButton.click()) + expect(refetch).toHaveBeenCalledTimes(1) const nameInput = container.querySelector('input[autocomplete="organization"]') await act(async () => { diff --git a/apps/api/src/routes/billing.js b/apps/api/src/routes/billing.js index cb61467..f0c4303 100644 --- a/apps/api/src/routes/billing.js +++ b/apps/api/src/routes/billing.js @@ -4,7 +4,7 @@ const billingService = require('../services/billing/billingService'); function errorStatus(error) { if (error.code === 'AccountMigrationRequired') return 409; - if (['CheckoutNotConfigured', 'BillingDisabled', 'BillingProviderUnavailable', 'BillingPiiNotConfigured'].includes(error.code)) return 503; + if (['CheckoutNotConfigured', 'BillingDisabled', 'BillingProviderUnavailable', 'BillingProviderNetworkUnavailable', 'BillingPiiNotConfigured'].includes(error.code)) return 503; if (['PortalRequired', 'PortalUnavailable', 'BillingJurisdictionLocked'].includes(error.code)) return 409; if (['BillingProfileIncomplete', 'CommercialAgreementRequired', 'InvalidBillingProfile', 'PlanPriceUnavailable'].includes(error.code)) return 422; return error.statusCode || 400; diff --git a/apps/api/src/services/billing/iyzicoNetwork.test.mjs b/apps/api/src/services/billing/iyzicoNetwork.test.mjs new file mode 100644 index 0000000..9393293 --- /dev/null +++ b/apps/api/src/services/billing/iyzicoNetwork.test.mjs @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const iyzicoProvider = await import("./iyzicoProvider.js"); +const originalApiKey = process.env.IYZICO_API_KEY; +const originalSecretKey = process.env.IYZICO_SECRET_KEY; +const originalEnvironment = process.env.IYZICO_ENV; + +afterEach(() => { + if (originalApiKey === undefined) delete process.env.IYZICO_API_KEY; + else process.env.IYZICO_API_KEY = originalApiKey; + if (originalSecretKey === undefined) delete process.env.IYZICO_SECRET_KEY; + else process.env.IYZICO_SECRET_KEY = originalSecretKey; + if (originalEnvironment === undefined) delete process.env.IYZICO_ENV; + else process.env.IYZICO_ENV = originalEnvironment; +}); + +describe("iyzico checkout transport error", () => { + it("returns a provider network error without retrying an ambiguous initialize request", async () => { + process.env.IYZICO_API_KEY = "test-api-key"; + process.env.IYZICO_SECRET_KEY = "test-secret-key"; + process.env.IYZICO_ENV = "live"; + const cause = Object.assign(new Error("socket hang up"), { + code: "ECONNRESET", + }); + const fetchImpl = vi.fn(async () => { + throw new TypeError("fetch failed", { cause }); + }); + + await expect( + iyzicoProvider.default.iyzicoRequest( + "/v2/subscription/checkoutform/initialize", + { + method: "POST", + body: {}, + fetchImpl, + }, + ), + ).rejects.toMatchObject({ + code: "BillingProviderNetworkUnavailable", + statusCode: 503, + cause: { cause: { code: "ECONNRESET" } }, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/services/billing/iyzicoProvider.js b/apps/api/src/services/billing/iyzicoProvider.js index 4093719..36bef30 100644 --- a/apps/api/src/services/billing/iyzicoProvider.js +++ b/apps/api/src/services/billing/iyzicoProvider.js @@ -20,21 +20,29 @@ function generateAuthorizationHeader(pathname, body, options = {}) { return { authorization: `IYZWSv2 ${encoded}`, randomKey }; } -async function iyzicoRequest(pathname, { method = 'GET', body } = {}) { +async function iyzicoRequest(pathname, { method = 'GET', body, fetchImpl = fetch } = {}) { // iyzico's V2 signature payload uses the URI path without its query string. // The query remains on the actual request URL. const signaturePath = String(pathname).split('?')[0]; const { authorization, randomKey } = generateAuthorizationHeader(signaturePath, body); - const response = await fetch(`${getBaseUrl()}${pathname}`, { - method, - headers: { - Authorization: authorization, - 'Content-Type': 'application/json', - 'x-iyzi-rnd': randomKey, - 'x-iyzi-client-version': 'contexthub-1', - }, - body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body), - }); + let response; + try { + response = await fetchImpl(`${getBaseUrl()}${pathname}`, { + method, + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + 'x-iyzi-rnd': randomKey, + 'x-iyzi-client-version': 'contexthub-1', + }, + body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body), + }); + } catch (cause) { + const networkError = new Error('Ödeme sağlayıcısıyla bağlantı kurulamadı. Lütfen daha sonra tekrar deneyin.', { cause }); + networkError.code = 'BillingProviderNetworkUnavailable'; + networkError.statusCode = 503; + throw networkError; + } const result = await response.json().catch(() => ({})); if (!response.ok || String(result.status || '').toLowerCase() !== 'success') { const providerError = new Error(result?.errorMessage || `iyzico request failed (${response.status})`); diff --git a/package.json b/package.json index 5309daa..89d41ac 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "deploy": "pnpm build:admin:hosted && pnpm hosted:entitlements:verify && node scripts/deploy-admin.mjs", "deploy:admin": "pnpm hosted:entitlements:verify && node scripts/deploy-admin.mjs", "rollback:admin": "node scripts/rollback-admin.mjs", + "api:pm2:env:check": "node scripts/reload-api-pm2-with-env.cjs --check", + "api:pm2:reload": "node scripts/reload-api-pm2-with-env.cjs --reload", "test": "turbo test", "test:watch": "turbo test:watch", "lint": "turbo lint", diff --git a/scripts/reload-api-pm2-with-env.cjs b/scripts/reload-api-pm2-with-env.cjs new file mode 100644 index 0000000..a3f8549 --- /dev/null +++ b/scripts/reload-api-pm2-with-env.cjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// Check the running API env, or reload it from .env without exposing secret values. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const dotenv = require("dotenv"); + +const apiRoot = path.resolve(__dirname, ".."); +const processName = "contexthub-api"; +const mode = process.argv[2] || "--check"; + +function requireSuccess(result, action) { + if (result.status !== 0) throw new Error(`${action} failed`); +} + +function runPm2(args, env = process.env) { + return spawnSync("pm2", args, { + cwd: apiRoot, + env, + encoding: "utf8", + timeout: 120000, + maxBuffer: 16 * 1024 * 1024, + }); +} + +function listApiProcesses() { + const result = runPm2(["jlist"]); + requireSuccess(result, "PM2 list"); + const processes = JSON.parse(result.stdout).filter( + (item) => item.name === processName, + ); + if (!processes.length) throw new Error("API process missing"); + return processes; +} + +function compare(processes, expected) { + const driftKeys = [ + ...new Set( + processes.flatMap(({ pm2_env: actual = {} }) => + Object.keys(expected).filter( + (key) => String(actual[key] ?? "") !== expected[key], + ), + ), + ), + ].sort(); + const online = processes.filter( + ({ pm2_env }) => pm2_env?.status === "online", + ).length; + return { processes: processes.length, online, driftKeys }; +} + +function restrictDumpFiles() { + const pm2Home = process.env.PM2_HOME || path.join(os.homedir(), ".pm2"); + for (const name of ["dump.pm2", "dump.pm2.bak"]) { + const filename = path.join(pm2Home, name); + if (fs.existsSync(filename)) fs.chmodSync(filename, 0o600); + } +} + +function main() { + if (!["--check", "--reload"].includes(mode) || process.argv.length > 3) { + throw new Error("Usage: reload-api-pm2-with-env.cjs [--check|--reload]"); + } + const expected = dotenv.parse(fs.readFileSync(path.join(apiRoot, ".env"))); + if (!expected.MONGODB_URI) throw new Error("MONGODB_URI missing from .env"); + + if (mode === "--reload") { + // Reloading by ecosystem file preserved inherited PM2 env on this host. + // The process-name path with --update-env uses these explicit values. + const result = runPm2(["reload", processName, "--update-env"], { + ...process.env, + ...expected, + }); + requireSuccess(result, "PM2 reload"); + } + + const state = compare(listApiProcesses(), expected); + console.log(JSON.stringify({ action: mode.slice(2), ...state })); + if (state.driftKeys.length || state.online !== state.processes) { + process.exitCode = 1; + return; + } + if (mode === "--reload") { + requireSuccess(runPm2(["save"]), "PM2 save"); + restrictDumpFiles(); + console.log(JSON.stringify({ action: "save", status: "ok" })); + } +} + +try { + main(); +} catch (error) { + // PM2 and driver errors may contain an env value; never print the message. + console.error( + JSON.stringify({ action: mode.slice(2), errorType: error.name || "Error" }), + ); + process.exitCode = 1; +} From 4bbc8cd3aeee70cd1e489e7383ef2b1f452f86cd Mon Sep 17 00:00:00 2001 From: devburak Date: Tue, 15 Sep 2026 19:11:43 +0300 Subject: [PATCH 05/12] chore(release): v0.1.17 hotfix --- apps/admin/package.json | 2 +- apps/api/package.json | 2 +- package.json | 2 +- packages/common/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 6e036fb..36548b8 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@contexthub/admin", - "version": "0.1.16", + "version": "0.1.17", "private": true, "description": "React administration interface for contextHub", "type": "module", diff --git a/apps/api/package.json b/apps/api/package.json index 3cc3b2e..ef3be0a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@contexthub/api", - "version": "0.1.16", + "version": "0.1.17", "private": true, "description": "Fastify back‑end service for contextHub", "main": "src/server.js", diff --git a/package.json b/package.json index 89d41ac..0d4fff2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contexthub", - "version": "0.1.16", + "version": "0.1.17", "private": true, "packageManager": "pnpm@10.13.1", "description": "Multi‑tenant headless CMS and content services platform.", diff --git a/packages/common/package.json b/packages/common/package.json index 1ce5a6f..55063c0 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@contexthub/common", - "version": "0.1.16", + "version": "0.1.17", "private": true, "description": "Shared models, types and utilities for contextHub", "main": "src/index.js", From 7f80e9f218682699acb3decd4f0f5cc935ff5408 Mon Sep 17 00:00:00 2001 From: devburak Date: Wed, 16 Sep 2026 09:28:25 +0300 Subject: [PATCH 06/12] fix(notifications): identify tenant in quota alerts --- apps/api/src/services/quotaAlertService.js | 47 +++++++++++++++---- .../src/services/quotaAlertService.test.mjs | 21 +++++++-- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/apps/api/src/services/quotaAlertService.js b/apps/api/src/services/quotaAlertService.js index 5e431fc..377e632 100644 --- a/apps/api/src/services/quotaAlertService.js +++ b/apps/api/src/services/quotaAlertService.js @@ -1,6 +1,26 @@ -const { Membership, QuotaAlert, User } = require('@contexthub/common'); +const { Membership, QuotaAlert, Tenant, User } = require('@contexthub/common'); const { sendNotificationEmail } = require('../utils/mailUtils'); +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function normalizeTenantText(value) { + return String(value ?? '') + .replace(/[\r\n]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeSubjectText(value) { + return normalizeTenantText(value).replace(/[<>]/g, ''); +} + const COPY = { tr: { labels: { @@ -9,7 +29,8 @@ const COPY = { storage: 'depolama', requests: 'aylık API isteği', }, - subject: (threshold) => `ContextHub kota uyarısı: %${threshold}`, + subject: ({ tenantLabel, threshold }) => `ContextHub kota uyarısı: ${tenantLabel} · %${threshold}`, + tenant: ({ name, slug }) => `

Tenant: ${name}
Slug: ${slug}

`, message: ({ label, usage, limit }) => `

${label} kullanımınız ${usage}/${limit} seviyesine ulaştı.

Kesinti yaşamamak için faturalandırma ekranından paket ve limitlerinizi inceleyin.

`, }, en: { @@ -19,7 +40,8 @@ const COPY = { storage: 'storage', requests: 'monthly API request', }, - subject: (threshold) => `ContextHub quota alert: ${threshold}%`, + subject: ({ tenantLabel, threshold }) => `ContextHub quota alert: ${tenantLabel} · ${threshold}%`, + tenant: ({ name, slug }) => `

Tenant: ${name}
Slug: ${slug}

`, message: ({ label, usage, limit }) => `

Your ${label} usage has reached ${usage}/${limit}.

Review your plan and limits on the billing page to avoid an interruption.

`, }, }; @@ -28,12 +50,17 @@ function normalizeLocale(value) { return String(value || '').toLowerCase().startsWith('en') ? 'en' : 'tr'; } -function buildQuotaEmail(alert, locale = 'tr') { +function buildQuotaEmail(alert, locale = 'tr', tenant) { const copy = COPY[normalizeLocale(locale)]; const label = copy.labels[alert.metric] || alert.metric; + const tenantName = normalizeTenantText(tenant?.name); + const tenantSlug = normalizeTenantText(tenant?.slug); + if (!tenantName || !tenantSlug) throw new Error('Tenant name and slug are required for quota notifications'); + const tenantLabel = `${normalizeSubjectText(tenantName)} (${normalizeSubjectText(tenantSlug)})`; return { - subject: copy.subject(alert.threshold), - message: copy.message({ label, usage: alert.usage, limit: alert.limit }), + subject: copy.subject({ tenantLabel, threshold: alert.threshold }), + message: copy.tenant({ name: escapeHtml(tenantName), slug: escapeHtml(tenantSlug) }) + + copy.message({ label, usage: alert.usage, limit: alert.limit }), }; } @@ -59,13 +86,17 @@ function currentMonthKey(date = new Date()) { } async function notifyOwners(tenantId, alert) { - const memberships = await Membership.find({ tenantId, role: 'owner', status: 'active' }).select('userId').lean(); + const [tenant, memberships] = await Promise.all([ + Tenant.findById(tenantId).select('name slug').lean(), + Membership.find({ tenantId, role: 'owner', status: 'active' }).select('userId').lean(), + ]); + if (!tenant) throw new Error(`Tenant not found for quota notification: ${tenantId}`); const recipients = await User.find({ _id: { $in: memberships.map((item) => item.userId) } }) .select('email language') .lean(); const deliverable = recipients.filter((item) => item.email); const results = await Promise.allSettled(deliverable.map((item) => { - const { subject, message } = buildQuotaEmail(alert, item.language); + const { subject, message } = buildQuotaEmail(alert, item.language, tenant); return sendNotificationEmail(item.email, subject, message, tenantId); })); return summarizeNotificationResults(results); diff --git a/apps/api/src/services/quotaAlertService.test.mjs b/apps/api/src/services/quotaAlertService.test.mjs index 0593a38..d8420f8 100644 --- a/apps/api/src/services/quotaAlertService.test.mjs +++ b/apps/api/src/services/quotaAlertService.test.mjs @@ -5,22 +5,35 @@ const require = createRequire(import.meta.url); const { buildQuotaEmail, summarizeNotificationResults } = require('./quotaAlertService'); const alert = { metric: 'requests', threshold: 90, usage: 900, limit: 1000 }; +const tenant = { name: 'ContextHub Demo', slug: 'contexthub-demo' }; describe('quota alert localization', () => { it('builds Turkish quota messages by default', () => { - const message = buildQuotaEmail(alert); - expect(message.subject).toBe('ContextHub kota uyarısı: %90'); + const message = buildQuotaEmail(alert, 'tr', tenant); + expect(message.subject).toBe('ContextHub kota uyarısı: ContextHub Demo (contexthub-demo) · %90'); + expect(message.message).toContain('Tenant: ContextHub Demo'); + expect(message.message).toContain('Slug: contexthub-demo'); expect(message.message).toContain('aylık API isteği'); expect(message.message).toContain('900/1000'); }); it('builds English quota messages for English profiles', () => { - const message = buildQuotaEmail(alert, 'en-US'); - expect(message.subject).toBe('ContextHub quota alert: 90%'); + const message = buildQuotaEmail(alert, 'en-US', tenant); + expect(message.subject).toBe('ContextHub quota alert: ContextHub Demo (contexthub-demo) · 90%'); expect(message.message).toContain('monthly API request'); expect(message.message).toContain('900/1000'); }); + it('escapes tenant details in HTML and strips line breaks from the subject', () => { + const message = buildQuotaEmail(alert, 'tr', { + name: 'Demo \nInjected', + slug: 'demo&tenant', + }); + expect(message.subject).toBe('ContextHub kota uyarısı: Demo Tenant Injected (demo&tenant) · %90'); + expect(message.message).toContain('Demo <Tenant> Injected'); + expect(message.message).toContain('demo&tenant'); + }); + it('does not classify partial or failed delivery as sent', () => { expect(summarizeNotificationResults([ { status: 'fulfilled', value: {} }, From 034f3c8df37026686af9ff08f21003d2e8c2f50a Mon Sep 17 00:00:00 2001 From: devburak Date: Thu, 17 Sep 2026 12:01:53 +0300 Subject: [PATCH 07/12] fix(admin): stabilize pasted table editing workspace --- apps/admin/src/components/Footer.jsx | 2 +- apps/admin/src/components/Layout.jsx | 2 +- .../src/pages/contents/ContentEditor.css | 100 ++++++++++++++++ .../src/pages/contents/ContentEditor.jsx | 14 ++- .../contents/plugins/TableCellFocusPlugin.jsx | 110 ++++-------------- .../plugins/TableCellFocusPlugin.test.jsx | 63 ++++++++++ .../plugins/TableCellResizerPlugin.jsx | 6 +- .../plugins/TableHoverActionsPlugin.jsx | 13 +-- .../pages/contents/plugins/tableNavigation.js | 49 ++++++++ 9 files changed, 255 insertions(+), 104 deletions(-) create mode 100644 apps/admin/src/pages/contents/plugins/TableCellFocusPlugin.test.jsx create mode 100644 apps/admin/src/pages/contents/plugins/tableNavigation.js diff --git a/apps/admin/src/components/Footer.jsx b/apps/admin/src/components/Footer.jsx index eedaca3..acd135c 100644 --- a/apps/admin/src/components/Footer.jsx +++ b/apps/admin/src/components/Footer.jsx @@ -19,7 +19,7 @@ export default function Footer({ const currentYear = new Date().getFullYear() return ( -
+
{/* Dil seçici - sol */} diff --git a/apps/admin/src/components/Layout.jsx b/apps/admin/src/components/Layout.jsx index ac95da1..aada45d 100644 --- a/apps/admin/src/components/Layout.jsx +++ b/apps/admin/src/components/Layout.jsx @@ -559,7 +559,7 @@ export default function Layout() { ? : }
-