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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 20:32:08 +00:00
1 parent 48b13f4488
commit bb2f46e191
11 files changed
+856 -30

No files matched your search

+21 -8
View File
@@ -175,6 +175,8 @@ export async function syncSubscription(subscriptionId) {
consentIp: meta.consentIp || null,
consentUserAgent: meta.consentUserAgent || null,
consentTextVersion: meta.consentTextVersion || null,
offerId: meta.offerId || null,
offerText: meta.offerText || null,
})
return doc
}
@@ -243,9 +245,10 @@ export async function grantCreditForInvoice(invoice) {
const ref = subscriptionIdOfInvoice(invoice)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
// total statt amount_paid: Eine Monatsgebuehr, die ein Guthaben (Uebernahmegutschrift) deckt,
// ist bezahlt, obwohl nichts abgebucht wurde. Nur Probezeit-Rechnungen (0 €) zaehlen nicht.
if (!(invoice.total > 0)) return { skipped: 'Betrag 0' }
// subtotal (vor Rabatt und Guthaben) statt amount_paid: Auch ein Monat, den ein Angebot oder eine
// Uebernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Nur Probezeit-Rechnungen
// haben eine Zwischensumme von 0 € und bringen keine Stunde.
if (!(invoice.subtotal > 0)) return { skipped: 'Betrag 0' }
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
@@ -327,8 +330,10 @@ async function ensureTestCoupon() {
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist'
* (15 Tage Probezeit ohne Leistung und ohne Abbuchung, Start nach der Widerrufsfrist).
* offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten
* bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred).
*/
export async function createAboCheckout(customer, consent) {
export async function createAboCheckout(customer, consent, offer = null) {
const current = await getCurrentSubscription(customer.$id)
if (current && ACTIVE_STATUSES.has(current.status)) {
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
@@ -354,7 +359,13 @@ export async function createAboCheckout(customer, consent) {
earlyStartConsentAt: consent.earlyStartConsentAt || '',
consentIp: String(consent.consentIp || '').slice(0, 64),
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
...(offer
? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) }
: {}),
}
// Checkout erlaubt nur einen Rabatt: Testkonto -> Testrabatt, sonst der Angebots-Coupon
const coupon = testCoupon || offer?.stripeCouponId || null
const offerPrice = offer ? ` Preis wie vereinbart: ${offer.text}.` : ''
const params = {
mode: 'subscription',
customer: stripeCustomerId,
@@ -370,12 +381,14 @@ export async function createAboCheckout(customer, consent) {
...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}),
},
metadata: meta,
...(testCoupon ? { discounts: [{ coupon: testCoupon }] } : {}),
...(coupon ? { discounts: [{ coupon }] } : {}),
custom_text: {
submit: {
message: lateStart
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
message: offer
? `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.${lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : ''}${offerPrice} Mindestlaufzeit 12 Monate, danach monatlich kündbar.`
: lateStart
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
},
},
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,