Files
Webklar-Kundenbereich/scripts/setup-abo-collections.mjs
T
ClaudeandClaude Opus 5.5 bb2f46e191 Abo: individuelle Angebote (z. B. erste 2 Monate je 25 EUR) mit Stripe-Coupon
- Admin-Tab "Abo-Angebote" und Button "Angebot" in der Kundenliste: Preis, Monate,
  Gueltigkeit, Hinweis fuer den Kunden, interne Notiz, Vorschau vor dem Anlegen
- Je Angebot sofort ein Stripe-Coupon (amount_off, repeating ueber die
  Angebotsmonate, max. 1 Einloesung); danach automatisch wieder Normalpreis
- Kunde ohne Abo: Angebot im Portal, Angebotspreis in der Bestelluebersicht,
  Coupon im Checkout; Angebot schaltet die Bestellung auch ausserhalb der
  Pilotliste frei; Bestellung prueft, dass das angezeigte Angebot noch gilt
- Laufendes Abo: Coupon sofort am Abo fuer die naechsten Abbuchungen
- Testkonten: Testrabatt im Checkout, Angebot per Webhook zusaetzlich am Abo
- Angebot in Abo-Metadaten, Vertragsbestaetigung, Admin-Mail und Abo-Karte
- Zurueckziehen loescht den Coupon; abgelaufene Angebote nicht mehr bestellbar
- Stundenkontingent nach invoice.subtotal: auch voll rabattierte oder per
  Guthaben bezahlte Monate bringen die Stunde, die Probezeit nicht
