Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen verteilen den Skonto auf die Restforderung nach Abzug der Abschläge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
import { FastifyInstance } from "fastify"
|
||
import jwt from "jsonwebtoken"
|
||
import { secrets } from "../utils/secrets"
|
||
import { createHash, randomBytes } from "node:crypto"
|
||
|
||
import {
|
||
authTenantUsers,
|
||
authUsers,
|
||
authProfiles,
|
||
tenants,
|
||
m2mApiKeys
|
||
} from "../../db/schema"
|
||
|
||
import {and, desc, eq, inArray, isNull} from "drizzle-orm"
|
||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||
import { rotateRefreshToken } from "../utils/authTokens"
|
||
import { normalizeCashDiscountAccountsInput } from "../modules/cash-discount"
|
||
|
||
|
||
export default async function tenantRoutes(server: FastifyInstance) {
|
||
const generateApiKey = () => {
|
||
const raw = randomBytes(32).toString("base64url")
|
||
return `fedeo_m2m_${raw}`
|
||
}
|
||
const hashApiKey = (apiKey: string) =>
|
||
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||
|
||
|
||
// -------------------------------------------------------------
|
||
// GET CURRENT TENANT
|
||
// -------------------------------------------------------------
|
||
server.get("/tenant", async (req, reply) => {
|
||
if (req.user?.tenant_id) {
|
||
const tenantRows = await server.db
|
||
.select()
|
||
.from(tenants)
|
||
.where(eq(tenants.id, Number(req.user.tenant_id)))
|
||
.limit(1)
|
||
|
||
if (!tenantRows.length) {
|
||
return reply.code(404).send({ error: "Tenant not found" })
|
||
}
|
||
|
||
return tenantRows[0]
|
||
}
|
||
|
||
if (req.tenant) {
|
||
return {
|
||
message: `Hallo vom Tenant ${req.tenant?.name}`,
|
||
tenant_id: req.tenant?.id,
|
||
}
|
||
}
|
||
return {
|
||
message: "Server ist im MultiTenant-Modus – es werden alle verfügbaren Tenants geladen."
|
||
}
|
||
})
|
||
|
||
|
||
|
||
// -------------------------------------------------------------
|
||
// SWITCH TENANT
|
||
// -------------------------------------------------------------
|
||
server.post("/tenant/switch", async (req, reply) => {
|
||
try {
|
||
if (!req.user) {
|
||
return reply.code(401).send({ error: "Unauthorized" })
|
||
}
|
||
|
||
const { tenant_id, refreshToken } = req.body as { tenant_id: string; refreshToken?: string }
|
||
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
||
|
||
// prüfen ob der User zu diesem Tenant gehört
|
||
const membership = await server.db
|
||
.select()
|
||
.from(authTenantUsers)
|
||
.where(and(
|
||
eq(authTenantUsers.user_id, req.user.user_id),
|
||
eq(authTenantUsers.tenant_id, Number(tenant_id))
|
||
))
|
||
|
||
if (!membership.length) {
|
||
return reply.code(403).send({ error: "Not a member of this tenant" })
|
||
}
|
||
|
||
if (refreshToken) {
|
||
const session = await rotateRefreshToken(
|
||
server,
|
||
refreshToken,
|
||
Number(tenant_id),
|
||
req.user.user_id
|
||
)
|
||
|
||
if (!session) {
|
||
return reply.code(401).send({ error: "Invalid or expired refresh token" })
|
||
}
|
||
|
||
return session
|
||
}
|
||
|
||
// Abwärtskompatibilität für Clients ohne persistente Session
|
||
const token = jwt.sign(
|
||
{
|
||
user_id: req.user.user_id,
|
||
email: req.user.email,
|
||
tenant_id,
|
||
},
|
||
secrets.JWT_SECRET!,
|
||
{ expiresIn: "6h" }
|
||
)
|
||
|
||
reply.setCookie("token", token, {
|
||
path: "/",
|
||
httpOnly: true,
|
||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||
secure: process.env.NODE_ENV === "production",
|
||
maxAge: 60 * 60 * 6,
|
||
})
|
||
|
||
return { token }
|
||
|
||
} catch (err) {
|
||
console.error("TENANT SWITCH ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
|
||
|
||
// -------------------------------------------------------------
|
||
// TENANT USERS (auth_users + auth_profiles)
|
||
// -------------------------------------------------------------
|
||
server.get("/tenant/users", async (req, reply) => {
|
||
try {
|
||
const authUser = req.user
|
||
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
|
||
|
||
const tenantId = authUser.tenant_id
|
||
|
||
// 1) auth_tenant_users → user_ids
|
||
const tenantUsers = await server.db
|
||
.select()
|
||
.from(authTenantUsers)
|
||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||
|
||
const userIds = tenantUsers.map(u => u.user_id)
|
||
|
||
if (!userIds.length) {
|
||
return { tenant_id: tenantId, users: [] }
|
||
}
|
||
|
||
// 2) auth_users laden
|
||
const users = await server.db
|
||
.select()
|
||
.from(authUsers)
|
||
.where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at)))
|
||
|
||
// 3) auth_profiles pro Tenant laden
|
||
const profileRows = await server.db
|
||
.select()
|
||
.from(authProfiles)
|
||
.where(
|
||
and(
|
||
eq(authProfiles.tenant_id, tenantId),
|
||
inArray(authProfiles.user_id, userIds),
|
||
eq(authProfiles.active, true),
|
||
isNull(authProfiles.archived_at)
|
||
))
|
||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||
|
||
const combined = users.map(u => {
|
||
const profile = profiles.find(p => p.user_id === u.id)
|
||
return {
|
||
id: u.id,
|
||
email: u.email,
|
||
profile,
|
||
full_name: profile?.full_name ?? null
|
||
}
|
||
})
|
||
|
||
return { tenant_id: tenantId, users: combined }
|
||
|
||
} catch (err) {
|
||
console.error("/tenant/users ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
|
||
|
||
// -------------------------------------------------------------
|
||
// TENANT PROFILES
|
||
// -------------------------------------------------------------
|
||
server.get("/tenant/profiles", async (req, reply) => {
|
||
try {
|
||
const tenantId = req.user?.tenant_id
|
||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||
|
||
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true"
|
||
const profileRows = await server.db
|
||
.select()
|
||
.from(authProfiles)
|
||
.where(and(
|
||
eq(authProfiles.tenant_id, tenantId),
|
||
...(includeArchived ? [] : [eq(authProfiles.active, true), isNull(authProfiles.archived_at)])
|
||
))
|
||
|
||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||
return { data }
|
||
|
||
} catch (err) {
|
||
console.error("/tenant/profiles ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
|
||
|
||
// -------------------------------------------------------------
|
||
// UPDATE NUMBER RANGE
|
||
// -------------------------------------------------------------
|
||
server.put("/tenant/numberrange/:numberrange", async (req, reply) => {
|
||
try {
|
||
const user = req.user
|
||
if (!user) return reply.code(401).send({ error: "Unauthorized" })
|
||
|
||
const { numberrange } = req.params as { numberrange: string }
|
||
const { numberRange } = req.body as { numberRange: any }
|
||
|
||
if (!numberRange) {
|
||
return reply.code(400).send({ error: "numberRange required" })
|
||
}
|
||
|
||
const tenantId = Number(user.tenant_id)
|
||
|
||
const currentTenantRows = await server.db
|
||
.select()
|
||
.from(tenants)
|
||
.where(eq(tenants.id, tenantId))
|
||
|
||
const current = currentTenantRows[0]
|
||
if (!current) return reply.code(404).send({ error: "Tenant not found" })
|
||
|
||
const updatedRanges = {
|
||
//@ts-ignore
|
||
...current.numberRanges,
|
||
[numberrange]: numberRange
|
||
}
|
||
|
||
const updated = await server.db
|
||
.update(tenants)
|
||
.set({ numberRanges: updatedRanges })
|
||
.where(eq(tenants.id, tenantId))
|
||
.returning()
|
||
|
||
return updated[0]
|
||
|
||
} catch (err) {
|
||
console.error("/tenant/numberrange ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
|
||
|
||
// -------------------------------------------------------------
|
||
// UPDATE TENANT OTHER FIELDS
|
||
// -------------------------------------------------------------
|
||
server.put("/tenant/other/:id", async (req, reply) => {
|
||
try {
|
||
const user = req.user
|
||
if (!user) return reply.code(401).send({ error: "Unauthorized" })
|
||
|
||
const { id } = req.params as { id: string }
|
||
const { data } = req.body as { data: any }
|
||
|
||
if (!data) return reply.code(400).send({ error: "data required" })
|
||
|
||
if (Object.prototype.hasOwnProperty.call(data, "portalConfig")) {
|
||
const config = data.portalConfig
|
||
if (!config || typeof config !== "object" || Array.isArray(config)
|
||
|| typeof config.primaryColor !== "string" || !/^#[0-9a-f]{6}$/i.test(config.primaryColor.trim())) {
|
||
return reply.code(400).send({ error: "Die Portal-Akzentfarbe muss ein Hex-Farbwert wie #69c350 sein." })
|
||
}
|
||
data.portalConfig = { ...config, primaryColor: config.primaryColor.trim().toLowerCase() }
|
||
delete data.portalConfig.primayColor
|
||
}
|
||
|
||
if (Object.prototype.hasOwnProperty.call(data, "cashDiscountAccounts")) {
|
||
try {
|
||
data.cashDiscountAccounts = normalizeCashDiscountAccountsInput(data.cashDiscountAccounts)
|
||
} catch (err: any) {
|
||
return reply.code(400).send({ error: err.message })
|
||
}
|
||
}
|
||
|
||
const updated = await server.db
|
||
.update(tenants)
|
||
.set(data)
|
||
.where(eq(tenants.id, Number(user.tenant_id)))
|
||
.returning()
|
||
|
||
return updated[0]
|
||
|
||
} catch (err) {
|
||
console.error("/tenant/other ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
// -------------------------------------------------------------
|
||
// M2M API KEYS
|
||
// -------------------------------------------------------------
|
||
server.get("/tenant/api-keys", async (req, reply) => {
|
||
try {
|
||
const tenantId = req.user?.tenant_id
|
||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||
|
||
const keys = await server.db
|
||
.select({
|
||
id: m2mApiKeys.id,
|
||
name: m2mApiKeys.name,
|
||
tenant_id: m2mApiKeys.tenantId,
|
||
user_id: m2mApiKeys.userId,
|
||
active: m2mApiKeys.active,
|
||
key_prefix: m2mApiKeys.keyPrefix,
|
||
created_at: m2mApiKeys.createdAt,
|
||
updated_at: m2mApiKeys.updatedAt,
|
||
expires_at: m2mApiKeys.expiresAt,
|
||
last_used_at: m2mApiKeys.lastUsedAt,
|
||
})
|
||
.from(m2mApiKeys)
|
||
.where(eq(m2mApiKeys.tenantId, tenantId))
|
||
.orderBy(desc(m2mApiKeys.createdAt))
|
||
|
||
return keys
|
||
} catch (err) {
|
||
console.error("/tenant/api-keys GET ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
server.post("/tenant/api-keys", async (req, reply) => {
|
||
try {
|
||
const tenantId = req.user?.tenant_id
|
||
const creatorUserId = req.user?.user_id
|
||
if (!tenantId || !creatorUserId) {
|
||
return reply.code(401).send({ error: "Unauthorized" })
|
||
}
|
||
|
||
const { name, user_id, expires_at } = req.body as {
|
||
name: string
|
||
user_id: string
|
||
expires_at?: string | null
|
||
}
|
||
|
||
if (!name || !user_id) {
|
||
return reply.code(400).send({ error: "name and user_id are required" })
|
||
}
|
||
|
||
const userMembership = await server.db
|
||
.select()
|
||
.from(authTenantUsers)
|
||
.where(and(
|
||
eq(authTenantUsers.tenant_id, tenantId),
|
||
eq(authTenantUsers.user_id, user_id)
|
||
))
|
||
.limit(1)
|
||
|
||
if (!userMembership[0]) {
|
||
return reply.code(400).send({ error: "user_id is not assigned to this tenant" })
|
||
}
|
||
|
||
const plainApiKey = generateApiKey()
|
||
const keyPrefix = plainApiKey.slice(0, 16)
|
||
const keyHash = hashApiKey(plainApiKey)
|
||
|
||
const inserted = await server.db
|
||
.insert(m2mApiKeys)
|
||
.values({
|
||
tenantId,
|
||
userId: user_id,
|
||
createdBy: creatorUserId,
|
||
name,
|
||
keyPrefix,
|
||
keyHash,
|
||
expiresAt: expires_at ? new Date(expires_at) : null,
|
||
})
|
||
.returning({
|
||
id: m2mApiKeys.id,
|
||
name: m2mApiKeys.name,
|
||
tenant_id: m2mApiKeys.tenantId,
|
||
user_id: m2mApiKeys.userId,
|
||
key_prefix: m2mApiKeys.keyPrefix,
|
||
created_at: m2mApiKeys.createdAt,
|
||
expires_at: m2mApiKeys.expiresAt,
|
||
active: m2mApiKeys.active,
|
||
})
|
||
|
||
return reply.code(201).send({
|
||
...inserted[0],
|
||
api_key: plainApiKey, // only returned once
|
||
})
|
||
} catch (err) {
|
||
console.error("/tenant/api-keys POST ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
server.patch("/tenant/api-keys/:id", async (req, reply) => {
|
||
try {
|
||
const tenantId = req.user?.tenant_id
|
||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||
|
||
const { id } = req.params as { id: string }
|
||
const { name, active, expires_at } = req.body as {
|
||
name?: string
|
||
active?: boolean
|
||
expires_at?: string | null
|
||
}
|
||
|
||
const updateData: any = {
|
||
updatedAt: new Date()
|
||
}
|
||
if (name !== undefined) updateData.name = name
|
||
if (active !== undefined) updateData.active = active
|
||
if (expires_at !== undefined) updateData.expiresAt = expires_at ? new Date(expires_at) : null
|
||
|
||
const updated = await server.db
|
||
.update(m2mApiKeys)
|
||
.set(updateData)
|
||
.where(and(
|
||
eq(m2mApiKeys.id, id),
|
||
eq(m2mApiKeys.tenantId, tenantId)
|
||
))
|
||
.returning({
|
||
id: m2mApiKeys.id,
|
||
name: m2mApiKeys.name,
|
||
tenant_id: m2mApiKeys.tenantId,
|
||
user_id: m2mApiKeys.userId,
|
||
active: m2mApiKeys.active,
|
||
key_prefix: m2mApiKeys.keyPrefix,
|
||
updated_at: m2mApiKeys.updatedAt,
|
||
expires_at: m2mApiKeys.expiresAt,
|
||
last_used_at: m2mApiKeys.lastUsedAt,
|
||
})
|
||
|
||
if (!updated[0]) {
|
||
return reply.code(404).send({ error: "API key not found" })
|
||
}
|
||
|
||
return updated[0]
|
||
} catch (err) {
|
||
console.error("/tenant/api-keys PATCH ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
server.delete("/tenant/api-keys/:id", async (req, reply) => {
|
||
try {
|
||
const tenantId = req.user?.tenant_id
|
||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||
|
||
const { id } = req.params as { id: string }
|
||
await server.db
|
||
.delete(m2mApiKeys)
|
||
.where(and(
|
||
eq(m2mApiKeys.id, id),
|
||
eq(m2mApiKeys.tenantId, tenantId)
|
||
))
|
||
|
||
return { success: true }
|
||
} catch (err) {
|
||
console.error("/tenant/api-keys DELETE ERROR:", err)
|
||
return reply.code(500).send({ error: "Internal Server Error" })
|
||
}
|
||
})
|
||
|
||
}
|