b
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
Generated
+2213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "fislio-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Fislio Node.js Backend API with PostgreSQL, BunnyCDN, and Pluggable OCR",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pg": "^8.13.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.8",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^20.17.19",
|
||||||
|
"@types/pg": "^8.11.11",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: parseInt(process.env.PORT || '5001', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
jwtSecret: requireEnv('JWT_SECRET'),
|
||||||
|
|
||||||
|
databaseUrl: requireEnv('DATABASE_URL'),
|
||||||
|
|
||||||
|
bunny: {
|
||||||
|
storageZone: process.env.BUNNY_STORAGE_ZONE || 'apexlegal',
|
||||||
|
apiKey: requireEnv('BUNNY_STORAGE_API_KEY'),
|
||||||
|
cdnUrl: process.env.BUNNY_CDN_URL || 'https://cdn.ayrislegal.com',
|
||||||
|
folder: process.env.BUNNY_FOLDER || 'fisio',
|
||||||
|
},
|
||||||
|
|
||||||
|
ocr: {
|
||||||
|
provider: (process.env.OCR_PROVIDER || 'gemini') as 'gemini' | 'custom',
|
||||||
|
geminiApiKey: process.env.GEMINI_API_KEY || '',
|
||||||
|
customUrl: process.env.CUSTOM_OCR_URL || '',
|
||||||
|
customApiKey: process.env.CUSTOM_OCR_API_KEY || '',
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { query } from '../db';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { AuthRequest } from '../middlewares/auth.middleware';
|
||||||
|
|
||||||
|
export class AuthController {
|
||||||
|
// SMM Register
|
||||||
|
static async registerSMM(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { email, password, full_name } = req.body;
|
||||||
|
if (!email || !password || !full_name) {
|
||||||
|
res.status(400).json({ error: 'E-posta, şifre ve ad soyad zorunludur.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await query('SELECT id FROM users WHERE LOWER(email) = LOWER($1)', [email.trim()]);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
res.status(400).json({ error: 'Bu e-posta adresi ile zaten bir hesap bulunuyor.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
const insertRes = await query(
|
||||||
|
`INSERT INTO users (email, password_hash, role, full_name)
|
||||||
|
VALUES ($1, $2, 'smm', $3)
|
||||||
|
RETURNING id, email, role, full_name, created_at`,
|
||||||
|
[email.trim().toLowerCase(), password_hash, full_name.trim()]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = insertRes.rows[0];
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, email: user.email, role: user.role, full_name: user.full_name },
|
||||||
|
config.jwtSecret,
|
||||||
|
{ expiresIn: '30d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ user, token });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Register error:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Kayıt sırasında sunucu hatası oluştu.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMM & Client Login
|
||||||
|
static async login(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ error: 'E-posta ve şifre gereklidir.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRes = await query('SELECT * FROM users WHERE LOWER(email) = LOWER($1)', [email.trim()]);
|
||||||
|
if (userRes.rows.length === 0) {
|
||||||
|
res.status(401).json({ error: 'Kullanıcı bulunamadı. Lütfen bilgilerinizi kontrol edin.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userRes.rows[0];
|
||||||
|
if (user.password_hash) {
|
||||||
|
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
res.status(401).json({ error: 'Hatalı şifre girdiniz.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, email: user.email, role: user.role, smm_id: user.smm_id, full_name: user.full_name },
|
||||||
|
config.jwtSecret,
|
||||||
|
{ expiresIn: '30d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
delete user.password_hash;
|
||||||
|
res.json({ user, token });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Giriş sırasında sunucu hatası oluştu.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMM Creates Client Account & Company
|
||||||
|
static async createClient(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const smmId = req.user?.id;
|
||||||
|
const { client_name, email, password, company_name, tax_number } = req.body;
|
||||||
|
|
||||||
|
if (!client_name || !email || !password || !company_name) {
|
||||||
|
res.status(400).json({ error: 'Müşteri adı, e-posta, şifre ve şirket adı zorunludur.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await query('SELECT id FROM users WHERE LOWER(email) = LOWER($1)', [email.trim()]);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
res.status(400).json({ error: 'Bu e-posta adresi ile kayıtlı başka bir hesap var.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// 1. Create client user
|
||||||
|
const userRes = await query(
|
||||||
|
`INSERT INTO users (email, password_hash, role, smm_id, full_name)
|
||||||
|
VALUES ($1, $2, 'client', $3, $4)
|
||||||
|
RETURNING id, email, role, smm_id, full_name, created_at`,
|
||||||
|
[email.trim().toLowerCase(), password_hash, smmId, client_name.trim()]
|
||||||
|
);
|
||||||
|
const client = userRes.rows[0];
|
||||||
|
|
||||||
|
// 2. Create company for client
|
||||||
|
const compRes = await query(
|
||||||
|
`INSERT INTO companies (user_id, smm_id, name, tax_number)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, smm_id, name, tax_number, created_at`,
|
||||||
|
[client.id, smmId, company_name.trim(), tax_number ? tax_number.trim() : null]
|
||||||
|
);
|
||||||
|
const company = compRes.rows[0];
|
||||||
|
|
||||||
|
res.status(201).json({ client, company });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Create client error:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Müşteri oluşturulamadı.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMM Lists Their Clients
|
||||||
|
static async getClients(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const smmId = req.user?.id;
|
||||||
|
const clientsRes = await query(
|
||||||
|
`SELECT u.id, u.email, u.full_name, u.created_at,
|
||||||
|
c.id AS company_id, c.name AS company_name, c.tax_number
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN companies c ON c.user_id = u.id
|
||||||
|
WHERE u.role = 'client' AND u.smm_id = $1
|
||||||
|
ORDER BY u.created_at DESC`,
|
||||||
|
[smmId]
|
||||||
|
);
|
||||||
|
res.json(clientsRes.rows);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Get clients error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMM Deletes a Client Account (cascades to companies & receipts)
|
||||||
|
static async deleteClient(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const smmId = req.user?.id;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM users WHERE id = $1 AND smm_id = $2 AND role = 'client' RETURNING id`,
|
||||||
|
[id, smmId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
res.status(404).json({ error: 'Müşteri bulunamadı.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Müşteri ve ilişkili şirketleri silindi.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Delete client error:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Müşteri silinemedi.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user profile
|
||||||
|
static async getMe(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userRes = await query(
|
||||||
|
'SELECT id, email, role, smm_id, full_name, created_at FROM users WHERE id = $1',
|
||||||
|
[req.user?.id]
|
||||||
|
);
|
||||||
|
if (userRes.rows.length === 0) {
|
||||||
|
res.status(404).json({ error: 'Kullanıcı bulunamadı.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(userRes.rows[0]);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { query } from '../db';
|
||||||
|
import { AuthRequest } from '../middlewares/auth.middleware';
|
||||||
|
|
||||||
|
export class CompanyController {
|
||||||
|
// List companies for current user (SMM sees their managed companies, Client sees their own)
|
||||||
|
static async listCompanies(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
let q = '';
|
||||||
|
let params: any[] = [];
|
||||||
|
|
||||||
|
if (user.role === 'smm') {
|
||||||
|
q = `
|
||||||
|
SELECT c.*,
|
||||||
|
COUNT(r.id)::int AS receipt_count,
|
||||||
|
COALESCE(SUM(r.total_amount), 0)::numeric AS total_spending
|
||||||
|
FROM companies c
|
||||||
|
LEFT JOIN receipts r ON r.company_id = c.id
|
||||||
|
WHERE c.smm_id = $1 OR c.user_id = $1
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
`;
|
||||||
|
params = [user.id];
|
||||||
|
} else {
|
||||||
|
q = `
|
||||||
|
SELECT c.*,
|
||||||
|
COUNT(r.id)::int AS receipt_count,
|
||||||
|
COALESCE(SUM(r.total_amount), 0)::numeric AS total_spending
|
||||||
|
FROM companies c
|
||||||
|
LEFT JOIN receipts r ON r.company_id = c.id
|
||||||
|
WHERE c.user_id = $1
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
`;
|
||||||
|
params = [user.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(q, params);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('List companies error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Company
|
||||||
|
static async createCompany(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { name, tax_number } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
res.status(400).json({ error: 'Şirket adı zorunludur.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smm_id = user.role === 'smm' ? user.id : user.smm_id;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO companies (user_id, smm_id, name, tax_number)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[user.id, smm_id, name.trim(), tax_number ? tax_number.trim() : null]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Company
|
||||||
|
static async updateCompany(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, tax_number } = req.body;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE companies
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
tax_number = COALESCE($2, tax_number),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3
|
||||||
|
RETURNING *`,
|
||||||
|
[name ? name.trim() : null, tax_number ? tax_number.trim() : null, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
res.status(404).json({ error: 'Şirket bulunamadı.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Company (cascades to receipts)
|
||||||
|
static async deleteCompany(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await query('DELETE FROM companies WHERE id = $1', [id]);
|
||||||
|
res.json({ message: 'Şirket ve ilişkili fişler başarıyla silindi.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { query } from '../db';
|
||||||
|
import { AuthRequest } from '../middlewares/auth.middleware';
|
||||||
|
import { OCRService } from '../services/ocr.service';
|
||||||
|
import { BunnyService } from '../services/bunny.service';
|
||||||
|
|
||||||
|
export class ReceiptController {
|
||||||
|
// Analyze Receipt with Pluggable OCR Service
|
||||||
|
static async analyzeReceipt(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { imageBase64 } = req.body;
|
||||||
|
if (!imageBase64) {
|
||||||
|
res.status(400).json({ error: 'imageBase64 gereklidir.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ocrResult = await OCRService.analyzeReceipt(imageBase64);
|
||||||
|
res.json(ocrResult);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('OCR analyze error:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Fiş okunamadı.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Receipt & Upload to BunnyCDN
|
||||||
|
static async createReceipt(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const {
|
||||||
|
company_id,
|
||||||
|
receipt_no,
|
||||||
|
firm_name,
|
||||||
|
date,
|
||||||
|
imageBase64,
|
||||||
|
items
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!company_id || !firm_name) {
|
||||||
|
res.status(400).json({ error: 'Şirket ve firma adı zorunludur.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Upload photo to BunnyCDN
|
||||||
|
let image_url = '';
|
||||||
|
if (imageBase64) {
|
||||||
|
try {
|
||||||
|
image_url = await BunnyService.uploadImage(imageBase64);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Bunny upload warning:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compute total amount
|
||||||
|
const itemsList = Array.isArray(items) ? items : [];
|
||||||
|
const totalAmount = itemsList.reduce((acc: number, item: any) => acc + (parseFloat(item.amount) || 0), 0);
|
||||||
|
|
||||||
|
// 3. Insert receipt
|
||||||
|
const receiptRes = await query(
|
||||||
|
`INSERT INTO receipts (company_id, user_id, receipt_no, firm_name, date, image_url, total_amount)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
company_id,
|
||||||
|
userId,
|
||||||
|
receipt_no || '',
|
||||||
|
firm_name,
|
||||||
|
date ? new Date(date) : new Date(),
|
||||||
|
image_url,
|
||||||
|
totalAmount
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const receipt = receiptRes.rows[0];
|
||||||
|
|
||||||
|
// 4. Insert items
|
||||||
|
if (itemsList.length > 0) {
|
||||||
|
for (const item of itemsList) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO receipt_items (receipt_id, product_name, kdv_rate, amount, category_code, category_name)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
receipt.id,
|
||||||
|
item.product_name || '',
|
||||||
|
parseFloat(item.kdv_rate) || 20,
|
||||||
|
parseFloat(item.amount) || 0,
|
||||||
|
item.category_code || '770.01',
|
||||||
|
item.category_name || 'Genel Gider'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Return complete receipt with items
|
||||||
|
const finalItems = await query('SELECT * FROM receipt_items WHERE receipt_id = $1', [receipt.id]);
|
||||||
|
receipt.items = finalItems.rows;
|
||||||
|
|
||||||
|
res.status(201).json(receipt);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Create receipt error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Receipts for a Company
|
||||||
|
static async getCompanyReceipts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { companyId } = req.params;
|
||||||
|
|
||||||
|
const receiptsRes = await query(
|
||||||
|
`SELECT r.*,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(ri.*) FILTER (WHERE ri.id IS NOT NULL), '[]'
|
||||||
|
) AS items
|
||||||
|
FROM receipts r
|
||||||
|
LEFT JOIN receipt_items ri ON ri.receipt_id = r.id
|
||||||
|
WHERE r.company_id = $1
|
||||||
|
GROUP BY r.id
|
||||||
|
ORDER BY r.created_at DESC`,
|
||||||
|
[companyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(receiptsRes.rows);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Receipt
|
||||||
|
static async updateReceipt(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { firm_name, receipt_no, date, items } = req.body;
|
||||||
|
|
||||||
|
const itemsList = Array.isArray(items) ? items : [];
|
||||||
|
const totalAmount = itemsList.reduce((acc: number, item: any) => acc + (parseFloat(item.amount) || 0), 0);
|
||||||
|
|
||||||
|
const receiptRes = await query(
|
||||||
|
`UPDATE receipts
|
||||||
|
SET firm_name = COALESCE($1, firm_name),
|
||||||
|
receipt_no = COALESCE($2, receipt_no),
|
||||||
|
date = COALESCE($3, date),
|
||||||
|
total_amount = $4,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $5
|
||||||
|
RETURNING *`,
|
||||||
|
[firm_name, receipt_no, date ? new Date(date) : null, totalAmount, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (receiptRes.rows.length === 0) {
|
||||||
|
res.status(404).json({ error: 'Fiş bulunamadı.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-insert items
|
||||||
|
await query('DELETE FROM receipt_items WHERE receipt_id = $1', [id]);
|
||||||
|
if (itemsList.length > 0) {
|
||||||
|
for (const item of itemsList) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO receipt_items (receipt_id, product_name, kdv_rate, amount, category_code, category_name)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
item.product_name,
|
||||||
|
parseFloat(item.kdv_rate) || 20,
|
||||||
|
parseFloat(item.amount) || 0,
|
||||||
|
item.category_code,
|
||||||
|
item.category_name
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalItems = await query('SELECT * FROM receipt_items WHERE receipt_id = $1', [id]);
|
||||||
|
const updatedReceipt = receiptRes.rows[0];
|
||||||
|
updatedReceipt.items = finalItems.rows;
|
||||||
|
|
||||||
|
res.json(updatedReceipt);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Receipt
|
||||||
|
static async deleteReceipt(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await query('DELETE FROM receipts WHERE id = $1', [id]);
|
||||||
|
res.json({ message: 'Fiş silindi.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: config.databaseUrl,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('Unexpected error on idle PostgreSQL client', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function query(text: string, params?: any[]) {
|
||||||
|
const start = Date.now();
|
||||||
|
const res = await pool.query(text, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
if (config.nodeEnv === 'development') {
|
||||||
|
console.log('Executed query', { text: text.substring(0, 100), duration, rows: res.rowCount });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { config } from './config';
|
||||||
|
import routes from './routes';
|
||||||
|
import { pool } from './db';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
|
// API Root Routes
|
||||||
|
app.use('/api', routes);
|
||||||
|
|
||||||
|
// Global Error Handler
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Unhandled server error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal Server Error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start Server
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
// Test DB connection
|
||||||
|
const client = await pool.connect();
|
||||||
|
console.log('✅ Connected to PostgreSQL Database successfully');
|
||||||
|
client.release();
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`🚀 Fislio Backend running on http://localhost:${config.port}`);
|
||||||
|
console.log(`📡 OCR Provider configured: ${config.ocr.provider.toUpperCase()}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to connect to database:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: 'smm' | 'client';
|
||||||
|
smm_id?: string | null;
|
||||||
|
full_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: 'Erişim yetkiniz bulunmamaktadır (Token eksik).' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt.verify(token, config.jwtSecret, (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
res.status(403).json({ error: 'Geçersiz veya süresi dolmuş oturum anahtarı.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.user = user as AuthUser;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSMM(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
|
if (req.user?.role !== 'smm') {
|
||||||
|
res.status(403).json({ error: 'Bu işlem sadece Mali Müşavirler (SMM) tarafından yapılabilir.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { AuthController } from '../controllers/auth.controller';
|
||||||
|
import { authenticateToken, requireSMM } from '../middlewares/auth.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/register-smm', AuthController.registerSMM);
|
||||||
|
router.post('/login', AuthController.login);
|
||||||
|
router.get('/me', authenticateToken, AuthController.getMe);
|
||||||
|
router.post('/create-client', authenticateToken, requireSMM, AuthController.createClient);
|
||||||
|
router.get('/clients', authenticateToken, requireSMM, AuthController.getClients);
|
||||||
|
router.delete('/clients/:id', authenticateToken, requireSMM, AuthController.deleteClient);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { CompanyController } from '../controllers/company.controller';
|
||||||
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
router.get('/', CompanyController.listCompanies);
|
||||||
|
router.post('/', CompanyController.createCompany);
|
||||||
|
router.put('/:id', CompanyController.updateCompany);
|
||||||
|
router.delete('/:id', CompanyController.deleteCompany);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import authRoutes from './auth.routes';
|
||||||
|
import companyRoutes from './company.routes';
|
||||||
|
import receiptRoutes from './receipt.routes';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use('/auth', authRoutes);
|
||||||
|
router.use('/companies', companyRoutes);
|
||||||
|
router.use('/receipts', receiptRoutes);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { ReceiptController } from '../controllers/receipt.controller';
|
||||||
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
router.post('/analyze', ReceiptController.analyzeReceipt);
|
||||||
|
router.post('/', ReceiptController.createReceipt);
|
||||||
|
router.get('/company/:companyId', ReceiptController.getCompanyReceipts);
|
||||||
|
router.put('/:id', ReceiptController.updateReceipt);
|
||||||
|
router.delete('/:id', ReceiptController.deleteReceipt);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export class BunnyService {
|
||||||
|
/**
|
||||||
|
* Uploads binary or base64 buffer to BunnyCDN Storage
|
||||||
|
* @param buffer Image binary buffer or base64 string
|
||||||
|
* @param filename Optional filename
|
||||||
|
* @returns Public CDN URL
|
||||||
|
*/
|
||||||
|
static async uploadImage(buffer: Buffer | string, filename?: string): Promise<string> {
|
||||||
|
const { storageZone, apiKey, cdnUrl, folder } = config.bunny;
|
||||||
|
const name = filename || `rcpt_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
|
||||||
|
const uploadUrl = `https://storage.bunnycdn.com/${storageZone}/${folder}/${name}`;
|
||||||
|
|
||||||
|
let bodyData: Buffer;
|
||||||
|
if (typeof buffer === 'string') {
|
||||||
|
const cleanBase64 = buffer.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
bodyData = Buffer.from(cleanBase64, 'base64');
|
||||||
|
} else {
|
||||||
|
bodyData = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'AccessKey': apiKey,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
body: bodyData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`BunnyCDN upload error (${response.status}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${cdnUrl}/${folder}/${name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export interface ReceiptItemResult {
|
||||||
|
product_name: string;
|
||||||
|
kdv_rate: string;
|
||||||
|
amount: string;
|
||||||
|
category_code: string;
|
||||||
|
category_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OCRResult {
|
||||||
|
firm_name: string;
|
||||||
|
receipt_no: string;
|
||||||
|
date: string;
|
||||||
|
items: ReceiptItemResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACCOUNTING_CATEGORIES = [
|
||||||
|
{ kod: '770.01', ad: 'İlk Madde ve Tüketim Malzemesi (Kırtasiye/Temizlik)' },
|
||||||
|
{ kod: '770.06.01', ad: 'Enerji ve Su Giderleri' },
|
||||||
|
{ kod: '770.06.02', ad: 'Bakım ve Onarım Giderleri' },
|
||||||
|
{ kod: '770.06.03', ad: 'Ulaştırma, Taşıt ve Akaryakıt Giderleri' },
|
||||||
|
{ kod: '770.06.04', ad: 'Nakliye ve Kargo Giderleri' },
|
||||||
|
{ kod: '770.06.05', ad: 'Bilişim ve Yazılım Giderleri' },
|
||||||
|
{ kod: '770.12.01', ad: 'Yolluk ve Seyahat Giderleri' },
|
||||||
|
{ kod: '770.12.05', ad: 'Temsil, Ağırlama ve Yemek Giderleri' },
|
||||||
|
{ kod: '760.07', ad: 'Pazarlama, Reklam ve Tanıtım Giderleri' },
|
||||||
|
{ kod: '740.06', ad: 'Hizmet Üretim Maliyet Giderleri' },
|
||||||
|
{ kod: '255.02', ad: 'Demirbaşlar (Büro Makineleri, Elektronik, Telefon)' },
|
||||||
|
{ kod: '153.01', ad: 'Ticari Mallar / Emtia' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class OCRService {
|
||||||
|
/**
|
||||||
|
* Main entry point to extract structured receipt data from base64 image
|
||||||
|
* Uses provider configured in .env (Gemini or Custom)
|
||||||
|
*/
|
||||||
|
static async analyzeReceipt(imageBase64: string): Promise<OCRResult> {
|
||||||
|
const provider = config.ocr.provider;
|
||||||
|
|
||||||
|
if (provider === 'custom' && config.ocr.customUrl) {
|
||||||
|
return this.analyzeWithCustomEndpoint(imageBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: Gemini 2.5 Flash Vision
|
||||||
|
return this.analyzeWithGemini(imageBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini 2.5 Flash Vision OCR Implementation
|
||||||
|
*/
|
||||||
|
private static async analyzeWithGemini(imageBase64: string): Promise<OCRResult> {
|
||||||
|
const cleanBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
const apiKey = config.ocr.geminiApiKey;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('GEMINI_API_KEY is not configured in backend .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `Bu bir Türkçe fiş veya fatura fotoğrafı. Fişten aşağıdaki bilgileri JSON formatında çıkar.
|
||||||
|
|
||||||
|
Muhasebe Hesap Kodları Listesi (her ürün için en uygununu seç):
|
||||||
|
${ACCOUNTING_CATEGORIES.map(k => `${k.kod} - ${k.ad}`).join('\n')}
|
||||||
|
|
||||||
|
YANITI SADECE şu JSON formatında ver, başka hiçbir şey yazma:
|
||||||
|
{
|
||||||
|
"firm_name": "firma veya mağaza adı",
|
||||||
|
"receipt_no": "fiş numarası, yoksa boş string",
|
||||||
|
"date": "GG.AA.YYYY formatında tarih",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_name": "ürün adı",
|
||||||
|
"kdv_rate": "kdv oranı sadece rakam, örnek: 1, 10, 20",
|
||||||
|
"amount": "ürün fiyatı sadece rakam, örnek: 150.50",
|
||||||
|
"category_code": "kategori kodu (örn: 770.06.03)",
|
||||||
|
"category_name": "kategori adı"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
İptal edilen satırları (ÜRÜN İPTAL) dahil etme.`;
|
||||||
|
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{
|
||||||
|
parts: [
|
||||||
|
{ text: prompt },
|
||||||
|
{ inline_data: { mime_type: 'image/jpeg', data: cleanBase64 } }
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
generationConfig: { temperature: 0.1 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Gemini OCR failed (${response.status}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
const text: string | undefined = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Gemini did not return any readable content.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanJson = text.replace(/```json|```/g, '').trim();
|
||||||
|
return JSON.parse(cleanJson) as OCRResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom / External OCR API Implementation
|
||||||
|
* For easily switching to any other OCR service/microservice in the future
|
||||||
|
*/
|
||||||
|
private static async analyzeWithCustomEndpoint(imageBase64: string): Promise<OCRResult> {
|
||||||
|
const { customUrl, customApiKey } = config.ocr;
|
||||||
|
const cleanBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (customApiKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${customApiKey}`;
|
||||||
|
headers['x-api-key'] = customApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(customUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: cleanBase64,
|
||||||
|
categories: ACCOUNTING_CATEGORIES,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Custom OCR endpoint failed (${response.status}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result as OCRResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user