From dc61649228562e0529f4116d3b6d141ee9ac96af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:45:09 +0000 Subject: [PATCH] Stundenkonto: Abos ohne bekannten Preis bekommen als Altbestand weiter ihre Stunde Wie im Abo-Spiegel gelten Rechnungen ohne jeden bekannten Abo-Preis als Wartung. Ein unbekannter Preis neben einem bekannten (z. B. Hosting) bringt weiterhin keine Stunde. Co-Authored-By: Claude Opus 5.5 --- server/services/products.js | 11 ++++++++--- test/hourCredits.test.js | 24 +++++++++++++++++++++--- test/products.test.js | 10 +++++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/server/services/products.js b/server/services/products.js index 0a3e56f..c4edf0d 100644 --- a/server/services/products.js +++ b/server/services/products.js @@ -61,12 +61,17 @@ export function priceIdsOfInvoice(invoice) { /** * Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten * regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige - * Verrechnungen, Probezeiten (0 €), Hosting, Zusatzspeicher und unbekannte Preise zählen nicht. + * Verrechnungen, Probezeiten (0 €), Hosting und Zusatzspeicher zählen nicht. + * Unbekannte Preise zählen nur, wenn die Rechnung gar keinen bekannten Abo-Preis enthält: Solche + * Abos gelten wie im Abo-Spiegel als Wartungs-Abo (Altbestand bzw. von Hand angelegt). * priceKeys: Map Preis-ID -> 'wartung' | 'hosting' | 'mailStorage' | null */ export function creditMinutesForInvoice(invoice, priceKeys) { - for (const line of invoice?.lines?.data || []) { - const key = priceKeys.get(priceIdOfLine(line)) + const lines = invoice?.lines?.data || [] + const legacy = !lines.some((line) => PRODUCT_KEYS.includes(priceKeys.get(priceIdOfLine(line)))) + for (const line of lines) { + const known = priceKeys.get(priceIdOfLine(line)) + const key = legacy && known == null ? 'wartung' : known if (!PRODUCT_KEYS.includes(key)) continue const minutes = config.products[key].minutesPerMonth const regular = diff --git a/test/hourCredits.test.js b/test/hourCredits.test.js index 09cef03..9f4ef56 100644 --- a/test/hourCredits.test.js +++ b/test/hourCredits.test.js @@ -93,8 +93,26 @@ test('Preis-Zuordnung wird zwischengespeichert', async () => { assert.equal(st.calls.filter((c) => c.path === '/v1/prices/price_w').length, 1) }) -test('Unbekannte Preis-ID (404): keine Stunde, kein Absturz', async () => { - st.invoices.set('in_5', inv('in_5', 'sub_w', [regular('price_weg', 5000)])) - assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_w', 5000)), { skipped: 'keine Wartungs-Leistung' }) +test('Unbekannte Preis-ID (404) neben Hosting: keine Stunde, kein Absturz', async () => { + st.invoices.set('in_5', inv('in_5', 'sub_h', [regular('price_h', 1500), regular('price_weg', 800)])) + assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_h', 2300)), { skipped: 'keine Wartungs-Leistung' }) assert.equal(aw.docs('hourCredits').has('in_5'), false) }) + +test('Altbestand: Abo ohne bekannten Preis bekommt weiter seine Stunde, auch bei alter Nutzlast', async () => { + st.prices.set('price_alt', price('price_alt', null, 5000)) + st.subscriptions.set('sub_alt', sub('sub_alt', st.prices.get('price_alt'))) + st.invoices.set('in_6', inv('in_6', 'sub_alt', [regular('price_alt', 5000)])) + const oldPayload = { + id: 'in_6', + subscription: 'sub_alt', + subtotal: 5000, + created: 1790000000, + status_transitions: { paid_at: 1790000100 }, + lines: { data: [{ amount: 5000, price: { id: 'price_alt' }, proration: false }] }, + } + const r = await grantCreditForInvoice(oldPayload) + assert.equal(r.granted, true) + assert.equal(aw.docs('hourCredits').get('in_6').minutes, 60) + assert.equal(aw.docs('subscriptions').get('sub_alt').productKey, 'wartung') +}) diff --git a/test/products.test.js b/test/products.test.js index e7b26ca..92dc2e6 100644 --- a/test/products.test.js +++ b/test/products.test.js @@ -77,7 +77,8 @@ test('Stunden nur für eine reguläre Wartungs-Zeile über 0 €', () => { assert.equal(creditMinutesForInvoice(inv(line('price_h', 1500), line('price_s', 200)), keys), 0) assert.equal(creditMinutesForInvoice(inv(line('price_w', 2300, true)), keys), 0) // anteilige Verrechnung assert.equal(creditMinutesForInvoice(inv(line('price_w', 0)), keys), 0) // Probezeit - assert.equal(creditMinutesForInvoice(inv(line('price_x', 5000)), keys), 0) // unbekannter Preis + // unbekannter Preis neben einem bekannten Abo-Preis: keine Stunde + assert.equal(creditMinutesForInvoice(inv(line('price_h', 1500), line('price_x', 800)), keys), 0) const invoiceItem = { amount: 1200, pricing: { price_details: { price: 'price_w' } }, @@ -89,6 +90,13 @@ test('Stunden nur für eine reguläre Wartungs-Zeile über 0 €', () => { assert.equal(creditMinutesForInvoice({}, keys), 0) }) +test('Altbestand: Rechnung ohne bekannten Abo-Preis zählt als Wartung (wie im Abo-Spiegel)', () => { + const inv = (...lines) => ({ lines: { data: lines } }) + assert.equal(creditMinutesForInvoice(inv(line('price_x', 5000)), keys), 60) + assert.equal(creditMinutesForInvoice(inv(line('price_x', 0)), keys), 0) // Probezeit bleibt ohne Stunde + assert.equal(creditMinutesForInvoice(inv(line('price_x', 2300, true)), keys), 0) // anteilig bleibt ohne Stunde +}) + test('priceIdsOfInvoice: eindeutige Preis-IDs, ohne Lücken', () => { const inv = { lines: { data: [line('price_w', 5000), line('price_w', -100, true), { amount: 5 }] } } assert.deepEqual(priceIdsOfInvoice(inv), ['price_w'])