diff --git a/controller/admin/CoffeeController.js b/controller/admin/CoffeeController.js
index 0660eea..670e7fc 100644
--- a/controller/admin/CoffeeController.js
+++ b/controller/admin/CoffeeController.js
@@ -12,16 +12,61 @@ function buildPictureUrlFromKey(key) {
return key; // fallback: store key only
}
+function normalizePictureFiles(req) {
+ const files = [];
+
+ // upload.any() -> req.files is usually an array
+ if (Array.isArray(req.files)) {
+ files.push(...req.files);
+ }
+
+ // Defensive fallback for potential object-shaped middleware output
+ if (req.files && !Array.isArray(req.files) && typeof req.files === 'object') {
+ for (const value of Object.values(req.files)) {
+ if (Array.isArray(value)) files.push(...value);
+ else if (value) files.push(value);
+ }
+ }
+
+ if (req.file) files.push(req.file);
+ return files.filter(Boolean);
+}
+
+function toCoffeeResponse(row) {
+ const pictures = Array.isArray(row?.pictures) && row.pictures.length
+ ? row.pictures.map((p) => ({
+ ...p,
+ url: p.object_storage_id ? buildPictureUrlFromKey(p.object_storage_id) : ''
+ }))
+ : (row?.object_storage_id
+ ? [{
+ id: null,
+ coffee_id: row.id,
+ object_storage_id: row.object_storage_id,
+ original_filename: row.original_filename || null,
+ sort_order: 0,
+ created_at: row.created_at || null,
+ url: buildPictureUrlFromKey(row.object_storage_id)
+ }]
+ : []);
+
+ const pictureUrls = pictures.map((p) => p.url).filter(Boolean);
+ return {
+ ...row,
+ pictureUrl: pictureUrls[0] || '',
+ pictureUrls,
+ pictures,
+ };
+}
+
exports.list = async (req, res) => {
const rows = await CoffeeService.list();
- const items = (rows || []).map(r => ({
- ...r,
- pictureUrl: r.object_storage_id ? buildPictureUrlFromKey(r.object_storage_id) : ''
- }));
+ const items = (rows || []).map((r) => toCoffeeResponse(r));
res.json(items);
};
exports.create = async (req, res) => {
+ let uploadedKeys = [];
try {
const { title, description, price } = req.body;
const currency = req.body.currency || 'EUR';
@@ -31,19 +76,23 @@ exports.create = async (req, res) => {
const interval_count = 1;
const state = req.body.state === 'false' || req.body.state === false ? false : true; // default available
- // If file uploaded, push to Exoscale and set object_storage_id
+ // If files uploaded, push to Exoscale and store all uploaded images
let object_storage_id = null;
let original_filename = null;
- let uploadedKey = null;
- if (req.file) {
+ let images = [];
+ const incomingFiles = normalizePictureFiles(req);
+ logger.info('[CoffeeController.create] incoming multipart files', {
+ count: incomingFiles.length,
+ fields: incomingFiles.map((f) => f?.fieldname).filter(Boolean),
+ });
+ if (incomingFiles.length > 10) {
+ return res.status(400).json({ error: 'Maximum 10 images allowed' });
+ }
+
+ if (incomingFiles.length) {
const allowedMime = ['image/jpeg','image/png','image/webp'];
- if (!allowedMime.includes(req.file.mimetype)) {
- return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP' });
- }
const maxBytes = 10 * 1024 * 1024; // 10MB
- if (req.file.size > maxBytes) {
- return res.status(400).json({ error: 'Image exceeds 10MB limit' });
- }
+
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
@@ -52,18 +101,38 @@ exports.create = async (req, res) => {
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
},
});
- const key = `coffee/products/${Date.now()}_${req.file.originalname}`;
- await s3.send(new PutObjectCommand({
- Bucket: process.env.EXOSCALE_BUCKET,
- Key: key,
- Body: req.file.buffer,
- ContentType: req.file.mimetype,
- ACL: 'public-read'
- }));
- object_storage_id = key;
- original_filename = req.file.originalname;
- uploadedKey = key;
- logger.info('[CoffeeController.create] uploaded picture', { key });
+
+ for (let i = 0; i < incomingFiles.length; i += 1) {
+ const file = incomingFiles[i];
+ if (!allowedMime.includes(file.mimetype)) {
+ return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP' });
+ }
+ if (file.size > maxBytes) {
+ return res.status(400).json({ error: 'Image exceeds 10MB limit' });
+ }
+
+ const key = `coffee/products/${Date.now()}_${i}_${file.originalname}`;
+ await s3.send(new PutObjectCommand({
+ Bucket: process.env.EXOSCALE_BUCKET,
+ Key: key,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ ACL: 'public-read'
+ }));
+
+ uploadedKeys.push(key);
+ images.push({
+ object_storage_id: key,
+ original_filename: file.originalname,
+ sort_order: i,
+ });
+ }
+
+ if (images[0]) {
+ object_storage_id = images[0].object_storage_id;
+ original_filename = images[0].original_filename;
+ }
+ logger.info('[CoffeeController.create] uploaded pictures', { count: images.length });
}
const created = await CoffeeService.create({
@@ -76,17 +145,15 @@ exports.create = async (req, res) => {
interval_count,
object_storage_id,
original_filename,
+ images,
state,
});
- res.status(201).json({
- ...created,
- pictureUrl: created.object_storage_id ? buildPictureUrlFromKey(created.object_storage_id) : ''
- });
+ res.status(201).json(toCoffeeResponse(created));
} catch (e) {
logger.error('[CoffeeController.create] error', { msg: e.message, stack: e.stack?.split('\n')[0] });
// best-effort cleanup of uploaded object on failure
try {
- if (object_storage_id) {
+ if (uploadedKeys.length) {
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
@@ -95,7 +162,9 @@ exports.create = async (req, res) => {
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
},
});
- await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: object_storage_id }));
+ for (const key of uploadedKeys) {
+ await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key }));
+ }
}
} catch (cleanupErr) {
logger.warn('[CoffeeController.create] cleanup failed', { msg: cleanupErr.message });
@@ -251,13 +320,181 @@ exports.remove = async (req, res) => {
exports.listActive = async (req, res) => {
try {
const rows = await CoffeeService.listActive();
- const items = (rows || []).map(r => ({
- ...r,
- pictureUrl: r.object_storage_id ? buildPictureUrlFromKey(r.object_storage_id) : ''
- }));
+ const items = (rows || []).map((r) => toCoffeeResponse(r));
res.json(items);
} catch (e) {
logger.error('[CoffeeController.listActive] error', { msg: e.message });
res.status(500).json({ error: 'Failed to fetch active coffee products' });
}
};
+
+exports.getPictures = async (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id <= 0) {
+ return res.status(400).json({ error: 'Invalid id' });
+ }
+
+ const row = await CoffeeService.get(id);
+ if (!row) {
+ return res.status(404).json({ error: 'Not found' });
+ }
+
+ const normalized = toCoffeeResponse(row);
+ return res.json({
+ coffeeId: id,
+ pictures: normalized.pictures,
+ pictureUrls: normalized.pictureUrls,
+ pictureUrl: normalized.pictureUrl,
+ });
+ } catch (e) {
+ logger.error('[CoffeeController.getPictures] error', { msg: e.message });
+ return res.status(500).json({ error: 'Failed to fetch coffee pictures' });
+ }
+};
+
+exports.editPictures = async (req, res) => {
+ let uploadedKeys = [];
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id <= 0) {
+ return res.status(400).json({ error: 'Invalid id' });
+ }
+
+ const parseBoolean = (value, fallback = false) => {
+ if (value === undefined || value === null || value === '') return fallback;
+ if (typeof value === 'boolean') return value;
+ const normalized = String(value).trim().toLowerCase();
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
+ return fallback;
+ };
+
+ const parseIds = (value) => {
+ if (value === undefined || value === null || value === '') return [];
+ if (Array.isArray(value)) return value.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0);
+
+ const text = String(value).trim();
+ if (!text) return [];
+
+ try {
+ const parsed = JSON.parse(text);
+ if (Array.isArray(parsed)) {
+ return parsed.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0);
+ }
+ } catch (_) {
+ // Fall through to CSV parsing.
+ }
+
+ return text
+ .split(',')
+ .map((x) => Number(String(x).trim()))
+ .filter((x) => Number.isFinite(x) && x > 0);
+ };
+
+ const replaceAll = parseBoolean(req.body?.replaceAll, false);
+ const removePictureIds = parseIds(req.body?.removePictureIds);
+
+ const incomingFiles = normalizePictureFiles(req);
+ if (incomingFiles.length > 10) {
+ return res.status(400).json({ error: 'Maximum 10 images allowed per edit request' });
+ }
+
+ const images = [];
+ if (incomingFiles.length) {
+ const allowedMime = ['image/jpeg','image/png','image/webp'];
+ const maxBytes = 10 * 1024 * 1024; // 10MB
+
+ const s3 = new S3Client({
+ region: process.env.EXOSCALE_REGION,
+ endpoint: process.env.EXOSCALE_ENDPOINT,
+ credentials: {
+ accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
+ secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
+ },
+ });
+
+ for (let i = 0; i < incomingFiles.length; i += 1) {
+ const file = incomingFiles[i];
+ if (!allowedMime.includes(file.mimetype)) {
+ return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP' });
+ }
+ if (file.size > maxBytes) {
+ return res.status(400).json({ error: 'Image exceeds 10MB limit' });
+ }
+
+ const key = `coffee/products/${Date.now()}_${i}_${file.originalname}`;
+ await s3.send(new PutObjectCommand({
+ Bucket: process.env.EXOSCALE_BUCKET,
+ Key: key,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ ACL: 'public-read'
+ }));
+
+ uploadedKeys.push(key);
+ images.push({
+ object_storage_id: key,
+ original_filename: file.originalname,
+ });
+ }
+ }
+
+ const result = await CoffeeService.editPictures(id, {
+ replaceAll,
+ removePictureIds,
+ images,
+ });
+
+ if (!result || !result.updated) {
+ return res.status(404).json({ error: 'Not found' });
+ }
+
+ const deletedKeys = (result.deleted || [])
+ .map((x) => x?.object_storage_id)
+ .filter(Boolean);
+ if (deletedKeys.length) {
+ try {
+ const s3 = new S3Client({
+ region: process.env.EXOSCALE_REGION,
+ endpoint: process.env.EXOSCALE_ENDPOINT,
+ credentials: {
+ accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
+ secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
+ },
+ });
+
+ for (const key of deletedKeys) {
+ await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key }));
+ }
+ } catch (cleanupErr) {
+ logger.warn('[CoffeeController.editPictures] cleanup failed', { msg: cleanupErr.message });
+ }
+ }
+
+ return res.json(toCoffeeResponse(result.updated));
+ } catch (e) {
+ logger.error('[CoffeeController.editPictures] error', { msg: e.message });
+
+ try {
+ if (uploadedKeys.length) {
+ const s3 = new S3Client({
+ region: process.env.EXOSCALE_REGION,
+ endpoint: process.env.EXOSCALE_ENDPOINT,
+ credentials: {
+ accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
+ secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
+ },
+ });
+
+ for (const key of uploadedKeys) {
+ await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key }));
+ }
+ }
+ } catch (cleanupErr) {
+ logger.warn('[CoffeeController.editPictures] rollback cleanup failed', { msg: cleanupErr.message });
+ }
+
+ return res.status(500).json({ error: 'Failed to edit coffee pictures' });
+ }
+};
diff --git a/controller/admin/CompanySettingsController.js b/controller/admin/CompanySettingsController.js
index dd92ccb..32d03a7 100644
--- a/controller/admin/CompanySettingsController.js
+++ b/controller/admin/CompanySettingsController.js
@@ -1,111 +1,62 @@
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
const { logger } = require('../../middleware/logger');
-const crypto = require('crypto');
const repo = new CompanySettingsRepository();
+const ALLOWED_LOGO_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']);
+const MAX_LOGO_BYTES = 1024 * 1024;
-class CompanySettingsController {
- static _normalizeBase64ImageInput(value) {
- if (value === undefined) return undefined;
- if (value === null) return null;
-
- const stripDataUri = (str) => {
- const s = String(str || '').trim();
- if (!s) return '';
- const m = s.match(/^data:([^;]+);base64,(.*)$/i);
- return m ? (m[2] || '').trim() : s;
- };
-
- if (typeof value === 'string') {
- // If base64 came via urlencoded forms, '+' often becomes ' ' after decoding.
- // Restoring spaces back to '+' makes the payload usable again.
- let normalized = stripDataUri(value);
- if (normalized.includes(' ') && !normalized.includes('+')) {
- normalized = normalized.replace(/ /g, '+');
- }
- normalized = normalized.replace(/\s+/g, '');
- return normalized || null;
- }
-
- if (typeof value === 'object') {
- // Common Node.js JSON shape for Buffers: { type: 'Buffer', data: [..bytes..] }
- if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
- try {
- const buf = Buffer.from(value.data);
- const asBase64 = buf.toString('base64');
- return asBase64 || null;
- } catch (_) {
- return undefined;
- }
- }
-
- // Common frontend shapes:
- // { kind: 'base64', base64: '...' }
- // { kind: 'base64', data: '...' }
- // { dataUrl: 'data:image/png;base64,...' }
- // { value: '...' }
- const candidates = [
- value.base64,
- value.data,
- value.value,
- value.content,
- value.full,
- value.raw,
- value.payload,
- value.src,
- value.b64,
- value.base64String,
- value.dataUrl,
- value.dataURL,
- value.data_uri,
- value.dataURI,
- value.uri,
- ];
- for (const c of candidates) {
- if (typeof c === 'string' && c.trim()) {
- let normalized = stripDataUri(c);
- if (normalized.includes(' ') && !normalized.includes('+')) {
- normalized = normalized.replace(/ /g, '+');
- }
- normalized = normalized.replace(/\s+/g, '');
- return normalized || null;
- }
- }
-
- // Fallback: scan shallow object for a base64-ish string
- const looksLikeImageBase64 = (str) => {
- const s = String(str || '').trim();
- return s.startsWith('data:image/') || s.startsWith('iVBORw0KGgo') || s.startsWith('/9j/') || s.startsWith('R0lGOD');
- };
-
- for (const v of Object.values(value)) {
- if (typeof v === 'string' && looksLikeImageBase64(v)) {
- let normalized = stripDataUri(v);
- if (normalized.includes(' ') && !normalized.includes('+')) {
- normalized = normalized.replace(/ /g, '+');
- }
- normalized = normalized.replace(/\s+/g, '');
- return normalized || null;
- }
- if (v && typeof v === 'object') {
- for (const vv of Object.values(v)) {
- if (typeof vv === 'string' && looksLikeImageBase64(vv)) {
- let normalized = stripDataUri(vv);
- if (normalized.includes(' ') && !normalized.includes('+')) {
- normalized = normalized.replace(/ /g, '+');
- }
- normalized = normalized.replace(/\s+/g, '');
- return normalized || null;
- }
- }
- }
- }
- }
-
- // Unknown shape; ignore instead of persisting garbage
- return undefined;
+function normalizeBase64ImageInput(rawValue, rawMimeType) {
+ if (rawValue === undefined && rawMimeType === undefined) {
+ return { provided: false };
}
+ if (rawValue === null || rawValue === '') {
+ return { provided: true, base64: null, mimeType: null };
+ }
+
+ if (typeof rawValue !== 'string') {
+ throw new Error('company_logo_base64 must be a string or null');
+ }
+
+ let value = rawValue.trim();
+ let mimeType = typeof rawMimeType === 'string' ? rawMimeType.trim() : '';
+
+ if (!value) {
+ return { provided: true, base64: null, mimeType: null };
+ }
+
+ const dataUriMatch = value.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\r\n]+)$/);
+ if (dataUriMatch) {
+ mimeType = dataUriMatch[1];
+ value = dataUriMatch[2];
+ }
+
+ const compactBase64 = value.replace(/\s+/g, '');
+ if (!mimeType) {
+ throw new Error('company_logo_mime_type is required when company_logo_base64 is provided');
+ }
+
+ if (!ALLOWED_LOGO_MIME_TYPES.has(mimeType)) {
+ throw new Error(`Unsupported company logo type '${mimeType}'`);
+ }
+
+ if (!/^[A-Za-z0-9+/=]+$/.test(compactBase64)) {
+ throw new Error('company_logo_base64 is not valid base64');
+ }
+
+ const bytes = Buffer.from(compactBase64, 'base64').length;
+ if (bytes > MAX_LOGO_BYTES) {
+ throw new Error('Company logo exceeds 1 MB');
+ }
+
+ return {
+ provided: true,
+ base64: compactBase64,
+ mimeType,
+ };
+}
+
+class CompanySettingsController {
static async get(req, res) {
try {
const settings = await repo.get();
@@ -114,8 +65,8 @@ class CompanySettingsController {
company_street: '',
company_postal_city: '',
company_country: '',
- qr_code_60_base64: null,
- qr_code_120_base64: null,
+ company_logo_base64: null,
+ company_logo_mime_type: null,
});
} catch (err) {
return res.status(500).json({ message: 'Failed to load company settings' });
@@ -146,59 +97,21 @@ class CompanySettingsController {
});
}
- const summarizeValue = (val) => {
- const t = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val;
- const summary = { type: t };
- if (t === 'string') {
- const s = String(val);
- summary.length = s.length;
- summary.hasDataUriPrefix = s.trim().toLowerCase().startsWith('data:image/');
- summary.hasWhitespace = /\s/.test(s);
- summary.startsWithPngSig = s.trim().startsWith('iVBORw0KGgo');
- } else if (t === 'array') {
- summary.length = val.length;
- } else if (t === 'object' && val) {
- summary.keys = Object.keys(val).slice(0, 30);
- if (val.type === 'Buffer' && Array.isArray(val.data)) {
- summary.bufferLike = true;
- summary.dataLength = val.data.length;
- }
- }
- return summary;
- };
-
- const hash12 = (s) => {
- try {
- if (typeof s !== 'string' || !s) return null;
- return crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
- } catch {
- return null;
- }
- };
-
- // Log request shape (not the base64 itself)
- const incoming60Raw = body.qr_code_60_base64 ?? body.qrCode60Base64;
- const incoming120Raw = body.qr_code_120_base64 ?? body.qrCode120Base64;
- if (incoming60Raw !== undefined || incoming120Raw !== undefined) {
- logger.info('companySettings:update:incoming_qr', {
- requestId,
- contentType: req.get('content-type') || null,
- contentLength,
- bodyKeys: Object.keys(body).slice(0, 50),
- qr60: summarizeValue(incoming60Raw),
- qr120: summarizeValue(incoming120Raw),
- });
- }
-
// Accept both snake_case and camelCase
const payload = {
company_name: body.company_name ?? body.companyName,
company_street: body.company_street ?? body.companyStreet,
company_postal_city: body.company_postal_city ?? body.companyPostalCity,
company_country: body.company_country ?? body.companyCountry,
- qr_code_60_base64: CompanySettingsController._normalizeBase64ImageInput(body.qr_code_60_base64 ?? body.qrCode60Base64),
- qr_code_120_base64: CompanySettingsController._normalizeBase64ImageInput(body.qr_code_120_base64 ?? body.qrCode120Base64),
};
+ const normalizedLogo = normalizeBase64ImageInput(
+ body.company_logo_base64 ?? body.companyLogoBase64,
+ body.company_logo_mime_type ?? body.companyLogoMimeType,
+ );
+ if (normalizedLogo.provided) {
+ payload.company_logo_base64 = normalizedLogo.base64;
+ payload.company_logo_mime_type = normalizedLogo.mimeType;
+ }
// Only forward keys that were actually provided (so we don't wipe values on partial updates)
const provided = {};
@@ -206,52 +119,8 @@ class CompanySettingsController {
if (value !== undefined) provided[key] = value;
}
- // Debug without leaking base64
- if (incoming60Raw !== undefined && provided.qr_code_60_base64 === undefined) {
- logger.warn('companySettings:update:qr60_ignored', {
- requestId,
- incoming: summarizeValue(incoming60Raw),
- reason: 'normalize_returned_undefined_or_unrecognized_shape'
- });
- }
- if (incoming120Raw !== undefined && provided.qr_code_120_base64 === undefined) {
- logger.warn('companySettings:update:qr120_ignored', {
- requestId,
- incoming: summarizeValue(incoming120Raw),
- reason: 'normalize_returned_undefined_or_unrecognized_shape'
- });
- }
-
- if (provided.qr_code_60_base64 !== undefined || provided.qr_code_120_base64 !== undefined) {
- const len60 = typeof provided.qr_code_60_base64 === 'string' ? provided.qr_code_60_base64.length : null;
- const len120 = typeof provided.qr_code_120_base64 === 'string' ? provided.qr_code_120_base64.length : null;
- logger.info('companySettings:update:qr_normalized', {
- requestId,
- has60: provided.qr_code_60_base64 !== undefined,
- type60: provided.qr_code_60_base64 === null ? 'null' : typeof provided.qr_code_60_base64,
- len60,
- sha60: typeof provided.qr_code_60_base64 === 'string' ? hash12(provided.qr_code_60_base64) : null,
- has120: provided.qr_code_120_base64 !== undefined,
- type120: provided.qr_code_120_base64 === null ? 'null' : typeof provided.qr_code_120_base64,
- len120,
- sha120: typeof provided.qr_code_120_base64 === 'string' ? hash12(provided.qr_code_120_base64) : null,
- });
- }
-
const updated = await repo.update(provided);
- if (updated && (provided.qr_code_60_base64 !== undefined || provided.qr_code_120_base64 !== undefined)) {
- const storedLen60 = typeof updated.qr_code_60_base64 === 'string' ? updated.qr_code_60_base64.length : null;
- const storedLen120 = typeof updated.qr_code_120_base64 === 'string' ? updated.qr_code_120_base64.length : null;
- logger.info('companySettings:update:qr_stored', {
- requestId,
- storedLen60,
- storedSha60: typeof updated.qr_code_60_base64 === 'string' ? hash12(updated.qr_code_60_base64) : null,
- storedLen120,
- storedSha120: typeof updated.qr_code_120_base64 === 'string' ? hash12(updated.qr_code_120_base64) : null,
- });
- }
-
return res.json(updated);
} catch (err) {
logger.error('companySettings:update:failed', {
diff --git a/controller/admin/I18nPreferencesController.js b/controller/admin/I18nPreferencesController.js
new file mode 100644
index 0000000..8ccc1f5
--- /dev/null
+++ b/controller/admin/I18nPreferencesController.js
@@ -0,0 +1,308 @@
+const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository');
+const { logger } = require('../../middleware/logger');
+
+const repo = new I18nPreferencesRepository();
+
+class I18nPreferencesController {
+ static _normalizeLanguageCode(value) {
+ const raw = String(value == null ? '' : value).trim();
+ if (!raw) return '';
+
+ const normalized = raw.replace('_', '-');
+ const pattern = /^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i;
+ if (!pattern.test(normalized)) return null;
+ return normalized.toLowerCase();
+ }
+
+ static _normalizeStringArray(values) {
+ if (!Array.isArray(values)) return [];
+ const normalized = values
+ .map((v) => (v == null ? '' : String(v).trim()))
+ .filter(Boolean);
+ return [...new Set(normalized)];
+ }
+
+ static _normalizeCategories(categories) {
+ if (!Array.isArray(categories)) return [];
+
+ return categories
+ .map((item, idx) => {
+ const id = String(item?.id ?? '').trim() || `category_${idx + 1}`;
+ const label = String(item?.label ?? id).trim() || id;
+ const namespaces = I18nPreferencesController._normalizeStringArray(item?.namespaces);
+ const isCustom = Boolean(item?.isCustom);
+ return { id, label, namespaces, isCustom };
+ });
+ }
+
+ static _buildResponse(preferences) {
+ return {
+ ok: true,
+ preferences,
+ categories: preferences.categories,
+ globalKeys: preferences.globalKeys,
+ };
+ }
+
+ static _normalizeLanguagePayload(body) {
+ const source = (body?.language && typeof body.language === 'object') ? body.language : body;
+
+ const rawLanguageCode = source?.languageCode ?? source?.code ?? source?.lang;
+ const hasAnyLanguageField = rawLanguageCode !== undefined
+ || source?.label !== undefined
+ || source?.name !== undefined
+ || source?.isEnabled !== undefined
+ || source?.isCustom !== undefined;
+
+ if (!hasAnyLanguageField) return null;
+
+ const languageCode = I18nPreferencesController._normalizeLanguageCode(rawLanguageCode);
+ if (languageCode === null || !languageCode) {
+ throw new Error('Invalid language payload: languageCode is required');
+ }
+
+ const label = String(source?.label ?? source?.name ?? languageCode.toUpperCase()).trim();
+ const toBool = (v, fallback) => {
+ if (v === undefined || v === null) return fallback;
+ if (typeof v === 'boolean') return v;
+ const n = String(v).trim().toLowerCase();
+ if (['1', 'true', 'yes', 'on'].includes(n)) return true;
+ if (['0', 'false', 'no', 'off'].includes(n)) return false;
+ return fallback;
+ };
+
+ return {
+ languageCode,
+ label,
+ isEnabled: toBool(source?.isEnabled, true),
+ isCustom: toBool(source?.isCustom, true),
+ };
+ }
+
+ static _normalizeTranslationsPayload(body, fallbackLanguageCode) {
+ const raw = body?.translations ?? body?.translationOverrides ?? body?.entries;
+ if (raw == null) return [];
+
+ const normalized = [];
+
+ if (Array.isArray(raw)) {
+ for (const item of raw) {
+ const languageCode = I18nPreferencesController._normalizeLanguageCode(
+ item?.languageCode ?? item?.lang ?? fallbackLanguageCode
+ );
+ const namespace = String(item?.namespace ?? '').trim();
+ const key = String(item?.key ?? item?.t_key ?? '').trim();
+ const value = item?.value ?? item?.t_value;
+ if (!languageCode || !namespace || !key || value === undefined) continue;
+ normalized.push({
+ languageCode,
+ namespace,
+ key,
+ value: String(value),
+ isCustom: item?.isCustom,
+ });
+ }
+ return normalized;
+ }
+
+ if (typeof raw === 'object') {
+ for (const [namespace, kv] of Object.entries(raw)) {
+ if (!kv || typeof kv !== 'object') continue;
+ for (const [key, value] of Object.entries(kv)) {
+ const languageCode = I18nPreferencesController._normalizeLanguageCode(fallbackLanguageCode);
+ if (!languageCode || !namespace || !key || value === undefined) continue;
+ normalized.push({
+ languageCode,
+ namespace: String(namespace).trim(),
+ key: String(key).trim(),
+ value: String(value),
+ isCustom: true,
+ });
+ }
+ }
+ }
+
+ return normalized;
+ }
+
+ static _extractPreferencesPayload(body) {
+ const source = (body?.preferences && typeof body.preferences === 'object')
+ ? body.preferences
+ : body;
+
+ const hasCategories = Object.prototype.hasOwnProperty.call(source || {}, 'categories');
+ const hasGlobalKeys = Object.prototype.hasOwnProperty.call(source || {}, 'globalKeys');
+
+ return {
+ categories: hasCategories ? I18nPreferencesController._normalizeCategories(source?.categories) : undefined,
+ globalKeys: hasGlobalKeys ? I18nPreferencesController._normalizeStringArray(source?.globalKeys) : undefined,
+ };
+ }
+
+ static async _upsertBundle(req, res) {
+ try {
+ const userId = req.user?.userId ?? req.user?.id ?? null;
+ const language = I18nPreferencesController._normalizeLanguagePayload(req.body || {});
+ const fallbackLanguageCode = language?.languageCode
+ || I18nPreferencesController._normalizeLanguageCode(req.body?.languageCode ?? req.body?.lang);
+ const translations = I18nPreferencesController._normalizeTranslationsPayload(req.body || {}, fallbackLanguageCode);
+ const extracted = I18nPreferencesController._extractPreferencesPayload(req.body || {});
+
+ const result = await repo.upsertBundle({
+ categories: extracted.categories,
+ globalKeys: extracted.globalKeys,
+ language,
+ translations,
+ updatedByUserId: userId,
+ });
+
+ return res.status(200).json({
+ ...I18nPreferencesController._buildResponse(result.preferences),
+ language: result.language || null,
+ translationsUpserted: result.translationsUpserted || 0,
+ });
+ } catch (error) {
+ if (String(error?.message || '').includes('Invalid language payload')) {
+ return res.status(400).json({ ok: false, message: error.message });
+ }
+ logger.error('i18nPreferences:upsertBundle:failed', { error: error?.message });
+ return res.status(500).json({ ok: false, message: 'Failed to upsert i18n language data' });
+ }
+ }
+
+ static async get(req, res) {
+ try {
+ const preferences = await repo.get();
+ return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
+ } catch (error) {
+ logger.error('i18nPreferences:get:failed', { error: error?.message });
+ return res.status(500).json({ ok: false, message: 'Failed to load i18n preferences' });
+ }
+ }
+
+ static async post(req, res) {
+ return I18nPreferencesController._upsertBundle(req, res);
+ }
+
+ static async put(req, res) {
+ return I18nPreferencesController._upsertBundle(req, res);
+ }
+
+ static async delete(req, res) {
+ try {
+ const requestedLanguageCode = I18nPreferencesController._normalizeLanguageCode(req.query?.languageCode);
+ if (requestedLanguageCode === null) {
+ return res.status(400).json({ ok: false, message: 'Invalid languageCode query parameter' });
+ }
+
+ if (requestedLanguageCode) {
+ const result = await repo.deleteLanguageEntries(
+ requestedLanguageCode,
+ req.user?.userId ?? req.user?.id ?? null
+ );
+
+ return res.status(200).json({
+ ok: true,
+ languageCode: requestedLanguageCode,
+ deletedRows: result.deletedRows,
+ touchedTables: result.touchedTables,
+ });
+ }
+
+ const hasBodyReplacement = req.body && (
+ Object.prototype.hasOwnProperty.call(req.body, 'categories') ||
+ Object.prototype.hasOwnProperty.call(req.body, 'globalKeys')
+ );
+
+ const preferences = hasBodyReplacement
+ ? await repo.upsert({
+ categories: I18nPreferencesController._normalizeCategories(req.body?.categories),
+ globalKeys: I18nPreferencesController._normalizeStringArray(req.body?.globalKeys),
+ updatedByUserId: req.user?.userId ?? req.user?.id ?? null,
+ })
+ : await repo.clear(req.user?.userId ?? req.user?.id ?? null);
+
+ return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
+ } catch (error) {
+ logger.error('i18nPreferences:delete:failed', { error: error?.message });
+ return res.status(500).json({ ok: false, message: 'Failed to delete i18n preferences' });
+ }
+ }
+
+ static async getTranslations(req, res) {
+ try {
+ const languageCodeRaw = req.query?.languageCode ?? req.query?.lang;
+ const normalizedLanguageCode = languageCodeRaw
+ ? I18nPreferencesController._normalizeLanguageCode(languageCodeRaw)
+ : '';
+ if (normalizedLanguageCode === null) {
+ return res.status(400).json({ ok: false, message: 'Invalid languageCode query parameter' });
+ }
+
+ const namespace = String(req.query?.namespace ?? '').trim() || undefined;
+ const translations = await repo.listTranslations({
+ languageCode: normalizedLanguageCode || undefined,
+ namespace,
+ });
+
+ return res.status(200).json({ ok: true, translations });
+ } catch (error) {
+ logger.error('i18nTranslations:get:failed', { error: error?.message });
+ return res.status(500).json({ ok: false, message: 'Failed to load translations' });
+ }
+ }
+
+ static async upsertTranslations(req, res) {
+ try {
+ const language = I18nPreferencesController._normalizeLanguagePayload(req.body || {});
+ const fallbackLanguageCode = language?.languageCode
+ || I18nPreferencesController._normalizeLanguageCode(req.body?.languageCode ?? req.body?.lang);
+ const translations = I18nPreferencesController._normalizeTranslationsPayload(req.body || {}, fallbackLanguageCode);
+
+ if (!translations.length) {
+ return res.status(400).json({ ok: false, message: 'No valid translations provided' });
+ }
+
+ const userId = req.user?.userId ?? req.user?.id ?? null;
+ const result = await repo.upsertTranslations({ translations, updatedByUserId: userId });
+
+ if (language) {
+ await repo.upsertBundle({ language, updatedByUserId: userId });
+ }
+
+ return res.status(200).json({
+ ok: true,
+ translationsUpserted: result.upsertedCount || 0,
+ });
+ } catch (error) {
+ if (String(error?.message || '').includes('Invalid language payload')) {
+ return res.status(400).json({ ok: false, message: error.message });
+ }
+ logger.error('i18nTranslations:upsert:failed', { error: error?.message });
+ return res.status(500).json({ ok: false, message: 'Failed to upsert translations' });
+ }
+ }
+
+ static async scan(req, res) {
+ try {
+ const languageCodeRaw = req.query?.languageCode ?? req.query?.lang ?? req.body?.languageCode ?? req.body?.lang;
+ const normalizedLanguageCode = languageCodeRaw
+ ? I18nPreferencesController._normalizeLanguageCode(languageCodeRaw)
+ : '';
+ if (normalizedLanguageCode === null) {
+ return res.status(400).json({ ok: false, message: 'Invalid languageCode parameter' });
+ }
+
+ const summary = await repo.getScanSummary({ languageCode: normalizedLanguageCode || undefined });
+ return res.status(200).json({
+ ok: true,
+ ...summary,
+ });
+ } catch (error) {
+ logger.error('i18nScan:failed', { error: error?.message });
+ return res.status(500).json({ ok: false, message: 'Failed to scan i18n data' });
+ }
+ }
+}
+
+module.exports = I18nPreferencesController;
diff --git a/controller/admin/MailTemplatesController.js b/controller/admin/MailTemplatesController.js
new file mode 100644
index 0000000..b40e5dc
--- /dev/null
+++ b/controller/admin/MailTemplatesController.js
@@ -0,0 +1,99 @@
+const MailTemplateService = require('../../services/template/MailTemplateService');
+
+class MailTemplatesController {
+ static _currentUserId(req) {
+ return req.user?.userId ?? req.user?.id ?? null;
+ }
+
+ static async list(req, res) {
+ try {
+ const data = await MailTemplateService.list(req.query || {});
+ return res.json({ success: true, data });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to list mail templates' });
+ }
+ }
+
+ static async getById(req, res) {
+ try {
+ const data = await MailTemplateService.getById(req.params.id);
+ if (!data) {
+ return res.status(404).json({ success: false, message: 'Mail template not found' });
+ }
+ return res.json({ success: true, data });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to load mail template' });
+ }
+ }
+
+ static async create(req, res) {
+ try {
+ const data = await MailTemplateService.create(req.body || {}, MailTemplatesController._currentUserId(req));
+ return res.status(201).json({ success: true, data });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to create mail template' });
+ }
+ }
+
+ static async update(req, res) {
+ try {
+ const data = await MailTemplateService.update(req.params.id, req.body || {}, MailTemplatesController._currentUserId(req));
+ if (!data) {
+ return res.status(404).json({ success: false, message: 'Mail template not found' });
+ }
+ return res.json({ success: true, data });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to update mail template' });
+ }
+ }
+
+ static async activate(req, res) {
+ try {
+ const data = await MailTemplateService.activate(req.params.id, MailTemplatesController._currentUserId(req));
+ if (!data) {
+ return res.status(404).json({ success: false, message: 'Mail template not found' });
+ }
+ return res.json({ success: true, data });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to activate mail template' });
+ }
+ }
+
+ static async archive(req, res) {
+ try {
+ const data = await MailTemplateService.archive(req.params.id, MailTemplatesController._currentUserId(req));
+ if (!data) {
+ return res.status(404).json({ success: false, message: 'Mail template not found' });
+ }
+ return res.json({ success: true, data });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to archive mail template' });
+ }
+ }
+
+ static async unarchive(req, res) {
+ try {
+ const data = await MailTemplateService.unarchive(req.params.id, MailTemplatesController._currentUserId(req));
+ if (!data) {
+ return res.status(404).json({ success: false, message: 'Mail template not found' });
+ }
+ return res.json({ success: true, data });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to unarchive mail template' });
+ }
+ }
+
+ static async remove(req, res) {
+ try {
+ const deleted = await MailTemplateService.remove(req.params.id);
+ if (!deleted) {
+ return res.status(404).json({ success: false, message: 'Mail template not found' });
+ }
+ return res.json({ success: true });
+ } catch (error) {
+ return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to delete mail template' });
+ }
+ }
+}
+
+module.exports = MailTemplatesController;
diff --git a/controller/documentTemplate/DocumentTemplateController.js b/controller/documentTemplate/DocumentTemplateController.js
index de08451..c7d9965 100644
--- a/controller/documentTemplate/DocumentTemplateController.js
+++ b/controller/documentTemplate/DocumentTemplateController.js
@@ -36,8 +36,8 @@ function saveDebugFile(filename, data, encoding = 'utf8') {
// Helper to remove/empty placeholders except allow-list
// Updated: match any content inside {{ ... }} (not only \w+) so placeholders like {{company.name}} are sanitized.
-// allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'profitplanetSignature').
-function sanitizePlaceholders(html, allowList = ['currentDate','companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']) {
+// allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'companyLogo', 'profitplanetSignature').
+function sanitizePlaceholders(html, allowList = ['currentDate','companyStamp','companyStampInline','companyStampSmall','companyLogo','profitplanetSignature']) {
if (!html || typeof html !== 'string') return html;
const allowSet = new Set((allowList || []).map(s => String(s).trim()).filter(Boolean));
@@ -412,7 +412,7 @@ async function renderLatestActiveContractHtmlForUser({ targetUserId, userType, c
});
html = html.replace(/{{\s*signatureImage\s*}}/g, 'Your signature will appear here');
- html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']);
+ html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','companyLogo','profitplanetSignature']);
const reqForStamp = (req && req.user) ? req : { user: { id: targetUserId, user_type: userType } };
try { html = await applyCompanyStampPlaceholders(html, reqForStamp); } catch (e) {}
@@ -2101,7 +2101,7 @@ exports.downloadPdf = async (req, res) => {
// SANITIZE: remove variables for downloaded PDF.
// Allow only stamp/signature placeholders to remain so images can still be injected.
- html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']);
+ html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','companyLogo','profitplanetSignature']);
// Do NOT keep currentDate for download (user requested variables emptied)
// Apply company stamp & profitplanet signature (these placeholders were preserved above)
diff --git a/controller/invoice/InvoiceController.js b/controller/invoice/InvoiceController.js
index b74c99b..6c53a7d 100644
--- a/controller/invoice/InvoiceController.js
+++ b/controller/invoice/InvoiceController.js
@@ -30,6 +30,16 @@ module.exports = {
}
},
+ async revenueSummary(req, res) {
+ try {
+ const data = await service.getRevenueSummary();
+ return res.json({ success: true, data });
+ } catch (e) {
+ console.error('[INVOICE REVENUE SUMMARY]', e);
+ return res.status(403).json({ success: false, message: e.message });
+ }
+ },
+
async pay(req, res) {
try {
const data = await service.markPaid(req.params.id, {
@@ -96,4 +106,34 @@ module.exports = {
return res.status(400).json({ success: false, message: e.message });
}
},
+
+ async adminCreate(req, res) {
+ try {
+ const fields = {
+ buyer_name: req.body.buyer_name,
+ buyer_email: req.body.buyer_email,
+ buyer_street: req.body.buyer_street,
+ buyer_postal_code: req.body.buyer_postal_code,
+ buyer_city: req.body.buyer_city,
+ buyer_country: req.body.buyer_country,
+ currency: req.body.currency || 'EUR',
+ total_net: req.body.total_net,
+ total_tax: req.body.total_tax,
+ total_gross: req.body.total_gross,
+ vat_rate: req.body.vat_rate,
+ status: req.body.status || 'issued',
+ issued_at: req.body.issued_at,
+ due_at: req.body.due_at,
+ };
+ if (!fields.total_gross || isNaN(Number(fields.total_gross))) {
+ return res.status(400).json({ success: false, message: 'total_gross is required and must be a number' });
+ }
+ const pdfBuffer = req.file ? req.file.buffer : null;
+ const data = await service.adminCreateManual(fields, pdfBuffer);
+ return res.status(201).json({ success: true, data });
+ } catch (e) {
+ console.error('[INVOICE ADMIN CREATE]', e);
+ return res.status(400).json({ success: false, message: e.message });
+ }
+ },
};
diff --git a/controller/login/LoginController.js b/controller/login/LoginController.js
index f65c84d..e923dbb 100644
--- a/controller/login/LoginController.js
+++ b/controller/login/LoginController.js
@@ -261,6 +261,56 @@ class LoginController {
return res.status(500).json({ success: false, message: 'Internal server error' });
}
}
+
+ static async validate(req, res) {
+ try {
+ const refreshToken = req.cookies?.refreshToken;
+ if (!refreshToken) {
+ return res.status(401).json({ ok: false, message: 'No refresh token provided' });
+ }
+
+ const result = await LoginService.validateByRefreshToken(refreshToken);
+ const user = result?.user || {};
+
+ const role = String(user.role || '').toLowerCase();
+ const userRoles = Array.isArray(user.roles)
+ ? user.roles.map((r) => String(r).toLowerCase())
+ : [];
+ const mergedRoles = [...new Set([role, ...userRoles].filter(Boolean))];
+
+ const isAdmin = Boolean(
+ user.isAdmin ||
+ role === 'admin' ||
+ role === 'super_admin' ||
+ role === 'superadmin' ||
+ mergedRoles.includes('admin') ||
+ mergedRoles.includes('super_admin') ||
+ mergedRoles.includes('superadmin')
+ );
+
+ return res.status(200).json({
+ ok: true,
+ user: {
+ id: String(user.id ?? ''),
+ role: user.role || null,
+ isAdmin,
+ roles: mergedRoles,
+ },
+ isAdmin,
+ role: user.role || null,
+ roles: mergedRoles,
+ });
+ } catch (error) {
+ if (error.status) {
+ return res.status(error.status).json({ ok: false, message: error.message });
+ }
+ logger.error('authValidate:error', {
+ message: error?.message,
+ stack: error?.stack,
+ });
+ return res.status(500).json({ ok: false, message: 'Internal server error' });
+ }
+ }
}
module.exports = LoginController;
\ No newline at end of file
diff --git a/database/createDb.js b/database/createDb.js
index d662f78..827c5d7 100644
--- a/database/createDb.js
+++ b/database/createDb.js
@@ -94,6 +94,13 @@ async function addColumnIfMissing(conn, table, column, ddlFragment /* includes t
return true;
}
+async function dropColumnIfExists(conn, table, column) {
+ if (!(await columnExists(conn, table, column))) return false;
+ await conn.query(`ALTER TABLE \`${table}\` DROP COLUMN \`${column}\``);
+ console.log(`🗑️ Dropped column ${table}.${column}`);
+ return true;
+}
+
async function constraintExists(conn, table, constraintName) {
const [rows] = await conn.query(
`SELECT 1
@@ -451,6 +458,30 @@ const createDatabase = async () => {
`);
console.log('✅ Document templates table created/verified');
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS mail_templates (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ template_type VARCHAR(100) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ subject VARCHAR(255) NULL,
+ html_content LONGTEXT NOT NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 0,
+ is_archived TINYINT(1) NOT NULL DEFAULT 0,
+ archived_at TIMESTAMP NULL,
+ created_by INT NULL,
+ updated_by INT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_mail_templates_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT fk_mail_templates_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
+ INDEX idx_mail_templates_type (template_type),
+ INDEX idx_mail_templates_active (is_active),
+ INDEX idx_mail_templates_archived (is_archived),
+ INDEX idx_mail_templates_type_active (template_type, is_active)
+ );
+ `);
+ console.log('✅ Mail templates table created/verified');
+
// Ensure enum includes 'abo' on existing schemas
try {
await connection.query(`
@@ -812,9 +843,9 @@ const createDatabase = async () => {
company_name VARCHAR(200) NOT NULL DEFAULT 'ProfitPlanet GmbH',
company_street VARCHAR(255) NOT NULL DEFAULT '',
company_postal_city VARCHAR(255) NOT NULL DEFAULT '',
- company_country VARCHAR(100) NOT NULL DEFAULT 'Germany',
- qr_code_60_base64 LONGTEXT NULL,
- qr_code_120_base64 LONGTEXT NULL,
+ company_country VARCHAR(100) NOT NULL DEFAULT 'Austria',
+ company_logo_base64 MEDIUMTEXT NULL,
+ company_logo_mime_type VARCHAR(100) NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CHECK (id = 1)
);
@@ -823,11 +854,89 @@ const createDatabase = async () => {
await connection.query(`
INSERT IGNORE INTO company_settings (id) VALUES (1);
`);
+ await addColumnIfMissing(connection, 'company_settings', 'company_logo_base64', `MEDIUMTEXT NULL AFTER company_country`);
+ await addColumnIfMissing(connection, 'company_settings', 'company_logo_mime_type', `VARCHAR(100) NULL AFTER company_logo_base64`);
console.log('✅ Company settings table created/verified');
- // Backward-compatible: add QR code columns if missing
- await addColumnIfMissing(connection, 'company_settings', 'qr_code_60_base64', 'LONGTEXT NULL');
- await addColumnIfMissing(connection, 'company_settings', 'qr_code_120_base64', 'LONGTEXT NULL');
+ // Cleanup legacy invoice QR columns, which are no longer used.
+ await dropColumnIfExists(connection, 'company_settings', 'qr_code_60_base64');
+ await dropColumnIfExists(connection, 'company_settings', 'qr_code_120_base64');
+
+ // --- I18n Preferences (single-row, admin language-management settings) ---
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS i18n_preferences (
+ id TINYINT PRIMARY KEY DEFAULT 1,
+ categories_json JSON NULL,
+ global_keys_json JSON NULL,
+ updated_by_user_id INT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT chk_i18n_preferences_singleton CHECK (id = 1),
+ FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
+ );
+ `);
+ await connection.query(`
+ INSERT IGNORE INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
+ VALUES (1, JSON_ARRAY(), JSON_ARRAY(), NULL);
+ `);
+ console.log('✅ i18n preferences table created/verified');
+
+ // Backward-compatible for older schemas
+ await addColumnIfMissing(connection, 'i18n_preferences', 'categories_json', 'JSON NULL');
+ await addColumnIfMissing(connection, 'i18n_preferences', 'global_keys_json', 'JSON NULL');
+ await addColumnIfMissing(connection, 'i18n_preferences', 'updated_by_user_id', 'INT NULL');
+
+ await addForeignKeyIfMissing(
+ connection,
+ 'i18n_preferences',
+ 'i18n_preferences_ibfk_1',
+ `ALTER TABLE \`i18n_preferences\`
+ ADD CONSTRAINT \`i18n_preferences_ibfk_1\` FOREIGN KEY (\`updated_by_user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE`
+ );
+
+ // Language metadata used by language-management UI
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS i18n_languages (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ language_code VARCHAR(16) NOT NULL,
+ label VARCHAR(100) NULL,
+ is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ is_custom BOOLEAN NOT NULL DEFAULT FALSE,
+ created_by_user_id INT NULL,
+ updated_by_user_id INT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT uq_i18n_languages_code UNIQUE (language_code),
+ FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
+ FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
+ );
+ `);
+ console.log('✅ i18n languages table created/verified');
+
+ // Translation overrides/custom values per language
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS i18n_translation_overrides (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ language_code VARCHAR(16) NOT NULL,
+ namespace VARCHAR(128) NOT NULL,
+ t_key VARCHAR(255) NOT NULL,
+ t_value LONGTEXT NOT NULL,
+ is_custom BOOLEAN NOT NULL DEFAULT TRUE,
+ created_by_user_id INT NULL,
+ updated_by_user_id INT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT uq_i18n_translation_overrides UNIQUE (language_code, namespace, t_key),
+ FOREIGN KEY (language_code) REFERENCES i18n_languages(language_code) ON DELETE CASCADE ON UPDATE CASCADE,
+ FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
+ FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
+ );
+ `);
+ console.log('✅ i18n translation overrides table created/verified');
+
+ await ensureIndex(connection, 'i18n_translation_overrides', 'idx_i18n_overrides_lang', '`language_code`');
+ await ensureIndex(connection, 'i18n_translation_overrides', 'idx_i18n_overrides_namespace', '`namespace`');
+ await ensureIndex(connection, 'i18n_translation_overrides', 'idx_i18n_overrides_lang_namespace', '`language_code`, `namespace`');
// --- Dashboard Platforms (admin managed dashboard cards) ---
await connection.query(`
@@ -1035,6 +1144,21 @@ const createDatabase = async () => {
`);
console.log('✅ Coffee table (simplified) created/verified');
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS coffee_table_images (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ coffee_id BIGINT NOT NULL,
+ object_storage_id VARCHAR(255) NOT NULL,
+ original_filename VARCHAR(255) NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_coffee_table_images_coffee FOREIGN KEY (coffee_id) REFERENCES coffee_table(id) ON DELETE CASCADE ON UPDATE CASCADE,
+ INDEX idx_coffee_table_images_coffee_sort (coffee_id, sort_order),
+ INDEX idx_coffee_table_images_key (object_storage_id)
+ );
+ `);
+ console.log('✅ Coffee table images created/verified');
+
// --- Coffee shipping fees (fixed package sizes) ---
await connection.query(`
CREATE TABLE IF NOT EXISTS coffee_shipping_fees (
@@ -1187,7 +1311,7 @@ const createDatabase = async () => {
total_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00,
total_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00,
vat_rate DECIMAL(6,3) NULL,
- status ENUM('draft','issued','paid','canceled') NOT NULL DEFAULT 'draft',
+ status ENUM('draft','issued','paid','overdue','canceled') NOT NULL DEFAULT 'draft',
issued_at DATETIME NULL,
due_at DATETIME NULL,
pdf_storage_key VARCHAR(255) NULL,
@@ -1202,6 +1326,16 @@ const createDatabase = async () => {
`);
console.log('✅ Invoices table created/verified');
+ try {
+ await connection.query(`
+ ALTER TABLE invoices
+ MODIFY COLUMN status ENUM('draft','issued','paid','overdue','canceled') NOT NULL DEFAULT 'draft'
+ `);
+ console.log('✅ Updated invoices.status column to include overdue');
+ } catch (err) {
+ console.warn('⚠️ Could not modify invoices.status column:', err.message);
+ }
+
await connection.query(`
CREATE TABLE IF NOT EXISTS invoice_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -1730,6 +1864,12 @@ const createDatabase = async () => {
await ensureIndex(connection, 'rate_limit', 'idx_rate_limit_rate_key', 'rate_key');
await ensureIndex(connection, 'document_templates', 'idx_document_templates_user_type', 'user_type');
await ensureIndex(connection, 'document_templates', 'idx_document_templates_state_user_type', 'state, user_type');
+ await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_type', 'template_type');
+ await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_active', 'is_active');
+ await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_archived', 'is_archived');
+ await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_type_active', 'template_type, is_active');
+ await ensureIndex(connection, 'coffee_table_images', 'idx_coffee_table_images_coffee_sort', 'coffee_id, sort_order');
+ await ensureIndex(connection, 'coffee_table_images', 'idx_coffee_table_images_key', 'object_storage_id');
await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_company', 'company_id');
await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_active', 'is_active');
console.log('🚀 Performance indexes created/verified');
diff --git a/package-lock.json b/package-lock.json
index 663d587..ceb6f91 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2644,7 +2644,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/dfa": {
"version": "1.2.0",
diff --git a/repositories/invoice/InvoiceRepository.js b/repositories/invoice/InvoiceRepository.js
index 292a7aa..2ecbd6f 100644
--- a/repositories/invoice/InvoiceRepository.js
+++ b/repositories/invoice/InvoiceRepository.js
@@ -206,8 +206,35 @@ class InvoiceRepository {
return rows.map((r) => new Invoice(r));
}
+ async getPaidRevenueSummary() {
+ const [rows] = await pool.query(
+ `SELECT COALESCE(SUM(total_gross), 0) AS total_paid_all_time,
+ COALESCE(MAX(currency), 'EUR') AS currency,
+ COUNT(*) AS paid_invoice_count
+ FROM invoices
+ WHERE status = 'paid'`
+ );
+
+ return {
+ total_paid_all_time: Number(rows?.[0]?.total_paid_all_time || 0),
+ currency: rows?.[0]?.currency || 'EUR',
+ paid_invoice_count: Number(rows?.[0]?.paid_invoice_count || 0),
+ };
+ }
+
+ async markIssuedPastDueAsOverdue() {
+ const [result] = await pool.query(
+ `UPDATE invoices
+ SET status = 'overdue', updated_at = NOW()
+ WHERE status = 'issued'
+ AND due_at IS NOT NULL
+ AND due_at < CURDATE()`
+ );
+ return result?.affectedRows || 0;
+ }
+
async updateStatus(invoiceId, newStatus) {
- const allowed = ['draft', 'issued', 'paid', 'canceled'];
+ const allowed = ['draft', 'issued', 'paid', 'overdue', 'canceled'];
if (!allowed.includes(newStatus)) {
throw new Error(`Invalid status '${newStatus}'. Allowed: ${allowed.join(', ')}`);
}
@@ -225,6 +252,57 @@ class InvoiceRepository {
);
return rows || [];
}
+
+ async createManualInvoice({
+ source_type = 'manual',
+ source_id = null,
+ user_id = null,
+ buyer_name = null,
+ buyer_email = null,
+ buyer_street = null,
+ buyer_postal_code = null,
+ buyer_city = null,
+ buyer_country = null,
+ currency = 'EUR',
+ total_net = 0,
+ total_tax = 0,
+ total_gross = 0,
+ vat_rate = null,
+ status = 'issued',
+ issued_at = null,
+ due_at = null,
+ context = null,
+ }) {
+ const invoice_number = await genInvoiceNumber();
+ const [res] = await pool.query(
+ `INSERT INTO invoices
+ (invoice_number, user_id, source_type, source_id, buyer_name, buyer_email, buyer_street, buyer_postal_code, buyer_city, buyer_country,
+ currency, total_net, total_tax, total_gross, vat_rate, status, issued_at, due_at, pdf_storage_key, context, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, NOW(), NOW())`,
+ [
+ invoice_number,
+ user_id || null,
+ source_type,
+ source_id || 0,
+ buyer_name || null,
+ buyer_email || null,
+ buyer_street || null,
+ buyer_postal_code || null,
+ buyer_city || null,
+ buyer_country || null,
+ currency,
+ +Number(total_net).toFixed(2),
+ +Number(total_tax).toFixed(2),
+ +Number(total_gross).toFixed(2),
+ vat_rate != null ? Number(vat_rate) : null,
+ status,
+ issued_at || null,
+ due_at || null,
+ context ? JSON.stringify(context) : null,
+ ],
+ );
+ return this.getById(res.insertId);
+ }
}
module.exports = InvoiceRepository;
diff --git a/repositories/settings/CompanySettingsRepository.js b/repositories/settings/CompanySettingsRepository.js
index 7153f1b..573fdea 100644
--- a/repositories/settings/CompanySettingsRepository.js
+++ b/repositories/settings/CompanySettingsRepository.js
@@ -2,7 +2,12 @@ const pool = require('../../database/database');
class CompanySettingsRepository {
async get() {
- const [rows] = await pool.query('SELECT * FROM company_settings WHERE id = 1');
+ const [rows] = await pool.query(
+ `SELECT id, company_name, company_street, company_postal_city, company_country,
+ company_logo_base64, company_logo_mime_type, updated_at
+ FROM company_settings
+ WHERE id = 1`
+ );
return rows[0] || null;
}
@@ -11,8 +16,8 @@ class CompanySettingsRepository {
company_street,
company_postal_city,
company_country,
- qr_code_60_base64,
- qr_code_120_base64,
+ company_logo_base64,
+ company_logo_mime_type,
} = {}) {
const current = await this.get();
const next = {
@@ -20,27 +25,30 @@ class CompanySettingsRepository {
company_street: company_street !== undefined ? company_street : (current?.company_street ?? ''),
company_postal_city: company_postal_city !== undefined ? company_postal_city : (current?.company_postal_city ?? ''),
company_country: company_country !== undefined ? company_country : (current?.company_country ?? ''),
- qr_code_60_base64: qr_code_60_base64 !== undefined ? qr_code_60_base64 : (current?.qr_code_60_base64 ?? null),
- qr_code_120_base64: qr_code_120_base64 !== undefined ? qr_code_120_base64 : (current?.qr_code_120_base64 ?? null),
+ company_logo_base64: company_logo_base64 !== undefined ? (company_logo_base64 || null) : (current?.company_logo_base64 ?? null),
+ company_logo_mime_type: company_logo_mime_type !== undefined ? (company_logo_mime_type || null) : (current?.company_logo_mime_type ?? null),
};
await pool.query(
- `INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country, qr_code_60_base64, qr_code_120_base64)
+ `INSERT INTO company_settings (
+ id, company_name, company_street, company_postal_city, company_country,
+ company_logo_base64, company_logo_mime_type
+ )
VALUES (1, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
company_name = VALUES(company_name),
company_street = VALUES(company_street),
company_postal_city = VALUES(company_postal_city),
company_country = VALUES(company_country),
- qr_code_60_base64 = VALUES(qr_code_60_base64),
- qr_code_120_base64 = VALUES(qr_code_120_base64)`,
+ company_logo_base64 = VALUES(company_logo_base64),
+ company_logo_mime_type = VALUES(company_logo_mime_type)`,
[
next.company_name || '',
next.company_street || '',
next.company_postal_city || '',
next.company_country || '',
- next.qr_code_60_base64 ?? null,
- next.qr_code_120_base64 ?? null,
+ next.company_logo_base64,
+ next.company_logo_mime_type,
]
);
return this.get();
diff --git a/repositories/settings/I18nPreferencesRepository.js b/repositories/settings/I18nPreferencesRepository.js
new file mode 100644
index 0000000..4b3d6a9
--- /dev/null
+++ b/repositories/settings/I18nPreferencesRepository.js
@@ -0,0 +1,421 @@
+const db = require('../../database/database');
+
+class I18nPreferencesRepository {
+ _safeJsonArray(value) {
+ if (Array.isArray(value)) return value;
+ if (value == null) return [];
+
+ try {
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (_) {
+ return [];
+ }
+ }
+
+ _normalizeRow(row) {
+ const categories = this._safeJsonArray(row?.categories_json);
+ const globalKeys = this._safeJsonArray(row?.global_keys_json);
+ return {
+ categories,
+ globalKeys,
+ };
+ }
+
+ _safeBoolean(value, fallback) {
+ if (value === undefined || value === null) return fallback;
+ if (typeof value === 'boolean') return value;
+ const normalized = String(value).trim().toLowerCase();
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
+ return fallback;
+ }
+
+ async get() {
+ const [rows] = await db.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1');
+ if (!rows.length) {
+ return { categories: [], globalKeys: [] };
+ }
+ return this._normalizeRow(rows[0]);
+ }
+
+ async upsert({ categories, globalKeys, updatedByUserId } = {}) {
+ const current = await this.get();
+
+ const nextCategories = categories !== undefined ? categories : current.categories;
+ const nextGlobalKeys = globalKeys !== undefined ? globalKeys : current.globalKeys;
+
+ await db.query(
+ `INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
+ VALUES (1, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ categories_json = VALUES(categories_json),
+ global_keys_json = VALUES(global_keys_json),
+ updated_by_user_id = VALUES(updated_by_user_id)`,
+ [JSON.stringify(nextCategories || []), JSON.stringify(nextGlobalKeys || []), updatedByUserId || null]
+ );
+
+ return this.get();
+ }
+
+ async clear(updatedByUserId) {
+ await db.query(
+ `INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
+ VALUES (1, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ categories_json = VALUES(categories_json),
+ global_keys_json = VALUES(global_keys_json),
+ updated_by_user_id = VALUES(updated_by_user_id)`,
+ [JSON.stringify([]), JSON.stringify([]), updatedByUserId || null]
+ );
+
+ return this.get();
+ }
+
+ async _tableExists(conn, tableName) {
+ const [rows] = await conn.query(
+ `SELECT 1
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = ?
+ LIMIT 1`,
+ [tableName]
+ );
+ return rows.length > 0;
+ }
+
+ async _columnExists(conn, tableName, columnName) {
+ const [rows] = await conn.query(
+ `SELECT 1
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = ?
+ AND COLUMN_NAME = ?
+ LIMIT 1`,
+ [tableName, columnName]
+ );
+ return rows.length > 0;
+ }
+
+ async deleteLanguageEntries(languageCode, updatedByUserId) {
+ const conn = await db.getConnection();
+ const safeLanguageCode = String(languageCode || '').trim();
+
+ const targets = [
+ // Language metadata
+ { table: 'i18n_languages', possibleColumns: ['language_code', 'lang', 'code'] },
+ { table: 'i18n_language_metadata', possibleColumns: ['language_code', 'lang', 'code'] },
+
+ // Translation/custom-value stores
+ { table: 'i18n_translation_overrides', possibleColumns: ['language_code', 'lang'] },
+ { table: 'i18n_translations', possibleColumns: ['language_code', 'lang'] },
+
+ // Potential language-scoped preference/link tables
+ { table: 'i18n_preferences_languages', possibleColumns: ['language_code', 'lang'] },
+ { table: 'i18n_preference_categories', possibleColumns: ['language_code', 'lang'] },
+ { table: 'i18n_preference_global_keys', possibleColumns: ['language_code', 'lang'] },
+ ];
+
+ let deletedRows = 0;
+ const touchedTables = [];
+
+ try {
+ await conn.beginTransaction();
+
+ for (const target of targets) {
+ const exists = await this._tableExists(conn, target.table);
+ if (!exists) continue;
+
+ let deleteColumn = null;
+ for (const col of target.possibleColumns) {
+ if (await this._columnExists(conn, target.table, col)) {
+ deleteColumn = col;
+ break;
+ }
+ }
+ if (!deleteColumn) continue;
+
+ const [result] = await conn.query(
+ `DELETE FROM \`${target.table}\` WHERE \`${deleteColumn}\` = ?`,
+ [safeLanguageCode]
+ );
+ const affected = Number(result?.affectedRows || 0);
+ if (affected > 0) {
+ deletedRows += affected;
+ touchedTables.push(target.table);
+ }
+ }
+
+ if (await this._tableExists(conn, 'i18n_preferences')) {
+ await conn.query(
+ `UPDATE i18n_preferences
+ SET updated_by_user_id = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = 1`,
+ [updatedByUserId || null]
+ );
+ }
+
+ await conn.commit();
+ return { deletedRows, touchedTables };
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ }
+
+ async upsertLanguage(conn, language, updatedByUserId) {
+ if (!language || !language.languageCode) return null;
+
+ const languageCode = String(language.languageCode).trim().toLowerCase();
+ const label = String(language.label || languageCode).trim();
+ const isEnabled = this._safeBoolean(language.isEnabled, true);
+ const isCustom = this._safeBoolean(language.isCustom, true);
+
+ await conn.query(
+ `INSERT INTO i18n_languages (language_code, label, is_enabled, is_custom, created_by_user_id, updated_by_user_id)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ label = VALUES(label),
+ is_enabled = VALUES(is_enabled),
+ is_custom = VALUES(is_custom),
+ updated_by_user_id = VALUES(updated_by_user_id)`,
+ [languageCode, label, isEnabled ? 1 : 0, isCustom ? 1 : 0, updatedByUserId || null, updatedByUserId || null]
+ );
+
+ const [rows] = await conn.query(
+ `SELECT language_code AS languageCode,
+ label,
+ is_enabled AS isEnabled,
+ is_custom AS isCustom,
+ created_at AS createdAt,
+ updated_at AS updatedAt
+ FROM i18n_languages
+ WHERE language_code = ?
+ LIMIT 1`,
+ [languageCode]
+ );
+ return rows[0] || null;
+ }
+
+ async upsertTranslationOverrides(conn, translationEntries, updatedByUserId) {
+ if (!Array.isArray(translationEntries) || !translationEntries.length) {
+ return { upsertedCount: 0 };
+ }
+
+ let upsertedCount = 0;
+ for (const entry of translationEntries) {
+ const languageCode = String(entry.languageCode || '').trim().toLowerCase();
+ const namespace = String(entry.namespace || '').trim();
+ const key = String(entry.key || '').trim();
+ const value = entry.value == null ? '' : String(entry.value);
+ const isCustom = this._safeBoolean(entry.isCustom, true);
+
+ if (!languageCode || !namespace || !key) continue;
+
+ await conn.query(
+ `INSERT INTO i18n_translation_overrides
+ (language_code, namespace, t_key, t_value, is_custom, created_by_user_id, updated_by_user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ t_value = VALUES(t_value),
+ is_custom = VALUES(is_custom),
+ updated_by_user_id = VALUES(updated_by_user_id)`,
+ [
+ languageCode,
+ namespace,
+ key,
+ value,
+ isCustom ? 1 : 0,
+ updatedByUserId || null,
+ updatedByUserId || null,
+ ]
+ );
+ upsertedCount += 1;
+ }
+
+ return { upsertedCount };
+ }
+
+ async upsertBundle({ categories, globalKeys, language, translations, updatedByUserId } = {}) {
+ const conn = await db.getConnection();
+ try {
+ await conn.beginTransaction();
+
+ const [prefRows] = await conn.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1');
+ const current = prefRows.length
+ ? this._normalizeRow(prefRows[0])
+ : { categories: [], globalKeys: [] };
+
+ const nextCategories = categories !== undefined ? categories : current.categories;
+ const nextGlobalKeys = globalKeys !== undefined ? globalKeys : current.globalKeys;
+
+ await conn.query(
+ `INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
+ VALUES (1, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ categories_json = VALUES(categories_json),
+ global_keys_json = VALUES(global_keys_json),
+ updated_by_user_id = VALUES(updated_by_user_id)`,
+ [JSON.stringify(nextCategories || []), JSON.stringify(nextGlobalKeys || []), updatedByUserId || null]
+ );
+
+ const languagesToEnsure = new Map();
+ if (language && language.languageCode) {
+ languagesToEnsure.set(String(language.languageCode).toLowerCase(), language);
+ }
+ for (const t of (Array.isArray(translations) ? translations : [])) {
+ const code = String(t.languageCode || '').trim().toLowerCase();
+ if (!code) continue;
+ if (!languagesToEnsure.has(code)) {
+ languagesToEnsure.set(code, {
+ languageCode: code,
+ label: code.toUpperCase(),
+ isEnabled: true,
+ isCustom: true,
+ });
+ }
+ }
+
+ let upsertedLanguage = null;
+ for (const [, langPayload] of languagesToEnsure) {
+ const row = await this.upsertLanguage(conn, langPayload, updatedByUserId);
+ if (language && row && row.languageCode === String(language.languageCode).toLowerCase()) {
+ upsertedLanguage = row;
+ }
+ }
+
+ const translationResult = await this.upsertTranslationOverrides(conn, translations, updatedByUserId);
+
+ const [savedRows] = await conn.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1');
+ const preferences = savedRows.length
+ ? this._normalizeRow(savedRows[0])
+ : { categories: [], globalKeys: [] };
+
+ await conn.commit();
+ return {
+ preferences,
+ language: upsertedLanguage,
+ translationsUpserted: translationResult.upsertedCount,
+ };
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ }
+
+ async listTranslations({ languageCode, namespace } = {}) {
+ const filters = [];
+ const params = [];
+
+ if (languageCode) {
+ filters.push('language_code = ?');
+ params.push(String(languageCode).trim().toLowerCase());
+ }
+ if (namespace) {
+ filters.push('namespace = ?');
+ params.push(String(namespace).trim());
+ }
+
+ const whereClause = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
+ const [rows] = await db.query(
+ `SELECT language_code AS languageCode,
+ namespace,
+ t_key AS \`key\`,
+ t_value AS value,
+ is_custom AS isCustom,
+ updated_at AS updatedAt
+ FROM i18n_translation_overrides
+ ${whereClause}
+ ORDER BY language_code, namespace, t_key`,
+ params
+ );
+
+ return rows || [];
+ }
+
+ async upsertTranslations({ translations, updatedByUserId } = {}) {
+ const conn = await db.getConnection();
+ try {
+ await conn.beginTransaction();
+
+ // Make sure every translation language exists in metadata table.
+ const ensured = new Set();
+ for (const t of (translations || [])) {
+ const code = String(t.languageCode || '').trim().toLowerCase();
+ if (!code || ensured.has(code)) continue;
+
+ await this.upsertLanguage(
+ conn,
+ {
+ languageCode: code,
+ label: code.toUpperCase(),
+ isEnabled: true,
+ isCustom: true,
+ },
+ updatedByUserId
+ );
+ ensured.add(code);
+ }
+
+ const result = await this.upsertTranslationOverrides(conn, translations || [], updatedByUserId);
+ await conn.commit();
+ return result;
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ }
+
+ async getScanSummary({ languageCode } = {}) {
+ const code = languageCode ? String(languageCode).trim().toLowerCase() : null;
+
+ const [languages] = await db.query(
+ `SELECT language_code AS languageCode,
+ label,
+ is_enabled AS isEnabled,
+ is_custom AS isCustom
+ FROM i18n_languages
+ ORDER BY language_code`
+ );
+
+ const [namespaces] = await db.query(
+ `SELECT DISTINCT namespace
+ FROM i18n_translation_overrides
+ ${code ? 'WHERE language_code = ?' : ''}
+ ORDER BY namespace`,
+ code ? [code] : []
+ );
+
+ const [countsByLanguage] = await db.query(
+ `SELECT language_code AS languageCode, COUNT(*) AS entryCount
+ FROM i18n_translation_overrides
+ ${code ? 'WHERE language_code = ?' : ''}
+ GROUP BY language_code
+ ORDER BY language_code`,
+ code ? [code] : []
+ );
+
+ const prefs = await this.get();
+ const categoryNamespaces = Array.isArray(prefs.categories)
+ ? [...new Set(prefs.categories.flatMap((c) => (Array.isArray(c?.namespaces) ? c.namespaces : [])))]
+ : [];
+
+ return {
+ languages: languages || [],
+ namespaces: (namespaces || []).map((r) => r.namespace),
+ countsByLanguage: countsByLanguage || [],
+ categories: prefs.categories || [],
+ globalKeys: prefs.globalKeys || [],
+ categoryNamespaces,
+ };
+ }
+}
+
+module.exports = I18nPreferencesRepository;
diff --git a/repositories/subscriptions/CoffeeRepository.js b/repositories/subscriptions/CoffeeRepository.js
index 4356f4c..c991941 100644
--- a/repositories/subscriptions/CoffeeRepository.js
+++ b/repositories/subscriptions/CoffeeRepository.js
@@ -2,16 +2,204 @@ const db = require('../../database/database');
const { logger } = require('../../middleware/logger');
class CoffeeRepository {
+ async _syncSortOrders(coffeeId, conn) {
+ const cx = conn || db;
+ const [rows] = await cx.query(
+ `SELECT id
+ FROM coffee_table_images
+ WHERE coffee_id = ?
+ ORDER BY sort_order ASC, id ASC`,
+ [coffeeId]
+ );
+
+ for (let i = 0; i < (rows || []).length; i += 1) {
+ await cx.query('UPDATE coffee_table_images SET sort_order = ? WHERE id = ?', [i, rows[i].id]);
+ }
+ }
+
+ async ensureLegacyPictureRow(coffeeId, conn) {
+ const cx = conn || db;
+
+ const [coffeeRows] = await cx.query(
+ `SELECT object_storage_id, original_filename
+ FROM coffee_table
+ WHERE id = ?
+ LIMIT 1`,
+ [coffeeId]
+ );
+ const coffee = coffeeRows?.[0];
+ if (!coffee || !coffee.object_storage_id) return;
+
+ const [countRows] = await cx.query(
+ `SELECT COUNT(*) AS c
+ FROM coffee_table_images
+ WHERE coffee_id = ?`,
+ [coffeeId]
+ );
+ const count = Number(countRows?.[0]?.c || 0);
+ if (count > 0) return;
+
+ await cx.query(
+ `INSERT INTO coffee_table_images (coffee_id, object_storage_id, original_filename, sort_order)
+ VALUES (?, ?, ?, 0)`,
+ [coffeeId, coffee.object_storage_id, coffee.original_filename || null]
+ );
+ }
+
+ async listPicturesByCoffeeId(coffeeId, conn) {
+ const cx = conn || db;
+ const [rows] = await cx.query(
+ `SELECT id, coffee_id, object_storage_id, original_filename, sort_order, created_at
+ FROM coffee_table_images
+ WHERE coffee_id = ?
+ ORDER BY sort_order ASC, id ASC`,
+ [coffeeId]
+ );
+ return rows || [];
+ }
+
+ async deleteAllPicturesByCoffeeId(coffeeId, conn) {
+ const cx = conn || db;
+ const toDelete = await this.listPicturesByCoffeeId(coffeeId, cx);
+ if (!toDelete.length) return [];
+
+ await cx.query('DELETE FROM coffee_table_images WHERE coffee_id = ?', [coffeeId]);
+ return toDelete;
+ }
+
+ async deletePicturesByIds(coffeeId, pictureIds, conn) {
+ const cx = conn || db;
+ const ids = Array.isArray(pictureIds)
+ ? pictureIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0)
+ : [];
+ if (!ids.length) return [];
+
+ const placeholders = ids.map(() => '?').join(',');
+ const [rows] = await cx.query(
+ `SELECT id, coffee_id, object_storage_id, original_filename, sort_order, created_at
+ FROM coffee_table_images
+ WHERE coffee_id = ?
+ AND id IN (${placeholders})`,
+ [coffeeId, ...ids]
+ );
+ if (!(rows || []).length) return [];
+
+ const deletePlaceholders = rows.map(() => '?').join(',');
+ await cx.query(
+ `DELETE FROM coffee_table_images
+ WHERE coffee_id = ?
+ AND id IN (${deletePlaceholders})`,
+ [coffeeId, ...rows.map((r) => r.id)]
+ );
+ return rows;
+ }
+
+ async addPictures(coffeeId, images, conn) {
+ const cx = conn || db;
+ const existing = await this.listPicturesByCoffeeId(coffeeId, cx);
+ let nextSort = existing.length;
+
+ for (const img of (images || [])) {
+ await cx.query(
+ `INSERT INTO coffee_table_images (coffee_id, object_storage_id, original_filename, sort_order)
+ VALUES (?, ?, ?, ?)`,
+ [
+ coffeeId,
+ img.object_storage_id,
+ img.original_filename || null,
+ nextSort,
+ ]
+ );
+ nextSort += 1;
+ }
+ }
+
+ async syncPrimaryPictureFromGallery(coffeeId, conn) {
+ const cx = conn || db;
+ const [rows] = await cx.query(
+ `SELECT object_storage_id, original_filename
+ FROM coffee_table_images
+ WHERE coffee_id = ?
+ ORDER BY sort_order ASC, id ASC
+ LIMIT 1`,
+ [coffeeId]
+ );
+
+ const first = rows?.[0];
+ await cx.query(
+ `UPDATE coffee_table
+ SET object_storage_id = ?,
+ original_filename = ?,
+ updated_at = NOW()
+ WHERE id = ?`,
+ [first?.object_storage_id || null, first?.original_filename || null, coffeeId]
+ );
+ }
+
+ async _attachPictures(rows, conn) {
+ const cx = conn || db;
+ if (!Array.isArray(rows) || !rows.length) return rows || [];
+
+ const ids = rows.map((r) => Number(r.id)).filter((id) => Number.isFinite(id));
+ if (!ids.length) return rows;
+
+ const placeholders = ids.map(() => '?').join(',');
+ const [pictureRows] = await cx.query(
+ `SELECT id, coffee_id, object_storage_id, original_filename, sort_order, created_at
+ FROM coffee_table_images
+ WHERE coffee_id IN (${placeholders})
+ ORDER BY coffee_id ASC, sort_order ASC, id ASC`,
+ ids
+ );
+
+ const grouped = new Map();
+ for (const p of (pictureRows || [])) {
+ const key = Number(p.coffee_id);
+ if (!grouped.has(key)) grouped.set(key, []);
+ grouped.get(key).push({
+ id: Number(p.id),
+ coffee_id: Number(p.coffee_id),
+ object_storage_id: p.object_storage_id,
+ original_filename: p.original_filename,
+ sort_order: Number(p.sort_order || 0),
+ created_at: p.created_at,
+ });
+ }
+
+ for (const row of rows) {
+ const coffeeId = Number(row.id);
+ const fromTable = grouped.get(coffeeId) || [];
+ if (fromTable.length) {
+ row.pictures = fromTable;
+ } else if (row.object_storage_id) {
+ row.pictures = [{
+ id: null,
+ coffee_id: coffeeId,
+ object_storage_id: row.object_storage_id,
+ original_filename: row.original_filename || null,
+ sort_order: 0,
+ created_at: row.created_at || null,
+ }];
+ } else {
+ row.pictures = [];
+ }
+ }
+
+ return rows;
+ }
+
async listAll(conn) {
const cx = conn || db;
const [rows] = await cx.query('SELECT * FROM coffee_table ORDER BY id DESC');
- return rows || [];
+ return this._attachPictures(rows || [], cx);
}
async getById(id, conn) {
const cx = conn || db;
const [rows] = await cx.query('SELECT * FROM coffee_table WHERE id = ? LIMIT 1', [id]);
- return rows && rows[0] ? rows[0] : null;
+ if (!rows || !rows[0]) return null;
+ const hydrated = await this._attachPictures([rows[0]], cx);
+ return hydrated[0] || null;
}
async create(data, conn) {
@@ -35,8 +223,32 @@ class CoffeeRepository {
data.state
];
const [result] = await cx.query(sql, params);
+ const createdId = result.insertId;
+
+ const pictures = Array.isArray(data.images) ? [...data.images] : [];
+ if (!pictures.length && data.object_storage_id) {
+ pictures.push({
+ object_storage_id: data.object_storage_id,
+ original_filename: data.original_filename || null,
+ sort_order: 0,
+ });
+ }
+
+ for (const picture of pictures) {
+ await cx.query(
+ `INSERT INTO coffee_table_images (coffee_id, object_storage_id, original_filename, sort_order)
+ VALUES (?, ?, ?, ?)`,
+ [
+ createdId,
+ picture.object_storage_id,
+ picture.original_filename || null,
+ Number.isFinite(Number(picture.sort_order)) ? Number(picture.sort_order) : 0,
+ ]
+ );
+ }
+
logger.info('[CoffeeRepository.create] insert', { id: result.insertId });
- return { id: result.insertId, ...data };
+ return this.getById(createdId, cx);
}
async update(id, data, conn) {
@@ -80,7 +292,33 @@ class CoffeeRepository {
async listActive(conn) {
const cx = conn || db;
const [rows] = await cx.query('SELECT * FROM coffee_table WHERE state = TRUE ORDER BY id DESC');
- return rows || [];
+ return this._attachPictures(rows || [], cx);
+ }
+
+ async editPictures(coffeeId, { replaceAll = false, removePictureIds = [], images = [] } = {}, conn) {
+ const cx = conn || db;
+
+ await this.ensureLegacyPictureRow(coffeeId, cx);
+
+ let deleted = [];
+ if (replaceAll) {
+ deleted = await this.deleteAllPicturesByCoffeeId(coffeeId, cx);
+ } else if (Array.isArray(removePictureIds) && removePictureIds.length) {
+ deleted = await this.deletePicturesByIds(coffeeId, removePictureIds, cx);
+ }
+
+ if (Array.isArray(images) && images.length) {
+ await this.addPictures(coffeeId, images, cx);
+ }
+
+ await this._syncSortOrders(coffeeId, cx);
+ await this.syncPrimaryPictureFromGallery(coffeeId, cx);
+
+ const updated = await this.getById(coffeeId, cx);
+ return {
+ updated,
+ deleted,
+ };
}
}
diff --git a/repositories/template/MailTemplateRepository.js b/repositories/template/MailTemplateRepository.js
new file mode 100644
index 0000000..dc684a1
--- /dev/null
+++ b/repositories/template/MailTemplateRepository.js
@@ -0,0 +1,207 @@
+const db = require('../../database/database');
+
+class MailTemplateRepository {
+ _mapRow(row) {
+ if (!row) return null;
+ return {
+ id: Number(row.id),
+ template_type: row.template_type,
+ name: row.name,
+ subject: row.subject,
+ html_content: row.html_content,
+ is_active: row.is_active === 1 || row.is_active === true,
+ is_archived: row.is_archived === 1 || row.is_archived === true,
+ archived_at: row.archived_at,
+ created_by: row.created_by,
+ updated_by: row.updated_by,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+ }
+
+ async list({ includeArchived = false, templateType } = {}) {
+ const where = [];
+ const params = [];
+
+ if (!includeArchived) {
+ where.push('is_archived = 0');
+ }
+
+ if (templateType) {
+ where.push('template_type = ?');
+ params.push(templateType);
+ }
+
+ const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
+ const [rows] = await db.query(
+ `SELECT *
+ FROM mail_templates
+ ${whereClause}
+ ORDER BY template_type ASC, is_active DESC, updated_at DESC`,
+ params
+ );
+
+ return (rows || []).map((row) => this._mapRow(row));
+ }
+
+ async getById(id) {
+ const [rows] = await db.query(
+ `SELECT *
+ FROM mail_templates
+ WHERE id = ?
+ LIMIT 1`,
+ [id]
+ );
+ return this._mapRow(rows?.[0]);
+ }
+
+ async create(payload) {
+ const [result] = await db.query(
+ `INSERT INTO mail_templates
+ (template_type, name, subject, html_content, is_active, is_archived, created_by, updated_by)
+ VALUES (?, ?, ?, ?, 0, 0, ?, ?)`,
+ [
+ payload.template_type,
+ payload.name,
+ payload.subject || null,
+ payload.html_content,
+ payload.userId || null,
+ payload.userId || null,
+ ]
+ );
+
+ return this.getById(result.insertId);
+ }
+
+ async update(id, payload) {
+ const fields = [];
+ const values = [];
+
+ if (payload.template_type !== undefined) {
+ fields.push('template_type = ?');
+ values.push(payload.template_type);
+ }
+ if (payload.name !== undefined) {
+ fields.push('name = ?');
+ values.push(payload.name);
+ }
+ if (payload.subject !== undefined) {
+ fields.push('subject = ?');
+ values.push(payload.subject || null);
+ }
+ if (payload.html_content !== undefined) {
+ fields.push('html_content = ?');
+ values.push(payload.html_content);
+ }
+
+ fields.push('updated_by = ?');
+ values.push(payload.userId || null);
+
+ if (!fields.length) return this.getById(id);
+
+ values.push(id);
+ const [result] = await db.query(
+ `UPDATE mail_templates
+ SET ${fields.join(', ')}, updated_at = NOW()
+ WHERE id = ?`,
+ values
+ );
+
+ if (!result?.affectedRows) return null;
+ return this.getById(id);
+ }
+
+ async activate(id, userId) {
+ const conn = await db.getConnection();
+
+ try {
+ await conn.beginTransaction();
+
+ const [targetRows] = await conn.query(
+ `SELECT id, template_type, is_archived
+ FROM mail_templates
+ WHERE id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [id]
+ );
+
+ const target = targetRows?.[0];
+ if (!target) {
+ await conn.rollback();
+ return null;
+ }
+
+ if (target.is_archived === 1 || target.is_archived === true) {
+ const error = new Error('Archived templates cannot be activated');
+ error.status = 400;
+ throw error;
+ }
+
+ await conn.query(
+ `UPDATE mail_templates
+ SET is_active = 0, updated_by = ?, updated_at = NOW()
+ WHERE template_type = ?`,
+ [userId || null, target.template_type]
+ );
+
+ await conn.query(
+ `UPDATE mail_templates
+ SET is_active = 1,
+ is_archived = 0,
+ archived_at = NULL,
+ updated_by = ?,
+ updated_at = NOW()
+ WHERE id = ?`,
+ [userId || null, id]
+ );
+
+ await conn.commit();
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+
+ return this.getById(id);
+ }
+
+ async archive(id, userId) {
+ const [result] = await db.query(
+ `UPDATE mail_templates
+ SET is_archived = 1,
+ is_active = 0,
+ archived_at = NOW(),
+ updated_by = ?,
+ updated_at = NOW()
+ WHERE id = ?`,
+ [userId || null, id]
+ );
+
+ if (!result?.affectedRows) return null;
+ return this.getById(id);
+ }
+
+ async unarchive(id, userId) {
+ const [result] = await db.query(
+ `UPDATE mail_templates
+ SET is_archived = 0,
+ archived_at = NULL,
+ updated_by = ?,
+ updated_at = NOW()
+ WHERE id = ?`,
+ [userId || null, id]
+ );
+
+ if (!result?.affectedRows) return null;
+ return this.getById(id);
+ }
+
+ async delete(id) {
+ const [result] = await db.query('DELETE FROM mail_templates WHERE id = ?', [id]);
+ return Number(result?.affectedRows || 0) > 0;
+ }
+}
+
+module.exports = new MailTemplateRepository();
diff --git a/repositories/user/company/CompanyUserRepository.js b/repositories/user/company/CompanyUserRepository.js
index c42af48..00b07f9 100644
--- a/repositories/user/company/CompanyUserRepository.js
+++ b/repositories/user/company/CompanyUserRepository.js
@@ -146,6 +146,7 @@ class CompanyUserRepository {
branch,
numberOfEmployees,
registrationNumber,
+ atuNumber,
businessType,
iban,
accountHolderName,
@@ -157,12 +158,13 @@ class CompanyUserRepository {
await conn.query(
`INSERT INTO company_profiles (
- user_id, company_name, registration_number, phone, address, zip_code, city, country,
+ user_id, company_name, registration_number, atu_number, phone, address, zip_code, city, country,
branch, number_of_employees, business_type, contact_person_name, contact_person_phone, account_holder_name
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
company_name = VALUES(company_name),
registration_number = VALUES(registration_number),
+ atu_number = VALUES(atu_number),
phone = VALUES(phone),
address = VALUES(address),
zip_code = VALUES(zip_code),
@@ -178,6 +180,7 @@ class CompanyUserRepository {
userId,
companyName,
registrationNumber || null,
+ atuNumber || null,
companyPhone || null,
address || null,
zip_code || null,
diff --git a/routes/deleteRoutes.js b/routes/deleteRoutes.js
index 79ff077..681266b 100644
--- a/routes/deleteRoutes.js
+++ b/routes/deleteRoutes.js
@@ -10,6 +10,8 @@ const CoffeeController = require('../controller/admin/CoffeeController');
const AffiliateController = require('../controller/affiliate/AffiliateController');
const NewsController = require('../controller/news/NewsController');
const PoolController = require('../controller/pool/PoolController');
+const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
+const MailTemplatesController = require('../controller/admin/MailTemplatesController');
// Helper middlewares for company-stamp
function forceCompanyForAdmin(req, res, next) {
@@ -21,6 +23,7 @@ function forceCompanyForAdmin(req, res, next) {
// DELETE /admin/user/:id (moved from routes/admin.js)
router.delete('/admin/user/:id', authMiddleware, adminOnly, AdminUserController.deleteUser);
+router.delete('/admin/mail-templates/:id', authMiddleware, adminOnly, MailTemplatesController.remove);
// DELETE /document-templates/:id (moved from routes/documentTemplates.js)
router.delete('/document-templates/:id', authMiddleware, DocumentTemplateController.deleteTemplate);
@@ -37,5 +40,7 @@ router.delete('/admin/news/:id', authMiddleware, adminOnly, NewsController.delet
// Admin: remove pool members
router.delete('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.removeMembers);
+router.delete('/admin/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.delete);
+router.delete('/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.delete);
module.exports = router;
diff --git a/routes/getRoutes.js b/routes/getRoutes.js
index 9f32e0a..8812d5d 100644
--- a/routes/getRoutes.js
+++ b/routes/getRoutes.js
@@ -30,6 +30,11 @@ const DevManagementController = require('../controller/dev/DevManagementControll
const CompanySettingsController = require('../controller/admin/CompanySettingsController');
const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController');
const ShippingFeesController = require('../controller/admin/ShippingFeesController');
+const LoginController = require('../controller/login/LoginController');
+const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
+const MailTemplatesController = require('../controller/admin/MailTemplatesController');
+
+const AUTH_VALIDATE_ROUTE_PATH = '/auth/validate';
// small helpers copied from original files
@@ -44,6 +49,7 @@ function forceCompanyForAdmin(req, res, next) {
// === GET routes moved from other files ===
// auth.js GETs
+router.get(AUTH_VALIDATE_ROUTE_PATH, LoginController.validate);
router.get('/me', authMiddleware, UserController.getMe);
router.get('/user/status', authMiddleware, UserStatusController.getStatus);
router.get('/user/status-progress', authMiddleware, UserStatusController.getStatusProgress);
@@ -53,12 +59,18 @@ router.get('/users/:id/permissions', authMiddleware, PermissionController.getUse
router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails);
router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo);
router.get('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.get);
+router.get('/admin/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.get);
+router.get('/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.get);
+router.get('/i18n/translations', authMiddleware, adminOnly, I18nPreferencesController.getTranslations);
+router.get('/i18n/scan', authMiddleware, adminOnly, I18nPreferencesController.scan);
router.get('/users/:id/documents', authMiddleware, UserController.getUserDocumentsAndContracts);
router.get('/verify-password-reset', (req, res) => { /* Note: was moved from PasswordResetController.verifyPasswordResetToken */ res.status(204).end(); }); // keep placeholder if controller already registered via other verb
// admin.js GETs
router.get('/admin/user-stats', authMiddleware, adminOnly, AdminUserController.getUserStats);
router.get('/admin/user-list', authMiddleware, adminOnly, AdminUserController.getUserList);
+router.get('/admin/mail-templates', authMiddleware, adminOnly, MailTemplatesController.list);
+router.get('/admin/mail-templates/:id', authMiddleware, adminOnly, MailTemplatesController.getById);
router.get('/admin/verification-pending-users', authMiddleware, adminOnly, AdminUserController.getVerificationPendingUsers);
router.get('/admin/unverified-users', authMiddleware, adminOnly, AdminUserController.getUnverifiedUsers);
router.get('/admin/user/:id/documents', authMiddleware, adminOnly, UserDocumentController.getAllDocumentsForUser);
@@ -131,7 +143,9 @@ router.get('/company-stamps/all', authMiddleware, adminOnly, forceCompanyForAdmi
// Admin: coffee products
router.get('/admin/coffee', authMiddleware, adminOnly, CoffeeController.list);
router.get('/admin/coffee/active', authMiddleware, adminOnly, CoffeeController.listActive);
+router.get('/admin/coffee/:id/pictures', authMiddleware, adminOnly, CoffeeController.getPictures);
router.get('/coffee/active', authMiddleware, CoffeeController.listActive);
+router.get('/coffee/:id/pictures', authMiddleware, CoffeeController.getPictures);
// Matrix GETs
@@ -190,6 +204,7 @@ router.get('/news/:slug', NewsController.getPublic);
router.get('/invoices/mine', authMiddleware, InvoiceController.listMine);
router.get('/invoices/:id/pdf', authMiddleware, InvoiceController.downloadPdf);
router.get('/admin/invoices', authMiddleware, adminOnly, InvoiceController.adminList);
+router.get('/admin/invoices/revenue-summary', authMiddleware, adminOnly, InvoiceController.revenueSummary);
router.get('/admin/invoices/:id/detail', authMiddleware, adminOnly, InvoiceController.getDetail);
// NOTE: Contract signing uses UnitOfWork; any DB cleanup must happen before commit() closes the connection.
diff --git a/routes/patchRoutes.js b/routes/patchRoutes.js
index ff78e8e..a867e6e 100644
--- a/routes/patchRoutes.js
+++ b/routes/patchRoutes.js
@@ -15,6 +15,7 @@ const NewsController = require('../controller/news/NewsController');
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
const InvoiceController = require('../controller/invoice/InvoiceController');
const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController');
+const MailTemplatesController = require('../controller/admin/MailTemplatesController');
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
@@ -36,11 +37,17 @@ router.patch('/company-stamps/:id/activate', authMiddleware, adminOnly, forceCom
// Admin user management PATCH routes
router.patch('/admin/archive-user/:id', authMiddleware, adminOnly, AdminUserController.archiveUser);
router.patch('/admin/unarchive-user/:id', authMiddleware, adminOnly, AdminUserController.unarchiveUser);
+router.patch('/admin/mail-templates/:id', authMiddleware, adminOnly, MailTemplatesController.update);
+router.patch('/admin/mail-templates/:id/activate', authMiddleware, adminOnly, MailTemplatesController.activate);
+router.patch('/admin/mail-templates/:id/archive', authMiddleware, adminOnly, MailTemplatesController.archive);
+router.patch('/admin/mail-templates/:id/unarchive', authMiddleware, adminOnly, MailTemplatesController.unarchive);
router.patch('/admin/update-verification/:id', authMiddleware, adminOnly, AdminUserController.updateUserVerification);
router.patch('/admin/update-user-profile/:id', authMiddleware, adminOnly, AdminUserController.updateUserProfile);
router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUserController.updateUserStatus);
// Admin: set state for coffee product
router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState);
+// Admin: edit coffee gallery pictures (upload/remove/replace)
+router.patch('/admin/coffee/:id/pictures', authMiddleware, adminOnly, upload.any(), CoffeeController.editPictures);
// NEW: Admin pool active status update
router.patch('/admin/pools/:id/active', authMiddleware, adminOnly, PoolController.updateActive);
// NEW: Admin update pool linked subscription
diff --git a/routes/postRoutes.js b/routes/postRoutes.js
index dc4b1fd..0e592dd 100644
--- a/routes/postRoutes.js
+++ b/routes/postRoutes.js
@@ -32,6 +32,8 @@ const NewsController = require('../controller/news/NewsController');
const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW
const DevManagementController = require('../controller/dev/DevManagementController');
const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController');
+const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
+const MailTemplatesController = require('../controller/admin/MailTemplatesController');
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
@@ -82,6 +84,11 @@ router.post('/profile/company/complete', authMiddleware, CompanyProfileControlle
// Admin POSTs (moved from routes/admin.js)
router.post('/admin/verify-user/:id', authMiddleware, adminOnly, AdminUserController.verifyUser);
+router.post('/admin/mail-templates', authMiddleware, adminOnly, MailTemplatesController.create);
+router.post('/admin/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.post);
+router.post('/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.post);
+router.post('/i18n/translations', authMiddleware, adminOnly, I18nPreferencesController.upsertTranslations);
+router.post('/i18n/scan', authMiddleware, adminOnly, I18nPreferencesController.scan);
router.post('/admin/send-password-reset/:userId', authMiddleware, adminOnly, async (req, res) => {
const userId = req.params.userId;
// require here to avoid circular/top-level ordering issues
@@ -145,8 +152,8 @@ function ensureUserFromBody(req, res, next) {
// Company-stamp POST
router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload);
-// Admin: create coffee product (supports multipart file 'picture')
-router.post('/admin/coffee', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.create);
+// Admin: create coffee product (supports multipart files 'pictures' and legacy 'picture')
+router.post('/admin/coffee', authMiddleware, adminOnly, upload.any(), CoffeeController.create);
// NEW: add user into matrix
router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // already added
// NEW: remove matrix user and create vacancy
@@ -188,6 +195,7 @@ router.post('/abonements/referred', authMiddleware, ensureUserFromBody, Abonemme
// NEW: Invoice POSTs
router.post('/invoices/:id/pay', authMiddleware, adminOnly, InvoiceController.pay);
router.post('/admin/invoices/email-report', authMiddleware, adminOnly, InvoiceController.sendEmailReport);
+router.post('/admin/invoices', authMiddleware, adminOnly, upload.single('pdf'), InvoiceController.adminCreate);
// Existing registration handlers (keep)
router.post('/register/personal', (req, res) => {
@@ -205,4 +213,4 @@ router.post('/register/guest', (req, res) => {
console.log('✅ POST routes configured successfully');
-module.exports = router;
+module.exports = router;
\ No newline at end of file
diff --git a/routes/putRoutes.js b/routes/putRoutes.js
index de17da9..ce27822 100644
--- a/routes/putRoutes.js
+++ b/routes/putRoutes.js
@@ -9,6 +9,7 @@ const CoffeeController = require('../controller/admin/CoffeeController');
const CompanySettingsController = require('../controller/admin/CompanySettingsController');
const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController');
const ShippingFeesController = require('../controller/admin/ShippingFeesController');
+const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
@@ -28,5 +29,8 @@ router.put('/admin/dashboard-platforms/:id', authMiddleware, adminOnly, Dashboar
// Admin: update shipping fee for a piece count (60/120)
router.put('/admin/shipping-fees/:pieceCount', authMiddleware, adminOnly, ShippingFeesController.updatePrice);
+router.put('/admin/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.put);
+router.put('/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.put);
+router.put('/i18n/translations', authMiddleware, adminOnly, I18nPreferencesController.upsertTranslations);
module.exports = router;
diff --git a/scripts/createCompanyUser.js b/scripts/createCompanyUser.js
index 82f3742..b3a21c4 100644
--- a/scripts/createCompanyUser.js
+++ b/scripts/createCompanyUser.js
@@ -2,7 +2,7 @@ const UnitOfWork = require('../database/UnitOfWork');
const argon2 = require('argon2');
async function createCompanyUser() {
- return
+
// Edit these values directly in code (no env vars)
const companyEmail = 'dummy-company@profitplanet.local';
const companyPassword = 'dummyPass!1234';
diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js
index dd1c46d..c2e520c 100644
--- a/services/abonemments/AbonemmentService.js
+++ b/services/abonemments/AbonemmentService.js
@@ -7,6 +7,22 @@ const ReferralService = require('../referral/ReferralService');
const ReferralTokenRepository = require('../../repositories/referral/ReferralTokenRepository');
const MailService = require('../email/MailService');
+const CAPSULES_PER_PACK = 10;
+const MIN_ABO_PACKS = 6;
+const MAX_ABO_PACKS = 10000;
+
+function getPackCountError(totalPacks) {
+ if (totalPacks < MIN_ABO_PACKS) {
+ return `Order must contain at least ${MIN_ABO_PACKS} packs (${MIN_ABO_PACKS * CAPSULES_PER_PACK} capsules).`;
+ }
+
+ if (totalPacks > MAX_ABO_PACKS) {
+ return `Order cannot contain more than ${MAX_ABO_PACKS} packs (${MAX_ABO_PACKS * CAPSULES_PER_PACK} capsules).`;
+ }
+
+ return null;
+}
+
class AbonemmentService {
constructor() {
this.repo = new AbonemmentRepository();
@@ -40,7 +56,7 @@ class AbonemmentService {
return typeof email === 'string' ? email.trim().toLowerCase() : null;
}
- // NEW: single bundle subscribe using items array (12 packs, 120 capsules)
+ // NEW: single bundle subscribe using items array (pack-based order content)
async subscribeOrder({
items,
billingInterval,
@@ -114,7 +130,7 @@ class AbonemmentService {
const coffeeId = item?.coffeeId;
const packs = Number(item?.quantity ?? 0);
if (!coffeeId) throw new Error('coffeeId is required for each item');
- if (!Number.isFinite(packs) || packs <= 0) throw new Error('quantity must be a positive integer per item');
+ if (!Number.isInteger(packs) || packs <= 0) throw new Error('quantity must be a positive integer per item');
const product = await this.getCoffeeProduct(coffeeId);
if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`);
@@ -134,6 +150,9 @@ class AbonemmentService {
});
}
+ const packCountError = getPackCountError(totalPacks);
+ if (packCountError) throw new Error(packCountError);
+
const now = new Date();
const nextBilling = this.addInterval(now, billingInterval || 'month', intervalCount || 1);
@@ -590,7 +609,7 @@ class AbonemmentService {
const coffeeId = item?.coffeeId;
const packs = Number(item?.quantity ?? 0);
if (!coffeeId) throw new Error('coffeeId is required for each item');
- if (!Number.isFinite(packs) || packs <= 0) {
+ if (!Number.isInteger(packs) || packs <= 0) {
throw new Error('quantity must be a positive integer per item');
}
@@ -611,9 +630,8 @@ class AbonemmentService {
});
}
- if (totalPacks !== 6 && totalPacks !== 12) {
- throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).');
- }
+ const packCountError = getPackCountError(totalPacks);
+ if (packCountError) throw new Error(packCountError);
const previousPacks = Array.isArray(abon.pack_breakdown)
? abon.pack_breakdown.reduce((sum, item) => sum + Number(item?.packs || item?.quantity || 0), 0)
diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js
index 78d2609..dfb49fd 100644
--- a/services/invoice/InvoiceService.js
+++ b/services/invoice/InvoiceService.js
@@ -1,6 +1,5 @@
const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
-const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
const PoolInflowService = require('../pool/PoolInflowService');
const DocumentTemplateService = require('../template/DocumentTemplateService');
const MailService = require('../email/MailService');
@@ -10,8 +9,8 @@ const { logger } = require('../../middleware/logger');
const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const path = require('path');
+const pool = require('../../database/database');
-const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService');
class InvoiceService {
@@ -19,15 +18,6 @@ class InvoiceService {
this.repo = new InvoiceRepository();
}
- _inferImageMimeFromBase64(base64) {
- const s = String(base64 || '').trim();
- if (!s) return 'image/png';
- if (s.startsWith('iVBORw0KGgo')) return 'image/png';
- if (s.startsWith('/9j/')) return 'image/jpeg';
- if (s.startsWith('R0lGOD')) return 'image/gif';
- return 'image/png';
- }
-
_templateHasVars(template, varNames) {
if (!template) return false;
return varNames.every((name) => {
@@ -47,15 +37,19 @@ class InvoiceService {
}
_resolvePieceCountForQr(abonement) {
+ const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
+ const totalPacks = breakdown.reduce((sum, item) => sum + Number(item?.packs || item?.quantity || 0), 0);
+ const piecesByPack = totalPacks ? totalPacks * 10 : null;
+ if (piecesByPack != null) {
+ if (piecesByPack >= 120) return 120;
+ if (piecesByPack >= 60) return 60;
+ return null;
+ }
+
const packGroup = String(abonement?.pack_group || '').toLowerCase();
if (packGroup.includes('120')) return 120;
if (packGroup.includes('60')) return 60;
- const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
- const totalPacks = breakdown.reduce((sum, item) => sum + Number(item?.packs || 0), 0);
- const piecesByPack = totalPacks ? totalPacks * 10 : null;
- if (piecesByPack === 60 || piecesByPack === 120) return piecesByPack;
-
return null;
}
@@ -107,35 +101,8 @@ class InvoiceService {
return items;
}
- async _getCompanySettingsQrDataUri(pieceCount) {
- const safePieceCount = pieceCount === 120 ? 120 : 60;
- try {
- const repo = new CompanySettingsRepository();
- const row = await repo.get();
- if (!row) return null;
- const raw = safePieceCount === 120 ? row?.qr_code_120_base64 : row?.qr_code_60_base64;
- const value = (raw == null) ? '' : String(raw).trim();
- if (!value) return null;
- if (value.startsWith('data:image/')) return value;
- const mime = this._inferImageMimeFromBase64(value);
- return `data:${mime};base64,${value}`;
- } catch (e) {
- logger.warn('InvoiceService._getCompanySettingsQrDataUri:error', {
- pieceCount: safePieceCount,
- message: e?.message,
- });
- return null;
- }
- }
-
async _buildQrCodeImageTag({ abonement }) {
- const pieceCount = this._resolvePieceCountForQr(abonement);
- if (!pieceCount) return '';
-
- const dataUri = await this._getCompanySettingsQrDataUri(pieceCount);
- if (!dataUri) return '';
-
- return ``;
+ return '';
}
_escapeHtml(value) {
@@ -310,13 +277,37 @@ class InvoiceService {
return `${this._escapeHtml(bankAccountHolder)}
${this._escapeHtml(bankIban)}
${this._escapeHtml(bankBic)}`;
}
+ async _loadCompanyInfo() {
+ try {
+ const [rows] = await pool.query(
+ `SELECT company_name, company_street, company_postal_city, company_country,
+ company_logo_base64, company_logo_mime_type
+ FROM company_settings
+ WHERE id = 1
+ LIMIT 1`
+ );
+ return rows?.[0] || {};
+ } catch (e) {
+ logger.warn('InvoiceService._loadCompanyInfo:error', { message: e?.message });
+ return {};
+ }
+ }
+
+ _buildCompanyLogoTag(companyInfo) {
+ const base64 = typeof companyInfo?.company_logo_base64 === 'string' ? companyInfo.company_logo_base64.trim() : '';
+ const mimeType = typeof companyInfo?.company_logo_mime_type === 'string' ? companyInfo.company_logo_mime_type.trim() : '';
+ if (!base64 || !mimeType || !mimeType.startsWith('image/')) return '';
+
+ const alt = this._escapeHtml(companyInfo?.company_name || 'Company logo');
+ return ``;
+ }
+
_prepareVariablesForTemplate(templateHtml, variables) {
// Ensure backwards compatibility with older templates that only contain {{paymentInfoText}}
- // by injecting the Profit Planet bank block (and optionally QR) into paymentInfoText.
+ // by injecting the Profit Planet bank block into paymentInfoText.
if (!templateHtml) return variables;
const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
- const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']);
const bankBlock = this._getProfitPlanetBankBlockHtml({
bankAccountHolder: variables.bankAccountHolder || 'Profit Planet GmbH',
@@ -330,11 +321,6 @@ class InvoiceService {
next.paymentInfoText = bankBlock;
}
- if (!supportsQrVar && variables.qrCodeImage) {
- // Append QR under payment info text when there's no dedicated placeholder
- next.paymentInfoText = `${next.paymentInfoText || ''}
${variables.qrCodeImage}`;
- }
-
return next;
}
@@ -344,6 +330,8 @@ class InvoiceService {
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-';
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
+ const taxMode = String(invoice?.context?.tax_mode || 'standard').toLowerCase();
+ const isReverseCharge = taxMode === 'reverse_charge';
// Hardcoded bank info (Profit Planet)
const bankAccountHolder = 'Profit Planet GmbH';
@@ -365,12 +353,14 @@ class InvoiceService {
bankBic,
].join('
');
- // Hardcoded company address (Profit Planet)
+ const storedCompanyInfo = await this._loadCompanyInfo();
const companyInfo = {
- company_name: 'Profit Planet GmbH',
- company_street: 'Kärntner Straße 227',
- company_postal_city: '8053 Graz',
- company_country: '',
+ company_name: storedCompanyInfo.company_name || 'Profit Planet GmbH',
+ company_street: storedCompanyInfo.company_street || 'Kärntner Straße 227',
+ company_postal_city: storedCompanyInfo.company_postal_city || '8053 Graz',
+ company_country: storedCompanyInfo.company_country || 'Austria',
+ company_logo_base64: storedCompanyInfo.company_logo_base64 || null,
+ company_logo_mime_type: storedCompanyInfo.company_logo_mime_type || null,
};
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
@@ -415,6 +405,7 @@ class InvoiceService {
companyStreet: this._escapeHtml(companyInfo.company_street || ''),
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
+ companyLogo: this._buildCompanyLogoTag(companyInfo),
customerName: this._escapeHtml(customerName),
customerEmail: this._escapeHtml(customerEmail),
customerStreet: this._escapeHtml(invoice.buyer_street || ''),
@@ -429,16 +420,20 @@ class InvoiceService {
totalHeader: isDe ? 'Gesamt' : 'Total',
itemsRows: this._buildItemsTableRows(items, invoice.currency),
subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)',
- taxLabel: isDe ? 'MwSt.' : 'Tax',
- vatRateDisplay: vatRate ? `${vatRate}%` : '0%',
+ taxLabel: isReverseCharge ? (isDe ? 'Reverse Charge' : 'Reverse charge') : (isDe ? 'MwSt.' : 'Tax'),
+ vatRateDisplay: isReverseCharge ? (isDe ? 'nicht ausgewiesen' : 'not charged') : (vatRate ? `${vatRate}%` : '0%'),
totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)),
totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)),
totalLabel: isDe ? 'Gesamtbetrag (brutto)' : 'Total (gross)',
totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)),
paymentInfoTitle: isDe ? 'Zahlungsinformationen' : 'Payment Information',
paymentInfoText: isDe
- ? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
- : 'Please transfer the total amount stating the invoice number as reference.',
+ ? (isReverseCharge
+ ? 'Reverse-Charge-Verfahren: Steuerschuldnerschaft des Leistungsempfängers. Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
+ : 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.')
+ : (isReverseCharge
+ ? 'Reverse charge applies: VAT liability shifts to the recipient. Please transfer the total amount stating the invoice number as reference.'
+ : 'Please transfer the total amount stating the invoice number as reference.'),
bankAccountHolder: this._escapeHtml(bankAccountHolder),
bankIban: this._escapeHtml(bankIban),
bankBic: this._escapeHtml(bankBic),
@@ -553,29 +548,14 @@ class InvoiceService {
if (templateHtml) {
const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
- const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']);
- const pieceCountForQr = this._resolvePieceCountForQr(abonement);
logger.info('InvoiceService._sendInvoiceEmail:template_compat', {
invoiceId: invoice?.id,
lang,
supportsBankVars,
- supportsQrVar,
- pieceCountForQr,
- hasQrImage: Boolean(variables?.qrCodeImage),
});
const varsForTemplate = this._prepareVariablesForTemplate(templateHtml, variables);
html = this._renderTemplate(templateHtml, varsForTemplate);
-
- // Final guard: if we still didn't embed QR but we expected one, force local template
- const missingQr = variables.qrCodeImage && !html.includes('data:image/png;base64,');
- if (missingQr) {
- const localTemplate = await this._loadLocalInvoiceTemplateHtml();
- if (localTemplate) {
- const varsForLocal = this._prepareVariablesForTemplate(localTemplate, variables);
- html = this._renderTemplate(localTemplate, varsForLocal);
- }
- }
}
const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });
@@ -598,31 +578,92 @@ class InvoiceService {
}
// NEW: resolve current standard VAT rate for a buyer country code
- async resolveVatRateForCountry(countryCode) {
- if (!countryCode) return null;
+ async _resolveCountryByInput(conn, countryInput) {
+ const raw = String(countryInput || '').trim();
+ if (!raw) return null;
+
+ const code = raw.toUpperCase();
+ const [byCode] = await conn.query(
+ `SELECT id, country_code, country_name FROM countries WHERE UPPER(country_code) = ? LIMIT 1`,
+ [code],
+ );
+ if (byCode?.[0]) return byCode[0];
+
+ const [byName] = await conn.query(
+ `SELECT id, country_code, country_name FROM countries WHERE LOWER(country_name) = LOWER(?) LIMIT 1`,
+ [raw],
+ );
+ return byName?.[0] || null;
+ }
+
+ _normalizeUid(value) {
+ return String(value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
+ }
+
+ _isLikelyValidUid(value) {
+ const uid = this._normalizeUid(value);
+ return /^[A-Z]{2}[A-Z0-9]{6,14}$/.test(uid);
+ }
+
+ async _loadCompanyTaxProfile(userId) {
+ if (!userId) return null;
+ const [rows] = await pool.query(
+ `SELECT registration_number, atu_number, country
+ FROM company_profiles
+ WHERE user_id = ?
+ LIMIT 1`,
+ [userId],
+ );
+ return rows?.[0] || null;
+ }
+
+ async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId }) {
const uow = new UnitOfWork();
await uow.start();
- const taxRepo = new TaxRepository(uow);
+
try {
- const country = await taxRepo.getCountryByCode(String(countryCode).toUpperCase());
- if (!country) {
- await uow.commit();
- return null;
+ const country = await this._resolveCountryByInput(uow.getConnection(), buyerCountry);
+
+ let vatRate = null;
+ if (country?.id) {
+ const [rows] = await uow.getConnection().query(
+ `SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
+ [country.id],
+ );
+ vatRate = rows?.[0]?.standard_rate == null ? null : Number(rows[0].standard_rate);
}
- // get current vat row for this country
- const [rows] = await taxRepo.conn.query(
- `SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
- [country.id]
- );
+
await uow.commit();
- const rate = rows?.[0]?.standard_rate;
- return rate == null ? null : Number(rate);
+
+ const companyProfile = await this._loadCompanyTaxProfile(invoiceOwnerUserId);
+ const uidCandidate = companyProfile?.atu_number || companyProfile?.registration_number || '';
+ const normalizedUid = this._normalizeUid(uidCandidate);
+ const hasValidUid = this._isLikelyValidUid(normalizedUid);
+ const countryCode = String(country?.country_code || '').toUpperCase();
+
+ // Reverse charge for company customers with a valid UID outside seller country (AT).
+ const isReverseCharge = Boolean(hasValidUid && countryCode && countryCode !== 'AT');
+
+ return {
+ vatRate: isReverseCharge ? 0 : vatRate,
+ isReverseCharge,
+ countryCode: countryCode || null,
+ uid: hasValidUid ? normalizedUid : null,
+ };
} catch (e) {
await uow.rollback();
throw e;
}
}
+ async resolveVatRateForCountry(countryCode) {
+ const decision = await this.resolveTaxDecisionForSubscription({
+ buyerCountry: countryCode,
+ invoiceOwnerUserId: null,
+ });
+ return decision?.vatRate ?? null;
+ }
+
// Issue invoice for a subscription period, with items from pack_breakdown
async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, lang = 'en' } = {}) {
console.log('[INVOICE ISSUE] Inputs:', {
@@ -644,8 +685,12 @@ class InvoiceService {
};
const currency = abonement.currency || 'EUR';
- // NEW: resolve invoice vat_rate (standard) from buyer country
- const vat_rate = await this.resolveVatRateForCountry(addr.country);
+ // CHANGED: resolve tax mode for this subscription (standard VAT vs reverse charge)
+ const taxDecision = await this.resolveTaxDecisionForSubscription({
+ buyerCountry: addr.country,
+ invoiceOwnerUserId: actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null,
+ });
+ const vat_rate = taxDecision?.vatRate ?? null;
const items = await this._buildInvoiceItems({ abonement, vatRate: vat_rate, lang });
@@ -655,6 +700,9 @@ class InvoiceService {
period_start: periodStart,
period_end: periodEnd,
referred_by: abonement.referred_by || null,
+ tax_mode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard',
+ customer_country_code: taxDecision?.countryCode || null,
+ uid_number: taxDecision?.uid || null,
};
// CHANGED: prioritize token user id for invoice ownership
@@ -743,18 +791,34 @@ class InvoiceService {
return paidInvoice;
}
+ async syncOverdueStatuses() {
+ return this.repo.markIssuedPastDueAsOverdue();
+ }
+
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
+ await this.syncOverdueStatuses();
return this.repo.listByUser(userId, { status, limit, offset });
}
async listByAbonement(abonementId) {
+ await this.syncOverdueStatuses();
return this.repo.findByAbonement(abonementId);
}
async adminList({ status, limit = 200, offset = 0 } = {}) {
+ await this.syncOverdueStatuses();
return this.repo.listAll({ status, limit, offset });
}
+ async getRevenueSummary() {
+ const summary = await this.repo.getPaidRevenueSummary();
+ return {
+ totalPaidAllTime: Number(summary?.total_paid_all_time || 0),
+ currency: summary?.currency || 'EUR',
+ paidInvoiceCount: Number(summary?.paid_invoice_count || 0),
+ };
+ }
+
async updateStatus(invoiceId, newStatus) {
const invoice = await this.repo.getById(invoiceId);
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
@@ -772,6 +836,7 @@ class InvoiceService {
}
async getInvoiceDetail(invoiceId) {
+ await this.syncOverdueStatuses();
const invoice = await this.repo.getById(invoiceId);
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
const items = await this.repo.getItemsByInvoiceId(invoiceId);
@@ -949,6 +1014,42 @@ class InvoiceService {
return { sentCount: paidInvoices.length };
}
+
+ async adminCreateManual(fields, pdfBuffer = null) {
+ const { uploadBuffer } = require('../../utils/exoscaleUploader');
+
+ const invoice = await this.repo.createManualInvoice({
+ source_type: 'manual',
+ buyer_name: fields.buyer_name || null,
+ buyer_email: fields.buyer_email || null,
+ buyer_street: fields.buyer_street || null,
+ buyer_postal_code: fields.buyer_postal_code || null,
+ buyer_city: fields.buyer_city || null,
+ buyer_country: fields.buyer_country || null,
+ currency: fields.currency || 'EUR',
+ total_net: fields.total_net != null ? Number(fields.total_net) : 0,
+ total_tax: fields.total_tax != null ? Number(fields.total_tax) : 0,
+ total_gross: Number(fields.total_gross || 0),
+ vat_rate: fields.vat_rate != null ? Number(fields.vat_rate) : null,
+ status: fields.status || 'issued',
+ issued_at: fields.issued_at ? new Date(fields.issued_at) : new Date(),
+ due_at: fields.due_at ? new Date(fields.due_at) : null,
+ context: { source: 'admin_manual_upload' },
+ });
+
+ if (pdfBuffer && pdfBuffer.length > 0) {
+ const { objectKey } = await uploadBuffer(
+ pdfBuffer,
+ `invoice-${invoice.invoice_number}.pdf`,
+ 'application/pdf',
+ `invoices/admin/${invoice.id}`,
+ );
+ await this.repo.updateStorageKey(invoice.id, objectKey);
+ return this.repo.getById(invoice.id);
+ }
+
+ return invoice;
+ }
}
module.exports = InvoiceService;
diff --git a/services/login/LoginService.js b/services/login/LoginService.js
index e37c5dd..3e8c66a 100644
--- a/services/login/LoginService.js
+++ b/services/login/LoginService.js
@@ -221,6 +221,63 @@ class LoginService {
throw error;
}
}
+
+ static async validateByRefreshToken(refreshToken) {
+ logger.info('LoginService.validateByRefreshToken:start');
+ const unitOfWork = new UnitOfWork();
+ await unitOfWork.start();
+ unitOfWork.registerRepository('login', new LoginRepository(unitOfWork));
+ unitOfWork.registerRepository('user', new UserRepository(unitOfWork));
+ unitOfWork.registerRepository('status', new UserStatusRepository(unitOfWork));
+
+ try {
+ const loginRepo = unitOfWork.getRepository('login');
+ const tokenRecord = await loginRepo.findRefreshToken(refreshToken);
+ if (!tokenRecord) {
+ const error = new Error('Invalid or expired refresh token');
+ error.status = 401;
+ throw error;
+ }
+
+ if (new Date(tokenRecord.expires_at) < new Date()) {
+ const error = new Error('Refresh token expired');
+ error.status = 401;
+ throw error;
+ }
+
+ const userRepo = unitOfWork.getRepository('user');
+ const user = await userRepo.findUserByEmailOrId(tokenRecord.user_id);
+ if (!user) {
+ const error = new Error('User not found');
+ error.status = 401;
+ throw error;
+ }
+
+ const statusRepo = unitOfWork.getRepository('status');
+ const userStatus = await statusRepo.getStatusByUserId(user.id);
+ if (userStatus && userStatus.status === 'suspended') {
+ const error = new Error('Account suspended');
+ error.status = 403;
+ throw error;
+ }
+
+ const role = await loginRepo.getUserRole(user.id);
+ const permissions = await loginRepo.getUserPermissions(user.id);
+
+ await unitOfWork.commit();
+
+ return {
+ user: {
+ ...user.getPublicData(),
+ role,
+ permissions,
+ },
+ };
+ } catch (error) {
+ await unitOfWork.rollback(error);
+ throw error;
+ }
+ }
}
// Helper for finding user by id or email
diff --git a/services/profile/company/CompanyProfileService.js b/services/profile/company/CompanyProfileService.js
index cf07b82..495dbac 100644
--- a/services/profile/company/CompanyProfileService.js
+++ b/services/profile/company/CompanyProfileService.js
@@ -13,7 +13,6 @@ class CompanyProfileService {
{ key: 'zip_code', label: 'Postal code' },
{ key: 'city', label: 'City' },
{ key: 'country', label: 'Country' },
- { key: 'registrationNumber', label: 'VAT' },
{ key: 'accountHolderName', label: 'Account holder' },
{ key: 'iban', label: 'IBAN' }
];
@@ -26,6 +25,8 @@ class CompanyProfileService {
}
profileData.companyName = (profileData.companyName || '').toString().trim();
+ profileData.registrationNumber = (profileData.registrationNumber || '').toString().trim() || null;
+ profileData.atuNumber = (profileData.atuNumber || profileData.uidNumber || '').toString().trim() || null;
// Pass all profileData including country to repository
const repo = new CompanyUserRepository(unitOfWork);
diff --git a/services/subscriptions/CoffeeService.js b/services/subscriptions/CoffeeService.js
index c492e06..c893d43 100644
--- a/services/subscriptions/CoffeeService.js
+++ b/services/subscriptions/CoffeeService.js
@@ -99,6 +99,30 @@ class CoffeeService {
async listActive() {
return CoffeeRepository.listActive();
}
+
+ async editPictures(id, payload = {}) {
+ const numericId = Number(id);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ throw new Error('Invalid coffee id');
+ }
+
+ const uow = new UnitOfWork();
+ try {
+ await uow.start();
+ const existing = await CoffeeRepository.getById(numericId, uow.connection);
+ if (!existing) {
+ await uow.rollback();
+ return null;
+ }
+
+ const result = await CoffeeRepository.editPictures(numericId, payload, uow.connection);
+ await uow.commit();
+ return result;
+ } catch (e) {
+ try { await uow.rollback(e); } catch(_) {}
+ throw e;
+ }
+ }
}
module.exports = new CoffeeService();
diff --git a/services/template/MailTemplateService.js b/services/template/MailTemplateService.js
new file mode 100644
index 0000000..a2ac694
--- /dev/null
+++ b/services/template/MailTemplateService.js
@@ -0,0 +1,133 @@
+const MailTemplateRepository = require('../../repositories/template/MailTemplateRepository');
+
+class MailTemplateService {
+ _requiredString(value, fieldName) {
+ const parsed = String(value == null ? '' : value).trim();
+ if (!parsed) {
+ const error = new Error(`${fieldName} is required`);
+ error.status = 400;
+ throw error;
+ }
+ return parsed;
+ }
+
+ _optionalString(value) {
+ if (value === undefined || value === null) return null;
+ const parsed = String(value).trim();
+ return parsed || null;
+ }
+
+ _toBool(value, fallback = false) {
+ if (value === undefined || value === null) return fallback;
+ if (typeof value === 'boolean') return value;
+ const normalized = String(value).trim().toLowerCase();
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
+ return fallback;
+ }
+
+ async list(query = {}) {
+ return MailTemplateRepository.list({
+ includeArchived: this._toBool(query.includeArchived, false),
+ templateType: this._optionalString(query.templateType),
+ });
+ }
+
+ async getById(id) {
+ const numericId = Number(id);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ const error = new Error('Invalid id');
+ error.status = 400;
+ throw error;
+ }
+
+ return MailTemplateRepository.getById(numericId);
+ }
+
+ async create(payload = {}, userId = null) {
+ const template_type = this._requiredString(payload.template_type, 'template_type');
+ const name = this._requiredString(payload.name, 'name');
+ const html_content = this._requiredString(payload.html_content, 'html_content');
+
+ return MailTemplateRepository.create({
+ template_type,
+ name,
+ subject: this._optionalString(payload.subject),
+ html_content,
+ userId,
+ });
+ }
+
+ async update(id, payload = {}, userId = null) {
+ const numericId = Number(id);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ const error = new Error('Invalid id');
+ error.status = 400;
+ throw error;
+ }
+
+ const updatePayload = {};
+
+ if (Object.prototype.hasOwnProperty.call(payload, 'template_type')) {
+ updatePayload.template_type = this._requiredString(payload.template_type, 'template_type');
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'name')) {
+ updatePayload.name = this._requiredString(payload.name, 'name');
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'subject')) {
+ updatePayload.subject = this._optionalString(payload.subject);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'html_content')) {
+ updatePayload.html_content = this._requiredString(payload.html_content, 'html_content');
+ }
+
+ updatePayload.userId = userId;
+ return MailTemplateRepository.update(numericId, updatePayload);
+ }
+
+ async activate(id, userId = null) {
+ const numericId = Number(id);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ const error = new Error('Invalid id');
+ error.status = 400;
+ throw error;
+ }
+
+ return MailTemplateRepository.activate(numericId, userId);
+ }
+
+ async archive(id, userId = null) {
+ const numericId = Number(id);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ const error = new Error('Invalid id');
+ error.status = 400;
+ throw error;
+ }
+
+ return MailTemplateRepository.archive(numericId, userId);
+ }
+
+ async unarchive(id, userId = null) {
+ const numericId = Number(id);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ const error = new Error('Invalid id');
+ error.status = 400;
+ throw error;
+ }
+
+ return MailTemplateRepository.unarchive(numericId, userId);
+ }
+
+ async remove(id) {
+ const numericId = Number(id);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ const error = new Error('Invalid id');
+ error.status = 400;
+ throw error;
+ }
+
+ return MailTemplateRepository.delete(numericId);
+ }
+}
+
+module.exports = new MailTemplateService();
diff --git a/templates/abo/abo-contract-template-new.html b/templates/_archive/abo/abo-contract-DE.html
similarity index 99%
rename from templates/abo/abo-contract-template-new.html
rename to templates/_archive/abo/abo-contract-DE.html
index 133cb45..84664be 100644
--- a/templates/abo/abo-contract-template-new.html
+++ b/templates/_archive/abo/abo-contract-DE.html
@@ -344,8 +344,8 @@
ABO Vertrag
+Angebot auf Abschluss eines Kauf- Mietvertrages Kaffee-Service- Kapsel
+Bitte alle Felder vollständig ausfüllen und Zutreffendes ankreuzen.
+Zutreffendes bitte ankreuzen:
+ANGEBOTE
+Mindestbestellmenge für BIO Kaffee und BIO Tee und BIO Kakao beträgt pro Bestellung jeweils 60 Kapseln.
+Preise und Konditionen gemäß gültigem PROFIT PLANET GMBH Tarif.
+| + |
+ VITAMIN KAFFEE + |
+
+ MATCHA & DUBAI + |
+
+ BASIC KAFFEE + |
+
|
+ Preis ohne ABO ( BRUTTO ) + |
+
+ 2.97,-€ + |
+
+ 1,97,-€ + |
+
+ 0,99,-€ + |
+
|
+ Preis mit ABO ( BRUTTO ) + |
+
+ 1.22,-€ + |
+
+ 0,97,-€ + |
+
+ 0,69,-€ + |
+
|
+ Business ABO ( NETTO ) + |
+
+ 1,00,-€ + |
+
+ 0,79,-€ + |
+
+ 0,56,-€ + |
+
|
+ Superfood Coffee + |
+
+ ____________KAPSELN + |
+
|
+ Beauty Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Focus Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Focus Superfood Cafe Espresso + |
+ + |
|
+ Fitness Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Fitness Superfood Cafe Espresso + |
+ + |
|
+ Sleep Well Superfood Cafe Decaf Lungo + |
+ + |
|
+ Glow Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Glow Superfood Cafe Espresso + |
+ + |
|
+ Glow Collagen Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Longevity Mushroom Cafe Espresso + |
+ + |
|
+ Longevity Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Anti Aging Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Anti Aging Superfood Cafe Espresso + |
+ + |
|
+ Beauty Superfood Cafe Espresso Pink Edition + |
+ + |
|
+ Beauty Superfood Cafe Espresso Violett Edition + |
+ + |
|
+ Coffee Dubai Chocolate Style + |
+ + |
|
+ Mighty Matcha Tea + |
+ + |
|
+ Coffee Lungo Crema 7 + |
+ + |
|
+ Coffee Crema 10 + |
+ + |
|
+ Espresso 12 + |
+ + |
|
+ Espresso Intenso 13 + |
+ + |
|
+ Ristretto 14 + |
+ + |
|
+ Decaffeinato Lungo + |
+ + |
Bei Angabe einer automatischen Wiederbestellung, gemäß den Regelungen in nachstehendem Punkt 3, erhält der Kunde in regelmäßigen Abständen, BEGINNEND AM (Unterzeichnung des Vertrages) vorstehend eingetragene BIO Kaffee-Teemenge für die Dauer des Vertrages oder bis zum Widerruf der automatischen Wiederbestellung. Der BIO Kaffee-Tee wird automatisch im Abstand von (zutreffendes bitte ankreuzen)
+1 Monat
+fakturiert und innerhalb von drei bis fünf Werktagen an den Kunden geliefert.
++
Bitte wählen Sie Ihre Zahlungsart:
+§ 1 Vertragsgegenstand/Geltung der Allgemeinen Geschäftsbedingungen
+§ 2 Laufzeit des Vertrages/Zahlung per Lastschrift/Abrechnung
+§ 3 Automatische Wiederbestellungen
+§ 4 Verpflichtung zur Verwendung von Produkten der Profit Planet GmbH Vertragsstrafe/Liefervereinbarung
+§ 5 Wartung und Reparatur
+(1) Wartung und Reparaturen der Kaffeemaschinen sind in der Gestellung wie folgt enthalten:
++
§ 6 Außerordentliche Kündigung
+(3) Im Falle einer außerordentlichen fristlosen Kündigung durch Profit Planet GmbH, behält sich diese vor, dem Kunden eine Deckungsausgleichzahlung für die Restlaufzeit in Höhe von 25% der vereinbarten Mindestabnahmemenge, sowie der vertraglich vereinbarten Mietzinsen in Rechnung zu stellen. Die Geltendmachung weiteren Schadensersatzes bleibt vorbehalten. Dem Kunden bleibt der Nachweis offen, dass kein oder ein wesentlich geringerer Schaden entstanden ist.
+§ 7 Eigentumsverhältnisse
+Die gelieferten Maschinen bleiben Eigentum von Profit Planet GmbH.
+§ 8 Interne Bestellsysteme und Bestellungen, Datenschutz
+Weiteres stimme ich dem Erhalt von exklusiven Angeboten und Informationen wie folgt zu:
+Ich stimme zu, dass die angegebenen Daten von der Profit Planet GmbH verarbeitet und zur Information über exklusive Angebote und sonstige Informationen über E-Mail-Newsletters verwendet werden. Die Zustimmung kann jederzeit per E-Mail an office@profit-planet.com widerrufen werden. Ich akzeptiere hiermit die Datenschutzbestimmungen.
+Ich stimme zu, dass die angegebenen Daten von der Profit Planet GmbH verarbeitet und zur telefonischen Information über exklusive Angebote und sonstige Informationen verwendet werden. Die Zustimmung kann jederzeit per E-Mail an office@profit-planet.com widerrufen werden. Ich akzeptiere hiermit die Datenschutzbestimmungen.
+Sie haben uns schon früher Ihre Zustimmung gegeben und erhalten schon Informationen und Angebote zu unseren Produkten, wollen diese Einwilligung aber jetzt widerrufen: Diese Zustimmung kann jederzeit per E-Mail an office@profit-planet.com widerrufen werden.
+Mit dieser Unterschrift wird (werden) das (die) auf Seite 1 genannte (n) Gerät (e) zu genannten Konditionen übernommen.
+Es gelten die Allgemeinen Geschäftsbedingungen der Profit Planet GmbH als vereinbart. Der Kunde erklärt hiermit ausdrücklich, dass er die Allgemeinen Geschäftsbedingungen und die Datenschutzbestimmungen gelesen hat und diesen zustimmt. Der Vertrag kommt mittels Annahme durch Profit Planet GmbH zustande und ist unter der Voraussetzung einer positiven Bonitätsprüfung gültig.
+Ort: {{signingCity}} Datum: {{currentDate}}
+Informationen über Inhaltsstoffe, Nährwertangaben etc. finden Sie auf der Lieferanten-Homepage www.lanaturalifestyle.com oder unter der Telefonnummer: 0043 552 322 960 zur Verfügung.
+Allgemeine Geschäftsbedingungen Kaffee-Service
+§ 1 Geltungsbereich, Form
+§ 2 Vertragsschluss
+§ 3 Lieferfrist und Lieferverzug
++
§ 4 Lieferung, Gefahrübergang, Abnahme, Annahmeverzug
+§ 5 Preise und Zahlungsbedingungen
+§ 6 Eigentumsvorbehalt
+.
+§ 7 Sachmängel
+(1) In dringenden Fällen, z.B. bei Gefährdung der Betriebssicherheit oder zur Abwehr unverhältnismäßiger Schäden, hat der Käufer das Recht, den Mangel selbst zu beseitigen und von uns Ersatz der hierzu objektiv erforderlichen (angemessenen) Aufwendungen zu verlangen. Von einer derartigen Selbstvornahme sind wir unverzüglich – nach Möglichkeit vorher – zu benachrichtigen. Das Selbstvornahmerecht besteht nicht, wenn wir berechtigt wären, eine entsprechende Nacherfüllung nach den gesetzlichen Vorschriften zu verweigern.
+(2) Wenn die Nacherfüllung fehlgeschlagen ist oder eine für die Nacherfüllung vom Käufer zu setzende angemessene Frist erfolglos abgelaufen oder nach den gesetzlichen Vorschriften entbehrlich ist, kann der Käufer vom Kaufvertrag zurücktreten oder den Kaufpreis mindern. Bei einem unerheblichen Mangel besteht jedoch kein Rücktrittsrecht. Bei Verbrauchern gelten unbeschadet vorstehender Regelungen die gesetzlichen Gewährleistungsrechte uneingeschränkt. Insbesondere beträgt die Gewährleistungsfrist für Verbraucher zwei Jahre ab Übergabe der Ware.
+(3) Ansprüche des Käufers auf Schadensersatz bzw. Ersatz vergeblicher Aufwendungen bestehen auch bei Mängeln nur nach Maßgabe von § 8 und sind im Übrigen ausgeschlossen.
+§ 8 Sonstige Haftung
+(1) Soweit sich aus diesen AGB einschließlich der nachfolgenden Bestimmungen nichts anderes ergibt, haften wir bei der Verletzung vertraglicher und außervertraglicher Pflichten nach den gesetzlichen Vorschriften.
+(2) Wir haften – gleich aus welchem Rechtsgrund – im Rahmen der Verschuldenshaftung bei Vorsatz und grober Fahrlässigkeit. Bei leichter Fahrlässigkeit haften wir nur
+a) für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit,
+b) für Schäden aus der Verletzung wesentlicher Vertragspflichten (d. h. solcher Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Vertragspartner regelmäßig vertrauen darf). In diesem Fall ist unsere Haftung jedoch auf den Ersatz des typischen, vorhersehbaren Schadens begrenzt.
+(3) Die vorstehenden Haftungsbeschränkungen gelten auch zugunsten Dritter sowie für Pflichtverletzungen durch Personen, deren Verschulden uns nach den gesetzlichen Vorschriften zuzurechnen ist. Sie gelten nicht, soweit wir einen Mangel arglistig verschwiegen oder eine Garantie für die Beschaffenheit der Ware übernommen haben sowie bei Ansprüchen nach dem Produkthaftungsgesetz.
+(4) Soweit gesetzlich zulässig, haften wir nicht für mittelbare Schäden, Folgeschäden oder entgangenen Gewinn. Gegenüber Verbrauchern gilt dieser Haftungsausschluss nicht, soweit ein kausaler Zusammenhang mit der Verletzung wesentlicher Vertragspflichten besteht.
+(5) Ein Rücktritt oder eine Kündigung wegen Pflichtverletzung, die nicht auf einem Mangel der Ware beruht, ist nur zulässig, wenn wir diese zu vertreten haben. Ein darüberhinausgehendes freies Rücktritts- oder Kündigungsrecht des Käufers wird – soweit rechtlich zulässig – ausgeschlossen.
+(6) Die zwingenden Bestimmungen des Produkthaftungsgesetzes sowie die Haftung für vorsätzliches oder grob fahrlässiges Verhalten bleiben von den vorstehenden Regelungen unberührt.
+§ 9 Verjährung
+(1) Bei Verträgen mit Unternehmern im Sinne des § 1 KSchG wird die gesetzliche Gewährleistungsfrist für bewegliche Sachen gemäß § 933 ABGB auf ein Jahr ab Übergabe verkürzt. Dies gilt nicht bei Arglist oder bei Übernahme einer Garantie für die Beschaffenheit der Ware.
+(2) Die in Abs. 1 genannte Fristverkürzung gilt nicht für Ansprüche des Käufers wegen Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, bei grob fahrlässigem oder vorsätzlichem Verhalten oder bei Ansprüchen nach dem Produkthaftungsgesetz.
+(3) Schadenersatzansprüche wegen Mängeln (§ 933a ABGB) verjähren unabhängig von einer verkürzten Gewährleistungsfrist innerhalb der gesetzlichen Frist von drei Jahren ab Kenntnis von Schaden und Schädiger.
+(4) Gegenüber Verbrauchern gelten uneingeschränkt die gesetzlichen Gewährleistungs- und Verjährungsfristen (§§ 922 ff ABGB, § 9 KSchG).
+§ 10 Rechtswahl und Gerichtsstand
+(1) Für sämtliche Rechtsverhältnisse zwischen uns und dem Käufer gilt ausschließlich des materiellen Rechtes der Republik Österreich unter Ausschluss des UN-Kaufrechts (CISG) und sonstiger internationaler Kollisionsnormen, soweit zwingende Verbraucherschutzvorschriften nicht entgegenstehen.
+(2) Ist der Käufer Unternehmer im Sinne des § 1 KSchG, so wird für alle Streitigkeiten aus oder im Zusammenhang mit diesem Vertrag einschließlich seiner Gültigkeit und Durchführung das sachlich zuständige Gericht in Graz vereinbart. Wir sind jedoch berechtigt, auch am allgemeinen Gerichtsstand des Käufers oder an einem sonst gesetzlich zulässigen Gerichtsstand Klage zu erheben.
+(3) Gegenüber Verbrauchern gelten die gesetzlichen Gerichtsstandregelungen. Eine abweichende Gerichtsstandsvereinbarung mit Verbrauchern wird nicht getroffen.
+§ 11 Schlussbestimmungen
+Sollten einzelne Bestimmungen dieses Vertrages unwirksam oder nichtig sein oder werden, so berührt dies die Gültigkeit der übrigen Bestimmungen dieses Vertrages nicht. Die Parteien verpflichten sich, unwirksame oder nichtige Bestimmungen durch neue Bestimmungen zu ersetzen, die dem in den unwirksamen oder nichtigen Bestimmungen enthaltenen wirtschaftlichen Regelungsgehalt in rechtlich zulässiger Weise gerecht werden. Entsprechendes gilt, wenn sich in dem Vertrag eine Lücke herausstellen sollte. Zur Ausfüllung der Lücke verpflichten sich die Parteien auf die Etablierung angemessener Regelungen in diesem Vertrag hinzuwirken, die dem am nächsten kommen, was die Vertragsschließenden nach dem Sinn und Zweck dieses Vertrages bestimmt hätten, wenn der Punkt von ihnen bedacht worden wäre.
+Stand der Allgemeinen Geschäftsbedingungen Profit Planet Kaffee-Service: 01.08.2025
+| + |
Informationen für Verbraucher über das Rücktrittsrecht (Widerrufsrecht)
Widerrufsrecht: Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie (oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist) die erste Ware im Rahmen dieses Vertrages in Besitz genommen haben. Bei einem Vertrag über Dienstleistungen (z.B. Miete einer Kaffeemaschine) beginnt die Widerrufsfrist mit dem Tag des Vertragsabschlusses.
+Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (Profit Planet GmbH, Liebenauer Hauptstraße 82c, 8041 Graz, E-Mail: office@profit-planet.com) mittels einer eindeutigen Erklärung (z.B. ein mit der Post versandter Brief oder E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das unten angefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
+Folgen des Widerrufs: Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben – einschließlich etwaiger Lieferkosten (mit Ausnahme jener zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung, als die von uns angebotene günstigste Standardlieferung gewählt haben) – unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart. Ihnen werden wegen dieser Rückzahlung keine Entgelte berechnet.
+Handelt es sich bei dem widerrufenen Vertrag um einen Kaufvertrag über Waren, können wir die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder Sie den Nachweis erbracht haben, dass Sie die Waren abgesandt haben – je nachdem, welcher Zeitpunkt früher eintritt. Sie haben die Waren in diesem Fall unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden. Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.
+Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.
+Haben Sie verlangt, dass eine Dienstleistung (oder die regelmäßige Lieferung von Waren) während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil, der bis zu dem Zeitpunkt der Widerrufsausübung bereits erbrachten Leistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Leistungen entspricht.
+Muster-Widerrufsformular
(Wenn Sie den Vertrag widerrufen wollen, können Sie dieses Formular ausfüllen und an uns zurücksenden.)
+– An Profit Planet GmbH, Liebenauer Hauptstraße 82c, 8041 Graz, E-Mail: office@profit-planet.com:
+– Hiermit widerrufe(n) ich/wir ()
+den von mir/uns () abgeschlossenen Vertrag über den Kauf der folgenden Ware(n)/die Erbringung der folgenden Dienstleistung
+– Bestellt am () / erhalten am ()
+– Name des/der Verbraucher(s)
+– Anschrift des/der Verbraucher(s)
+– Datum
+– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
+ABO pogodba
+Ponudba za sklenitev kupoprodajne/najemne pogodbe za kapsulo za kavno postrežbo
+Prosimo, izpolnite vsa polja in označite ustrezne možnosti.
+Prosimo, označite ustrezno polje:
+PONUDBE
+Minimalna količina naročila Za ekološko kavo, ekološki čaj in ekološki kakav je količina naročila 60 kapsul.
+Cene in pogoji so v skladu z veljavno tarifo PROFIT PLANET GMBH.
+| + |
+ Vitamin Kapsul + |
+
+ Matcha & Dubai Kapsul + |
+
+ Basic Kapsul + |
+
|
+ Stranka brez naročnine bruto + |
+
+ 2,97 € + |
+
+ 1,97 € + |
+
+ 0,99 € + |
+
|
+ Stranka z naročnino bruto + |
+
+ 1,22 € + |
+
+ 0,97 € + |
+
+ 0,69 € + |
+
|
+ Poslovno brez davka neto + |
+
+ 1,00 € + |
+
+ 0,79 € + |
+
+ 0,56 € + |
+
|
+ Superživilska kava + |
+
+ ___________KAPSUL + |
+
|
+ Beauty Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Focus Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Focus Superfood Cafe Espresso + |
+ + |
|
+ Fitness Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Fitnes Superfood Cafe Espresso + |
+ + |
|
+ Sleep Well Superfood Cafe Brez kofeina Lungo + |
+ + |
|
+ Glow Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Kavarna Glow Superfood Espresso + |
+ + |
|
+ Glow Collagen Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Kavarna z gobami dolgoživosti Espresso + |
+ + |
|
+ Longevity Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Anti Aging Superfood Cafe Lungo Forte Crema + |
+ + |
|
+ Espresso s superhrano proti staranju + |
+ + |
|
+ Lepotilni Superfood Cafe Espresso Pink Edition + |
+ + |
|
+ Beauty Superfood Cafe Espresso Violet Edition + |
+ + |
|
+ Kava v Dubaju v čokoladnem slogu + |
+ + |
|
+ Močan čaj Matcha + |
+ + |
|
+ Kava Lungo Crema 7 + |
+ + |
|
+ Kava Crema 10 + |
+ + |
|
+ Espresso 12 + |
+ + |
|
+ Espresso Intenso 13 + |
+ + |
|
+ Ristretto 14 + |
+ + |
|
+ Brez kofeina Lungo + |
+ + |
Z določitvijo samodejnega ponovnega naročila v skladu z določbami v točki 3 spodaj bo stranka prej omenjeno količino ekološke kave in čaja prejemala v rednih intervalih, začenši z (datum podpisa pogodbe), za čas trajanja pogodbe ali do preklica samodejnega ponovnega naročila. Ekološka kava in čaj bosta dostavljena samodejno v intervalih (prosimo, označite ustrezno).
+1 mesec
+fakturirano in dostavljeno stranki v treh do petih delovnih dneh.
++
Izberite način plačila:
+§ 1 Predmet pogodbe/Veljavnost splošnih pogojev poslovanja
+§ 2 Trajanje pogodbe/Plačilo z direktno obremenitvijo/Izstavitev računa
+§ 3 Samodejna ponovna naročila
+§ 4 Obveznost uporabe izdelkov podjetja Profit Planet GmbH Kazen/Dobavna pogodba
+§ 5 Vzdrževanje in popravila
+(1) Vzdrževanje in popravila kavnih avtomatov so vključena v določbo, kot sledi:
++
§ 6Izredna odpoved
+(3) V primeru izredne odpovedi brez odpovednega roka s strani družbe Profit Planet GmbH si družba pridržuje pravico, da stranki zaračuna odškodnino za preostalo obdobje v višini 25 % dogovorjene minimalne količine nakupa, kot tudi pogodbeno dogovorjene najemnine. Pravica do zahtevka za nadaljnjo odškodnino ostaja pridržana. Stranka ima pravico dokazati, da ni nastala nobena škoda ali pa je nastala bistveno manjša škoda.
+§ 7 Lastništvo
+Dobavljeni stroji ostanejo last podjetja Profit Planet GmbH.
+§ 8 Notranji sistemi za naročanje in naročila, varstvo podatkov
+Poleg tega se strinjam s prejemanjem ekskluzivnih ponudb in informacij, kot sledi:
+Strinjam se, da lahko Profit Planet GmbH obdeluje posredovane podatke in me obvešča o ekskluzivnih ponudbah in drugih informacijah prek e-novic. To soglasje lahko kadar koli prekličem s pošiljanjem e-pošte na [e-poštni naslov manjka].office@profit-planet.com.To soglasje je mogoče preklicati. S tem sprejemam politiko zasebnosti.
+Strinjam se, da lahko Profit Planet GmbH obdeluje posredovane podatke in me po telefonu kontaktira glede ekskluzivnih ponudb in drugih informacij. To soglasje lahko kadar koli prekličem po elektronski pošti na naslov office@profit-planet.com. To soglasje je mogoče preklicati. S tem sprejemam politiko zasebnosti.
+Predhodno ste nam dali soglasje in že prejemate informacije in ponudbe o naših izdelkih, zdaj pa želite to soglasje preklicati: To soglasje lahko kadar koli prekličete tako, da pošljete e-pošto na naslov office@profit-planet.com biti razveljavljen.
+S tem podpisom je/so naprava/e, omenjene na strani 1, sprejete/sprejete pod navedenimi pogoji.
+Velja naslednje: Splošni pogoji poslovanja pogodbo s podjetjem Profit Planet GmbH. Stranka s tem izrecno izjavlja, da Splošni pogoji poslovanja in Pravilnik o zasebnosti je prebral/a te pogoje in se z njimi strinja. Pogodba začne veljati z dnem, ko jo sprejme Profit Planet GmbH. Pogodba je sklenjena in veljavna pod pogojem pozitivnega kreditnega preverjanja.
+Kraj, {{signingCity}} datum: {{currentDate}}
+Informacije o sestavinah, hranilnih vrednostih itd.Najdete ga na domači strani dobavitelja.www.lanaturalifestyle.com ali po telefonu: 0043 552 322 960 na voljo.
+Splošni pogoji poslovanja za postrežbo kave
+§ 1 Področje uporabe, oblika
+§ 2 Sklenitev pogodbe
+§ 3 Dobavni rok in zamuda pri dobavi
++
§ 4 Dobava, prenos tveganja, prevzem, neizpolnitev prevzema
+§ 5 Cene in plačilni pogoji
+§ 6 Pridržek lastništva
+.
+§ 7 Bistvene napake
+(1) V nujnih primerih, npr. če je ogrožena obratovalna varnost ali če je treba preprečiti nesorazmerno škodo, ima kupec pravico, da napako odpravi sam in od nas zahteva povračilo objektivno potrebnih (razumnih) nastalih stroškov. O takšni samoodpravi nas je treba nemudoma – če je mogoče, predhodno – obvestiti.Pravica do samoreklamacije ne obstaja, če bi bili upravičeni zavrniti ustrezno naknadno izpolnitev v skladu z zakonskimi določbami.
+(2) Če naknadna izpolnitev ni uspešna ali če razumen rok, ki ga je kupec določil za naknadno izpolnitev, poteče brez uspeha ali če naknadna izpolnitev v skladu z zakonskimi določbami ni potrebna, lahko kupec odstopi od kupoprodajne pogodbe ali zniža kupnino. Vendar pa v primeru nepomembne napake ni pravice do odstopa od pogodbe. Ne glede na zgornje določbe veljajo zakonske garancijske pravice za potrošnike brez omejitev. Zlasti garancijski rok za potrošnike je dve leti od dobave blaga.
+(3) Kupčeve zahtevke za odškodnino ali povračilo nepotrebnih stroškov obstajajo tudi v primeru napak le v skladu z 8. členom in so sicer izključeni.
+§ 8 Druga odgovornost
+(1) Razen če ni v teh pogojih poslovanja, vključno z naslednjimi določbami, določeno drugače, odgovarjamo za kršitve pogodbenih in nepogodbenih obveznosti v skladu z zakonskimi predpisi.
+(2) Ne glede na pravno podlago odgovarjamo v okviru odgovornosti za krivdo v primerih naklepa in hude malomarnosti. V primerih lahke malomarnosti odgovarjamo le.
+a) za škodo, nastalo zaradi poškodbe življenja, telesa ali zdravja,
+b) za škodo, ki izhaja iz kršitve bistvenih pogodbenih obveznosti (tj. obveznosti, katerih izpolnitev je bistvena za pravilno izvajanje pogodbe in na katere spoštovanje se pogodbeni partner lahko redno zanese). V tem primeru pa je naša odgovornost omejena na odškodnino za tipično, predvidljivo škodo.
+(3) Zgoraj navedene omejitve odgovornosti veljajo tudi v korist tretjih oseb in za kršitve dolžnosti oseb, katerih krivda je v skladu z zakonskimi določbami naša. Ne veljajo, če smo goljufivo prikrili napako ali prevzeli jamstvo za kakovost blaga, niti ne veljajo za zahtevke po Zakonu o odgovornosti za izdelke.
+(4) V obsegu, ki ga dovoljuje zakonodaja, ne odgovarjamo za posredno škodo, posledično škodo ali izgubljeni dobiček. Ta izključitev odgovornosti ne velja za potrošnike, če obstaja vzročna zveza s kršitvijo bistvenih pogodbenih obveznosti.
+(5) Odstop od pogodbe ali odpoved pogodbe zaradi kršitve pogodbe, ki ne temelji na napaki blaga, je dovoljena le, če smo zanjo odgovorni.Vsaka nadaljnja prosta pravica kupca do odstopa od pogodbe ali odpovedi je izključena – v obsegu, ki je zakonsko dovoljen.
+(6) Zgornji predpisi ne vplivajo na obvezne določbe Zakona o odgovornosti za izdelke in odgovornost za namerno ali hudo malomarno ravnanje.
+§ 9 Zastaralni rok
+(1) V pogodbah s podjetji, kot so opredeljene v 1. členu avstrijskega zakona o varstvu potrošnikov (KSchG), se zakonsko določena garancijska doba za premičnine v skladu z 933. členom avstrijskega civilnega zakonika (ABGB) skrajša na eno leto od dobave. To ne velja v primerih goljufivega prikrivanja ali če je bila dana garancija za kakovost blaga.
+(2) Skrajšanje roka iz 1. odstavka ne velja za zahtevke kupca za odškodnino, ki je nastala zaradi poškodbe življenja, telesa ali zdravja, v primerih hude malomarnosti ali namernega ravnanja ali za zahtevke v skladu z Zakonom o odgovornosti za izdelke.
+(3) Zahtevki za odškodnino zaradi napak (§ 933a ABGB) zastarajo v zakonskem roku treh let od vedenja za škodo in oškodovalca, ne glede na skrajšano garancijsko dobo.
+(4) Zakonski garancijski in zastaralni roki veljajo za potrošnike brez omejitev (§§ 922 in naslednji ABGB, § 9 KSchG).
+§ 10 Izbira prava in pristojnosti
+(1) Vsa pravna razmerja med nami in kupcem so predmet naslednjega:izključno materialno pravo Republike Avstrije, z izjemo Konvencije ZN o pogodbah o mednarodni prodaji blaga (CISG) in drugih mednarodnih kolizijskih pravil, razen če obvezni predpisi o varstvu potrošnikov določajo drugače.
+(2) Če je kupec podjetnik v smislu 1. člena avstrijskega zakona o varstvu potrošnikov (KSchG), je za vse spore, ki izhajajo iz te pogodbe ali so z njo povezani, vključno z njeno veljavnostjo in izpolnjevanjem, pristojno sodišče v Gradcu. Vendar pa smo upravičeni tudi do vložitve tožbe na splošnem kraju pristojnosti kupca ali na katerem koli drugem zakonsko dovoljenem kraju pristojnosti.
+(3) Za potrošnike veljajo zakonska pravila o pristojnosti. S potrošniki se ne bo sklenil noben odstopni dogovor o pristojnosti.
+§ 11 Končne določbe
+Če bi katera koli določba te pogodbe bila ali postala neveljavna ali neizvršljiva, to ne vpliva na veljavnost preostalih določb. Stranki se zavezujeta, da bosta vse neveljavne ali neizvršljive določbe nadomestili z novimi določbami, ki v obsegu, ki je pravno dovoljen, odražajo ekonomski namen neveljavnih ali neizvršljivih določb. Enako velja, če se v tej pogodbi ugotovi vrzel. Da bi zapolnili takšno vrzel, se stranki zavezujeta, da si bosta prizadevali za vzpostavitev ustreznih določb v tej pogodbi, ki se bodo čim bolj približale temu, kar bi pogodbeni stranki nameravali v skladu z namenom in namenom te pogodbe, če bi zadevo upoštevali.
+Pogoji poslovanja za storitev Profit Planet Coffee, veljavni od 1. avgusta 2025
+| + |
Informacije za potrošnike o pravici do odstopa od pogodbe (pravica do preklica)
Pravica do odstopa od pogodbe:Od te pogodbe imate pravico odstopiti v 14 dneh brez navedbe razloga. Odstopni rok poteče 14 dni od dneva, ko vi ali tretja oseba, ki ni prevoznik in jo določite vi, pridobi fizično posest prvega blaga iz te pogodbe. V primeru pogodbe o storitvah (npr. najem kavnega avtomata) odstopni rok začne teči z dnem sklenitve pogodbe.
+Za uveljavljanje pravice do odstopa od pogodbe nas morate (Profit Planet GmbH, Liebenauer Hauptstraße 82c, 8041 Gradec, Avstrija, e-pošta: office@profit-planet.com ) obvestiti o svoji odločitvi o odstopu od te pogodbe z nedvoumno izjavo (npr. s pismom, poslanim po pošti ali elektronski pošti). Uporabite lahko priloženi vzorčni obrazec za odstop od pogodbe, vendar to ni obvezno. Za spoštovanje roka za odstop od pogodbe zadostuje, da nam sporočilo o uveljavljanju pravice do odstopa od pogodbe pošljete pred iztekom roka za odstop od pogodbe.
+Posledice preklica:Če odstopite od te pogodbe, vam bomo brez nepotrebnega odlašanja in najkasneje v štirinajstih dneh od dneva, ko smo prejeli vaše obvestilo o odstopu od pogodbe, povrnili vsa prejeta plačila – vključno s stroški dostave (razen morebitnih dodatnih stroškov, ki bi nastali, če bi izbrali način dostave, ki ni naša najcenejša standardna možnost dostave) – povrnili vsa plačila, ki ste jih prejeli od vas – vključno s stroški dostave (razen morebitnih dodatnih stroškov, ki bi nastali, če bi izbrali način dostave, ki ni naša najcenejša standardna možnost dostave). Za to povračilo bomo uporabili isti način plačila, kot ste ga uporabili za prvotno transakcijo, razen če ni izrecno dogovorjeno drugače. Za to povračilo vam ne bomo zaračunali nobenih stroškov.
+Če je odpovedana pogodba kupoprodajna pogodba za blago, lahko zadržimo vračilo kupnine, dokler ne prejmemo blaga nazaj ali dokler nam ne predložite dokazila o vrnitvi blaga, kar nastopi prej. V tem primeru nam morate blago vrniti ali izročiti brez nepotrebnega odlašanja in v vsakem primeru najpozneje v štirinajstih dneh od dneva, ko nas obvestite o odstopu od te pogodbe. Rok se šteje za izpolnjen, če blago pošljete pred iztekom štirinajstdnevnega roka. Neposredne stroške vračila blaga krijete sami.
+Za izgubo vrednosti blaga odgovarjate le, če je ta izguba vrednosti posledica ravnanja z blagom, ki ni potrebno za preizkus njegovega stanja, lastnosti in funkcionalnosti.
+Če ste zahtevali, da se storitev (ali redna dobava blaga) začne v roku za odstop od pogodbe, nam morate plačati razumen znesek, ki ustreza deležu storitev, ki so bile že opravljene do trenutka uveljavljanja vaše pravice do odstopa od pogodbe, v primerjavi s celotnim obsegom storitev, predvidenih v pogodbi.
+Vzorec obrazca za odpoved
(Če želite preklicati pogodbo, lahko izpolnite ta obrazec in nam ga vrnete.)
+– Za Profit Planet GmbH, Liebenauer Hauptstraße 82c, 8041 Graz, E-pošta:office@profit-planet.com:
+– S tem prekličem/prekličemo ()
+tisti od mene/nas () sklenjena pogodba za nakup naslednjega blaga/opravljanje naslednje storitve
+– Naročeno dne () / prejeto dne ()
+– Ime potrošnika/potrošnikov
+– Naslov potrošnika(-ov)
+- Datum
+– Podpis potrošnika(-ov) (samo za obvestila na papirju)
+i.S.d. Art. 28 Abs. 3 Datenschutz-Grundverordnung (DS-GVO)
+abgeschlossen zwischen
+Profit Planet GmbH (kurz Auftraggeber)
+ FN 649474i
+ Kärntner Straße 227
+ A-8053 Graz
und
+Vertriebspartner (kurz Auftragnehmer)
+ + + +1. Diese Anlage konkretisiert die Verpflichtungen der Vertragsparteien zum Datenschutz, die sich aus der im bestehenden Vertriebspartner-Vertrag („Hauptvertrag“) und seinen Anlagen in ihren Einzelheiten beschriebenen Auftragsverarbeitung ergeben. Sie findet Anwendung auf alle Tätigkeiten, die mit dem Vertrag in Zusammenhang stehen, und bei denen Beschäftigte des Auftragnehmers oder durch den Auftragnehmer Beauftragte personenbezogene Daten („Daten“) des Auftraggebers verarbeiten.
+2. Der Auftragnehmer ist sich bewusst, dass der Auftraggeber als Auftragsverarbeiter für Dritte („Verantwortliche“ im Sinne des Art. 4 Nr. 7 DS-GVO) tätig ist. Im Rahmen des vorbezeichneten Hauptvertrags nimmt der Auftraggeber die Dienste des Auftragnehmers als „weiteren Auftragsverarbeiter“ im Sinne von Art. 28 Nr. 4 DS-GVO in Anspruch, um bestimmte Verarbeitungstätigkeiten im Namen des Dritten („Verantwortlicher“ iSd Art. 4 Nr. 7 DS-GVO) auszuführen.
+3. Der Auftragnehmer ist sich bewusst, dass der Auftraggeber gegenüber Dritten für die Einhaltung der Pflichten des Auftragnehmers haftet, falls der Auftragnehmer seinen Datenschutzpflichten nach diesem Vertrag und nach dem Gesetz nicht nachkommt.
+4. Die Laufzeit dieser Anlage richtet sich nach der Laufzeit des Vertriebspartner-Vertrages, sofern sich aus den Bestimmungen dieser Anlage nicht darüber hinausgehende Verpflichtungen ergeben.
+1. Alle Daten dürfen nur so lange verarbeitet werden, als das durch die Vertragserfüllung oder den Zweck der Datenverarbeitung erforderlich ist.
+2. Aus dem Vertrag ergeben sich Gegenstand und Dauer des Auftrags sowie Art und Zweck der Verarbeitung.
+3. Im Einzelnen sind insbesondere die folgenden Daten Bestandteil der Datenverarbeitung:
+| Art der Daten | +Interessenten- und Kundendaten; Kontaktdaten beim Auftraggeber; Kontaktdaten des jeweiligen Datenverantwortlichen | +
|---|---|
| Art und Zweck der Datenverarbeitung | +Datenerfassung beim Interessenten (potenziellen Kunden); Datenübermittlung (auch elektronisch via E-Mail bzw. falls vorhanden über elektronische Schnittstellen der Verantwortlichen) an Auftraggeber bzw. Datenverantwortliche zur Legung eines Angebots bzw. zur Verwirklichung der Kundenbestellung; ggf. telefonischer Nachkontakt zur Qualitätskontrolle | +
| Kategorien betroffener Daten | +Name, Vorname, Adresse, Geburtsdatum, SV-Nr., E-Mail, Kontodaten, Ausweiskopie; Daten zur Energieversorgung (z.B. Zählpunkt, Zählernummer, Kilowattprognose, Jahresverbrauch); Aufzeichnung etwaiger Qualitätskontrollen; Aufzeichnung etwaiger Interessensgebiete im Bereich Versicherung, Kreditwirtschaft, Telekommunikation, Energieeffizienz (PV, Speicher, LED, Infrarotheizung, Kalkschutz…). | +
1. Der Auftragnehmer verarbeitet personenbezogene Daten im Auftrag des Auftraggebers. Dies umfasst Tätigkeiten, die im Vertrag und in der Leistungsbeschreibung konkretisiert sind.
+2. Der Auftraggeber ist gegenüber dem/den Dritten als („Verantwortliche Person“ iSd Art. 4 Nr. 7 DS-GVO) für die Einhaltung der gesetzlichen Bestimmungen der Datenschutzgesetze, insbesondere für die Rechtmäßigkeit der Datenweitergabe an den Auftragnehmer sowie für die Rechtmäßigkeit der Datenverarbeitung verantwortlich.
+3. Der Auftragnehmer ist gegenüber dem Auftraggeber im Rahmen dieses Vertrages für die Einhaltung der gesetzlichen Bestimmungen der Datenschutzgesetze, insbesondere für die Rechtmäßigkeit der Datenweitergabe sowie der Datenverarbeitung verantwortlich.
+4. Die Weisungen werden anfänglich durch diese Vertragsanlage festgelegt und können vom Auftraggeber danach in schriftlicher Form oder in einem elektronischen Format (Textform) an die vom Auftragnehmer bezeichnete Stelle durch einzelne Weisungen geändert, ergänzt oder ersetzt werden (Einzelweisung). Weisungen, die in der Vertragsanlage nicht vorgesehen sind, werden als Antrag auf Leistungsänderung behandelt. Mündliche Weisungen sind unverzüglich schriftlich oder in Textform zu bestätigen.
+1. Der Auftragnehmer darf Daten von betroffenen Personen nur im Rahmen des Auftrages und der Weisungen des Auftraggebers verarbeiten, außer es liegt ein Ausnahmefall iSd Art 28 Abs. 3 a) DS-GVO vor. Der Auftragnehmer informiert den Auftraggeber unverzüglich, wenn er der Auffassung ist, dass eine Weisung gegen anwendbare Gesetze verstößt. Der Auftragnehmer darf die Umsetzung der Weisung solange aussetzen, bis sie vom Auftraggeber bestätigt oder abgeändert wurde.
+2. Der Auftragnehmer wird in seinem Verantwortungsbereich die innerbetriebliche Organisation so gestalten, dass sie den besonderen Anforderungen des Datenschutzes gerecht wird. Er wird technische und organisatorische Maßnahmen zum angemessenen Schutz der Daten des Auftraggebers treffen, die den Anforderungen der Datenschutz- Grundverordnung (Art. 32 DS-GVO) genügen. Der Auftragnehmer hat technische und organisatorische Maßnahmen zu treffen, die die Vertraulichkeit, Integrität, Verfügbarkeit und Belastbarkeit der Systeme und Dienste im Zusammenhang mit der Verarbeitung auf Dauer sicherstellen. Der Auftraggeber ist berechtigt, diese technischen und organisatorischen Maßnahmen dahingehend zu überprüfen, ob sie für die Risiken der zu verarbeitenden Daten ein angemessenes Schutzniveau bieten. Eine Änderung der getroffenen Sicherheitsmaßnahmen bleibt dem Auftragnehmer vorbehalten, wobei jedoch sichergestellt sein muss, dass das vertraglich vereinbarte Schutzniveau nicht unterschritten wird.
+3. Der Auftragnehmer gewährleistet, seinen Pflichten nach Art. 32 Abs. 1 lit. d) DS-GVO nachzukommen, ein Verfahren zur regelmäßigen Überprüfung der Wirksamkeit der technischen und organisatorischen Maßnahmen zur Gewährleistung der Sicherheit der Verarbeitung einzusetzen.
+4. Der Auftragnehmer unterstützt den Auftraggeber im Rahmen seiner Möglichkeiten bei der Erfüllung der Anfragen und Ansprüche betroffener Personen gem. Kapitel III der DS-GVO sowie bei der Einhaltung der in Art. 33 bis 36 DS-GVO genannten Pflichten.
+5. Der Auftragnehmer gewährleistet, dass es den mit der Verarbeitung der Daten des Auftraggebers befassten Mitarbeiter und andere für den Auftragnehmer tätigen Personen untersagt ist, die Daten außerhalb der Weisung zu verarbeiten. Ferner gewährleistet der Auftragnehmer, dass sich die zur Verarbeitung der personenbezogenen Daten befugten Personen zur Vertraulichkeit verpflichtet haben oder einer angemessenen gesetzlichen Verschwiegenheitspflicht unterliegen. Die Vertraulichkeits-/ Verschwiegenheitspflicht besteht auch nach Beendigung des Auftrages fort.
+6. Der Auftragnehmer unterrichtet den Auftraggeber unverzüglich, wenn ihm Verletzungen des Schutzes personenbezogener Daten des Auftraggebers bekannt werden. Der Auftragnehmer trifft die erforderlichen Maßnahmen zur Sicherung der Daten und zur Minderung möglicher nachteiliger Folgen der betroffenen Personen und spricht sich hierzu unverzüglich mit dem Auftraggeber ab.
+7. Der Auftragnehmer nennt dem Auftraggeber den Ansprechpartner für im Rahmen des Vertrages anfallende Datenschutzfragen.
+8. Der Auftragnehmer berichtigt oder löscht die vertragsgegenständlichen Daten, wenn der Auftraggeber dies anweist und dies vom Weisungsrahmen umfasst ist. Ist eine datenschutzkonforme Löschung oder eine entsprechende Einschränkung der Datenverarbeitung nicht möglich, übernimmt der Auftragnehmer die datenschutzkonforme Vernichtung von Datenträgern und sonstigen Materialien auf Grund einer Einzelbeauftragung durch den Auftraggeber oder gibt diese Datenträger an den Auftraggeber zurück, sofern nicht im Vertrag bereits vereinbart.
+9. Daten, Datenträger sowie sämtliche sonstige Materialien sind nach Auftragsende auf Verlangen des Auftraggebers entweder herauszugeben oder zu löschen.
+10. Im Falle einer Inanspruchnahme des Auftraggebers oder des Dritten durch eine betroffene Person hinsichtlich etwaiger Ansprüche nach Art. 82 DS-GVO, verpflichtet sich der Auftragnehmer den Auftraggeber bei der Abwehr des Anspruches im Rahmen seiner Möglichkeiten zu unterstützen.
+11. Im Falle einer Inanspruchnahme des Auftraggebers durch den Dritten, verpflichtet sich der Auftragnehmer den Auftraggeber bei der Abwehr des Anspruches im Rahmen seiner Möglichkeiten zu unterstützen.
+1. Der Auftraggeber hat den Auftragnehmer unverzüglich und vollständig zu informieren, wenn er in den Auftragsergebnissen Fehler oder Unregelmäßigkeiten bzgl. datenschutzrechtlicher Bestimmungen feststellt.
+2. Im Falle einer Inanspruchnahme des Auftraggebers oder des Dritten durch eine betroffene Person hinsichtlich etwaiger Ansprüche nach Art. 82 DS-GVO, gilt §3 Abs. 10 entsprechend.
+3. Der Auftraggeber nennt dem Auftragnehmer den Ansprechpartner für im Rahmen des Vertrages anfallende Datenschutzfragen.
+1. Wendet sich eine betroffene Person mit Forderungen zur Berichtigung, Löschung oder Auskunft an den Auftragnehmer, wird der Auftragnehmer die betroffene Person an den Auftraggeber verweisen und ggf. den Antrag der betroffenen Person unverzüglich an den Auftraggeber weiterleiten. Der Auftragnehmer unterstützt den Auftraggeber im Rahmen seiner Möglichkeiten bei der Erfüllung der jeweiligen Forderung.
+2. Der Auftragnehmer haftet nicht, wenn das Ersuchen der betroffenen Person vom Auftraggeber nicht, nicht richtig oder nicht fristgerecht beantwortet wird.
+3. Der Auftraggeber haftet nicht für Forderungen betroffener Personen, die dadurch entstehen, dass der Auftragnehmer das entsprechende Anliegen nicht zeitgerecht an den Auftraggeber übermittelt hat.
+1. Der Auftragnehmer weist dem Auftraggeber die Einhaltung der in diesem Vertrag niedergelegten Pflichten mit geeigneten Mitteln nach.
+2. Sollten im Einzelfall Inspektionen durch den Auftraggeber oder einen von diesem beauftragten Prüfer erforderlich sein, werden diese zu den üblichen Geschäftszeiten ohne Störung des Betriebsablaufs nach Anmeldung unter Berücksichtigung einer angemessenen Vorlaufzeit durchgeführt. Der Auftragnehmer darf diese von der Unterzeichnung einer Verschwiegenheitserklärung hinsichtlich der Daten anderer Kunden und der eingerichteten technischen und organisatorischen Maßnahmen abhängig machen. Sollte der durch den Auftraggeber beauftragte Prüfer in einem Wettbewerbsverhältnis zu dem Auftragnehmer stehen, hat der Auftragnehmer gegen diesen ein Einspruchsrecht.
+1. Der Einsatz von Subunternehmern als weitere Auftragsverarbeiter ist nur zulässig, wenn der Auftraggeber vorher zugestimmt hat.
+2. Ein zustimmungspflichtiges Subunternehmerverhältnis liegt vor, wenn der Auftragnehmer weitere Auftragnehmer mit der ganzen oder einer Teilleistung der im Vertrag vereinbarten Leistung beauftragt. Der Auftragnehmer wird mit diesen Dritten im erforderlichen Umfang Vereinbarungen treffen, um angemessene Datenschutz- und Informationssicherheitsmaßnahmen zu gewährleisten.
+3. Erteilt der Auftragnehmer Aufträge an Subunternehmer, so obliegt es dem Auftragnehmer, seine datenschutzrechtlichen Pflichten aus diesem Vertrag dem Subunternehmer zu überbinden.
+1. Sollten die Daten des Auftraggebers beim Auftragnehmer durch Pfändung oder Beschlagnahme, durch ein Insolvenz- oder Vergleichsverfahren oder durch sonstige Ereignisse oder Maßnahmen Dritter gefährdet werden, so hat der Auftragnehmer den Auftraggeber unverzüglich darüber zu informieren. Der Auftragnehmer wird alle in diesem Zusammenhang Verantwortlichen unverzüglich darüber informieren, dass die Hoheit und das Eigentum an den Daten ausschließlich beim Dritten als verantwortliche Person im Sinne der Datenschutz-Grundverordnung liegen.
+2. Änderungen und Ergänzungen dieser Anlage und aller ihrer Bestandteile – einschließlich etwaiger Zusicherungen des Auftragnehmers – bedürfen einer schriftlichen Vereinbarung, die auch in einem elektronischen Format (Textform) erfolgen kann, und des ausdrücklichen Hinweises darauf, dass es sich um eine Änderung bzw. Ergänzung dieser Bedingungen handelt. Dies gilt auch für den Verzicht auf dieses Formerfordernis.
+3. Bei etwaigen Widersprüchen gehen Regelungen dieser Anlage zum Datenschutz den Regelungen des Vertrages vor. Sollten einzelne Teile dieser Anlage unwirksam sein, so berührt dies die Wirksamkeit der Anlage im Übrigen nicht.
+4. Es gilt das auf dem Hauptvertrag anwendbare Recht sowie Gerichtsstand.
+Für PROFIT PLANET (Auftraggeber)
+Datum, Unterschrift
+Für den VP (Auftragnehmer)
+Name, Datum, Unterschrift
+Für PROFIT PLANET (Auftraggeber) Für den VP (Auftragnehmer)
+.............................................................................. ...............................................................................
+Datum, Unterschrift Name, Datum, Unterschrift
++