Entwurf eines Abrechnungs- und Verlängerungssystems für Abos
1. Überblick
Aboverlängerung klingt einfach: wenn die Zeit abläuft, buche Gebühren ab. In der Praxis gibt es viele Fallstricke. Du kannst Gebühren doppelt abbuchen, einen Zeitraum übersehen oder das Zahlungsgateway am 1. des Monats überlasten.
Dieser Beitrag behandelt das komplette Design: Anforderungen, Datenmodell, API, Verarbeitung und schwierige Punkte. Jeden schwierigen Punkt begleitet eine Lösung mit Java 21, Spring Boot 3.x, PostgreSQL, Redis und Kafka. Nach dem Lesen hast du einen soliden Entwurf, den du in echten Systemen anwenden kannst.
2. Anforderungen
2.1. Funktionale Anforderungen
- Kunden registrieren sich für einen Plan monatlich oder jährlich.
- Das System verlängert automatisch und bucht Gebühren ab, wenn ein Zeitraum endet.
- Kunden können sofort kündigen oder am Ende des Zeitraums.
- Kunden können den Plan während eines Zeitraums wechseln. Gebühren werden zeitbasiert berechnet (proration).
- Wenn die Zahlung fehlschlägt, versucht das System es nach einem Zeitplan erneut (dunning). Nach allen Versuchen wird das Abo storniert.
- Andere Systeme (E-Mail, Berechtigungen) erhalten Events wie
InvoicePaidundSubscriptionCanceled.
2.2. Skalierung
- 5 Millionen aktive Abos.
- Durchschnittlich etwa 170.000 Verlängerungen pro Tag.
- Spitze etwa 1 Million am 1. des Monats durch Kampagnen, die Startdaten auf den 1. legen.
- Das Zahlungsgateway kann 200 TPS verarbeiten. Bei 1 Million Verlängerungen dauert nur die Abrechnung etwa 1,4 Stunden.
2.3. Einschränkungen
- Niemals Gebühren doppelt abbuchen für denselben Zeitraum.
- Kein Zeitraum darf fehlen, auch wenn eine Instanz mittendrin abstürzt.
- Beträge als ganze Zahlen in kleinster Einheit (
BIGINT). Keinedouble. - Zeit als UTC (
TIMESTAMPTZ). - Der Dienst läuft auf mehreren Instanzen hinter einem Load Balancer.
3. Datenmodell
Das System hat drei Haupttabellen und eine Outbox-Tabelle:
CREATE TABLE plan (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
price_amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
billing_interval VARCHAR(10) NOT NULL -- MONTH | YEAR
);
CREATE TABLE subscription (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL REFERENCES plan(id),
status VARCHAR(20) NOT NULL, -- ACTIVE | PAST_DUE | CANCELED
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_sub_due ON subscription (current_period_end) WHERE status = 'ACTIVE';
CREATE TABLE invoice (
id BIGSERIAL PRIMARY KEY,
subscription_id BIGINT NOT NULL REFERENCES subscription(id),
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- OPEN | PAID | FAILED
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ,
UNIQUE (subscription_id, period_start)
);
CREATE INDEX idx_inv_retry ON invoice (next_attempt_at) WHERE status = 'OPEN';
CREATE TABLE outbox_event (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
Ein paar Entscheidungen sind bemerkenswert:
UNIQUE (subscription_id, period_start)ist das letzte Sicherheitsnetz. Selbst wenn Code fehlerhaft ist, lässt die Datenbank nicht zu, zwei Rechnungen für denselben Zeitraum zu erstellen.- Der Partial Index
idx_sub_dueenthält nurACTIVEAbos. Die Query für fällige Abos bleibt damit klein und schnell. - Die Spalte
versionunterstützt optimistic locking für API-Operationen wie Planwechsel oder Kündigung.
4. API
| Method | Endpoint | Beschreibung |
|---|---|---|
POST |
/subscriptions |
Abo erstellen. Header Idempotency-Key erforderlich |
GET |
/subscriptions/{id} |
Status und aktuellen Zeitraum anzeigen |
POST |
/subscriptions/{id}/cancel |
Body {"atPeriodEnd": true} |
POST |
/subscriptions/{id}/change-plan |
Body {"planCode": "PRO_MONTHLY"} |
POST |
/webhooks/payments |
Zahlungsgateway meldet asynchrones Ergebnis |
Alle Schreiboperationen müssen idempotent sein. Mobiles Netz führt zu Wiederholungen, und Kunden klicken “Registrieren” doppelt – es sollte trotzdem nur ein Abo entstehen.
5. Verlängerungslogik
Die Verlängerung spaltet sich in zwei unabhängige Schritte:
- Rechnung erstellen: Finde fällige Abos, erstelle eine Rechnung mit Status
OPEN, schiebe danncurrent_period_endzum neuen Zeitraum. Dieser Schritt arbeitet nur mit der Datenbank. - Gebühren abbuchen: Finde Rechnungen mit Status
OPEN, derennext_attempt_at <= now, rufe das Zahlungsgateway auf, schreibe dann das Ergebnis.
Warum spalten? Aufrufe zum Zahlungsgateway können Sekunden dauern. Machst du das in einer Transaction mit gehaltener Row-Lock, erschöpfst du den Connection Pool sehr schnell.
Zunächst brauchst du diese Dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6. Schwierige Punkte und Lösungen
6.1. Konkurrenz: mehrere Instanzen führen den Job parallel aus
Jede Instanz hat @Scheduled. Wenn alle gleichzeitig fällige Abos abrufen (SELECT), wird dasselbe Abo mehrfach verarbeitet. Die Lösung ist FOR UPDATE SKIP LOCKED:
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
@Query(value = """
SELECT * FROM subscription
WHERE status = 'ACTIVE' AND current_period_end <= :now
ORDER BY current_period_end
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<Subscription> lockDueBatch(Instant now, int batchSize);
}
FOR UPDATE sperrt die ausgewählten Zeilen. SKIP LOCKED lässt andere Instanzen diese Zeilen überspringen und den nächsten Batch holen, anstatt zu warten. Jede Instanz erhält also eine eigene Menge. Um den Durchsatz zu erhöhen, fügst du einfach eine Instanz hinzu.
Der Service, der Rechnungen erstellt, verarbeitet jeden Batch in einer Transaction:
@Service
@RequiredArgsConstructor
public class RenewalService {
private final SubscriptionRepository subscriptions;
private final InvoiceRepository invoices;
@Transactional
public int createDueInvoices(Instant now) {
List<Subscription> due = subscriptions.lockDueBatch(now, 500);
for (Subscription sub : due) {
if (sub.isCancelAtPeriodEnd()) {
sub.cancel();
continue;
}
Instant start = sub.getCurrentPeriodEnd();
Instant end = sub.getPlan().nextPeriodEnd(start);
invoices.save(Invoice.open(sub, start, end, sub.getPlan().getPriceAmount(), now));
sub.advancePeriod(end);
}
return due.size();
}
}
In der Schleife: Hat ein Abo die Flag cancelAtPeriodEnd, wird es storniert statt verlängert. Der Rest bekommt eine Rechnung mit Status OPEN und next_attempt_at = now, dann wird der Zeitraum zum neuen Meilenstein verschoben. Beide Änderungen sind in derselben Transaction, daher gibt es keinen Zwischenzustand “Rechnung erstellt aber Zeitraum nicht verschoben”.
Der Scheduler ruft den Service wiederholt auf, bis keine Arbeit mehr bleibt:
@Scheduled(fixedDelay = 10_000)
public void renew() {
while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}
API-Operationen wie Planwechsel oder Kündigung können mit dem Verlängerungsjob kollidieren. Die Spalte version zusammen mit JPA’s @Version Annotation kümmert sich darum. Wer später committed, bekommt OptimisticLockException. Der Client liest nur den Status neu und versucht es nochmal.
6.2. Niemals doppelt abbuchen: Idempotency Key
Das ist der schwierigste Punkt. Du rufst das Zahlungsgateway auf und erhältst einen Timeout. Du weißt nicht, ob der Kunde bereits belastet wurde. Ein blinder Retry könnte zweimal abbuchen.
Die Lösung: Sende einen Idempotency Key. Wenn das Gateway denselben Key erhält, gibt es das alte Ergebnis zurück, ohne erneut zu buchen:
public void chargeInvoice(long invoiceId) {
Invoice inv = invoices.findById(invoiceId).orElseThrow();
if (inv.getStatus() != InvoiceStatus.OPEN) {
return;
}
String key = "inv-%d-try-%d".formatted(inv.getId(), inv.getAttemptCount());
PaymentResult result = paymentGateway.charge(inv.getCustomerId(), inv.getAmount(), key);
billingTx.recordResult(invoiceId, result);
}
Der Key besteht aus invoiceId und attemptCount, nicht nur invoiceId. Viele Zahlungsgateways speichern auch fehlgeschlagene Ergebnisse pro Key. Verwendest du einen eindeutigen Schlüssel, erhält der Wiederholungsversuch drei Tage später (nachdem der Kunde Geld aufgeladen hat) immer noch den alten Fehler “Karte abgelehnt”.
Regeln für Increment attemptCount:
- Erfolg: Rechnung wird
PAID. - Explizite Ablehnung (Guthaben leer, Karte abgelaufen): Increment
attemptCountund zum Retry-Zeitplan. - Unsicheres Ergebnis (Timeout, 5xx-Fehler):
attemptCountnicht ändern. Der nächste Retry nutzt denselben Key, also gibt das Gateway das echte Ergebnis des letzten Versuchs zurück.
Hinweis: Webhooks vom Zahlungsgateway können vorher, nachher oder mehrfach ankommen. Der Webhook-Handler prüft den Rechnungsstatus vor dem Update. Eine bereits
PAIDRechnung wird ignoriert.
6.3. Retry-Plan bei Zahlungsfehlern (dunning)
Der Retry-Plan ist eine reine Funktion, daher sehr leicht zu testen:
public final class DunningPolicy {
private static final List<Duration> SCHEDULE =
List.of(Duration.ofDays(1), Duration.ofDays(3), Duration.ofDays(5));
public static Optional<Instant> nextAttempt(int failedAttempts, Instant now) {
if (failedAttempts > SCHEDULE.size()) {
return Optional.empty();
}
long jitterMinutes = ThreadLocalRandom.current().nextLong(0, 120);
return Optional.of(now.plus(SCHEDULE.get(failedAttempts - 1)).plusSeconds(jitterMinutes * 60));
}
}
Nach dem ersten Fehler wird nach 1 Tag erneut versucht. Beim zweiten Mal 3 Tage, beim dritten 5 Tage. Nach dem vierten Versuch gibt die Funktion Optional.empty() zurück. Dann wird die Rechnung FAILED und das Abo CANCELED. Während des Dunning-Zeitraums ist das Abo im Status PAST_DUE und der Kunde kann die Leistung nutzen (grace period).
Das zufällige Jitter von 0–120 Minuten verhindert, dass sich Retry-Versuche auf denselben Zeitpunkt konzentrieren.
6.4. Konsistenz zwischen Datenbank und Kafka: Outbox Pattern
Nach dem Markieren einer Rechnung als PAID musst du ein InvoicePaid Event senden. Commitest du die DB und die Instanz stirbt dann, bevor das Kafka-Event gesendet wird, ist das Event weg. Sendest du zu Kafka vorher, könnte die DB rollback und andere Systeme erhalten ein “Geister”-Event.
Das Outbox Pattern löst dies durch Speichern des Events in der outbox_event Tabelle in derselben Transaction:
@Transactional
public void recordResult(long invoiceId, PaymentResult result) {
Invoice inv = invoices.findById(invoiceId).orElseThrow();
switch (result.status()) {
case SUCCESS -> {
inv.markPaid();
outbox.save(OutboxEvent.of(inv.getId(), "InvoicePaid", inv.toJson()));
}
case DECLINED -> inv.recordFailure(Instant.now());
case UNKNOWN -> inv.retryLater(Instant.now().plus(Duration.ofMinutes(15)));
}
}
Diese Funktion läuft in einer eigenen Transaction, getrennt vom Aufruf zum Zahlungsgateway. Ein Relay-Service liest periodisch die Outbox und schiebt zu Kafka:
@Scheduled(fixedDelay = 1_000)
@Transactional
public void publishOutbox() {
for (OutboxEvent e : outbox.lockUnpublished(200)) {
kafka.send("billing-events", String.valueOf(e.getAggregateId()), e.getPayload()).join();
e.markPublished(Instant.now());
}
}
lockUnpublished nutzt auch FOR UPDATE SKIP LOCKED wie in Abschnitt 6.1. Der .join() Aufruf wartet, bis Kafka die Nachricht hat, bevor published_at gesetzt wird. Der Message Key ist die aggregateId, deshalb landen Events desselben Invoices immer in derselben Partition und bewahren die Reihenfolge.
Diese Herangehensweise garantiert at-least-once Semantik. Stirbt der Relay nach dem Senden aber vor dem Commit, wird das Event erneut gesendet. Deshalb muss der Consumer idempotent sein, z.B. durch Speichern der verarbeiteten eventId.
6.5. Lastspitzen am 1. des Monats
Am 1. könnten bis zu 1 Million Rechnungen eingezogen werden. Das Zahlungsgateway schafft nur 200 TPS. Es gibt drei Verteidigungsebenen:
- Zahlungsmeilensteine verteilen:
current_period_endnimmt den exakten Zeitpunkt der Kundenregistrierung, einschließlich Stunde und Minute, statt auf 00:00 zu runden. Das allein verteilt die Last über 24 Stunden. - Natürliche Warteschlange: Rechnungen sitzen in der DB mit
next_attempt_at. Worker verarbeiten sie nach Kapazität. Nichts geht verloren, nur langsamer. - Globales Rate Limiting mit Redis: alle Instanzen teilen einen Zähler.
@Component
@RequiredArgsConstructor
public class GatewayRateLimiter {
private final StringRedisTemplate redis;
private final int limitPerSecond = 200;
public boolean tryAcquire() {
String key = "rl:gateway:" + Instant.now().getEpochSecond();
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(2));
}
return count != null && count <= limitPerSecond;
}
}
Jede Sekunde bekommt einen eigenen Key. INCR ist atomar, also bleibt die Gesamtzahl der Aufrufe pro Sekunde unter 200, egal ob 10 Instanzen laufen. Wenn ein Worker false erhält, lässt er die Rechnung für die nächste Runde. Das ist der Fixed-Window-Algorithmus – einfach und ausreichend hier, kann aber leicht an den Grenzen zwischen Sekunden überschießen.
Hinweis: Falls Redis unerreichbar ist, fail-closed (pausiere Abbuchungen) statt Rate Limit zu ignorieren. Ein paar Minuten Verzögerung ist besser als ein gesperrtes Merchant-Konto.
7. Proration beim Planwechsel und Tests
Wenn ein Kunde während eines Zeitraums ein höherwertiges Plan wählt, erstattet das System den ungenutzten Teil des alten Plans und verrechnet den Rest des neuen:
public final class Proration {
public static long amountDue(long oldPrice, long newPrice,
Instant periodStart, Instant periodEnd, Instant changeAt) {
long total = Duration.between(periodStart, periodEnd).toSeconds();
long remaining = Duration.between(changeAt, periodEnd).toSeconds();
long credit = Math.floorDiv(oldPrice * remaining, total);
long charge = Math.floorDiv(newPrice * remaining, total);
return charge - credit;
}
}
Die Funktion multipliziert zuerst, teilt dann, um Präzisionsverluste zu vermeiden. Alle Berechnungen nutzen long. Ein negatives Ergebnis bedeutet Downgrade, und dieser Betrag wird als Guthaben für die nächste Periode notiert.
Test mit JUnit 5 und AssertJ:
class BillingRulesTest {
private final Instant start = Instant.parse("2026-10-01T00:00:00Z");
private final Instant end = Instant.parse("2026-10-31T00:00:00Z");
@Test
void upgradeAtHalfPeriodChargesHalfOfDifference() {
Instant mid = Instant.parse("2026-10-16T00:00:00Z");
long due = Proration.amountDue(100_000, 300_000, start, end, mid);
assertThat(due).isEqualTo(100_000);
}
@Test
void stopsRetryingAfterThreeScheduledAttempts() {
Instant now = Instant.parse("2026-10-01T00:00:00Z");
assertThat(DunningPolicy.nextAttempt(1, now)).get()
.satisfies(t -> assertThat(t).isBetween(now.plus(Duration.ofDays(1)),
now.plus(Duration.ofDays(1)).plus(Duration.ofHours(2))));
assertThat(DunningPolicy.nextAttempt(4, now)).isEmpty();
}
}
Der erste Test wechselt während der Mitte eines 30-Tage-Zeitraums von 100.000 zu 300.000. Guthaben ist 50.000, Gebühr 150.000, also zahlt der Kunde 100.000 zusätzlich. Der zweite Test prüft: Der erste Retry-Versuch liegt im 1-Tage-Fenster plus Jitter, und nach dem vierten fehlgeschlagenen Versuch gibt es keinen Plan mehr.
BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED
Für den Konkurrenzpunkt in Abschnitt 6.1 schreib auch einen Integration-Test mit Testcontainers PostgreSQL. Starte zwei Threads, die gleichzeitig createDueInvoices aufrufen, und assert, dass die Rechnungszahl gleich der Zahl der fälligen Abos ist.
8. Häufige Fehler
- Zahlungsgateway in
@Transactionalaufrufen: Die Transaction hält Locks und Verbindungen während des Netzwartens. Trenne in Leseschritt, Gateway-Aufruf außerhalb und Schreibschritt. @Scheduledohne Locking-Mechanismus bei mehreren Instanzen: führt zu Duplikatverarbeitung.- Datum mit
LocalDateTimenach Serverzeit rechnen: Zeitzonenversätze und Monatsende-Fehler (z.B. 31. Januar). NutzeInstant. Für Monatszeiträume nutzeZonedDateTime.plusMonths(1)in der Kundenzeit – diese Methode klemmt 31/1 automatisch auf 28 oder 29/2. - Einen Idempotency Key für alle Versuche nutzen: alte Fehler werden gecacht und immer zurückgegeben.
- Nicht alles neu bauen, wenn du nur wenige tausend Kunden und einfache Preislogik hast. Billing-Services von Zahlungsgateways sind meist billiger als die Maintenance. Eigenentwicklung lohnt sich nur mit Custom-Preislogik, mehreren Gateways oder strikten Daten-Anforderungen.
9. Fazit
Der Beitrag hat ein Abrechnungs- und Verlängerungssystem für Millionen von Kunden entworfen. Die Kernideen sind:
- Rechnungserstellung und Abbuchen in zwei Schritte trennen.
FOR UPDATE SKIP LOCKEDzum Verteilen auf mehrere Instanzen, mitUNIQUEConstraint als Sicherheitsnetz.- Idempotency Keys pro Versuch, damit Gebühren niemals doppelt abgebucht werden.
- Outbox Pattern für Konsistenz zwischen DB und Kafka.
- Meilensteine verteilen, DB-Warteschlange und Redis Rate Limit kombinieren, um Lastspitzen zu bewältigen.
Von diesem Framework kannst du Steuern, Gutscheine oder nutzungsbasierte Gebühren hinzufügen, ohne die Kernlogik zu ändern.