Das Empfehlungsprogramm von Asterias, als Open Source
Asterias ist ein geschlossenes Produkt, einer seiner Bausteine aber nicht mehr. Das Empfehlungsprogramm, das hier produktiv läuft, wurde herausgelöst, neu geschrieben und unter der MIT-Lizenz veröffentlicht. Hier steht, was es tut, wie es das tut, und welche drei Fallen es vermeidet.
- Lizenz
- MIT
- Gebaut auf
- Next.js 15 · Prisma 6 · Stripe 18 · TypeScript
- Unit-Tests
- 89
- Sprachen
- EN · FR · DE · IT · ES
Was dieses Modul tut
Die meisten Empfehlungsprogramme belohnen eine Registrierung. Eine Registrierung herzustellen kostet nichts, also werden solche Programme entweder abgefarmt oder in Betrugsregeln gehüllt, die niemand einem ehrlichen Kunden erklären kann.
Dieses hier belohnt Umsatz, und nur solange er anhält. Jeder Geworbene, der zahlender Kunde wird, senkt das Abo seines Werbers um einen Anteil. An dem Tag, an dem er aufhört zu zahlen, kommt dieser Anteil auf der nächsten Rechnung wieder hinzu.
- Sarah wirbt Tom. Tom registriert sich: Sarah bekommt nichts.
- Tom startet eine kostenlose Testphase: Sarah bekommt weiterhin nichts.
- Toms Karte wird belastet: Sarah bekommt 20 % Rabatt.
- Toms Zahlung scheitert: Sarah zahlt wieder den vollen Preis.
- Tom zahlt erneut: der Rabatt kommt zurück.
Fünf zahlende Geworbene, und Sarahs Abo ist kostenlos. Schrittweite und Obergrenze legen Sie in einer Umgebungsdatei fest.
Es gibt kein Punktekonto, kein Guthabenbuch und keine Auszahlungswarteschlange. Der Rabatt ist eine Funktion der Zahl der Geworbenen, die in diesem Moment zahlen, neu berechnet aus der Datenbank und an Stripe geschrieben. Genau das verhindert, dass Bildschirm und Rechnung zwei verschiedene Dinge sagen, denn beide lesen dieselbe Funktion.
Drei Fallen, und was dieses Modul daraus macht
Drei Dinge gehen in fast jedem selbst gebauten Empfehlungsprogramm schief. Dieses Modul ist vor allem die Form, die das Vermeiden dieser drei annimmt.
| Die Falle | Was passiert | Was dieses Modul tut |
|---|---|---|
| Ein Gutschein zur einmaligen Nutzung | Er sieht nach der sauberen Art aus, „jeden Monat neu berechnet“ wahr zu machen: einen Gutschein anhängen, abfallen lassen, den nächsten anhängen. Der Rabatt hängt dann davon ab, dass ihn vor jeder einzelnen Rechnung etwas neu anhängt, und jedes Fenster, in dem das nicht lief, ist eine Belastung zum vollen Preis, die niemand meldet. | duration: forever. Der Rabatt ist ein dauerhafter Zustand, den das Modul bewusst wieder entfernt. Eine verspätete Synchronisierung kostet nichts. |
| Dem Inhalt des Webhooks glauben | Stripe garantiert die Zustellreihenfolge nicht. Ein past_due-Ereignis kann nach dem active eintreffen, das es abgelöst hatte. Wer schreibt, was die Nutzlast sagt, verschiebt einen Rabatt auf dem Abo eines Dritten, auf Grundlage eines Zustands, der nicht mehr gilt. | Jedes Ereignis löst ein frisches Lesen der Stripe-API aus. Gleichzeitige Verarbeitungen werden über den Zeitstempel ihres Lesevorgangs geordnet. |
| Dem Webhook überhaupt glauben | Ein Deployment, ein 500er, ein Ereignis, das Stripe nicht mehr wiederholt: der Werber steht auf der falschen Stufe, und ein falscher Rabatt sieht genau wie ein richtiger aus. | Ein Abgleich berechnet alle Stufen aus der Datenbank neu, repariert die Abweichung und sagt, was er repariert hat. |
Wie der Rabatt berechnet wird
Es gibt bewusst keinen gespeicherten „aktuellen Rabatt“. Die Stufe ist eine reine Funktion davon, wie viele Geworbene in diesem Moment zahlen.
discountPercent(activeReferrals) =
min(max(activeReferrals, 0), MAX_REFERRALS) × PERCENT_STEPDieselbe Funktion speist die Seite des Kunden und die Stripe-Synchronisierung. Was ihm angezeigt und was von seiner Karte abgebucht wird, kann also nicht auseinanderlaufen, weil zwei Stellen aus dem Takt geraten sind.
Der Kunde merkt es auf seiner nächsten Rechnung: Rabatte werden mit proration_behavior auf none geschrieben, ein Stufenwechsel erzeugt also nie eine Gutschrift oder eine sofortige Belastung für einen bereits gelieferten Monat.
| Stripe-Status | Zählt? | Warum |
|---|---|---|
| active | Ja | Der Geworbene zahlt. |
| trialing | Je nach Einstellung | Nur wenn REFERRAL_COUNT_TRIALING auf true steht. Lesen Sie den Abschnitt zum Missbrauch, bevor Sie das ändern. |
| past_due | Nein | Eine Verlängerung ist gescheitert. Genau das heißt „der Rabatt endet bei der ersten ausbleibenden Zahlung“. |
| unpaid | Nein | Die Rechnung wurde nie beglichen. |
| canceled | Nein | Das Abo ist gekündigt. |
| paused | Nein | Das Abo pausiert, es wird also nichts abgebucht. |
| incomplete | Nein | Nie kam eine Zahlung zustande. Ebenso bei einem Konto ohne Abo oder auf einem kostenlosen Tarif. |
Konfiguration
Alles liegt in der Umgebung und wird beim Start geprüft. Eine Konfiguration, die nicht eingehalten werden kann, startet gar nicht erst, statt an dem Tag zu scheitern, an dem ein Kunde die höchste Stufe erreicht.
| Variable | Standard | Wozu sie dient |
|---|---|---|
| REFERRAL_PERCENT_STEP | 20 | Rabatt in Prozent je zahlendem Geworbenen. |
| REFERRAL_MAX_REFERRALS | 5 | Wie viele Geworbene noch einen Rabatt einbringen. |
| REFERRAL_COUNT_TRIALING | false | Zählt ein Geworbener, der noch in der kostenlosen Testphase ist? Lesen Sie den Abschnitt zum Missbrauch, bevor Sie das auf true setzen. |
| REFERRAL_COUPON_PREFIX | referral_off | Präfix der Gutscheine, die diese Anwendung anlegt. Der Prozentsatz wird angehängt. |
| REFERRAL_COOKIE_DAYS | 30 | Wie lange ein erfasster Code bis zur Registrierung überlebt. |
| APP_URL | keiner | Öffentliche Herkunft. Daraus werden die Empfehlungslinks gebaut. |
| STRIPE_SECRET_KEY | keiner | Ohne ihn werden Empfehlungen erfasst und angezeigt, aber kein Rabatt angewendet. |
| STRIPE_WEBHOOK_SECRET | keiner | Ohne ihn weist der Endpunkt alles ab. Beide sind für die Abrechnungshälfte nötig. |
Schrittweite und Obergrenze wählen
REFERRAL_PERCENT_STEP mal REFERRAL_MAX_REFERRALS muss bei höchstens 100 bleiben. Stripe kennt keinen Gutschein über 100 % Rabatt, und eine Stufe, die einen verlangt, fiele stillschweigend auf gar keinen Rabatt zurück, ausgerechnet für das Konto, das am meisten dafür getan hat.
20 × 5 # a referral is worth 20%, five make it free (default)
10 × 5 # a gentler program that caps at half price
25 × 2 # two referrals, half price, nothing beyond
33 × 3 # 99% at the top; the last 1% keeps a card on fileBei 100 % Rabatt stellt Stripe eine Rechnung über null aus und belastet die Karte nicht. Die Karte wird also nicht mehr geprüft, und wenn die Stufe später wieder auf eine zahlende fällt, kann diese erste echte Belastung an einer seit Monaten abgelaufenen Karte scheitern. Deshalb nennt die Empfehlungsseite den Betrag der nächsten Rechnung, und deshalb steht 33 × 3 in der Liste: es deckelt bei 99 % und hält eine echte Zahlung auf dem Konto.
In einer Minute ausprobieren
Das Repository ist eine vollständige Next.js-Anwendung: das Modul, eine Postgres-Datenbank, Demokonten und die Empfehlungsseite.
git clone https://github.com/MaxenceLassus/nextjs-stripe-referrals
cd nextjs-stripe-referrals
pnpm install
docker compose up -d # Postgres on :5433
cp .env.example .env.local # works as-is without Stripe
pnpm db:migrate && pnpm db:seed
pnpm devOhne Stripe-Schlüssel erfasst das Programm Empfehlungen, zeigt sie an, wendet keinen Rabatt an und sagt das auf dem Bildschirm. Ein Testschlüssel und ein Webhook-Secret schalten die Abrechnungshälfte ein.
Beide Seiten in einem Browser durchspielen: registrieren, den Link kopieren, ihn in einem privaten Fenster öffnen und sich als jemand anderes registrieren, dieses zweite Konto abonnieren, dann die Seite des ersten Kontos ansehen: 20 % Rabatt und die nächste Rechnung beziffert. Das zweite Abo kündigen und zusehen, wie der Rabatt wieder verschwindet.
In Ihre Anwendung einbauen
Das ganze Modul ist ein Ordner, src/referrals. Kopieren, dann bleiben drei Dinge zu tun.
Die vier Prisma-Modelle, die Sie Ihrem Schema hinzufügen, tragen keinen Fremdschlüssel auf Ihre Benutzertabelle: userId ist eine undurchsichtige Zeichenkette, das, was Ihre Anwendung eine Konto-ID nennt. Das macht daraus einen Ordner zum Kopieren statt einer Migration zum Zusammenführen. Der Preis dafür ist, dass beim Löschen eines Kontos nichts kaskadiert, daher forgetUser(userId), aufgerufen aus Ihrem eigenen Löschpfad.
1. Das Konto bei der Registrierung zuordnen
attachReferral wirft nie eine Ausnahme. Eine Empfehlung ist eine Nettigkeit, eine Registrierung ist das Geschäft: ein abgelaufener, vertippter, selbstbezüglicher oder gefälschter Code liefert ein Ergebnis, das Sie protokollieren und ignorieren dürfen, und das Konto entsteht in jedem Fall.
import { attachReferral, readReferralCookie, clearReferralCookie, accountLabel } from '@/referrals'
const user = await createYourAccount(...)
await attachReferral(user.id, await readReferralCookie(), {
label: accountLabel({ name: user.name, email: user.email }),
})
await clearReferralCookie()Menschen teilen die Seite, die sie überzeugt hat, und das ist weit häufiger die Preisseite als das Registrierungsformular. Eine Zeile in der Middleware, die Sie ohnehin haben, fängt ?ref= auf jeder Seite ab.
2. Den Code überall auffangen, nicht nur auf /signup
Der erfasste Code überlebt standardmäßig dreißig Tage. Das reicht für einen Besucher, der geht, darüber nachdenkt und später wiederkommt, um ein Konto anzulegen.
// src/middleware.ts
export function middleware(request: NextRequest) {
return captureReferral(request, NextResponse.next())
}3. Den Webhook einhängen und Ihr Checkout markieren
Im Webhook erfährt das Programm, dass ein Geworbener gezahlt hat. Das Checkout wiederum sagt ihm, zu welchem Konto der gerade erzeugte Stripe-Kunde gehört.
// src/app/api/referrals/webhook/route.ts
export { POST } from '@/referrals/stripe/webhook'
// wherever you create a Checkout session
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price, quantity: 1 }],
...referralCheckoutOptions(user.id),
})Und die Seite
ReferralPage bekommt eine userId und ermittelt absichtlich nie selbst, wer angemeldet ist. Die Authentifizierung ist Ihre: eine Komponente, die die Sitzung selbst auflöst, ist eine Komponente, die auf einer Route hängen kann, auf der niemand geprüft hat.
// src/app/dashboard/referrals/page.tsx
export default async function Page() {
const user = await requireUser() // your auth, not this module's
return <ReferralPage
userId={user.id}
locale={user.locale}
label={user.name}
price={{ amount: 2900, currency: 'eur' }}
/>
}Missbrauch, und warum dieser Entwurf ihm standhält
Die wichtigste Zeile dieses Moduls lautet: eine Testphase zählt nicht.
Würden Testphasen zählen, öffnete jeder fünf Konten, startete fünf Testphasen, nähme 100 % Rabatt auf das eigene Abo und kündigte alle fünf, bevor je eine Karte belastet wird. Das Programm zahlt für nichts.
Ohne Testphasen verlangt ein Rabatt echte, wirklich bezahlte Abos. Wer fünf zahlende Geworbene vortäuschen will, muss fünf Abos tatsächlich bezahlen, um eines kostenlos zu bekommen. Die Ökonomie übernimmt die Kontrolle, und deshalb gibt es hier keinen Betrugsscore, den man einstellen oder in einem Support-Ticket erklären müsste.
Was bei Ihnen bleibt: die Rate Ihrer Registrierungsroute begrenzen. Dieses Modul sieht Ihren Verkehr nicht, und die Kontoerstellung ist Ihr Endpunkt.
| Der Angriff | Warum er scheitert |
|---|---|
| Sich selbst werben | resolveReferrer weist ab, wenn der Inhaber des Codes das gerade entstehende Konto ist. |
| Zwei Werber für ein Konto | referredUserId ist eindeutig. Der Erste gewinnt, endgültig. |
| Das Cookie oder den Wert von ?ref= fälschen | Beide sind besucherseitig und keinem wird geglaubt: der Code wird bei der Registrierung in der Datenbank nachgeschlagen. Eine Fälschung gibt immer nur dem genannten Konto einen Rabatt, es gibt also nichts zu stehlen. |
| Empfehlungen rückwirkend beanspruchen | attachReferral weist ein Konto ab, das bereits Kunde war. |
| Einen Stripe-Webhook erneut einspielen | Jede Ereignis-ID wird vor der Verarbeitung gespeichert, und der Primärschlüssel verwirft die Wiederholung. |
| Die Empfehlungen eines fremden Kontos lesen | Jede Abfrage ist an die userId gebunden, die Sie übergeben. Übergeben Sie eine authentifizierte. |
Betrieb
Vier Befehle, von denen zwei wirklich zählen.
pnpm referrals:doctor # will this deployment actually apply discounts?
pnpm referrals:reconcile # repair drift; run hourly
pnpm referrals:reconcile --dry-run # report it without writing
pnpm referrals:verify-flow # walk the whole program against a real databasereconcile meldet sich nur, wenn es etwas getan hat. Ein Lauf, der bei jedem ruhigen Durchgang protokolliert, gewöhnt alle daran, ihn zu übersehen, und dann wird der laute Tag ebenfalls übersehen. Repariert er zwanzig Rabatte am Tag, ist weiter oben etwas kaputt, und das müssen Sie wissen.
Die Diagnose, vom Server aus
doctor beantwortet die Frage, die im Browser keine Antwort hat: ein fehlendes Webhook-Secret, ein Schlüssel auf das falsche Stripe-Konto, ein Gutschein mit der falschen Dauer. Von außen sieht jeder dieser Fälle genau so aus wie ein Programm, in dem noch niemand jemanden geworben hat.
Configuration
ok tiers: 20% x 5 = 100% maximum
ok trials do not count
Stripe
ok key works: account acct_1234 (Your Company)
ok mode: test
Coupons
ok referral_off_20: 20% off, forever
FAIL referral_off_40 has duration "once", not "forever".
A `once` coupon falls off after one invoice and the discount silently stops.Was getestet ist, und was nicht
89 Unit-Tests, ohne Datenbank und ohne Netz: die Arithmetik der Stufen und ihre Grenzen, das Erzeugen und Maskieren der Codes, die Prüfung der Konfiguration, die Zuordnung der Status, die Schutzregeln der Zuordnung, Signatur, Idempotenz und Verteilung des Webhooks sowie die Rabattsynchronisierung samt ihres Fehlerpfads.
Acht davon sind Wire-Tests: das echte Stripe-SDK, echte serialisierte Parameter, gegen einen lokalen Rekorder. Sie prüfen die Bytes statt eines Mocks: duration=forever auf dem Gutschein, discounts[0][coupon] beim Setzen, ein leeres discounts beim Entfernen, proration_behavior=none in beiden Fällen. Das sind die drei Dinge, die in einer Stripe-Integration am ehesten stillschweigend falsch sind und einem gemockten Test am sichersten entgehen.
Wovon nichts davon etwas weiß: wie Stripe tatsächlich antwortet, und ob eine echte Rechnung niedriger ausfällt. Das braucht einen Testschlüssel und fünf Minuten, und das Repository beschreibt die Schritte.
Fünf Sprachen
Englisch, Französisch, Deutsch, Italienisch, Spanisch. Englisch ist die Vorgabe und der Rückfall für alles Unbekannte; fr-CA wird zu fr aufgelöst. Die Wörterbücher sind pro Sprache typisiert, eine neue Sprache oder auch nur eine neue Zeichenkette kompiliert also erst, wenn alle fünf sie haben. Ein Test prüft zusätzlich, dass keine Übersetzung einen Platzhalter verliert.
Was es hier antreibt
Das ist keine Wochenendabstraktion. Es betreibt das Empfehlungsprogramm von Asterias, der Software für Google-Bewertungen, auf deren Website Sie gerade sind. Ein Inhaber, der einen Kollegen wirbt, sieht sein Abo um 20 % sinken, sobald dieser Kollege wirklich zahlt, und wieder steigen, wenn er aufhört.
Die veröffentlichte Fassung ist keine Kopie des Codes von hier: sie ist eine Neufassung und behebt zwei echte Fehler, die beim Herauslösen gefunden wurden. Wer etwas Vergleichbares betreibt, sollte zehn Minuten dafür aufwenden.
Lizenz
MIT. Kopieren Sie den Ordner, ändern Sie ihn, verkaufen Sie, was Sie daraus machen. Wenn Sie ihn einsetzen, freut eine Nachricht.
Häufige Fragen
Kann der Geworbene auch einen Rabatt bekommen?
Nicht ab Werk. Ein Rabatt für das geworbene Konto wird gewährt, bevor es irgendetwas bezahlt hat, und genau das vermeidet dieser Entwurf bewusst: er beruhte auf einem Versprechen statt auf einem Beleg, und er ist der einzige echte Missbrauchsweg im ganzen System. Wenn Sie ihn trotzdem wollen, wenden Sie im Checkout Ihren eigenen Gutschein an, hier stört nichts.
Braucht es den Webhook?
Ja, und anders als bei der Freischaltung eines eigenen Abos ist das nicht verhandelbar. Die Abrechnungsänderung einer Empfehlung betrifft ein Konto, das nicht vor dem Bildschirm sitzt. Niemand schaut zu, es gibt also keine träge Aktualisierung, auf die man ausweichen könnte.
Funktioniert es ohne Stripe?
Es läuft, erfasst Empfehlungen, zeigt sie an und sagt auf dem Bildschirm, dass kein Rabatt angewendet wird. Nützlich für die lokale Entwicklung und für ein Deployment, dessen Stripe-Einrichtung noch nicht fertig ist.
Was, wenn meine Anwendung nicht auf Next.js läuft?
Regeln, Codes, Abfragen, Stripe-Synchronisierung, Webhook-Handler und Abgleich sind reines TypeScript ohne Framework darin. Nur capture.ts, middleware.ts und ui/ sind Next-spezifisch, etwa ein Drittel des Ordners.
Warum kein npm-Paket?
Weil die beiden Dinge, die am dringendsten installiert werden müssen, ein Prisma-Modell und eine App-Router-Seite sind, und npm kann keines von beiden liefern, ohne dass Sie sie ohnehin kopieren. Ein Ordner ist dabei ehrlich.
Der Code liegt auf GitHub
MIT-lizenziert, mit einer README, die die vollständige API, die Fehlerfälle und den Weg beschreibt, gegen einen echten Stripe-Schlüssel zu prüfen, dass eine Rechnung wirklich niedriger ausfällt.