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:
17
services/billing-svc/Dockerfile
Normal file
17
services/billing-svc/Dockerfile
Normal 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"]
|
||||
36
services/billing-svc/drizzle/0000_init.sql
Normal file
36
services/billing-svc/drizzle/0000_init.sql
Normal 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);
|
||||
24
services/billing-svc/package.json
Normal file
24
services/billing-svc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
services/billing-svc/src/db/index.ts
Normal file
8
services/billing-svc/src/db/index.ts
Normal 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 });
|
||||
18
services/billing-svc/src/db/push.ts
Normal file
18
services/billing-svc/src/db/push.ts
Normal 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);
|
||||
});
|
||||
35
services/billing-svc/src/db/schema.ts
Normal file
35
services/billing-svc/src/db/schema.ts
Normal 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(),
|
||||
});
|
||||
321
services/billing-svc/src/index.ts
Normal file
321
services/billing-svc/src/index.ts
Normal 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)');
|
||||
});
|
||||
56
services/billing-svc/src/plans.ts
Normal file
56
services/billing-svc/src/plans.ts
Normal 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 },
|
||||
},
|
||||
];
|
||||
16
services/billing-svc/tsconfig.json
Normal file
16
services/billing-svc/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user