attachments

This commit is contained in:
2026-05-07 11:04:24 -05:00
parent 5b3da47d87
commit 82bc055c4c
4 changed files with 331 additions and 6 deletions

View File

@@ -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
@@ -1012,6 +1123,9 @@ module.exports = {
createExpense,
listExpenses,
// Phase 2 Lieferung 3
attachFileToEntity,
// Audit
writeAuditLog
};