Files
Webklar-Kundenbereich/server/services/products.js
T
ClaudeandClaude Opus 5.5 dc61649228 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 <noreply@anthropic.com>
2026-09-27 15:45:09 +00:00

84 lines
3.4 KiB
JavaScript

import { config } from '../config.js'
/**
* Abo-Produkte (Hosting-Abo, Wartungs-Abo) und E-Mail-Zusatzspeicher.
*
* Reine Funktionen ohne Netzwerk: Sie ordnen Stripe-Preise (lookup_key) den Produkten zu und
* werten Abo-Positionen und Rechnungszeilen aus. Stripe-Objekte haben die Form der API-Version
* des SDK (2025-10-29.clover).
*/
export const PRODUCT_KEYS = Object.freeze(['wartung', 'hosting'])
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
/** Produkt aus dem Katalog; unbekannter Schlüssel -> 400. */
export function getProduct(key) {
const product = PRODUCT_KEYS.includes(key) ? config.products[key] : null
if (!product) throw httpError(400, `Unbekanntes Abo: ${key}`)
return product
}
/** lookup_key eines Stripe-Preises -> 'wartung' | 'hosting' | 'mailStorage' | null */
export function productKeyForLookupKey(lookupKey) {
if (!lookupKey) return null
const product = PRODUCT_KEYS.find((key) => config.products[key].lookupKey === lookupKey)
if (product) return product
return config.mailStorage.lookupKey === lookupKey ? 'mailStorage' : null
}
/**
* Positionen eines Stripe-Abos (subscription.items.data) auswerten. Der Abo-Posten ist die
* erste Position mit einem Hosting- oder Wartungs-Preis, unabhängig von der Reihenfolge.
*/
export function describeItems(items) {
const list = Array.isArray(items) ? items : []
const abo = list.find((item) => PRODUCT_KEYS.includes(productKeyForLookupKey(item?.price?.lookup_key)))
const storage = list.find((item) => productKeyForLookupKey(item?.price?.lookup_key) === 'mailStorage')
return {
productKey: abo ? productKeyForLookupKey(abo.price.lookup_key) : null,
aboItemId: abo?.id || null,
aboPriceId: abo?.price?.id || null,
lookupKey: abo?.price?.lookup_key || null,
priceAmount: abo?.price?.unit_amount ?? null,
storageItemId: storage?.id || null,
storageUnits: storage ? Number(storage.quantity) || 0 : 0,
}
}
const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null
/** Preis-IDs aller Rechnungszeilen, jede nur einmal. */
export function priceIdsOfInvoice(invoice) {
const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean)
return [...new Set(ids)]
}
/**
* 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 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) {
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 =
line.parent?.type === 'subscription_item_details' &&
line.parent.subscription_item_details?.proration === false
if (minutes > 0 && regular && line.amount > 0) return minutes
}
return 0
}