attachments
This commit is contained in:
@@ -31,6 +31,8 @@ let lineCount = 0;
|
|||||||
|
|
||||||
let isSaving = false;
|
let isSaving = false;
|
||||||
|
|
||||||
|
let selectedFile = null; // File object aus <input type="file">
|
||||||
|
let attachmentLimits = null; // { maxBytes, maxMb, allowedMimeTypes, allowedExtensions }
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
// Helpers
|
// Helpers
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
@@ -55,6 +57,83 @@ function fmtMoney(n) {
|
|||||||
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onFileSelected(e) {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
handleFile(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const zone = document.getElementById('exp-attach-zone');
|
||||||
|
if (zone) zone.classList.remove('border-blue-500', 'bg-blue-50');
|
||||||
|
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
handleFile(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
// Validate type
|
||||||
|
const allowed = attachmentLimits?.allowedMimeTypes ||
|
||||||
|
['image/png','image/jpeg','image/jpg','image/heic','image/heif','application/pdf'];
|
||||||
|
if (!allowed.includes(file.type)) {
|
||||||
|
alert(`Unsupported file type: ${file.type || 'unknown'}\nAllowed: PNG, JPG, HEIC, PDF`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = attachmentLimits?.maxBytes || (5 * 1024 * 1024);
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
const maxMb = attachmentLimits?.maxMb || 5;
|
||||||
|
alert(`File too large: ${(file.size/1024/1024).toFixed(2)} MB\nMax: ${maxMb} MB`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile = file;
|
||||||
|
renderFilePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFile() {
|
||||||
|
selectedFile = null;
|
||||||
|
const input = document.getElementById('exp-attach-input');
|
||||||
|
if (input) input.value = '';
|
||||||
|
renderFilePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilePreview() {
|
||||||
|
const el = document.getElementById('exp-attach-preview');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (!selectedFile) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
el.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMb = (selectedFile.size / 1024 / 1024).toFixed(2);
|
||||||
|
const isImage = selectedFile.type.startsWith('image/') && selectedFile.type !== 'image/heic' && selectedFile.type !== 'image/heif';
|
||||||
|
|
||||||
|
let thumbHtml = '';
|
||||||
|
if (isImage) {
|
||||||
|
const url = URL.createObjectURL(selectedFile);
|
||||||
|
thumbHtml = `<img src="${url}" class="h-16 w-16 object-cover rounded border border-gray-200" onload="URL.revokeObjectURL(this.src)">`;
|
||||||
|
} else if (selectedFile.type === 'application/pdf') {
|
||||||
|
thumbHtml = `<div class="h-16 w-16 bg-red-50 border border-red-200 rounded flex items-center justify-center text-red-600 font-bold text-xs">PDF</div>`;
|
||||||
|
} else {
|
||||||
|
thumbHtml = `<div class="h-16 w-16 bg-gray-100 border border-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">${selectedFile.type.split('/')[1] || 'file'}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded">
|
||||||
|
${thumbHtml}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">${escapeHtml(selectedFile.name)}</p>
|
||||||
|
<p class="text-xs text-gray-500">${sizeMb} MB · ${selectedFile.type || 'unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="window.expenseModal.clearFile()"
|
||||||
|
class="text-red-500 hover:text-red-700 text-sm px-2">Remove</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
// Public Entry
|
// Public Entry
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
@@ -68,11 +147,12 @@ export async function openExpenseModal({ onSaved } = {}) {
|
|||||||
|
|
||||||
// Lade Stammdaten parallel (alles aus dem Cache → schnell)
|
// Lade Stammdaten parallel (alles aus dem Cache → schnell)
|
||||||
try {
|
try {
|
||||||
[vendors, expenseAccounts, paymentAccounts, paymentMethods] = await Promise.all([
|
[vendors, expenseAccounts, paymentAccounts, paymentMethods, attachmentLimits] = await Promise.all([
|
||||||
window.API.accounting.getVendors('', 1000),
|
window.API.accounting.getVendors('', 1000),
|
||||||
window.API.accounting.getExpenseAccounts(),
|
window.API.accounting.getExpenseAccounts(),
|
||||||
window.API.accounting.getPaymentAccounts(),
|
window.API.accounting.getPaymentAccounts(),
|
||||||
window.API.accounting.getPaymentMethods()
|
window.API.accounting.getPaymentMethods(),
|
||||||
|
window.API.accounting.getAttachmentLimits()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Errors aus dem Backend abfangen
|
// Errors aus dem Backend abfangen
|
||||||
@@ -105,6 +185,7 @@ function resetForm() {
|
|||||||
document.getElementById('exp-ref-no').value = '';
|
document.getElementById('exp-ref-no').value = '';
|
||||||
document.getElementById('exp-memo').value = '';
|
document.getElementById('exp-memo').value = '';
|
||||||
document.getElementById('exp-date').value = todayISO();
|
document.getElementById('exp-date').value = todayISO();
|
||||||
|
clearFile();
|
||||||
document.getElementById('exp-lines-tbody').innerHTML = '';
|
document.getElementById('exp-lines-tbody').innerHTML = '';
|
||||||
lineCount = 0;
|
lineCount = 0;
|
||||||
addLine();
|
addLine();
|
||||||
@@ -213,6 +294,30 @@ function renderModal() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachment -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Attachment <span class="text-gray-400 text-xs font-normal">(optional — receipt screenshot or PDF)</span>
|
||||||
|
</label>
|
||||||
|
<div id="exp-attach-zone"
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-md p-4 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition"
|
||||||
|
onclick="document.getElementById('exp-attach-input').click()"
|
||||||
|
ondragover="event.preventDefault(); this.classList.add('border-blue-500','bg-blue-50');"
|
||||||
|
ondragleave="this.classList.remove('border-blue-500','bg-blue-50');"
|
||||||
|
ondrop="window.expenseModal.onFileDrop(event)">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
📎 Drop file here or click to select
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
|
${attachmentLimits ? `PNG, JPG, HEIC, PDF · max ${attachmentLimits.maxMb} MB` : 'Loading limits…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="exp-attach-input" class="hidden"
|
||||||
|
accept=".png,.jpg,.jpeg,.heic,.heif,.pdf,image/png,image/jpeg,image/heic,application/pdf"
|
||||||
|
onchange="window.expenseModal.onFileSelected(event)">
|
||||||
|
<div id="exp-attach-preview" class="hidden mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Memo</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Memo</label>
|
||||||
<textarea id="exp-memo" rows="2"
|
<textarea id="exp-memo" rows="2"
|
||||||
@@ -577,6 +682,25 @@ async function save(mode) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment hochladen, falls vorhanden
|
||||||
|
if (selectedFile && result.id) {
|
||||||
|
try {
|
||||||
|
setButtonsDisabled(true, 'Uploading attachment…');
|
||||||
|
const attachResult = await window.API.accounting.attachToExpense(
|
||||||
|
result.id,
|
||||||
|
selectedFile
|
||||||
|
);
|
||||||
|
if (attachResult.error) {
|
||||||
|
// Expense ist schon erstellt — Fehler nur anzeigen, nicht blockieren
|
||||||
|
alert(`⚠️ Expense saved (Purchase #${result.id}) but attachment failed:\n${attachResult.error}\n\nYou can attach the file directly in QBO.`);
|
||||||
|
} else {
|
||||||
|
console.log(`📎 Attachment uploaded: ${attachResult.id}`);
|
||||||
|
}
|
||||||
|
} catch (attachErr) {
|
||||||
|
alert(`⚠️ Expense saved (Purchase #${result.id}) but attachment failed:\n${attachErr.message}\n\nYou can attach the file directly in QBO.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof onSavedCb === 'function') {
|
if (typeof onSavedCb === 'function') {
|
||||||
try { onSavedCb(result); } catch (e) { console.warn('onSaved cb threw:', e); }
|
try { onSavedCb(result); } catch (e) { console.warn('onSaved cb threw:', e); }
|
||||||
}
|
}
|
||||||
@@ -597,13 +721,14 @@ async function save(mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setButtonsDisabled(disabled) {
|
function setButtonsDisabled(disabled, customText) {
|
||||||
const a = document.getElementById('exp-save-new-btn');
|
const a = document.getElementById('exp-save-new-btn');
|
||||||
const b = document.getElementById('exp-save-close-btn');
|
const b = document.getElementById('exp-save-close-btn');
|
||||||
if (a) a.disabled = disabled;
|
if (a) a.disabled = disabled;
|
||||||
if (b) b.disabled = disabled;
|
if (b) b.disabled = disabled;
|
||||||
if (a) a.textContent = disabled ? 'Saving…' : 'Save & New';
|
const text = customText || (disabled ? 'Saving…' : null);
|
||||||
if (b) b.textContent = disabled ? 'Saving…' : 'Save & Close';
|
if (a) a.textContent = text || 'Save & New';
|
||||||
|
if (b) b.textContent = text || 'Save & Close';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(msg) {
|
function showError(msg) {
|
||||||
@@ -645,5 +770,8 @@ window.expenseModal = {
|
|||||||
removeLine,
|
removeLine,
|
||||||
clearLines,
|
clearLines,
|
||||||
updateTotal,
|
updateTotal,
|
||||||
save
|
save,
|
||||||
|
onFileSelected,
|
||||||
|
onFileDrop,
|
||||||
|
clearFile
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -169,6 +169,20 @@ const API = {
|
|||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
}).then(r => r.json()),
|
}).then(r => r.json()),
|
||||||
|
|
||||||
|
attachToExpense: (expenseId, file, note) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (note) fd.append('note', note);
|
||||||
|
return fetch(`/api/accounting/expenses/${expenseId}/attach`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd
|
||||||
|
// KEIN Content-Type-Header setzen — Browser fügt boundary auto ein!
|
||||||
|
}).then(r => r.json());
|
||||||
|
},
|
||||||
|
|
||||||
|
getAttachmentLimits: () =>
|
||||||
|
fetch('/api/accounting/attachments/limits').then(r => r.json()),
|
||||||
|
|
||||||
listExpenses: (startDate, endDate, onlyMine = false) => {
|
listExpenses: (startDate, endDate, onlyMine = false) => {
|
||||||
const params = new URLSearchParams({ startDate, endDate });
|
const params = new URLSearchParams({ startDate, endDate });
|
||||||
if (onlyMine) params.set('onlyMine', 'true');
|
if (onlyMine) params.set('onlyMine', 'true');
|
||||||
|
|||||||
@@ -6,7 +6,31 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const accountingService = require('../services/accounting-service');
|
const accountingService = require('../services/accounting-service');
|
||||||
|
const multer = require('multer');
|
||||||
|
|
||||||
|
// Limit aus ENV, default 5 MB. Akzeptierte Werte z.B. "5", "10", "20"
|
||||||
|
const ATTACHMENT_MAX_MB = parseInt(process.env.EXPENSE_ATTACHMENT_MAX_MB || '5', 10);
|
||||||
|
const ATTACHMENT_MAX_BYTES = ATTACHMENT_MAX_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
const ALLOWED_MIME = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/heic',
|
||||||
|
'image/heif',
|
||||||
|
'application/pdf'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const attachUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: ATTACHMENT_MAX_BYTES },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!ALLOWED_MIME.has(file.mimetype)) {
|
||||||
|
return cb(new Error(`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPG, HEIC, PDF`));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
function handleQboError(err, res, context) {
|
function handleQboError(err, res, context) {
|
||||||
const statusCode = err.statusCode || 500;
|
const statusCode = err.statusCode || 500;
|
||||||
@@ -206,5 +230,50 @@ router.get('/expenses', async (req, res) => {
|
|||||||
res.json(expenses);
|
res.json(expenses);
|
||||||
} catch (err) { handleQboError(err, res, 'expense-list'); }
|
} catch (err) { handleQboError(err, res, 'expense-list'); }
|
||||||
});
|
});
|
||||||
|
// ─── POST /api/accounting/expenses/:id/attach ───────────────────────
|
||||||
|
// Hängt eine Datei (Bild oder PDF) an ein bestehendes QBO Purchase.
|
||||||
|
// Body: multipart/form-data, field "file"
|
||||||
|
router.post('/expenses/:id/attach', (req, res, next) => {
|
||||||
|
// multer-Wrapper, damit wir Multer-Fehler hübsch zurückgeben können
|
||||||
|
attachUpload.single('file')(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
// Spezifische Multer-Fehler übersetzen
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `File too large. Max ${ATTACHMENT_MAX_MB} MB.`,
|
||||||
|
context: 'attach'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: err.message, context: 'attach' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const file = req.file;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded (field name must be "file")' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await accountingService.attachFileToEntity({
|
||||||
|
entityType: 'Purchase',
|
||||||
|
entityId: id,
|
||||||
|
fileBuffer: file.buffer,
|
||||||
|
fileName: file.originalname,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
note: req.body && req.body.note ? String(req.body.note).slice(0, 200) : undefined
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { handleQboError(err, res, 'attach'); }
|
||||||
|
});
|
||||||
|
router.get('/attachments/limits', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
maxBytes: ATTACHMENT_MAX_BYTES,
|
||||||
|
maxMb: ATTACHMENT_MAX_MB,
|
||||||
|
allowedMimeTypes: Array.from(ALLOWED_MIME),
|
||||||
|
allowedExtensions: ['.png', '.jpg', '.jpeg', '.heic', '.heif', '.pdf']
|
||||||
|
});
|
||||||
|
});
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -981,7 +981,118 @@ function normalizePurchase(p) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hängt ein File an eine bestehende QBO-Transaktion (Purchase, Invoice, ...).
|
||||||
|
*
|
||||||
|
* QBO erwartet multipart/form-data mit zwei Parts:
|
||||||
|
* - file_metadata_0 (JSON: Attachable-Object)
|
||||||
|
* - file_content_0 (binary)
|
||||||
|
*
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {string} opts.entityType - 'Purchase' | 'Invoice' | etc.
|
||||||
|
* @param {string} opts.entityId - QBO id der Transaktion
|
||||||
|
* @param {Buffer} opts.fileBuffer - Binärdaten
|
||||||
|
* @param {string} opts.fileName - z.B. "receipt.png"
|
||||||
|
* @param {string} opts.contentType - z.B. "image/png", "application/pdf"
|
||||||
|
* @param {string} [opts.note] - Anzeige-Text in QBO (default = fileName)
|
||||||
|
* @returns {{ id, fileName }}
|
||||||
|
*/
|
||||||
|
async function attachFileToEntity({ entityType, entityId, fileBuffer, fileName, contentType, note }) {
|
||||||
|
if (!entityType || !entityId) throw badRequest('entityType and entityId are required');
|
||||||
|
if (!fileBuffer || !fileBuffer.length) throw badRequest('fileBuffer is empty');
|
||||||
|
if (!fileName) throw badRequest('fileName is required');
|
||||||
|
if (!contentType) throw badRequest('contentType is required');
|
||||||
|
|
||||||
|
const { companyId, baseUrl } = getClientInfo();
|
||||||
|
|
||||||
|
// Boundary für multipart
|
||||||
|
const boundary = '----QBOAttachBoundary' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
||||||
|
const CRLF = '\r\n';
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
AttachableRef: [{
|
||||||
|
EntityRef: { type: entityType, value: String(entityId) }
|
||||||
|
}],
|
||||||
|
FileName: fileName,
|
||||||
|
ContentType: contentType,
|
||||||
|
Note: note || fileName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multipart body als Buffer (Header + Metadata-JSON + File-Bytes + Footer)
|
||||||
|
const head = Buffer.from(
|
||||||
|
`--${boundary}${CRLF}` +
|
||||||
|
`Content-Disposition: form-data; name="file_metadata_0"${CRLF}` +
|
||||||
|
`Content-Type: application/json${CRLF}${CRLF}` +
|
||||||
|
JSON.stringify(metadata) + CRLF +
|
||||||
|
`--${boundary}${CRLF}` +
|
||||||
|
`Content-Disposition: form-data; name="file_content_0"; filename="${fileName}"${CRLF}` +
|
||||||
|
`Content-Type: ${contentType}${CRLF}${CRLF}`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const tail = Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8');
|
||||||
|
const body = Buffer.concat([head, fileBuffer, tail]);
|
||||||
|
|
||||||
|
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/upload`);
|
||||||
|
|
||||||
|
let qboResponse;
|
||||||
|
try {
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
qboResponse = getJson(response);
|
||||||
|
} catch (err) {
|
||||||
|
await writeAuditLog({
|
||||||
|
action: 'attachment.upload',
|
||||||
|
entityType,
|
||||||
|
entityQboId: String(entityId),
|
||||||
|
status: 'error',
|
||||||
|
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
||||||
|
responseExcerpt: err.message
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QBO antwortet mit AttachableResponse[0].Attachable bei Erfolg,
|
||||||
|
// oder AttachableResponse[0].Fault bei Fehler.
|
||||||
|
const responses = qboResponse.AttachableResponse || [];
|
||||||
|
const first = responses[0] || {};
|
||||||
|
|
||||||
|
if (first.Fault) {
|
||||||
|
const msg = (first.Fault.Error || []).map(e => `${e.code}: ${e.Message}`).join('; ');
|
||||||
|
await writeAuditLog({
|
||||||
|
action: 'attachment.upload',
|
||||||
|
entityType,
|
||||||
|
entityQboId: String(entityId),
|
||||||
|
status: 'error',
|
||||||
|
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
||||||
|
responseExcerpt: msg
|
||||||
|
});
|
||||||
|
const err = new Error('QBO attach failed: ' + msg);
|
||||||
|
err.qboFault = first.Fault;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const att = first.Attachable;
|
||||||
|
if (!att || !att.Id) throw new Error('QBO returned no attachable id');
|
||||||
|
|
||||||
|
await writeAuditLog({
|
||||||
|
action: 'attachment.upload',
|
||||||
|
entityType,
|
||||||
|
entityQboId: String(entityId),
|
||||||
|
status: 'success',
|
||||||
|
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
||||||
|
responseExcerpt: `Attachable ${att.Id}`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📎 QBO Attachable created: ${att.Id} (${fileName}) → ${entityType} ${entityId}`);
|
||||||
|
return { id: att.Id, fileName };
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// Exports
|
// Exports
|
||||||
@@ -1012,6 +1123,9 @@ module.exports = {
|
|||||||
createExpense,
|
createExpense,
|
||||||
listExpenses,
|
listExpenses,
|
||||||
|
|
||||||
|
// Phase 2 Lieferung 3
|
||||||
|
attachFileToEntity,
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
writeAuditLog
|
writeAuditLog
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user