diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 805ca0c..bc76d4d 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -1,11 +1,15 @@ -import { getStripe, subscriptionIdOfInvoice } from '../stripeBilling.js' +import { config } from '../../config.js' +import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js' +import { getDocument } from '../appwriteAdmin.js' import { addDays, berlinDate, daysBetween } from './dates.js' /** - * Stripe-Rechnungen und -Auszahlungen fuer die Finanzen (nur lesen). Rechnungen und Auszahlungen werden nicht + * Stripe-Rechnungen und -Auszahlungen fuer die Finanzen. Rechnungen und Auszahlungen werden nicht * gespiegelt: sie kommen bei jeder Anfrage von Stripe, hoechstens alle 60 Sekunden (Zwischenspeicher je Art). * Die Rohdaten liegen im Zwischenspeicher, gemappt wird bei jedem Aufruf neu: so stimmt "ueberfaellig" auch, * wenn um Mitternacht ein neuer Tag beginnt, und ein Aufrufer kann den Zwischenspeicher nicht veraendern. + * Projektrechnungen werden hier auch geschrieben (anlegen, senden, stornieren, Entwurf loeschen, siehe unten); + * jede Schreibfunktion leert danach den Zwischenspeicher. */ const CACHE_MS = 60_000 @@ -96,3 +100,182 @@ export async function listPayouts({ force = false } = {}) { status: payout.status, })) } + +// --------------------------------------------------------------------------- +// Projektrechnungen schreiben +// --------------------------------------------------------------------------- + +const MAX_ITEMS = 20 +const MAX_DESCRIPTION = 200 +const MAX_QUANTITY = 999 +const MAX_UNIT_CENTS = 10_000_000 +const MAX_DAYS_UNTIL_DUE = 60 +const DEFAULT_DAYS_UNTIL_DUE = 14 +const MAX_MEMO = 1000 +const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' +const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.' + +function httpError(status, message) { + const err = new Error(message) + err.status = status + return err +} + +/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */ +export function invoiceFooter(bankDetailsText = '') { + const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : '' + return [config.stripe.invoiceFooter, bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n') +} + +const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max + +/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */ +function validateItems(items) { + if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) { + throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`) + } + return items.map((item, index) => { + const label = `Position ${index + 1}` + const description = typeof item?.description === 'string' ? item.description.trim() : '' + if (description.length < 1 || description.length > MAX_DESCRIPTION) { + throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`) + } + if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) { + throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`) + } + if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) { + throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`) + } + return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents } + }) +} + +/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */ +async function loadBillableCustomer(customerId) { + if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.') + let customer + try { + customer = await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.') + throw err + } + const stage = String(customer.customerStatus || '').trim().toLowerCase() + if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.') + return customer +} + +/** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */ +async function readInvoice(invoiceId) { + if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.') + let raw + try { + raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }) + } catch (err) { + if (err?.statusCode === 404 || err?.code === 'resource_missing') throw httpError(404, 'Rechnung nicht gefunden.') + throw err + } + return mapInvoice(raw, { today: berlinDate() }) +} + +// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden +async function finalizeAndSend(stripe, invoiceId) { + await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false }) + await stripe.invoices.sendInvoice(invoiceId) +} + +/** + * Projektrechnung bei Stripe anlegen: Entwurf mit den Positionen (Menge und Einzelpreis je Position), Zahlungsziel + * in Tagen, Rechnungsfuss mit Kleinunternehmer-Hinweis und Bankdaten. Mit senden=true wird sie sofort finalisiert und + * per E-Mail gesendet (offen), sonst bleibt sie ein Entwurf. Fehler mit status 400 bei ungueltigen Eingaben oder + * einem Kunden, der noch Lead/verloren ist. + */ +export async function createProjectInvoice({ customerId, items, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, createdBy, bankDetailsText = '' }) { + const lines = validateItems(items) + if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) { + throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`) + } + const note = typeof memo === 'string' ? memo.trim() : '' + if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`) + const customer = await loadBillableCustomer(customerId) + + const stripe = getStripe() + const stripeCustomerId = await getOrCreateStripeCustomer(customer) + let invoiceId = null + try { + const draft = await stripe.invoices.create({ + customer: stripeCustomerId, + collection_method: 'send_invoice', + days_until_due: daysUntilDue, + auto_advance: false, + pending_invoice_items_behavior: 'exclude', + currency: 'eur', + footer: invoiceFooter(bankDetailsText), + description: note || undefined, + metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) }, + }) + invoiceId = draft.id + // der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen + for (const line of lines) { + await stripe.invoiceItems.create({ + customer: stripeCustomerId, + invoice: invoiceId, + description: line.description, + quantity: line.quantity, + unit_amount_decimal: String(line.unitAmountCents), + currency: 'eur', + }) + } + } catch (err) { + // Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar + if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {}) + invalidateFinanceCache() + throw err + } + + try { + if (senden === true) await finalizeAndSend(stripe, invoiceId) + return await readInvoice(invoiceId) + } finally { + invalidateFinanceCache() + } +} + +/** Entwurf finalisieren (ohne Auto-Advance) und senden. Nur Projekt-Entwuerfe, sonst status 409. */ +export async function sendInvoiceNow(invoiceId) { + const invoice = await readInvoice(invoiceId) + // Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten + if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) + if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gesendet werden.') + try { + await finalizeAndSend(getStripe(), invoice.id) + return await readInvoice(invoice.id) + } finally { + invalidateFinanceCache() + } +} + +/** Offene oder ueberfaellige Projektrechnung stornieren (Stripe "void"), sonst status 409. */ +export async function voidInvoice(invoiceId) { + const invoice = await readInvoice(invoiceId) + if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) + if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.') + try { + await getStripe().invoices.voidInvoice(invoice.id) + return await readInvoice(invoice.id) + } finally { + invalidateFinanceCache() + } +} + +/** Projekt-Entwurf bei Stripe loeschen, sonst status 409 (nur Entwuerfe lassen sich loeschen). */ +export async function deleteDraft(invoiceId) { + const invoice = await readInvoice(invoiceId) + if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) + if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.') + try { + await getStripe().invoices.del(invoice.id) + } finally { + invalidateFinanceCache() + } +} diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index aaae8fb..91c1d0b 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -431,7 +431,7 @@ export async function grantCreditForInvoice(invoice, { fresh = false } = {}) { // Checkout / Kundenportal / Kuendigung // --------------------------------------------------------------------------- -async function getOrCreateStripeCustomer(customer) { +export async function getOrCreateStripeCustomer(customer) { const stripe = getStripe() const subs = await listSubscriptionsForCustomer(customer.$id) const known = subs.find((s) => s.stripeCustomerId)?.stripeCustomerId diff --git a/test/financeStripeWrite.test.js b/test/financeStripeWrite.test.js new file mode 100644 index 0000000..2f4317d --- /dev/null +++ b/test/financeStripeWrite.test.js @@ -0,0 +1,382 @@ +import { test, after, beforeEach } 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' +import { schemaKeys } from './support/schema.js' + +// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber stripeBilling) geladen wird +const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { seedFixtures } = await import('./support/fixtures.js') +const { berlinDate } = await import('../server/services/finance/dates.js') +const { getStripe } = await import('../server/services/stripeBilling.js') +const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache } = await import( + '../server/services/finance/stripeFinance.js' +) + +after(() => Promise.all([aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Interessent', customerStatus: 'lead', status: 'active' }) +aw.seed('customers', { $id: 'lost1', email: 'lost@example.invalid', name: 'Verloren', customerStatus: 'Lost', status: 'active' }) +aw.seed('customers', { $id: 'act1', email: 'act@example.invalid', name: 'Aktiv', companyName: 'Aktiv GmbH', customerStatus: 'active', status: 'active' }) + +const PAYMENT_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' +const BASE_ITEMS = [ + { description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 }, + { description: 'Domain', quantity: 1, unitAmountCents: 1200 }, +] + +beforeEach(() => { + invalidateFinanceCache() + st.calls.length = 0 + st.invoices.clear() + st.customers.clear() + // Das Abo von c1 kennt die Stripe-Kunden-ID cus_c1 (seedFixtures); der Nachbau soll den Kunden ebenfalls kennen + st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } }) +}) + +const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)) +const writes = () => st.calls.filter((c) => c.method !== 'GET') +const rejectsWith = (promise, status, message) => + assert.rejects(promise, (err) => { + assert.equal(err.status, status, `Status ${err.status}: ${err.message}`) + if (message) assert.equal(err.message, message) + return true + }) + +const aboInvoice = (over = {}) => ({ + id: 'in_abo', + object: 'invoice', + number: 'WK-0100', + status: 'open', + customer: 'cus_c1', + subscription: 'sub_h', + created: Math.floor(Date.now() / 1000), + due_date: null, + total: 2000, + amount_remaining: 2000, + amount_paid: 0, + metadata: {}, + lines: { object: 'list', data: [], has_more: false }, + ...over, +}) + +const projectInvoice = (over = {}) => ({ + id: 'in_proj', + object: 'invoice', + number: null, + status: 'draft', + customer: 'cus_c1', + created: Math.floor(Date.now() / 1000), + due_date: null, + total: 6000, + amount_remaining: 6000, + amount_paid: 0, + metadata: { quelle: 'woms' }, + lines: { object: 'list', data: [], has_more: false }, + ...over, +}) + +test('invoiceFooter: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Verwendungszweck-Satz', () => { + assert.equal(invoiceFooter(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`) + assert.equal(invoiceFooter(''), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`) + assert.equal(invoiceFooter(' \n '), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`) + assert.equal( + invoiceFooter('IBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF'), + `${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}` + ) +}) + +test('createProjectInvoice: Entwurf mit Positionen, Rechnungsfuss und Metadaten (nicht gesendet)', async () => { + const result = await createProjectInvoice({ + customerId: 'c1', + items: BASE_ITEMS, + memo: 'Leistungen im September', + createdBy: 'kenso@example.invalid', + bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00', + }) + + const [create] = callsTo('POST', '/v1/invoices') + assert.ok(create, 'POST /v1/invoices fehlt') + assert.equal(create.body.customer, 'cus_c1') + assert.equal(create.body.collection_method, 'send_invoice') + assert.equal(create.body.days_until_due, '14') + assert.equal(create.body.auto_advance, 'false') + assert.equal(create.body.pending_invoice_items_behavior, 'exclude') + assert.equal(create.body.currency, 'eur') + assert.equal(create.body.description, 'Leistungen im September') + assert.equal(create.body['metadata[quelle]'], 'woms') + assert.equal(create.body['metadata[webklarCustomerId]'], 'c1') + assert.equal(create.body['metadata[erstelltVon]'], 'kenso@example.invalid') + assert.ok(create.body.footer.startsWith(config.stripe.invoiceFooter)) + assert.ok(create.body.footer.endsWith(PAYMENT_NOTE)) + assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\n${PAYMENT_NOTE}`) + + // Menge und Einzelpreis jeder Position kommen bei Stripe an: im Aufruf ... + const items = callsTo('POST', '/v1/invoiceitems') + assert.equal(items.length, 2) + assert.deepEqual(items[0].body, { + customer: 'cus_c1', + invoice: result.id, + description: 'Website-Anpassung', + quantity: '2', + unit_amount_decimal: '5000', + currency: 'eur', + }) + assert.deepEqual(items[1].body, { + customer: 'cus_c1', + invoice: result.id, + description: 'Domain', + quantity: '1', + unit_amount_decimal: '1200', + currency: 'eur', + }) + // ... und auf der Rechnung (Zeilen mit Menge und Einzelpreis, Summe aus Menge mal Einzelpreis) + const lines = st.invoices.get(result.id).lines.data + assert.deepEqual( + lines.map((l) => [l.description, l.quantity, l.pricing.unit_amount_decimal, l.amount]), + [ + ['Website-Anpassung', 2, '5000', 10000], + ['Domain', 1, '1200', 1200], + ] + ) + + assert.equal(result.status, 'entwurf') + assert.equal(result.source, 'projekt') + assert.equal(result.totalCents, 11200) + assert.equal(result.remainingCents, 11200) + assert.equal(result.webklarCustomerId, 'c1') + assert.equal(result.customerName, 'Kunde Eins') + assert.equal(result.number, '') + // kein Finalisieren, kein Senden + assert.equal(callsTo('POST', /finalize|send/).length, 0) +}) + +test('createProjectInvoice: ohne Vermerk keine Beschreibung, ohne Bankdaten kuerzerer Fuss, Zahlungsziel 0 bleibt 0', async () => { + await createProjectInvoice({ customerId: 'c1', items: [BASE_ITEMS[1]], daysUntilDue: 0, createdBy: 'k' }) + const [create] = callsTo('POST', '/v1/invoices') + assert.equal(create.body.days_until_due, '0') + assert.equal('description' in create.body, false) + assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`) +}) + +test('createProjectInvoice: legt fuer Kunden ohne Stripe-Kunde einen an (Firmenname)', async () => { + const result = await createProjectInvoice({ customerId: 'act1', items: [BASE_ITEMS[1]], createdBy: 'k' }) + const created = callsTo('POST', '/v1/customers') + assert.equal(created.length, 1) + assert.equal(created[0].body.name, 'Aktiv GmbH') + assert.equal(result.webklarCustomerId, 'act1') + assert.equal(result.customerName, 'Aktiv GmbH') +}) + +test('createProjectInvoice: senden=true finalisiert (ohne Auto-Advance) und sendet, Rechnung ist offen mit Nummer', async () => { + const result = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, daysUntilDue: 30, senden: true, createdBy: 'k' }) + + const order = writes().map((c) => c.path.replace(/in_test_\d+/, ':id')) + assert.deepEqual(order.slice(-4), ['/v1/invoiceitems', '/v1/invoiceitems', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send']) + const [finalize] = callsTo('POST', /\/finalize$/) + assert.equal(finalize.body.auto_advance, 'false') + + assert.equal(result.status, 'offen') + assert.match(result.number, /^WK-\d{4}$/) + assert.equal(result.totalCents, 11200) + assert.equal(result.dueDate, berlinDate(new Date(Date.now() + 30 * 86_400_000))) + assert.ok(result.hostedUrl) +}) + +test('createProjectInvoice: Lead-, verlorene und unbekannte Kunden werden mit 400 abgelehnt, Stripe bleibt unberuehrt', async () => { + await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.') + await rejectsWith(createProjectInvoice({ customerId: 'lost1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.') + await rejectsWith(createProjectInvoice({ customerId: 'gibtEsNicht', items: BASE_ITEMS, createdBy: 'k' }), 400) + await rejectsWith(createProjectInvoice({ items: BASE_ITEMS, createdBy: 'k' }), 400) + assert.equal(writes().length, 0) +}) + +test('createProjectInvoice: Eingaben werden geprueft (Positionen, Texte, Mengen, Betraege, Zahlungsziel)', async () => { + const ok = BASE_ITEMS[1] + const bad = async (label, input) => { + try { + await createProjectInvoice({ customerId: 'c1', items: [ok], createdBy: 'k', ...input }) + } catch (err) { + assert.equal(err.status, 400, `${label}: Status ${err.status} (${err.message})`) + return + } + assert.fail(`${label}: wurde nicht abgelehnt`) + } + const item = (over) => ({ ...ok, ...over }) + + // 21 Positionen, keine Positionen, keine Liste + await rejectsWith(createProjectInvoice({ customerId: 'c1', items: Array.from({ length: 21 }, () => ok), createdBy: 'k' }), 400) + await bad('leer', { items: [] }) + await bad('keine Liste', { items: undefined }) + // Beschreibung + await bad('Beschreibung leer', { items: [item({ description: '' })] }) + await bad('Beschreibung nur Leerzeichen', { items: [item({ description: ' ' })] }) + await bad('Beschreibung 201 Zeichen', { items: [item({ description: 'x'.repeat(201) })] }) + await bad('Beschreibung keine Zeichenkette', { items: [item({ description: 5 })] }) + // Menge + for (const quantity of [0, 1000, 1.5, '2', -1, null]) await bad(`Menge ${quantity}`, { items: [item({ quantity })] }) + // Einzelpreis + for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) await bad(`Preis ${unitAmountCents}`, { items: [item({ unitAmountCents })] }) + // Zahlungsziel + for (const daysUntilDue of [-1, 61, 1.5, '14', null]) await bad(`Ziel ${daysUntilDue}`, { daysUntilDue }) + + assert.equal(writes().length, 0, 'bei ungueltigen Eingaben darf nichts an Stripe gehen') +}) + +test('createProjectInvoice: Grenzwerte sind erlaubt (20 Positionen, 200 Zeichen, Menge 999, 10.000.000 Cent, Ziel 60)', async () => { + const items = Array.from({ length: 20 }, (_, i) => ({ description: i === 0 ? 'x'.repeat(200) : `Position ${i}`, quantity: 999, unitAmountCents: 10_000_000 })) + const result = await createProjectInvoice({ customerId: 'c1', items, daysUntilDue: 60, createdBy: 'k' }) + assert.equal(callsTo('POST', '/v1/invoiceitems').length, 20) + assert.equal(result.totalCents, 20 * 999 * 10_000_000) + assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '60') +}) + +test('createProjectInvoice: scheitert eine Position, wird der halbfertige Entwurf geloescht', async () => { + st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'Nachbau: Position abgelehnt', after: 1 }) + await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }), /Position abgelehnt/) + assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1) + assert.equal(st.invoices.size, 0) +}) + +test('sendInvoiceNow: Entwurf wird finalisiert (ohne Auto-Advance) und gesendet', async () => { + const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }) + st.calls.length = 0 + const sent = await sendInvoiceNow(draft.id) + assert.deepEqual( + writes().map((c) => c.path.replace(draft.id, ':id')), + ['/v1/invoices/:id/finalize', '/v1/invoices/:id/send'] + ) + assert.equal(callsTo('POST', /\/finalize$/)[0].body.auto_advance, 'false') + assert.equal(sent.status, 'offen') + assert.match(sent.number, /^WK-\d{4}$/) +}) + +test('sendInvoiceNow: nur Entwuerfe (409), nur Projektrechnungen, unbekannte Rechnung 404', async () => { + st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0001' })) + st.invoices.set('in_abo', aboInvoice({ status: 'draft', number: null })) + await rejectsWith(sendInvoiceNow('in_open'), 409, 'Nur Entwürfe können gesendet werden.') + await rejectsWith(sendInvoiceNow('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.') + await rejectsWith(sendInvoiceNow('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.') + assert.equal(writes().length, 0) +}) + +test('voidInvoice: offene und ueberfaellige Projektrechnungen werden storniert', async () => { + const open = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, senden: true, createdBy: 'k' }) + const voided = await voidInvoice(open.id) + assert.equal(voided.status, 'storniert') + assert.equal(callsTo('POST', `/v1/invoices/${open.id}/void`).length, 1) + + // ueberfaellig: offen, Faelligkeit vor heute + st.invoices.set('in_late', projectInvoice({ id: 'in_late', status: 'open', number: 'WK-0007', due_date: Math.floor(Date.now() / 1000) - 10 * 86_400 })) + assert.equal((await voidInvoice('in_late')).status, 'storniert') +}) + +test('voidInvoice: Entwurf, bezahlte und Abo-Rechnungen werden mit 409 abgelehnt, unbekannte mit 404', async () => { + st.invoices.set('in_draft', projectInvoice({ id: 'in_draft' })) + st.invoices.set('in_paid', projectInvoice({ id: 'in_paid', status: 'paid', number: 'WK-0002' })) + st.invoices.set('in_abo', aboInvoice()) + await rejectsWith(voidInvoice('in_draft'), 409) + await rejectsWith(voidInvoice('in_paid'), 409) + await rejectsWith(voidInvoice('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.') + await rejectsWith(voidInvoice('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.') + assert.equal(writes().length, 0) + assert.equal(st.invoices.get('in_draft').status, 'draft') +}) + +test('deleteDraft: loescht einen Projekt-Entwurf bei Stripe', async () => { + const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }) + const result = await deleteDraft(draft.id) + assert.equal(result, undefined) + assert.equal(callsTo('DELETE', `/v1/invoices/${draft.id}`).length, 1) + assert.equal(st.invoices.has(draft.id), false) +}) + +test('deleteDraft: Abo-Rechnungen und alles ausser Entwuerfen werden mit 409 abgelehnt', async () => { + st.invoices.set('in_abo', aboInvoice()) + st.invoices.set('in_abo_draft', aboInvoice({ id: 'in_abo_draft', status: 'draft', number: null })) + st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0003' })) + await rejectsWith(deleteDraft('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.') + await rejectsWith(deleteDraft('in_abo_draft'), 409, 'Abo-Rechnungen verwaltet Stripe.') + await rejectsWith(deleteDraft('in_open'), 409) + await rejectsWith(deleteDraft('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.') + assert.equal(writes().length, 0) + assert.equal(st.invoices.size, 3) +}) + +test('Schreibzugriffe leeren den Zwischenspeicher: die Liste zeigt neue, gesendete, stornierte und geloeschte Rechnungen sofort', async () => { + assert.deepEqual(await listInvoices(), []) + + const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }) + assert.deepEqual((await listInvoices()).map((i) => [i.id, i.status]), [[draft.id, 'entwurf']]) + + await sendInvoiceNow(draft.id) + assert.equal((await listInvoices())[0].status, 'offen') + + await voidInvoice(draft.id) + assert.equal((await listInvoices())[0].status, 'storniert') + + const second = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }) + assert.equal((await listInvoices()).length, 2) + await deleteDraft(second.id) + assert.deepEqual((await listInvoices()).map((i) => i.id), [draft.id]) +}) + +test('Stripe-Nachbau: Statusuebergaenge wie Stripe (finalize nur Entwurf, send/void/pay nur offen, loeschen nur Entwurf)', async () => { + const stripe = getStripe() + const status = (id) => st.invoices.get(id).status + const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message)) + const create = () => stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice', days_until_due: 7 }) + const item = (id) => stripe.invoiceItems.create({ customer: 'cus_c1', invoice: id, description: 'Test', quantity: 3, unit_amount_decimal: '250', currency: 'eur' }) + + // send_invoice braucht ein Zahlungsziel + await assert.rejects(stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice' }), (err) => err.statusCode === 400) + + const draft = await create() + assert.equal(draft.status, 'draft') + await item(draft.id) + assert.equal(st.invoices.get(draft.id).total, 750) + await refused(stripe.invoices.sendInvoice(draft.id), /open/) + await refused(stripe.invoices.voidInvoice(draft.id), /open/) + await refused(stripe.invoices.pay(draft.id), /open/) + + // Metadaten zusammenfuehren, leerer Wert loescht + await stripe.invoices.update(draft.id, { metadata: { a: '1', b: '2' } }) + await stripe.invoices.update(draft.id, { metadata: { b: '', c: '3' } }) + assert.deepEqual(st.invoices.get(draft.id).metadata, { a: '1', c: '3' }) + + const open = await stripe.invoices.finalizeInvoice(draft.id, { auto_advance: false }) + assert.equal(open.status, 'open') + assert.match(open.number, /^WK-\d{4}$/) + assert.equal(open.auto_advance, false) + await refused(stripe.invoices.finalizeInvoice(draft.id), /draft/) + await refused(stripe.invoices.del(draft.id), /draft/) + await refused(item(draft.id), /draft/) + assert.equal(status(draft.id), 'open') + + // Nummern laufen fortlaufend + const second = await create() + assert.equal((await stripe.invoices.finalizeInvoice(second.id)).number, `WK-${String(Number(open.number.slice(3)) + 1).padStart(4, '0')}`) + + // bezahlen (ausserhalb von Stripe) und danach nichts mehr + const paid = await stripe.invoices.pay(draft.id, { paid_out_of_band: true }) + assert.equal(paid.status, 'paid') + assert.equal(paid.amount_paid, 750) + assert.equal(paid.amount_remaining, 0) + assert.equal(paid.paid_out_of_band, true) + assert.ok(paid.status_transitions.paid_at) + await refused(stripe.invoices.voidInvoice(draft.id), /open/) + await refused(stripe.invoices.pay(draft.id), /open/) + + // stornieren nur offen; Entwurf loeschen + await stripe.invoices.voidInvoice(second.id) + assert.equal(status(second.id), 'void') + const third = await create() + const removed = await stripe.invoices.del(third.id) + assert.equal(removed.deleted, true) + assert.equal(st.invoices.has(third.id), false) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 01ccdfc..a6050b9 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -17,6 +17,14 @@ import http from 'node:http' * Listen (Finanzen): GET /v1/invoices (expand data.customer ersetzt customer durch das Objekt aus st.customers, * sofern vorhanden) und GET /v1/payouts (st.payouts) blaettern wie Stripe mit limit (hoechstens 100) und * starting_after samt has_more; die Reihenfolge ist die Einfuegereihenfolge der Map. + * + * Rechnungen schreiben (Finanzen, Projektrechnungen): POST /v1/invoices (Entwurf; send_invoice braucht + * days_until_due), POST /v1/invoiceitems (mit invoice=... als Zeile mit quantity und unit_amount_decimal, + * Summe = Menge mal Einzelpreis; nur an Entwuerfe), POST /v1/invoices/:id (Metadaten zusammenfuehren, leerer + * 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) und + * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen + * Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). */ // Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/ @@ -36,11 +44,15 @@ export async function startFakeStripe() { const billingPortalConfigurations = new Map() const customers = new Map() const payouts = new Map() + // Rechnungsposten ohne Rechnung (pending invoice items), je ID + const invoiceItems = new Map() const checkoutSessions = new Map() // Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID const pendingUpdates = new Map() const calls = [] let seq = 0 + // Fortlaufende Rechnungsnummer, die finalize vergibt (WK-0001, ...) + let invoiceNumberSeq = 0 let nextSubscriptionUpdate = null let invoicePreview = null // Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig @@ -64,6 +76,23 @@ export async function startFakeStripe() { const flat = (params) => Object.fromEntries(params.entries()) const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }] const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }] + const nowUnix = () => Math.floor(Date.now() / 1000) + // Rechnungen aendern: nur Entwuerfe nehmen Posten an, finalize/send/void/pay haben feste Ausgangszustaende + const wrongState = (what, invoice, needed) => + invalid(undefined, `This invoice is ${invoice.status}; ${what} is only possible for ${needed} invoices.`) + // Metadaten wie Stripe: Schluessel hoechstens 40, Werte hoechstens 500 Zeichen, leerer Wert loescht + const mergeMetadata = (existing, incoming) => { + for (const [key, value] of Object.entries(incoming || {})) { + if (key.length > 40) return invalid(`metadata[${key}]`, 'Metadata keys can be at most 40 characters long.') + if (String(value).length > 500) return invalid(`metadata[${key}]`, 'Metadata values can be at most 500 characters long.') + } + const metadata = { ...(existing || {}) } + for (const [key, value] of Object.entries(incoming || {})) { + if (value === '') delete metadata[key] + else metadata[key] = value + } + return metadata + } const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' }) // Rechnungszeilen mit IDs (fuer starting_after), wie Stripe sie liefert; die Quelle behaelt alle Zeilen const invoiceLinesOf = (invoiceId) => @@ -230,10 +259,13 @@ export async function startFakeStripe() { if (!invoices.has(m[1])) return noSuch('invoice', m[1]) // Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more) const lines = invoiceLinesOf(m[1]) + const stored = invoices.get(m[1]) + const expandCustomer = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'customer') return [ 200, { - ...invoices.get(m[1]), + ...stored, + ...(expandCustomer && typeof stored.customer === 'string' && customers.has(stored.customer) ? { customer: customers.get(stored.customer) } : {}), lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` }, }, ] @@ -254,6 +286,152 @@ export async function startFakeStripe() { const preview = typeof invoicePreview === 'function' ? invoicePreview(data) : invoicePreview return [200, preview || { object: 'invoice', lines: { object: 'list', data: [], has_more: false }, amount_due: 0, subtotal: 0, total: 0 }] } + // Rechnungen schreiben (Finanzen): Entwurf anlegen, Posten anhaengen, finalisieren, senden, stornieren, bezahlen, loeschen + if (method === 'POST' && path === '/v1/invoices') { + const data = unflatten(body) + if (!data.customer) return invalid('customer', 'Missing required param: customer.') + const collectionMethod = data.collection_method || 'charge_automatically' + if (collectionMethod === 'send_invoice' && data.days_until_due === undefined && data.due_date === undefined) { + return invalid('days_until_due', 'Either days_until_due or due_date must be set when collection_method=send_invoice.') + } + const metadata = mergeMetadata({}, data.metadata) + if (Array.isArray(metadata)) return metadata + const customer = customers.get(data.customer) + const invoice = { + id: nextId('in'), + object: 'invoice', + number: null, + status: 'draft', + customer: data.customer, + customer_name: customer?.name ?? null, + customer_email: customer?.email ?? null, + collection_method: collectionMethod, + days_until_due: data.days_until_due !== undefined ? Number(data.days_until_due) : null, + due_date: null, + auto_advance: data.auto_advance === 'true', + pending_invoice_items_behavior: data.pending_invoice_items_behavior || 'exclude', + currency: data.currency || 'eur', + description: data.description ?? null, + footer: data.footer ?? null, + metadata, + created: nowUnix(), + total: 0, + subtotal: 0, + amount_due: 0, + amount_paid: 0, + amount_remaining: 0, + status_transitions: { finalized_at: null, paid_at: null, voided_at: null }, + hosted_invoice_url: null, + invoice_pdf: null, + lines: { object: 'list', data: [], has_more: false, url: '' }, + } + invoices.set(invoice.id, invoice) + return [200, invoice] + } + if (method === 'POST' && path === '/v1/invoiceitems') { + const data = unflatten(body) + if (!data.customer) return invalid('customer', 'Missing required param: customer.') + 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 + if (data.unit_amount_decimal !== undefined) { + if (!/^-?\d+(\.\d+)?$/.test(data.unit_amount_decimal)) return invalid('unit_amount_decimal', `Invalid decimal: ${data.unit_amount_decimal}`) + unit = data.unit_amount_decimal + } else if (data.amount !== undefined) { + unit = String(Number(data.amount)) + } else { + return invalid('amount', 'Missing required param: amount, unit_amount_decimal or pricing.') + } + const amount = Math.round(Number(unit) * (data.unit_amount_decimal !== undefined ? quantity : 1)) + const item = { + id: nextId('ii'), + object: 'line_item', + description: data.description ?? null, + quantity: data.unit_amount_decimal !== undefined ? quantity : 1, + amount, + currency: data.currency || 'eur', + pricing: { type: 'price_details', unit_amount_decimal: unit }, + metadata: data.metadata || {}, + } + if (data.invoice) { + const invoice = invoices.get(data.invoice) + if (!invoice) return noSuch('invoice', data.invoice) + if (invoice.status !== 'draft') return invalid('invoice', 'You can only add invoice items to draft invoices.') + const total = invoice.total + amount + invoices.set(invoice.id, { + ...invoice, + total, + subtotal: total, + amount_due: total, + amount_remaining: total, + lines: { object: 'list', has_more: false, ...invoice.lines, data: [...(invoice.lines?.data || []), item] }, + }) + item.invoice = invoice.id + } else { + item.invoice = null + } + invoiceItems.set(item.id, item) + return [200, { ...item, object: 'invoiceitem', customer: data.customer }] + } + if (method === 'POST' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/(finalize|send|void|pay)$/))) { + const invoice = invoices.get(m[1]) + if (!invoice) return noSuch('invoice', m[1]) + const data = unflatten(body) + const at = nowUnix() + let updated + if (m[2] === 'finalize') { + if (invoice.status !== 'draft') return wrongState('finalizing', invoice, 'draft') + invoiceNumberSeq += 1 + updated = { + ...invoice, + status: 'open', + number: `WK-${String(invoiceNumberSeq).padStart(4, '0')}`, + auto_advance: data.auto_advance !== undefined ? data.auto_advance === 'true' : invoice.auto_advance, + due_date: invoice.collection_method === 'send_invoice' && invoice.days_until_due !== null ? at + invoice.days_until_due * 86_400 : null, + hosted_invoice_url: `https://invoice.stripe.test/i/${invoice.id}`, + invoice_pdf: `https://invoice.stripe.test/i/${invoice.id}/pdf`, + status_transitions: { ...invoice.status_transitions, finalized_at: at }, + } + } else if (m[2] === 'send') { + if (invoice.status !== 'open') return wrongState('sending', invoice, 'open') + updated = { ...invoice, sent_at: at } + } else if (m[2] === 'void') { + if (invoice.status !== 'open') return wrongState('voiding', invoice, 'open') + updated = { ...invoice, status: 'void', status_transitions: { ...invoice.status_transitions, voided_at: at } } + } else { + if (invoice.status !== 'open') return wrongState('paying', invoice, 'open') + updated = { + ...invoice, + status: 'paid', + paid: true, + paid_out_of_band: data.paid_out_of_band === 'true', + amount_paid: invoice.total, + amount_remaining: 0, + status_transitions: { ...invoice.status_transitions, paid_at: at }, + } + } + invoices.set(invoice.id, updated) + return [200, updated] + } + if (method === 'POST' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) { + const invoice = invoices.get(m[1]) + if (!invoice) return noSuch('invoice', m[1]) + const data = unflatten(body) + const metadata = data.metadata ? mergeMetadata(invoice.metadata, data.metadata) : invoice.metadata + if (Array.isArray(metadata)) return metadata + const updated = { ...invoice, metadata } + invoices.set(invoice.id, updated) + return [200, updated] + } + if (method === 'DELETE' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) { + const invoice = invoices.get(m[1]) + if (!invoice) return noSuch('invoice', m[1]) + if (invoice.status !== 'draft') { + return invalid(undefined, 'You can only delete draft invoices. Finalized invoices must be voided or marked uncollectible instead.') + } + invoices.delete(m[1]) + return [200, { id: m[1], object: 'invoice', deleted: true }] + } // scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild) if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) { return products.has(m[1]) ? [200, products.get(m[1])] : noSuch('product', m[1]) @@ -412,6 +590,7 @@ export async function startFakeStripe() { billingPortalConfigurations, customers, payouts, + invoiceItems, checkoutSessions, /** * Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit