Finanzen-API: Projektkonten, Auftragsbestätigungen (samt Projekt zuordnen), Rechnungsvorschau und abgerechnete Arbeitsblätter (Pfade rein und getestet)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eaf9fce1bc
commit
e116e804e3
4 files changed
+164
No files matched your search
@@ -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: { <worksheetId>: '<Rechnungsnummer>' | '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' }),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } }; unbekanntes Ticket: { abgerechnet: {} } */
|
||||
export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId))
|
||||
|
||||
// --- Abo-Status (alle Mitarbeiter) ---
|
||||
|
||||
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user