/opt/canhelp/apps/api/src/routes
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