Löschfristen: bei abgeschnittenen Listen abbrechen, Teilfehler melden, Randfälle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c8e7703ae
commit
36b54f6dab
2 files changed
+126
-16
No files matched your search
@@ -17,6 +17,9 @@ import { filesOf, STATUS } from './takeoverCredits.js'
|
||||
* Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des
|
||||
* Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht.
|
||||
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
|
||||
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
|
||||
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
|
||||
* Liste abgeschnitten (>= LIST_LIMIT), bricht der Lauf ab, bevor etwas geplant oder geloescht wird.
|
||||
*/
|
||||
|
||||
const SUBSCRIPTIONS = () => config.collections.subscriptions
|
||||
@@ -47,6 +50,10 @@ const validDate = (value) => (value && !Number.isNaN(new Date(value).getTime())
|
||||
|
||||
/** Ende eines Abos (ISO) oder null, wenn es nicht beendet ist. */
|
||||
function endOf(doc) {
|
||||
// Stripe hat das Abo nie aktiviert und verfallen lassen: zaehlt als beendet
|
||||
if (doc.status === 'incomplete_expired' && !validDate(doc.endedAt)) {
|
||||
return validDate(doc.canceledAt) || validDate(doc.$updatedAt) || validDate(doc.startedAt)
|
||||
}
|
||||
if (!validDate(doc.endedAt) && doc.status !== 'canceled') return null
|
||||
return validDate(doc.endedAt) || validDate(doc.canceledAt) || validDate(doc.currentPeriodEnd)
|
||||
}
|
||||
@@ -54,11 +61,17 @@ function endOf(doc) {
|
||||
/**
|
||||
* @param {Date} [now]
|
||||
* @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[],
|
||||
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[] }>}
|
||||
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[],
|
||||
* pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>}
|
||||
*/
|
||||
export async function findDueDeletions(now = new Date()) {
|
||||
export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } = {}) {
|
||||
const currentYear = berlinYear(now)
|
||||
const subs = await listDocuments(SUBSCRIPTIONS(), [Query.limit(LIST_LIMIT)])
|
||||
const list = async (collection, queries = []) => {
|
||||
const docs = await listDocuments(collection, [...queries, Query.limit(limit)])
|
||||
if (docs.length >= limit) throw new Error(`Liste abgeschnitten, Paginierung nötig (${collection}, ${docs.length} Einträge)`)
|
||||
return docs
|
||||
}
|
||||
const subs = await list(SUBSCRIPTIONS())
|
||||
const byCustomer = new Map()
|
||||
for (const doc of subs) {
|
||||
if (!doc.customerId) continue
|
||||
@@ -67,19 +80,30 @@ export async function findDueDeletions(now = new Date()) {
|
||||
}
|
||||
|
||||
const customers = []
|
||||
const pending = []
|
||||
const skipped = []
|
||||
for (const [customerId, docs] of byCustomer) {
|
||||
const incomplete = docs.filter((d) => d.status === 'incomplete')
|
||||
const others = docs.filter((d) => d.status !== 'incomplete')
|
||||
if (incomplete.length && others.every((d) => endOf(d))) {
|
||||
pending.push({
|
||||
customerId,
|
||||
subscriptionIds: incomplete.map((d) => d.$id),
|
||||
reason: 'Abo im Status incomplete hält Löschung auf (Stripe klären: wird es aktiv oder verfällt es?)',
|
||||
})
|
||||
}
|
||||
const ends = docs.map(endOf)
|
||||
// ein laufendes Abo (oder ein beendetes ohne erkennbares Datum) haelt alle Daten des Kunden
|
||||
if (ends.some((end) => !end)) continue
|
||||
const lastEndAt = ends.reduce((a, b) => (new Date(a) > new Date(b) ? a : b))
|
||||
if (currentYear < berlinYear(lastEndAt) + RETENTION_YEARS + 1) continue
|
||||
const events = await listDocuments(EVENTS(), [Query.equal('customerId', customerId), Query.limit(LIST_LIMIT)])
|
||||
const events = await list(EVENTS(), [Query.equal('customerId', customerId)])
|
||||
customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) })
|
||||
}
|
||||
|
||||
const takeover = []
|
||||
const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000
|
||||
const rejected = await listDocuments(TAKEOVER(), [Query.equal('status', STATUS.rejected), Query.limit(LIST_LIMIT)])
|
||||
const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)])
|
||||
for (const doc of rejected) {
|
||||
if (!filesOf(doc).length || !validDate(doc.decidedAt) || new Date(doc.decidedAt).getTime() > rejectedCutoff) continue
|
||||
takeover.push({
|
||||
@@ -88,17 +112,21 @@ export async function findDueDeletions(now = new Date()) {
|
||||
reason: `abgelehnt am ${doc.decidedAt.slice(0, 10)}, Nachweise noch vorhanden (die tägliche Bereinigung löscht sie nach sechs Monaten; Logs und Upload-Ordner prüfen)`,
|
||||
})
|
||||
}
|
||||
const approved = await listDocuments(TAKEOVER(), [Query.equal('status', STATUS.approved), Query.limit(LIST_LIMIT)])
|
||||
const approved = await list(TAKEOVER(), [Query.equal('status', STATUS.approved)])
|
||||
for (const doc of approved) {
|
||||
const decided = validDate(doc.decidedAt) || validDate(doc.appliedAt)
|
||||
if (!decided || currentYear < berlinYear(decided) + RECEIPT_YEARS + 1) continue
|
||||
const decided = validDate(doc.decidedAt)
|
||||
if (!decided) {
|
||||
skipped.push({ id: doc.$id, reason: 'genehmigt, aber ohne Entscheidungsdatum (decidedAt); Frist nicht berechenbar, von Hand prüfen' })
|
||||
continue
|
||||
}
|
||||
if (currentYear < berlinYear(decided) + RECEIPT_YEARS + 1) continue
|
||||
takeover.push({
|
||||
id: doc.$id,
|
||||
action: 'loeschen',
|
||||
reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`,
|
||||
})
|
||||
}
|
||||
return { customers, takeover }
|
||||
return { customers, takeover, pending, skipped }
|
||||
}
|
||||
|
||||
const isGone = (err) => err?.status === 404
|
||||
@@ -118,12 +146,22 @@ async function deleteQuietly(collection, id) {
|
||||
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
|
||||
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
|
||||
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number },
|
||||
* manual: { customerId: string, subscriptionId: string, reason: string }[] }>}
|
||||
* manual: { customerId: string, subscriptionId: string, reason: string }[],
|
||||
* failed: { what: string, id: string, reason: string }[] }>}
|
||||
*/
|
||||
export async function applyDeletions(plan) {
|
||||
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0 }
|
||||
const manual = []
|
||||
const failed = []
|
||||
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
|
||||
const attempt = async (what, id, fn) => {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
failed.push({ what, id, reason: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (const customer of plan.customers || []) {
|
||||
for (const id of customer.subscriptionIds) {
|
||||
@@ -139,18 +177,22 @@ export async function applyDeletions(plan) {
|
||||
}
|
||||
}
|
||||
for (const id of customer.eventIds) {
|
||||
if (await deleteQuietly(EVENTS(), id)) deleted.contractEvents += 1
|
||||
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
|
||||
}
|
||||
for (const id of customer.subscriptionIds) {
|
||||
if (await deleteQuietly(SUBSCRIPTIONS(), id)) deleted.subscriptions += 1
|
||||
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
|
||||
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
|
||||
if (await deleteQuietly(TAKEOVER(), item.id)) deleted.takeover += 1
|
||||
const removed = await attempt('takeoverCredits', item.id, async () => {
|
||||
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
|
||||
return true
|
||||
})
|
||||
if (!removed) continue // Dokument bleibt, solange die Nachweise nicht weg sind
|
||||
if (await attempt('takeoverCredits', item.id, () => deleteQuietly(TAKEOVER(), item.id))) deleted.takeover += 1
|
||||
}
|
||||
return { deleted, manual }
|
||||
return { deleted, manual, failed }
|
||||
}
|
||||
|
||||
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })
|
||||
@@ -168,12 +210,21 @@ export function formatReport(plan, result = null) {
|
||||
const report = plan.takeover.filter((t) => t.action === 'melden')
|
||||
if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`))
|
||||
if (report.length) lines.push('', 'Zur Kenntnis (nicht von diesem Lauf gelöscht):', ...report.map((t) => ` ${t.id}: ${t.reason}`))
|
||||
if (plan.pending?.length) {
|
||||
lines.push('', 'Hält Löschung auf:', ...plan.pending.map((p) => ` Kunde ${p.customerId}, Abo ${p.subscriptionIds.join(', ')}: ${p.reason}`))
|
||||
}
|
||||
if (plan.skipped?.length) {
|
||||
lines.push('', 'Übersprungen (von Hand prüfen):', ...plan.skipped.map((t) => ` ${t.id}: ${t.reason}`))
|
||||
}
|
||||
if (result) {
|
||||
const d = result.deleted
|
||||
lines.push(
|
||||
'',
|
||||
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`
|
||||
)
|
||||
if (result.failed?.length) {
|
||||
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
|
||||
}
|
||||
if (result.manual.length) {
|
||||
lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user