Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-28 10:48:29 +00:00
1 parent 887e1e54a0
commit 379cc26dcd
11 files changed
+1531 -27

No files matched your search

+35 -9
View File
@@ -326,7 +326,8 @@ export function termStartOf(sub) {
return new Date(((sub.trial_end || sub.start_date) || 0) * 1000)
}
function subscriptionIdOfInvoice(invoice) {
/** Stripe-Abo-ID (oder -Objekt) einer Rechnung: Webhook-Nutzlast (alt/neu) oder frisch geladene Rechnung. */
export function subscriptionIdOfInvoice(invoice) {
return (
invoice.subscription ||
invoice.parent?.subscription_details?.subscription ||
@@ -356,8 +357,33 @@ export async function resolvePriceKeys(priceIds) {
return keys
}
/** Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
export async function grantCreditForInvoice(invoice) {
/**
* Alle Zeilen einer Rechnung. invoices.retrieve bettet hoechstens 10 Zeilen ein (lines.has_more); anteilige
* Zeilen stehen vor den Abo-Zeilen, mehrere Speicheraenderungen in einem Monat schieben also die Wartungs-
* oder Speicher-Zeile hinaus. Bei has_more kommen alle Seiten ueber invoices.listLineItems (F18). Eine
* Vorschau-Rechnung hat keine ID und laesst sich nicht nachladen: dort zaehlen die eingebetteten Zeilen.
*/
export async function invoiceLines(invoice) {
const embedded = invoice?.lines?.data || []
if (!invoice?.lines?.has_more || !invoice.id) return embedded
return getStripe().invoices.listLineItems(invoice.id, { limit: 100 }).autoPagingToArray({ limit: 1000 })
}
/**
* Rechnung frisch laden (in der API-Version des SDK, nicht der des Webhook-Endpunkts), mit allen Zeilen.
* Der Webhook invoice.paid laedt sie genau einmal und reicht sie an Stundengutschrift und Speicher-Buchung weiter.
*/
export async function loadInvoice(invoiceId) {
const fresh = await getStripe().invoices.retrieve(invoiceId)
const data = await invoiceLines(fresh)
return { ...fresh, lines: { ...(fresh.lines || {}), data, has_more: false } }
}
/**
* Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). invoice ist die
* Webhook-Nutzlast (wird frisch geladen) oder, mit fresh: true, eine schon per loadInvoice geladene Rechnung.
*/
export async function grantCreditForInvoice(invoice, { fresh = false } = {}) {
const ref = subscriptionIdOfInvoice(invoice)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
@@ -365,28 +391,28 @@ export async function grantCreditForInvoice(invoice) {
// Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot
// oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €),
// anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde.
const fresh = await getStripe().invoices.retrieve(invoice.id)
const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh)))
const loaded = fresh ? invoice : await loadInvoice(invoice.id)
const minutes = creditMinutesForInvoice(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded)))
if (!minutes) return { skipped: 'keine Wartungs-Leistung' }
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000)
const grantedAt = new Date((loaded.status_transitions?.paid_at || loaded.created) * 1000)
try {
await createDocument(
config.collections.hourCredits,
{
customerId: sub.customerId,
stripeSubscriptionId: subscriptionId,
stripeInvoiceId: invoice.id,
stripeInvoiceId: loaded.id,
minutes,
grantedAt: grantedAt.toISOString(),
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
source: 'stripe_invoice',
note: fresh.number ? `Rechnung ${fresh.number}` : '',
note: loaded.number ? `Rechnung ${loaded.number}` : '',
},
invoice.id
loaded.id
)
return { granted: true, customerId: sub.customerId, subscription: sub }
} catch (err) {