- setup-abo-collections.mjs: aboOffers, subscriptions.offerId/offerText,
  Attribut-Abfrage mit Limit (sonst Haenger ab 26 Attributen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:32:08 +00:00

219 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits
* (Uebernahmegutschriften) und aboOffers (individuelle Angebote) in woms-database an und
* ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto").
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
* Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs
*/
const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '')
const projectId = process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05'
const apiKey = process.env.APPWRITE_API_KEY
const databaseId = process.env.APPWRITE_DATABASE_ID || 'woms-database'
if (!apiKey) {
console.error('APPWRITE_API_KEY erforderlich')
process.exit(1)
}
async function aw(path, { method = 'GET', body } = {}) {
const response = await fetch(`${endpoint}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': projectId,
'X-Appwrite-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.message || `Appwrite ${response.status}`)
err.status = response.status
throw err
}
return data
}
async function ensure(label, fn) {
try {
await fn()
console.log(`+ ${label}`)
} catch (e) {
if (e.status === 409 || /already exists/i.test(e.message)) {
console.log(`= ${label} (existiert)`)
} else {
throw new Error(`${label}: ${e.message}`)
}
}
}
async function waitForAttributes(collectionId, keys, maxSec = 60) {
// Ohne Limit liefert Appwrite nur 25 Attribute -> spaetere Attribute waeren nie "verfuegbar"
const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [100] }))
for (let i = 0; i < maxSec; i += 1) {
const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes?queries[]=${limit}`)
const pending = keys.filter((k) => attributes.find((a) => a.key === k)?.status !== 'available')
if (!pending.length) return
await new Promise((r) => setTimeout(r, 1000))
}
throw new Error(`Attribute in ${collectionId} wurden nicht verfuegbar`)
}
const col = (id) => `/databases/${databaseId}/collections/${id}`
async function ensureCollection(id, name, attributes, indexes) {
// Keine Dokument-Permissions: Zugriff ausschliesslich ueber den Server-API-Key
await ensure(`Collection ${id}`, () =>
aw(`/databases/${databaseId}/collections`, {
method: 'POST',
body: { collectionId: id, name, permissions: [], documentSecurity: false },
})
)
for (const a of attributes) {
const { type, key, ...rest } = a
await ensure(`${id}.${key}`, () =>
aw(`${col(id)}/attributes/${type}`, { method: 'POST', body: { key, required: false, ...rest } })
)
}
await waitForAttributes(id, attributes.map((a) => a.key))
for (const idx of indexes) {
await ensure(`${id} index ${idx.key}`, () =>
aw(`${col(id)}/indexes`, { method: 'POST', body: idx })
)
}
}
await ensureCollection(
'subscriptions',
'Abos (Stripe)',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'email', size: 255 },
{ type: 'string', key: 'stripeCustomerId', size: 64 },
{ type: 'string', key: 'stripeSubscriptionId', size: 64 },
{ type: 'string', key: 'status', size: 32 },
{ type: 'string', key: 'planKey', size: 64 },
{ type: 'integer', key: 'priceAmount' },
{ type: 'datetime', key: 'startedAt' },
{ type: 'datetime', key: 'currentPeriodEnd' },
{ type: 'boolean', key: 'cancelAtPeriodEnd' },
{ type: 'datetime', key: 'canceledAt' },
{ type: 'datetime', key: 'endedAt' },
{ type: 'string', key: 'termsVersion', size: 32 },
{ type: 'datetime', key: 'termsAcceptedAt' },
{ type: 'datetime', key: 'earlyStartConsentAt' },
{ type: 'datetime', key: 'cancelRequestedAt' },
{ type: 'datetime', key: 'withdrawalRequestedAt' },
{ type: 'datetime', key: 'confirmationSentAt' },
// v2 (25.09.2026): Wahl des Leistungsbeginns + Nachweis der Zustimmung
{ type: 'string', key: 'startChoice', size: 16 },
{ type: 'datetime', key: 'trialEnd' },
{ type: 'string', key: 'consentIp', size: 64 },
{ type: 'string', key: 'consentUserAgent', size: 300 },
{ type: 'string', key: 'consentTextVersion', size: 32 },
// v3: Beendigungszeitpunkt (Mindestlaufzeit / Wunschtermin)
{ type: 'datetime', key: 'cancelAt' },
// Individuelles Angebot, mit dem das Abo abgeschlossen wurde (Nachweis der Preisabrede)
{ type: 'string', key: 'offerId', size: 64 },
{ type: 'string', key: 'offerText', size: 300 },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
{ key: 'idx_stripe_customer', type: 'key', attributes: ['stripeCustomerId'] },
]
)
await ensureCollection(
'hourCredits',
'Abo-Stundengutschriften',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'stripeSubscriptionId', size: 64 },
{ type: 'string', key: 'stripeInvoiceId', size: 64 },
{ type: 'integer', key: 'minutes' },
{ type: 'datetime', key: 'grantedAt' },
{ type: 'datetime', key: 'expiresAt' },
{ type: 'string', key: 'source', size: 32 },
{ type: 'string', key: 'note', size: 500 },
],
[{ key: 'idx_customer', type: 'key', attributes: ['customerId'] }]
)
// Uebernahmegutschrift: Antrag (Kunde) -> Pruefung/Entscheidung (Admin) -> Verrechnung (Stripe-Guthaben).
// Lange Texte mit size > 16381 werden in Appwrite zu TEXT-Spalten und zaehlen nicht zur Zeilenbreite.
await ensureCollection(
'takeoverCredits',
'Uebernahmegutschriften',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'email', size: 255 },
{ type: 'string', key: 'services', size: 128 },
{ type: 'string', key: 'provider', size: 200 },
{ type: 'string', key: 'domain', size: 255 },
{ type: 'integer', key: 'invoiceAmountCents' },
{ type: 'integer', key: 'billingMonths' },
{ type: 'datetime', key: 'paidUntil' },
{ type: 'string', key: 'message', size: 20000 },
{ type: 'string', key: 'files', size: 20000 },
{ type: 'string', key: 'status', size: 20 },
{ type: 'string', key: 'history', size: 60000 },
{ type: 'datetime', key: 'takeoverFrom' },
{ type: 'integer', key: 'amountCents' },
{ type: 'string', key: 'calculation', size: 1000 },
{ type: 'string', key: 'checks', size: 1000 },
{ type: 'string', key: 'decisionNote', size: 20000 },
{ type: 'datetime', key: 'decidedAt' },
{ type: 'string', key: 'decidedBy', size: 255 },
{ type: 'string', key: 'stripeCustomerId', size: 64 },
{ type: 'string', key: 'stripeBalanceTransactionId', size: 64 },
{ type: 'datetime', key: 'appliedAt' },
{ type: 'string', key: 'conditionsVersion', size: 32 },
{ type: 'datetime', key: 'consentAt' },
{ type: 'string', key: 'consentIp', size: 64 },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
{ key: 'idx_status', type: 'key', attributes: ['status'] },
]
)
// Individuelle Abo-Angebote: Admin legt vorab fest (z. B. erste 2 Monate je 25 €), Stripe-Coupon je Angebot
await ensureCollection(
'aboOffers',
'Abo-Angebote',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'status', size: 20 },
{ type: 'string', key: 'scope', size: 16 },
{ type: 'integer', key: 'monthlyPriceCents' },
{ type: 'integer', key: 'regularPriceCents' },
{ type: 'integer', key: 'months' },
{ type: 'datetime', key: 'validUntil' },
{ type: 'string', key: 'note', size: 500 },
{ type: 'string', key: 'internalNote', size: 2000 },
{ type: 'string', key: 'stripeCouponId', size: 64 },
{ type: 'string', key: 'stripeSubscriptionId', size: 64 },
{ type: 'datetime', key: 'appliedAt' },
{ type: 'string', key: 'createdBy', size: 255 },
{ type: 'datetime', key: 'withdrawnAt' },
{ type: 'string', key: 'withdrawnBy', size: 255 },
{ type: 'datetime', key: 'notifiedAt' },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
{ key: 'idx_status', type: 'key', attributes: ['status'] },
]
)
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
await ensure('worksheets.countsToPlan', () =>
aw(`${col('worksheets')}/attributes/boolean`, {
method: 'POST',
body: { key: 'countsToPlan', required: false, default: false },
})
)
await waitForAttributes('worksheets', ['countsToPlan'])
console.log('Fertig.')