/opt/canhelp/apps/api/src/routes
NameSizeModeActions
admin.ts950570644editdlrm
auto-match.ts74060644editdlrm
auto-response.ts81110644editdlrm
availability.ts72940644editdlrm
categories.ts18670644editdlrm
chat.ts93770644editdlrm
dashboard.ts51420644editdlrm
favorites.ts44410644editdlrm
fomo.ts68440644editdlrm
google.ts48720644editdlrm
health.ts12140644editdlrm
i18n.ts1670860644editdlrm
locations.ts17810644editdlrm
notifications.ts28620644editdlrm
offers.ts100470644editdlrm
payments.ts211670644editdlrm
plans.ts13520644editdlrm
portfolio.ts66580644editdlrm
price-list.ts144170644editdlrm
reports.ts20070644editdlrm
reviews.ts27830644editdlrm
skill-suggestions.ts10350644editdlrm
specialist-cards.ts186410644editdlrm
stats.ts10930644editdlrm
statuses.ts37310644editdlrm
support.ts84520644editdlrm
tasks.ts300180644editdlrm
uploads.ts15260644editdlrm
users.ts371570644editdlrm
Edit: /opt/canhelp/apps/api/src/routes/payments.ts (21167B)
import { Hono } from 'hono' import { createHash } from 'crypto' import { eq, desc, sql, and } from 'drizzle-orm' import { db } from '../db.js' import { users, payments, settings as settingsTable, plans } from '@canhelp/db' import { requireAuth, type AuthVariables } from '../middleware/auth.js' import { logActivity } from '../lib/activity.js' import { invalidatePlanCache } from '../middleware/plan.js' import { emailPlanActivated, emailPlanUpgraded, emailPlanDowngradeScheduled, } from '../lib/email.js' import { applyBalanceChange, getUserBalanceHistory } from '../lib/balance.js' import { logPlanHistory } from '../lib/plan-history.js' const app = new Hono<{ Variables: AuthVariables }>() // ─── Helpers ───────────────────────────────────────────────────────────────── const TIER_ORDER: Record = { free: 0, pro: 1, ultimate: 2 } async function getEPaySettings() { const rows = await db.select().from(settingsTable) const kv: Record = {} for (const r of rows) kv[r.key] = r.value return { merchantId: kv['payment.epay.merchantId'] ?? '', apiUser: kv['payment.epay.apiUser'] ?? '', apiPassword: kv['payment.epay.apiPassword'] ?? '', environment: (kv['payment.epay.environment'] ?? 'test') as 'test' | 'production', confirmationUrl: kv['payment.epay.confirmationUrl'] ?? '', cancelUrl: kv['payment.epay.cancelUrl'] ?? '', enabled: kv['payment.epay.enabled'] === 'true', } } // ePay Piraeus Bank redirect URL (same for test and production — credentials differ) const EPAY_URL = 'https://paycenter.piraeusbank.gr/redirection/pay' // Digest: Base64(MD5("TID;MerchantRef;AmountInCents;CurrencyISO;Password")) function computeInitDigest(tid: string, ref: string, amountCents: number, currency: string, password: string) { const raw = `${tid};${ref};${amountCents};${currency};${password}` return createHash('md5').update(raw, 'utf8').digest('base64') } // Confirmation digest: Base64(MD5("MerchantRef;StatusFlag;RespCode;Password")) function computeConfirmDigest(ref: string, statusFlag: string, respCode: string, password: string) { const raw = `${ref};${statusFlag};${respCode};${password}` return createHash('md5').update(raw, 'utf8').digest('base64') } // ─── POST /api/payments/topup/init ─────────────────────────────────────────── // Auth required. Creates a pending payment record and returns ePay form params. app.post('/topup/init', requireAuth, async (c) => { const user = c.get('user') const body = await c.req.json<{ amount: number }>().catch(() => ({ amount: 0 })) const amount = Number(body.amount) if (!amount || amount < 1 || amount > 9999) { return c.json({ error: 'Amount must be between 1 and 9999 EUR' }, 400) } const cfg = await getEPaySettings() if (!cfg.enabled) return c.json({ error: 'Payment gateway is disabled' }, 503) if (!cfg.merchantId || !cfg.apiPassword) { return c.json({ error: 'Payment gateway is not configured' }, 503) } // Unique order reference const ts = Date.now() const rnd = Math.random().toString(36).slice(2, 6).toUpperCase() const orderRef = `BAL-${ts}-${rnd}` // Persist payment record await db.insert(payments).values({ id: `pay_${ts}_${rnd}`, userId: user.id, orderRef, amount: amount.toFixed(2), currency: 'EUR', type: 'balance', status: 'pending', description: `Пополнение баланса на ${amount.toFixed(2)} EUR`, }) // ePay parameters const amountCents = Math.round(amount * 100) const currencyISO = '978' // EUR const digest = computeInitDigest(cfg.merchantId, orderRef, amountCents, currencyISO, cfg.apiPassword) // The WEB_URL is used to build the return URL for user after payment const webUrl = process.env.WEB_URL || 'http://localhost:3000' return c.json({ url: EPAY_URL, params: { Tid: cfg.merchantId, MerchantReference: orderRef, ParamBackLink: `${webUrl}/payment?ref=${orderRef}`, AllowedPays: '', Quantity: '1', Amount: String(amountCents), Currency: currencyISO, Installments: '0', Iban: '', Encoding: 'UTF-8', Lang: 'en', Parameters: '', Digest: digest, }, }) }) // ─── POST /api/payments/epay/confirm ───────────────────────────────────────── // Called server-to-server by Piraeus Bank ePay after payment completes. // No auth middleware — request comes from bank. app.post('/epay/confirm', async (c) => { let body: Record = {} try { // ePay sends as form-encoded POST const raw = await c.req.parseBody() for (const [k, v] of Object.entries(raw)) { if (typeof v === 'string') body[k] = v } } catch { return c.text('BAD_REQUEST', 400) } const { MerchantReference, StatusFlag, RespCode, TransactionId, Digest } = body if (!MerchantReference || !StatusFlag || !RespCode) { return c.text('MISSING_PARAMS', 400) } const cfg = await getEPaySettings() if (!cfg.apiPassword) return c.text('NOT_CONFIGURED', 503) // Verify digest const expected = computeConfirmDigest(MerchantReference, StatusFlag, RespCode, cfg.apiPassword) if (Digest !== expected) { console.error('[ePay confirm] Invalid digest for order:', MerchantReference) return c.text('INVALID_DIGEST', 400) } const [payment] = await db .select() .from(payments) .where(eq(payments.orderRef, MerchantReference)) .limit(1) if (!payment) return c.text('ORDER_NOT_FOUND', 404) if (payment.status !== 'pending') return c.text('OK') // already processed (idempotent) const success = StatusFlag === 'Success' && (RespCode === '0x00' || RespCode === '00') await db.transaction(async (tx) => { await tx .update(payments) .set({ status: success ? 'success' : 'failed', transactionId: TransactionId ?? null, ePayResponse: body, updatedAt: new Date(), }) .where(eq(payments.orderRef, MerchantReference)) if (success && payment.type === 'balance') { await applyBalanceChange(tx, { userId: payment.userId, amount: Number(payment.amount), direction: 'credit', kind: 'topup', currency: payment.currency, paymentId: payment.id, description: payment.description, metadata: { orderRef: MerchantReference, transactionId: TransactionId ?? null, }, }) } if (success && payment.type === 'plan') { // Extract planId from ePay Parameters field or payment description const planIdMatch = (body.Parameters ?? payment.description ?? '').match(/planId=([^\s&]+)/) const planId = planIdMatch?.[1] if (planId) { const [plan] = await tx.select().from(plans).where(eq(plans.id, planId)) if (plan) { const expiresAt = plan.durationDays ? new Date(Date.now() + plan.durationDays * 86400000) : null await tx .update(users) .set({ planId: plan.id, planExpiresAt: expiresAt, updatedAt: new Date() }) .where(eq(users.id, payment.userId)) logActivity({ userId: payment.userId, event: 'plan.purchase', details: { planId: plan.id, planName: plan.name, method: 'epay', price: payment.amount, orderRef: MerchantReference, }, }).catch(() => {}) logPlanHistory({ userId: payment.userId, event: 'activated', planId: plan.id, planName: plan.name, expiresAt, }) } } } }) if (success && payment.type === 'balance') { logActivity({ userId: payment.userId, event: 'payment.topup', details: { amount: payment.amount, currency: payment.currency, orderRef: MerchantReference, transactionId: TransactionId, }, }).catch(() => {}) } return c.text('OK') }) // ─── GET /api/payments/epay/cancel ─────────────────────────────────────────── // Redirect from ePay when user cancels. Mark payment as cancelled. app.get('/epay/cancel', async (c) => { const ref = c.req.query('MerchantReference') || c.req.query('ref') if (ref) { await db .update(payments) .set({ status: 'cancelled', updatedAt: new Date() }) .where(eq(payments.orderRef, ref)) .catch(() => {}) } const webUrl = process.env.WEB_URL || 'http://localhost:3000' return c.redirect(`${webUrl}/payment?status=cancelled`) }) // ─── GET /api/payments/history ──────────────────────────────────────────────── // Returns current user's payment history. app.get('/history', requireAuth, async (c) => { const user = c.get('user') const ref = (c.req.query('ref') || '').trim() const whereExpr = ref ? and(eq(payments.userId, user.id), eq(payments.orderRef, ref)) : eq(payments.userId, user.id) const rows = await db .select() .from(payments) .where(whereExpr) .orderBy(desc(payments.createdAt)) .limit(ref ? 1 : 50) return c.json(rows) }) app.get('/balance-history', requireAuth, async (c) => { const user = c.get('user') const limit = Math.min(Number(c.req.query('limit') || 50), 100) const rows = await getUserBalanceHistory(db, user.id, limit) return c.json(rows) }) // ─── POST /api/payments/plan/preview ───────────────────────────────────────── // Returns effective cost breakdown. No charge happens. app.post('/plan/preview', requireAuth, async (c) => { const user = c.get('user') as any const body = await c.req.json<{ planId: string }>().catch(() => ({ planId: '' })) if (!body.planId) return c.json({ error: 'planId is required' }, 400) const [targetPlan] = await db.select().from(plans).where(eq(plans.id, body.planId)) if (!targetPlan || !targetPlan.isActive) return c.json({ error: 'Plan not found' }, 404) const [dbUser] = await db .select({ planId: users.planId, planExpiresAt: users.planExpiresAt, balance: users.balance }) .from(users) .where(eq(users.id, user.id)) const currentBalance = Number(dbUser?.balance ?? 0) const targetPrice = Number(targetPlan.price) if (targetPrice === 0) { return c.json({ type: 'free', effectivePrice: '0.00', prorated: '0.00', currentBalance: currentBalance.toFixed(2), canPayFromBalance: true, scheduledDate: null }) } const hasActivePlan = dbUser?.planId && dbUser?.planExpiresAt && dbUser.planExpiresAt > new Date() if (!hasActivePlan) { return c.json({ type: 'upgrade', effectivePrice: targetPrice.toFixed(2), prorated: '0.00', currentBalance: currentBalance.toFixed(2), canPayFromBalance: currentBalance >= targetPrice, scheduledDate: null }) } const [currentPlan] = await db.select().from(plans).where(eq(plans.id, dbUser!.planId!)) if (!currentPlan) { return c.json({ type: 'upgrade', effectivePrice: targetPrice.toFixed(2), prorated: '0.00', currentBalance: currentBalance.toFixed(2), canPayFromBalance: currentBalance >= targetPrice, scheduledDate: null }) } const currentTier = TIER_ORDER[currentPlan.tier] ?? 0 const targetTier = TIER_ORDER[targetPlan.tier] ?? 0 if (currentTier === targetTier) { return c.json({ type: 'same', effectivePrice: targetPrice.toFixed(2), prorated: '0.00', currentBalance: currentBalance.toFixed(2), canPayFromBalance: currentBalance >= targetPrice, scheduledDate: null }) } if (targetTier > currentTier) { const expiresAt = dbUser!.planExpiresAt! const totalMs = (currentPlan.durationDays ?? 30) * 86400000 const remainingMs = Math.max(0, expiresAt.getTime() - Date.now()) const prorated = Number(currentPlan.price) * (remainingMs / totalMs) const effectivePrice = Math.max(0, targetPrice - prorated) return c.json({ type: 'upgrade', effectivePrice: effectivePrice.toFixed(2), prorated: prorated.toFixed(2), currentBalance: currentBalance.toFixed(2), canPayFromBalance: currentBalance >= effectivePrice, scheduledDate: null }) } // Downgrade return c.json({ type: 'downgrade', effectivePrice: '0.00', prorated: '0.00', currentBalance: currentBalance.toFixed(2), canPayFromBalance: true, scheduledDate: dbUser!.planExpiresAt!.toISOString() }) }) // ─── POST /api/payments/plan/purchase ──────────────────────────────────────── // Purchase, upgrade, or downgrade a plan. Pays from balance or initiates ePay. app.post('/plan/purchase', requireAuth, async (c) => { const user = c.get('user') as any const body = await c.req.json<{ planId: string; payFromBalance?: boolean }>() .catch(() => ({ planId: '', payFromBalance: false })) if (!body.planId) return c.json({ error: 'planId is required' }, 400) const [targetPlan] = await db.select().from(plans).where(eq(plans.id, body.planId)) if (!targetPlan || !targetPlan.isActive) return c.json({ error: 'Plan not found or inactive' }, 404) // Note: customer/specialist plan separation was removed — any user may purchase // any active plan. Helper capability is card-based, not gated by the plan role. const [dbUser] = await db .select({ planId: users.planId, planExpiresAt: users.planExpiresAt, balance: users.balance, name: users.name, email: users.email, locale: users.locale, }) .from(users) .where(eq(users.id, user.id)) const currentBalance = Number(dbUser?.balance ?? 0) const targetPrice = Number(targetPlan.price) const userLocale = (dbUser?.locale ?? 'el') as 'el' | 'en' | 'ru' // Free plan — assign immediately if (targetPrice === 0) { await db.update(users) .set({ planId: targetPlan.id, planExpiresAt: null, pendingPlanId: null, updatedAt: new Date() }) .where(eq(users.id, user.id)) emailPlanActivated({ to: dbUser!.email!, name: dbUser?.name ?? '', planName: targetPlan.name, price: '0.00', expiresAt: '—', locale: userLocale }).catch(() => {}) logActivity({ userId: user.id, event: 'plan.change', details: { planId: targetPlan.id, planName: targetPlan.name, method: 'free' } }).catch(() => {}) logPlanHistory({ userId: user.id, event: 'activated', planId: targetPlan.id, planName: targetPlan.name, previousPlanId: dbUser?.planId ?? null, expiresAt: null, }) invalidatePlanCache(user.id) return c.json({ success: true, plan: targetPlan, method: 'free' }) } // Determine current plan const hasActivePlan = dbUser?.planId && dbUser?.planExpiresAt && dbUser.planExpiresAt > new Date() let currentPlan: typeof targetPlan | null = null if (hasActivePlan && dbUser?.planId) { const [cp] = await db.select().from(plans).where(eq(plans.id, dbUser.planId)) currentPlan = cp ?? null } const currentTier = TIER_ORDER[currentPlan?.tier ?? 'free'] ?? 0 const targetTier = TIER_ORDER[targetPlan.tier] ?? 0 // DOWNGRADE — schedule for after current plan expires if (currentPlan && targetTier < currentTier && hasActivePlan) { await db.update(users) .set({ pendingPlanId: targetPlan.id, updatedAt: new Date() }) .where(eq(users.id, user.id)) emailPlanDowngradeScheduled({ to: dbUser!.email!, name: dbUser?.name ?? '', currentPlanName: currentPlan.name, newPlanName: targetPlan.name, scheduledDate: dbUser!.planExpiresAt!.toLocaleDateString('el-GR'), locale: userLocale, }).catch(() => {}) logActivity({ userId: user.id, event: 'plan.downgrade_scheduled', details: { planId: targetPlan.id, planName: targetPlan.name, scheduledDate: dbUser!.planExpiresAt } }).catch(() => {}) return c.json({ success: true, scheduled: true, activeUntil: dbUser!.planExpiresAt, plan: targetPlan }) } // UPGRADE / new purchase — calculate effective price let effectivePrice = targetPrice let prorated = 0 if (currentPlan && targetTier > currentTier && hasActivePlan && dbUser?.planExpiresAt) { const totalMs = (currentPlan.durationDays ?? 30) * 86400000 const remainingMs = Math.max(0, dbUser.planExpiresAt.getTime() - Date.now()) prorated = Number(currentPlan.price) * (remainingMs / totalMs) effectivePrice = Math.max(0, targetPrice - prorated) } // Pay from balance if (body.payFromBalance) { if (currentBalance < effectivePrice) { return c.json({ error: 'Insufficient balance', code: 'INSUFFICIENT_BALANCE', balance: currentBalance.toFixed(2), effectivePrice: effectivePrice.toFixed(2), }, 400) } const expiresAt = targetPlan.durationDays ? new Date(Date.now() + targetPlan.durationDays * 86400000) : null const ts = Date.now() const rnd = Math.random().toString(36).slice(2, 6).toUpperCase() await db.transaction(async (tx) => { const [payment] = await tx.insert(payments).values({ id: `pay_${ts}_${rnd}`, userId: user.id, orderRef: `PLAN-${ts}-${rnd}`, amount: effectivePrice.toFixed(2), currency: targetPlan.currency, type: 'plan', status: 'success', description: `Plan: ${targetPlan.name}`, }).returning() await applyBalanceChange(tx, { userId: user.id, amount: effectivePrice, direction: 'debit', kind: 'plan_purchase', currency: targetPlan.currency, paymentId: payment.id, description: `Plan: ${targetPlan.name}`, metadata: { planId: targetPlan.id, planName: targetPlan.name, prorated: prorated.toFixed(2), }, }) await tx .update(users) .set({ planId: targetPlan.id, planExpiresAt: expiresAt, pendingPlanId: null, renewalReminderSentAt: null, updatedAt: new Date(), }) .where(eq(users.id, user.id)) }) const expiresStr = expiresAt?.toLocaleDateString('el-GR') ?? '—' if (currentPlan && targetTier > currentTier) { emailPlanUpgraded({ to: dbUser!.email!, name: dbUser?.name ?? '', oldPlanName: currentPlan.name, newPlanName: targetPlan.name, charged: effectivePrice.toFixed(2), expiresAt: expiresStr, locale: userLocale }).catch(() => {}) } else { emailPlanActivated({ to: dbUser!.email!, name: dbUser?.name ?? '', planName: targetPlan.name, price: effectivePrice.toFixed(2), expiresAt: expiresStr, locale: userLocale }).catch(() => {}) } invalidatePlanCache(user.id) logActivity({ userId: user.id, event: 'plan.purchase', details: { planId: targetPlan.id, planName: targetPlan.name, method: 'balance', effectivePrice: effectivePrice.toFixed(2), prorated: prorated.toFixed(2) } }).catch(() => {}) const historyEvent = currentPlan && targetTier > currentTier ? 'activated' : (currentPlan && targetTier < currentTier ? 'downgraded' : currentPlan ? 'renewed' : 'activated') logPlanHistory({ userId: user.id, event: historyEvent, planId: targetPlan.id, planName: targetPlan.name, previousPlanId: currentPlan?.id ?? null, previousPlanName: currentPlan?.name ?? null, expiresAt, }) return c.json({ success: true, plan: targetPlan, method: 'balance', expiresAt, effectivePrice: effectivePrice.toFixed(2) }) } // Pay via ePay const cfg = await getEPaySettings() if (!cfg.enabled) return c.json({ error: 'Payment gateway is disabled' }, 503) if (!cfg.merchantId || !cfg.apiPassword) return c.json({ error: 'Payment gateway is not configured' }, 503) const ts = Date.now() const rnd = Math.random().toString(36).slice(2, 6).toUpperCase() const orderRef = `PLAN-${ts}-${rnd}` await db.insert(payments).values({ id: `pay_${ts}_${rnd}`, userId: user.id, orderRef, amount: effectivePrice.toFixed(2), currency: targetPlan.currency, type: 'plan', status: 'pending', description: `Plan: ${targetPlan.name}`, }) const amountCents = Math.round(effectivePrice * 100) const currencyISO = '978' const digest = computeInitDigest(cfg.merchantId, orderRef, amountCents, currencyISO, cfg.apiPassword) const webUrl = process.env.WEB_URL || 'http://localhost:3000' return c.json({ url: EPAY_URL, params: { Tid: cfg.merchantId, MerchantReference: orderRef, ParamBackLink: `${webUrl}/payment?ref=${orderRef}`, AllowedPays: '', Quantity: '1', Amount: String(amountCents), Currency: currencyISO, Installments: '0', Iban: '', Encoding: 'UTF-8', Lang: 'en', Parameters: `planId=${targetPlan.id}`, Digest: digest, }, }) }) export default app