diff --git a/src/lib/auftraegeEndpoints.js b/src/lib/auftraegeEndpoints.js new file mode 100644 index 0000000..a0f6b54 --- /dev/null +++ b/src/lib/auftraegeEndpoints.js @@ -0,0 +1,52 @@ +// Pfade und Methoden der neuen Portal-Aufrufe für Projektkonten, Auftragsbestätigungen (AB) und die Rechnungsvorschau +// (V5/V8 des Vorhabens „Aufträge und Rechnungen“). Rein (ohne Appwrite-Import): `request` kommt aus createFinanceRequest +// (financeRequest.js) und wird injiziert, damit Pfade, Methoden und Körper unter Node mit einer Fetch-Attrappe testbar sind. +// financeApi.js verdrahtet die Funktionen mit dem Appwrite-JWT. Alle Pfade gelten relativ zu /api/admin/finanzen. + +const enc = encodeURIComponent + +/** + * Pfad der abgerechneten Arbeitsblätter eines Tickets, relativ zu /api/team (teamApi.js: getAbgerechnet). + * Antwort: { abgerechnet: { : '' | 'Entwurf' } } + */ +export const abgerechnetPfad = (ticketId) => `/arbeitsblaetter/abgerechnet?ticketId=${enc(ticketId)}` + +/** createAuftraegeEndpoints(request) -> die 14 Funktionen von V8 (jede Kennung wird URL-kodiert). */ +export function createAuftraegeEndpoints(request) { + const post = (path, body) => request(path, { method: 'POST', ...(body === undefined ? {} : { body }) }) + + return { + // --- Projektkonten --- + /** { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk } */ + getProjektkonten: (kundeId) => request(`/projektkonten?kunde=${enc(kundeId)}`), + /** { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk } */ + getProjektkonto: (projektId) => request(`/projektkonten/${enc(projektId)}`), + + // --- Rechnungsvorschau (ohne Schreibzugriff, ohne Schlüssel) --- + /** { positionen, summeCents, leistungszeitraum, customFields } */ + previewInvoice: (body) => post('/rechnungen/vorschau', body), + + // --- Auftragsbestätigungen --- + /** { auftraege } (404, wenn der Schalter AUFTRAEGE_ENABLED für den Kunden aus ist) */ + listAuftraege: (kundeId) => request(`/auftraege?kunde=${enc(kundeId)}`), + getAuftrag: (id) => request(`/auftraege/${enc(id)}`), + /** { auftrag } */ + createAuftrag: (body) => post('/auftraege', body), + /** { auftrag } */ + patchAuftrag: (id, body) => request(`/auftraege/${enc(id)}`, { method: 'PATCH', body }), + /** { ok: true } */ + deleteAuftrag: (id) => request(`/auftraege/${enc(id)}`, { method: 'DELETE' }), + /** { auftrag, mailFehler } */ + sendAuftrag: (id) => post(`/auftraege/${enc(id)}/senden`), + /** { auftrag, mailFehler } */ + erinnereAuftrag: (id) => post(`/auftraege/${enc(id)}/erinnern`), + /** { auftrag } (neuer Entwurf) */ + neueFassungAuftrag: (id) => post(`/auftraege/${enc(id)}/neue-fassung`), + /** { auftrag } */ + storniereAuftrag: (id) => post(`/auftraege/${enc(id)}/stornieren`), + /** { auftrag }; 409, wenn die AB schon ein Projekt hat oder storniert, ersetzt oder widerrufen ist */ + ordneAuftragProjektZu: (id, projektId) => post(`/auftraege/${enc(id)}/projekt`, { projektId }), + /** PDF der AB (gesendet: gespeicherte Datei, Entwurf: Vorschau) als Blob */ + getAuftragPdf: (id) => request(`/auftraege/${enc(id)}/pdf`, { as: 'blob' }), + } +} diff --git a/src/lib/financeApi.js b/src/lib/financeApi.js index d74ae1e..fd1f2cf 100644 --- a/src/lib/financeApi.js +++ b/src/lib/financeApi.js @@ -1,5 +1,6 @@ import { account } from './appwrite' import { createFinanceRequest } from './financeRequest' +import { createAuftraegeEndpoints } from './auftraegeEndpoints' /** * Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen). @@ -26,6 +27,8 @@ export function getInvoices({ status, kunde } = {}) { } // Jeder Aufruf bekommt einen neuen Schluessel, damit ein Doppelklick keine zweite Rechnung erzeugt. +// Koerper: { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, +// daysUntilDue, memo, senden } (wird unveraendert durchgereicht) export const createInvoice = (body) => request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } }) export const sendInvoice = (id) => request(`/rechnungen/${enc(id)}/senden`, { method: 'POST' }) @@ -34,6 +37,24 @@ export const resendInvoice = sendInvoice export const voidInvoice = (id) => request(`/rechnungen/${enc(id)}/stornieren`, { method: 'POST' }) export const deleteInvoice = (id) => request(`/rechnungen/${enc(id)}`, { method: 'DELETE' }) +// --- Projektkonten, Auftraege und Rechnungsvorschau (Pfade und Methoden: auftraegeEndpoints.js, getestet) --- +export const { + getProjektkonten, + getProjektkonto, + previewInvoice, + listAuftraege, + getAuftrag, + createAuftrag, + patchAuftrag, + deleteAuftrag, + sendAuftrag, + erinnereAuftrag, + neueFassungAuftrag, + storniereAuftrag, + ordneAuftragProjektZu, + getAuftragPdf, +} = createAuftraegeEndpoints(request) + // --- Buchungen --- export const getTransactions = () => request('/buchungen') // target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js index d0c55c1..c8d10d6 100644 --- a/src/lib/teamApi.js +++ b/src/lib/teamApi.js @@ -1,6 +1,7 @@ import { account } from './appwrite' import { createFinanceRequest } from './financeRequest' import { createJwtCache } from './serverpulsAuth' +import { abgerechnetPfad } from './auftraegeEndpoints' import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView' import { ersatzPfad, zeitenTeamPfad } from './zeitenView' @@ -119,6 +120,10 @@ export const getZeitenErsatz = (customerId, projectId, plan) => request(ersatzPf /** Team-Einstellungen (Rumpf: teamBody in zeitenView): { team, teamVersion }; 400 ungueltig, 409 veraltet (err.code ZEITEN_VERALTET). PATCH, weil das Ticketsystem kein PUT durchlaesst. */ export const patchZeitenTeam = (customerId, body) => request(zeitenTeamPfad(customerId), { method: 'PATCH', body }) +// --- Abgerechnete Arbeitsblaetter (alle Mitarbeiter, ohne Betraege) --- +/** { abgerechnet: { : '' | 'Entwurf' } }; unbekanntes Ticket: { abgerechnet: {} } */ +export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId)) + // --- Abo-Status (alle Mitarbeiter) --- /** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */ diff --git a/tests/unit/auftraegeEndpoints.test.mjs b/tests/unit/auftraegeEndpoints.test.mjs new file mode 100644 index 0000000..2935f10 --- /dev/null +++ b/tests/unit/auftraegeEndpoints.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { createAuftraegeEndpoints, abgerechnetPfad } = await loadModule('src/lib/auftraegeEndpoints.js') +const { createFinanceRequest } = await loadModule('src/lib/financeRequest.js') + +const BASE = 'https://portal.example.invalid/api/admin/finanzen' + +// Echte Anfrage-Schicht mit Fetch-Attrappe: geprüft wird, was das Portal erreicht (URL, Methode, Kopf, JSON-Körper) +function setup(answer = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })) { + const calls = [] + const request = createFinanceRequest({ + baseUrl: BASE, + createJWT: async () => ({ jwt: 'jwt-1' }), + fetchImpl: async (url, init) => { + calls.push({ url, method: init.method, body: init.body, auth: init.headers.Authorization }) + return answer() + }, + }) + return { api: createAuftraegeEndpoints(request), calls } +} + +// [Funktion, Argumente, Methode, Pfad ab BASE, Körper] +const FAELLE = [ + ['getProjektkonten', ['k1'], 'GET', '/projektkonten?kunde=k1', undefined], + ['getProjektkonto', ['p1'], 'GET', '/projektkonten/p1', undefined], + ['previewInvoice', [{ customerId: 'k1', art: 'aufwand', projektId: 'p1' }], 'POST', '/rechnungen/vorschau', { customerId: 'k1', art: 'aufwand', projektId: 'p1' }], + ['listAuftraege', ['k1'], 'GET', '/auftraege?kunde=k1', undefined], + ['getAuftrag', ['a1'], 'GET', '/auftraege/a1', undefined], + ['createAuftrag', [{ kundenId: 'k1', titel: 'Website' }], 'POST', '/auftraege', { kundenId: 'k1', titel: 'Website' }], + ['patchAuftrag', ['a1', { titel: 'Neu' }], 'PATCH', '/auftraege/a1', { titel: 'Neu' }], + ['deleteAuftrag', ['a1'], 'DELETE', '/auftraege/a1', undefined], + ['sendAuftrag', ['a1'], 'POST', '/auftraege/a1/senden', undefined], + ['erinnereAuftrag', ['a1'], 'POST', '/auftraege/a1/erinnern', undefined], + ['neueFassungAuftrag', ['a1'], 'POST', '/auftraege/a1/neue-fassung', undefined], + ['storniereAuftrag', ['a1'], 'POST', '/auftraege/a1/stornieren', undefined], + ['ordneAuftragProjektZu', ['a1', 'p1'], 'POST', '/auftraege/a1/projekt', { projektId: 'p1' }], +] + +test('alle 14 Funktionen sind da (V8 samt „Projekt zuordnen“ aus V5)', () => { + const { api } = setup() + assert.deepEqual(Object.keys(api).sort(), [ + 'createAuftrag', 'deleteAuftrag', 'erinnereAuftrag', 'getAuftrag', 'getAuftragPdf', 'getProjektkonten', 'getProjektkonto', + 'listAuftraege', 'neueFassungAuftrag', 'ordneAuftragProjektZu', 'patchAuftrag', 'previewInvoice', 'sendAuftrag', 'storniereAuftrag', + ]) +}) + +test('Pfad, Methode, Kopf und Körper jeder Funktion (die Vorschau trägt keinen Schlüssel dazu)', async () => { + for (const [name, args, method, path, body] of FAELLE) { + const { api, calls } = setup() + await api[name](...args) + assert.equal(calls.length, 1, name) + assert.equal(calls[0].url, `${BASE}${path}`, name) + assert.equal(calls[0].method, method, name) + assert.equal(calls[0].auth, 'Bearer jwt-1', name) + assert.equal(calls[0].body, body === undefined ? undefined : JSON.stringify(body), name) + } +}) + +test('Kennungen werden URL-kodiert, auch im Pfad der abgerechneten Arbeitsblätter', async () => { + const { api, calls } = setup() + await api.getProjektkonten('k 1&x=2') + await api.getAuftrag('a/b?c') + await api.storniereAuftrag('a#1') + await api.ordneAuftragProjektZu('a b', 'p/1') + assert.deepEqual(calls.map((c) => c.url.slice(BASE.length)), [ + '/projektkonten?kunde=k%201%26x%3D2', + '/auftraege/a%2Fb%3Fc', + '/auftraege/a%231/stornieren', + '/auftraege/a%20b/projekt', + ]) + assert.equal(calls[3].body, JSON.stringify({ projektId: 'p/1' })) // die Projektkennung steht im Körper, nicht im Pfad + assert.equal(abgerechnetPfad('100131'), '/arbeitsblaetter/abgerechnet?ticketId=100131') + assert.equal(abgerechnetPfad('t 1/2'), '/arbeitsblaetter/abgerechnet?ticketId=t%201%2F2') +}) + +test('getAuftragPdf liefert das PDF als Blob; eine Fehlerantwort kommt als Meldung des Portals', async () => { + const datei = new Blob(['%PDF-1.7'], { type: 'application/pdf' }) + const ok = setup(() => ({ ok: true, status: 200, json: async () => assert.fail('kein JSON lesen'), blob: async () => datei })) + assert.equal(await ok.api.getAuftragPdf('a1'), datei) + assert.equal(ok.calls[0].url, `${BASE}/auftraege/a1/pdf`) + assert.equal(ok.calls[0].method, 'GET') + const fehler = setup(() => ({ ok: false, status: 404, json: async () => ({ error: 'PDF nicht gefunden.' }), blob: async () => assert.fail('kein Blob lesen') })) + await assert.rejects(fehler.api.getAuftragPdf('a1'), (e) => e.message === 'PDF nicht gefunden.' && e.status === 404) +})