feat: default locale Russian, geo determines language for other countries

- localization-svc: defaultLocale ru, resolveLocale only by geo
- web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru
- countryToLocale: default ru when no country or unknown country

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,17 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
COPY drizzle ./drizzle
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
COPY drizzle ./drizzle
EXPOSE 3008
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS billing_plans (
id VARCHAR(32) PRIMARY KEY,
name VARCHAR(64) NOT NULL,
price_monthly INTEGER NOT NULL DEFAULT 0,
price_yearly INTEGER NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'RUB',
features JSONB DEFAULT '[]'::jsonb,
limits JSONB DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS billing_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
plan_id VARCHAR(32) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
period VARCHAR(8) NOT NULL DEFAULT 'monthly',
started_at TIMESTAMP DEFAULT NOW() NOT NULL,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
);
CREATE TABLE IF NOT EXISTS billing_payments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
plan_id VARCHAR(32) NOT NULL,
amount INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'RUB',
status VARCHAR(24) NOT NULL DEFAULT 'pending',
yookassa_id TEXT,
period VARCHAR(8) NOT NULL DEFAULT 'monthly',
created_at TIMESTAMP DEFAULT NOW() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_user_id ON billing_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_billing_payments_user_id ON billing_payments(user_id);

View File

@@ -0,0 +1,24 @@
{
"name": "billing-svc",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "tsx src/db/push.ts"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/cors": "^9.0.1",
"drizzle-orm": "^0.40.1",
"postgres": "^3.4.4",
"drizzle-kit": "^0.30.5"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,8 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
const connectionString =
process.env.POSTGRES_URL ?? 'postgresql://gooseek:gooseek@localhost:5432/gooseek';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,18 @@
import postgres from 'postgres';
import { readFileSync } from 'fs';
import { join } from 'path';
const connectionString =
process.env.POSTGRES_URL ?? 'postgresql://gooseek:gooseek@localhost:5432/gooseek';
const client = postgres(connectionString);
async function main() {
const sql = readFileSync(join(process.cwd(), 'drizzle/0000_init.sql'), 'utf8');
await client.unsafe(sql);
console.log('Billing schema applied');
await client.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,35 @@
import { jsonb, pgTable, text, timestamp, varchar, integer } from 'drizzle-orm/pg-core';
export const plans = pgTable('billing_plans', {
id: varchar('id', { length: 32 }).primaryKey(),
name: varchar('name', { length: 64 }).notNull(),
priceMonthly: integer('price_monthly').notNull().default(0),
priceYearly: integer('price_yearly').notNull().default(0),
currency: varchar('currency', { length: 3 }).notNull().default('RUB'),
features: jsonb('features').$type<string[]>().default([]),
limits: jsonb('limits').$type<Record<string, number>>().default({}),
});
export const subscriptions = pgTable('billing_subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
planId: varchar('plan_id', { length: 32 }).notNull(),
status: varchar('status', { length: 24 }).notNull().default('active'),
period: varchar('period', { length: 8 }).notNull().default('monthly'),
startedAt: timestamp('started_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const payments = pgTable('billing_payments', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
planId: varchar('plan_id', { length: 32 }).notNull(),
amount: integer('amount').notNull(),
currency: varchar('currency', { length: 3 }).notNull().default('RUB'),
status: varchar('status', { length: 24 }).notNull().default('pending'),
yookassaId: text('yookassa_id'),
period: varchar('period', { length: 8 }).notNull().default('monthly'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});

View File

@@ -0,0 +1,321 @@
/**
* billing-svc — тарифы, подписки, ЮKassa
* docs/architecture: 01-perplexity-analogue-design.md §2.2.J, 02-k3s-microservices-spec.md §3.10
* API: GET /api/v1/billing/plans, /subscription, /payments; POST /checkout
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { desc, eq } from 'drizzle-orm';
import { db } from './db/index.js';
import { plans as plansTable, payments, subscriptions } from './db/schema.js';
import { PLANS } from './plans.js';
const PORT = parseInt(process.env.PORT ?? '3008', 10);
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? '';
const YOOKASSA_SHOP_ID = process.env.YOOKASSA_SHOP_ID ?? '';
const YOOKASSA_SECRET = process.env.YOOKASSA_SECRET ?? '';
const RETURN_URL =
process.env.BILLING_RETURN_URL ?? 'http://localhost:3000/profile?tab=billing';
async function getUserIdFromToken(authHeader: string | undefined): Promise<string | null> {
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.slice(7);
if (!AUTH_SERVICE_URL || !token) return null;
try {
const res = await fetch(
`${AUTH_SERVICE_URL.replace(/\/$/, '')}/api/auth/me`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
}
);
if (!res.ok) return null;
const data = (await res.json()) as { id?: string };
return data.id ?? null;
} catch {
return null;
}
}
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
app.get('/health', async () => ({ status: 'ok', service: 'billing-svc' }));
app.get('/ready', async () => ({ status: 'ready' }));
// Тарифы — публично, без авторизации
const plansResponse = PLANS.map((p) => ({
id: p.id,
name: p.name,
priceMonthly: p.priceMonthly,
priceYearly: p.priceYearly,
currency: p.currency,
features: p.features ?? [],
limits: p.limits ?? {},
}));
app.get('/api/v1/billing/plans', async (_req, reply) => {
try {
const existing = await db.select().from(plansTable);
if (existing.length > 0) {
return reply.send(
existing.map((p) => ({
id: p.id,
name: p.name,
priceMonthly: p.priceMonthly,
priceYearly: p.priceYearly,
currency: p.currency,
features: p.features ?? [],
limits: p.limits ?? {},
}))
);
}
try {
await db.insert(plansTable).values(PLANS);
} catch {
// Таблица может отсутствовать — возвращаем статичные планы
}
return reply.send(plansResponse);
} catch {
return reply.send(plansResponse);
}
});
// Текущая подписка пользователя
app.get('/api/v1/billing/subscription', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
try {
const [sub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.userId, userId))
.orderBy(desc(subscriptions.createdAt))
.limit(1);
if (!sub) {
return reply.send({
planId: 'free',
status: 'active',
period: 'monthly',
startedAt: null,
expiresAt: null,
});
}
return reply.send({
id: sub.id,
planId: sub.planId,
status: sub.status,
period: sub.period,
startedAt: sub.startedAt?.toISOString?.() ?? sub.startedAt,
expiresAt: sub.expiresAt?.toISOString?.() ?? sub.expiresAt,
});
} catch (err) {
app.log.error(err);
return reply.status(500).send({ error: 'Failed to fetch subscription' });
}
});
// История платежей
app.get('/api/v1/billing/payments', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
try {
const list = await db
.select()
.from(payments)
.where(eq(payments.userId, userId))
.orderBy(desc(payments.createdAt))
.limit(50);
return reply.send({
payments: list.map((p) => ({
id: p.id,
planId: p.planId,
amount: p.amount,
currency: p.currency,
status: p.status,
period: p.period,
createdAt: p.createdAt?.toISOString?.() ?? p.createdAt,
})),
});
} catch (err) {
app.log.error(err);
return reply.status(500).send({ error: 'Failed to fetch payments' });
}
});
// Создать платёж (checkout)
app.post<{
Body: { planId: string; period?: 'monthly' | 'yearly' };
}>('/api/v1/billing/checkout', async (req, reply) => {
const userId = await getUserIdFromToken(req.headers.authorization);
if (!userId) {
return reply.status(401).send({ error: 'Authorization required' });
}
const { planId, period = 'monthly' } = req.body ?? {};
if (!planId || !['free', 'pro', 'max'].includes(planId)) {
return reply.status(400).send({ error: 'Invalid planId' });
}
if (planId === 'free') {
return reply.status(400).send({ error: 'Free plan does not require payment' });
}
const plan = PLANS.find((p) => p.id === planId);
if (!plan) {
return reply.status(400).send({ error: 'Plan not found' });
}
const amount: number =
period === 'yearly'
? (plan.priceYearly ?? 0)
: (plan.priceMonthly ?? 0);
if (amount <= 0) {
return reply.status(400).send({ error: 'Plan is free' });
}
const paymentId = crypto.randomUUID();
if (YOOKASSA_SHOP_ID && YOOKASSA_SECRET) {
try {
const auth = Buffer.from(`${YOOKASSA_SHOP_ID}:${YOOKASSA_SECRET}`).toString('base64');
const res = await fetch('https://api.yookassa.ru/v3/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`,
'Idempotence-Key': paymentId,
},
body: JSON.stringify({
amount: {
value: (amount / 100).toFixed(2),
currency: plan.currency,
},
capture: true,
confirmation: {
type: 'redirect',
return_url: RETURN_URL,
},
description: `GooSeek ${plan.name} (${period})`,
metadata: { userId, planId, period },
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const errBody = await res.text();
app.log.warn({ status: res.status, body: errBody }, 'YooKassa error');
return reply.status(502).send({
error: 'Payment provider error',
details: await res.json().catch(() => ({})),
});
}
const data = (await res.json()) as {
id?: string;
confirmation?: { confirmation_url?: string };
status?: string;
};
await db.insert(payments).values({
id: paymentId,
userId,
planId,
amount,
currency: plan.currency,
status: data.status ?? 'pending',
yookassaId: data.id ?? null,
period,
});
return reply.send({
paymentId,
redirectUrl: data.confirmation?.confirmation_url ?? null,
status: data.status,
});
} catch (err) {
app.log.error(err);
return reply.status(502).send({ error: 'Payment provider unavailable' });
}
}
// Mock mode — без ЮKassa возвращаем тестовую ссылку
await db.insert(payments).values({
id: paymentId,
userId,
planId,
amount,
currency: plan.currency ?? 'RUB',
status: 'pending',
yookassaId: null,
period,
});
return reply.send({
paymentId,
redirectUrl: null,
status: 'pending',
message: 'YooKassa not configured. Set YOOKASSA_SHOP_ID and YOOKASSA_SECRET.',
});
});
// Webhook ЮKassa — подтверждение платежа (POST от ЮKassa)
app.post<{ Body: { object?: { id?: string; status?: string; metadata?: Record<string, string> } } }>(
'/api/v1/billing/webhook',
async (req, reply) => {
const body = req.body as { object?: { id?: string; status?: string; metadata?: Record<string, string> } };
const obj = body?.object;
if (!obj?.metadata?.userId || !obj?.metadata?.planId) {
return reply.status(400).send({ error: 'Invalid webhook payload' });
}
if (obj.status !== 'succeeded') {
return reply.send({ received: true });
}
try {
const [payment] = await db
.select()
.from(payments)
.where(eq(payments.yookassaId, obj.id ?? ''));
if (!payment) {
return reply.send({ received: true });
}
await db
.update(payments)
.set({ status: 'succeeded' })
.where(eq(payments.id, payment.id));
const expiresAt = new Date();
expiresAt.setMonth(expiresAt.getMonth() + (payment.period === 'yearly' ? 12 : 1));
await db.insert(subscriptions).values({
id: crypto.randomUUID(),
userId: payment.userId,
planId: payment.planId,
status: 'active',
period: payment.period,
startedAt: new Date(),
expiresAt,
});
return reply.send({ received: true });
} catch (err) {
app.log.error(err);
return reply.status(500).send({ error: 'Webhook processing failed' });
}
}
);
app.listen({ port: PORT, host: '0.0.0.0' }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log(`billing-svc listening on :${PORT}`);
console.log(' GET /api/v1/billing/plans — tariff plans');
console.log(' GET /api/v1/billing/subscription — current subscription (Bearer)');
console.log(' GET /api/v1/billing/payments — payment history (Bearer)');
console.log(' POST /api/v1/billing/checkout — create payment (Bearer)');
});

View File

@@ -0,0 +1,56 @@
/**
* Тарифы по аналогии с Perplexity: Free, Pro, Max
* docs/architecture: 01-perplexity-analogue-design.md §2.2
*/
import { plans } from './db/schema.js';
export const PLANS: (typeof plans.$inferInsert)[] = [
{
id: 'free',
name: 'Free',
priceMonthly: 0,
priceYearly: 0,
currency: 'RUB',
features: [
'5 Quick Searches per day',
'Standard models',
'Web search',
'Basic export',
],
limits: { quickSearchesPerDay: 5, proSearchesPerDay: 0, deepResearchPerMonth: 0 },
},
{
id: 'pro',
name: 'Pro',
priceMonthly: 1990, // 19.90 USD в копейках для RUB
priceYearly: 19900, // ~199 USD/год
currency: 'RUB',
features: [
'Unlimited Quick Searches',
'Pro Search (multi-step)',
'5 Deep Research per month',
'Pro models (GPT-4, Claude)',
'Image generation',
'File uploads',
'Memory',
'Export PDF/MD',
],
limits: { quickSearchesPerDay: -1, proSearchesPerDay: -1, deepResearchPerMonth: 5 },
},
{
id: 'max',
name: 'Max',
priceMonthly: 2990,
priceYearly: 29900,
currency: 'RUB',
features: [
'Everything in Pro',
'Unlimited Deep Research',
'Model Council (3 models)',
'Background Assistant',
'Priority support',
],
limits: { quickSearchesPerDay: -1, proSearchesPerDay: -1, deepResearchPerMonth: -1 },
},
];

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}