diff --git a/test/auftraegeFakeStripe.test.js b/test/auftraegeFakeStripe.test.js new file mode 100644 index 0000000..01cf13f --- /dev/null +++ b/test/auftraegeFakeStripe.test.js @@ -0,0 +1,52 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock({ schema: {} }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { getStripe } = await import('../server/services/stripeBilling.js') +after(() => Promise.all([aw.close(), st.close()])) + +const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message)) +const create = (extra = {}) => getStripe().invoices.create({ customer: 'cus_x', collection_method: 'send_invoice', days_until_due: 14, ...extra }) +const item = (invoice, extra) => getStripe().invoiceItems.create({ customer: 'cus_x', invoice, description: 'Posten', currency: 'eur', ...extra }) + +test('Stripe-Nachbau: custom_fields und rendering werden gespeichert und mit der Rechnung geliefert', async () => { + const felder = [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }] + const invoice = await create({ custom_fields: felder, rendering: { pdf: { page_size: 'a4' } } }) + assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder) + assert.deepEqual(st.invoices.get(invoice.id).rendering, { pdf: { page_size: 'a4' } }) + const gelesen = await getStripe().invoices.retrieve(invoice.id) + assert.deepEqual(gelesen.custom_fields, felder) + assert.deepEqual(gelesen.rendering, { pdf: { page_size: 'a4' } }) + await item(invoice.id, { quantity: 1, unit_amount_decimal: '1000' }) + assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder, 'Posten ändern die Zusatzfelder nicht') + const schlicht = await create() + assert.equal(st.invoices.get(schlicht.id).custom_fields, null) + assert.equal(st.invoices.get(schlicht.id).rendering, null) +}) + +test('Stripe-Nachbau: Grenzen der Zusatzfelder (4, Name 40, Wert 140) und der Seitengröße wie bei Stripe', async () => { + const viele = Array.from({ length: 5 }, (_, i) => ({ name: `Feld ${i}`, value: 'x' })) + await refused(create({ custom_fields: viele }), /up to 4 custom fields/) + await refused(create({ custom_fields: [{ name: 'n'.repeat(41), value: 'x' }] }), /names must be between 1 and 40/) + await refused(create({ custom_fields: [{ name: 'n', value: 'v'.repeat(141) }] }), /values must be between 1 and 140/) + await refused(create({ rendering: { pdf: { page_size: 'din-a4' } } }), /page_size/) + const vier = await create({ custom_fields: viele.slice(0, 4).map((f) => ({ ...f, value: 'v'.repeat(140) })), rendering: { pdf: { page_size: 'letter' } } }) + assert.equal(st.invoices.get(vier.id).custom_fields.length, 4) +}) + +test('Stripe-Nachbau: Posten mit negativem amount (ohne quantity) mindern die Summe; amount mit quantity oder Einzelpreis wird abgelehnt', async () => { + const invoice = await create() + await item(invoice.id, { description: 'Leistung', quantity: 1, unit_amount_decimal: '40000' }) + await item(invoice.id, { description: 'abzüglich Anzahlung', amount: -15000 }) + const gespeichert = st.invoices.get(invoice.id) + assert.deepEqual(gespeichert.lines.data.map((l) => [l.description, l.quantity, l.amount]), [['Leistung', 1, 40000], ['abzüglich Anzahlung', 1, -15000]]) + assert.deepEqual([gespeichert.total, gespeichert.amount_due], [25000, 25000]) + await refused(item(invoice.id, { amount: -100, quantity: 2 }), /only specify one of these parameters/) + await refused(item(invoice.id, { amount: -100, unit_amount_decimal: '100' }), /only specify one of these parameters/) + assert.equal(st.invoices.get(invoice.id).total, 25000) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 0df7463..6ad8177 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -26,7 +26,10 @@ import http from 'node:http' * Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit), * .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API * liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und - * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen + * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. + * custom_fields (hoechstens 4, Name 1 bis 40, Wert 1 bis 140 Zeichen) und rendering[pdf][page_size] (a4, auto, letter) werden wie + * bei Stripe geprueft, gespeichert (sonst null) und mit der Rechnung geliefert; Rechnungsposten mit amount (auch negativ) + * tragen weder quantity noch unit_amount_decimal. Der Kunde einer neuen * Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile * Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen). * Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern: @@ -461,6 +464,22 @@ export async function startFakeStripe() { } const metadata = mergeMetadata({}, data.metadata) if (Array.isArray(metadata)) return metadata + const customFields = data.custom_fields + if (customFields !== undefined) { + if (!Array.isArray(customFields) || customFields.length > 4) return invalid('custom_fields', 'You can specify up to 4 custom fields.') + for (const [i, field] of customFields.entries()) { + if (typeof field?.name !== 'string' || field.name.length < 1 || field.name.length > 40) { + return invalid(`custom_fields[${i}][name]`, 'Custom field names must be between 1 and 40 characters.') + } + if (typeof field.value !== 'string' || field.value.length < 1 || field.value.length > 140) { + return invalid(`custom_fields[${i}][value]`, 'Custom field values must be between 1 and 140 characters.') + } + } + } + const pageSize = data.rendering?.pdf?.page_size + if (pageSize !== undefined && !['a4', 'auto', 'letter'].includes(pageSize)) { + return invalid('rendering[pdf][page_size]', 'Invalid page_size: must be one of a4, auto, letter.') + } const customer = customers.get(data.customer) const invoice = { id: nextId('in'), @@ -479,6 +498,8 @@ export async function startFakeStripe() { description: data.description ?? null, footer: data.footer ?? null, metadata, + custom_fields: customFields ?? null, + rendering: data.rendering ?? null, created: nowUnix(), total: 0, subtotal: 0, @@ -496,6 +517,9 @@ export async function startFakeStripe() { if (method === 'POST' && path === '/v1/invoiceitems') { const data = unflatten(body) if (!data.customer) return invalid('customer', 'Missing required param: customer.') + if (data.amount !== undefined && (data.quantity !== undefined || data.unit_amount_decimal !== undefined)) { + return invalid('amount', 'You may only specify one of these parameters: amount, or quantity with unit_amount_decimal.') + } const quantity = data.quantity !== undefined ? Number(data.quantity) : 1 if (!Number.isInteger(quantity) || quantity < 0) return invalid('quantity', 'Invalid non-negative integer: quantity.') let unit = null