Finanzen: Stripe-Rechnungen und Auszahlungen lesen (deutsch gemappt, 60-s-Cache)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6af9834293
commit
db243f50bb
4 files changed
+441
No files matched your search
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Anzeigeformate fuer die Finanzen. Betraege sind immer ganze Cent (Ganzzahl), nie Gleitkomma-Euro.
|
||||
*/
|
||||
|
||||
// Einmal anlegen (neu anlegen ist teuer); de-DE liefert "1.234,56 €" mit geschuetztem Leerzeichen
|
||||
const euroFormat = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
|
||||
|
||||
/** Cent als deutscher Euro-Betrag, z. B. 123456 -> "1.234,56 €", -449 -> "−4,49 €" (echtes Minuszeichen U+2212). */
|
||||
export function euroDe(cents) {
|
||||
if (!Number.isSafeInteger(cents)) throw new Error('Betrag muss eine ganze Zahl in Cent sein.')
|
||||
const text = euroFormat.format(Math.abs(cents) / 100)
|
||||
return cents < 0 ? `−${text}` : text
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getStripe, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
||||
import { addDays, berlinDate, daysBetween } from './dates.js'
|
||||
|
||||
/**
|
||||
* Stripe-Rechnungen und -Auszahlungen fuer die Finanzen (nur lesen). 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.
|
||||
*/
|
||||
|
||||
const CACHE_MS = 60_000
|
||||
// Rueckblick: Rechnungen der letzten 400 Tage (reicht fuer ein volles Jahr samt Nachzuegler)
|
||||
const LOOKBACK_DAYS = 400
|
||||
const MAX_INVOICES = 1000
|
||||
|
||||
// Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig"
|
||||
const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' }
|
||||
|
||||
// Je Art ein Eintrag { at, promise }; die Zusage (statt der Daten) wird gemerkt, damit gleichzeitige Aufrufe
|
||||
// dieselbe Stripe-Anfrage teilen. Ein Fehler wird nicht zwischengespeichert.
|
||||
const caches = { invoices: null, payouts: null }
|
||||
|
||||
function cached(kind, force, load) {
|
||||
const entry = caches[kind]
|
||||
if (!force && entry && Date.now() - entry.at < CACHE_MS) return entry.promise
|
||||
const fresh = { at: Date.now(), promise: null }
|
||||
fresh.promise = load().catch((err) => {
|
||||
if (caches[kind] === fresh) caches[kind] = null
|
||||
throw err
|
||||
})
|
||||
caches[kind] = fresh
|
||||
return fresh.promise
|
||||
}
|
||||
|
||||
/** Zwischenspeicher leeren (nach jeder Aenderung an Rechnungen oder Auszahlungen, damit die Ansicht stimmt). */
|
||||
export function invalidateFinanceCache() {
|
||||
caches.invoices = null
|
||||
caches.payouts = null
|
||||
}
|
||||
|
||||
// Stripe liefert Zeitpunkte als Unix-Sekunden; die Finanzen rechnen mit Berliner Kalendertagen
|
||||
const ymdOfUnix = (seconds) => (Number.isFinite(seconds) ? berlinDate(new Date(seconds * 1000)) : null)
|
||||
|
||||
/** Eine rohe Stripe-Rechnung (Kunde moeglichst expandiert) in die deutsche Form der Finanzen; today = YYYY-MM-DD. */
|
||||
export function mapInvoice(raw, { today }) {
|
||||
const customer = raw.customer && typeof raw.customer === 'object' ? raw.customer : null
|
||||
const metadata = raw.metadata || {}
|
||||
const dueDate = ymdOfUnix(raw.due_date)
|
||||
let status = STATUS_DE[raw.status] || raw.status
|
||||
if (raw.status === 'open' && dueDate && dueDate < today) status = 'ueberfaellig'
|
||||
return {
|
||||
id: raw.id,
|
||||
number: raw.number || '',
|
||||
status,
|
||||
customerName: raw.customer_name || customer?.name || '',
|
||||
customerEmail: raw.customer_email || customer?.email || '',
|
||||
webklarCustomerId: metadata.webklarCustomerId || customer?.metadata?.webklarCustomerId || '',
|
||||
createdDate: ymdOfUnix(raw.created),
|
||||
dueDate,
|
||||
totalCents: raw.total ?? 0,
|
||||
remainingCents: raw.amount_remaining ?? 0,
|
||||
paidCents: raw.amount_paid ?? 0,
|
||||
// Bei einer Bankzuordnung zaehlt der Tag, an dem das Geld auf dem Konto ankam, nicht der der Stripe-Buchung
|
||||
paidDate: metadata.bankBookingDate || ymdOfUnix(raw.status_transitions?.paid_at),
|
||||
overdueDays: status === 'ueberfaellig' ? daysBetween(dueDate, today) : 0,
|
||||
source: subscriptionIdOfInvoice(raw) ? 'abo' : 'projekt',
|
||||
hostedUrl: raw.hosted_invoice_url || null,
|
||||
pdfUrl: raw.invoice_pdf || null,
|
||||
outOfBand: Boolean(metadata.bankTransactionId),
|
||||
bankTransactionId: metadata.bankTransactionId || '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Rechnungen der letzten 400 Tage (hoechstens 1000, neueste zuerst), mit Kunde, auf Deutsch gemappt. */
|
||||
export async function listInvoices({ force = false } = {}) {
|
||||
const raw = await cached('invoices', force, () =>
|
||||
getStripe()
|
||||
.invoices.list({
|
||||
limit: 100,
|
||||
created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 },
|
||||
expand: ['data.customer'],
|
||||
})
|
||||
.autoPagingToArray({ limit: MAX_INVOICES })
|
||||
)
|
||||
const today = berlinDate()
|
||||
return raw.map((invoice) => mapInvoice(invoice, { today }))
|
||||
}
|
||||
|
||||
/** Die letzten 50 Stripe-Auszahlungen: { id, amountCents, arrivalDate, status }. */
|
||||
export async function listPayouts({ force = false } = {}) {
|
||||
const raw = await cached('payouts', force, () => getStripe().payouts.list({ limit: 50 }).then((page) => page.data))
|
||||
return raw.map((payout) => ({
|
||||
id: payout.id,
|
||||
amountCents: payout.amount,
|
||||
arrivalDate: ymdOfUnix(payout.arrival_date),
|
||||
status: payout.status,
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user