Designing a Billing and Subscription Renewal System
1. Overview
Renewing a subscription sounds simple: when it’s due, charge the customer. In practice, there are many ways to get it wrong. You might charge twice, skip a period, or overwhelm the payment gateway on the 1st of the month.
This article walks through the complete design: requirements, data model, APIs, processing flow, and tricky problems. Each tricky part comes with a solution in Java 21, Spring Boot 3.x, PostgreSQL, Redis, and Kafka. By the end, you’ll have a solid design framework to apply to a real system.
2. Problem Requirements
2.1. Functional Requirements
- Customers sign up for a plan (
Plan) monthly or yearly. - The system automatically renews and charges when the current period ends.
- Customers can cancel immediately or cancel at the end of the current period.
- Customers can change plans mid-period. Fees are calculated based on remaining time (proration).
- When payment fails, the system retries on a schedule (dunning). After all retries are exhausted, the subscription is canceled.
- Other systems (email, authorization) receive events
InvoicePaidandSubscriptionCanceled.
2.2. Scale
- 5 million active subscriptions.
- Approximately 170,000 renewals per day on average.
- Peak of around 1 million renewals on the 1st, due to promotional campaigns bunching sign-up dates.
- Payment gateway is limited to 200 TPS. With 1 million charges, just the charging step alone takes around 1.4 hours.
2.3. Constraints
- Never charge twice for the same period.
- Never skip a period, even if an instance crashes mid-process.
- Money is stored as an integer in the smallest unit (
BIGINT). Don’t usedouble. - Timestamps stored in UTC (
TIMESTAMPTZ). - The service runs multiple instances behind a load balancer.
3. Data Model
The system has three main tables and one outbox table:
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
);
A few decisions worth noting:
UNIQUE (subscription_id, period_start)is the final safety net. Even if the code has a bug, the database won’t allow creating two invoices for the same period.- The partial index
idx_sub_duecontains onlyACTIVEsubscriptions. Queries finding subscriptions due are therefore always small and fast. - The
versioncolumn serves optimistic locking for API operations like changing plans or canceling.
4. API
| Method | Endpoint | Description |
|---|---|---|
POST |
/subscriptions |
Create a subscription. Requires Idempotency-Key header |
GET |
/subscriptions/{id} |
View status and current period |
POST |
/subscriptions/{id}/cancel |
Body {"atPeriodEnd": true} |
POST |
/subscriptions/{id}/change-plan |
Body {"planCode": "PRO_MONTHLY"} |
POST |
/webhooks/payments |
Payment gateway reports results asynchronously |
All write operations must be idempotent. Flaky networks or retried requests, and customers clicking “Sign up” twice, should still result in only one subscription being created.
5. Renewal Flow
The renewal flow splits into two independent steps:
- Create invoices: find subscriptions due, create
invoicewith statusOPEN, then advancecurrent_period_endto the next period. This step only works with the database. - Charge: find
OPENinvoices withnext_attempt_at <= now, call the payment gateway, then record the result.
Why split them: calling the payment gateway can take several seconds. If you call it directly within a transaction holding row locks, you’ll drain the connection pool very quickly.
First, you need the following 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. Tricky Problems and Solutions
6.1. Contention: Multiple Instances Running Jobs
Every instance has @Scheduled. If all of them select subscriptions due at the same time, the same subscription will be processed multiple times. The solution is to use 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 locks the selected rows. SKIP LOCKED causes other instances to skip those rows and fetch the next batch, instead of waiting. Each instance thus receives its own set. To increase throughput, you simply add more instances.
The invoice-creation service processes each batch in a 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 the loop, subscriptions with the cancelAtPeriodEnd flag are canceled instead of renewed. Others get an OPEN invoice with next_attempt_at = now, then the period advances to the next milestone. Both changes are in the same transaction, so there’s no intermediate state like “invoice created but period not advanced”.
The scheduler calls this service repeatedly until there’s no more work:
@Scheduled(fixedDelay = 10_000)
public void renew() {
while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}
API operations like changing plans or canceling can race with the renewal job. The version column and JPA’s @Version annotation handle this. Whoever commits second will get OptimisticLockException. The client just needs to read the state again and retry.
6.2. Never Charge Twice: Idempotency Key
This is the trickiest part. You call the payment gateway and get a timeout. You don’t know if the customer was charged or not. If you retry blindly, you might charge twice.
The solution is to send an idempotency key. When the payment gateway receives the same key again, it returns the previous result without charging again:
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);
}
The key includes invoiceId and attemptCount, not just invoiceId. Many payment gateways cache both successful and failed results by key. If you use a single key, three days later (after the customer has added funds), you’ll still get the old “card declined” error.
Rules for incrementing attemptCount:
- Success: invoice moves to
PAID. - Clear decline (insufficient funds, card expired): increment
attemptCountand schedule retry. - Unknown result (timeout, 5xx error): keep
attemptCountunchanged. The next retry uses the same key, so the gateway returns the actual result from before.
Note: Webhooks from the payment gateway may arrive early, late, or multiple times. The webhook handler must check the invoice status before updating. Skip if already
PAID.
6.3. Retry Schedule for Failed Payments (Dunning)
The retry schedule is a pure function, so it’s easy to test:
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));
}
}
After the first failure, the system retries after 1 day. The second attempt is 3 days later, the third is 5 days. After the fourth attempt, the function returns Optional.empty(). At that point, the invoice moves to FAILED and the subscription to CANCELED. During dunning, the subscription is in PAST_DUE status and the customer can still use the service (grace period).
The random jitter of 0–120 minutes prevents all retries from clustering at the same time.
6.4. Consistency Between Database and Kafka: Outbox Pattern
After marking an invoice PAID, you need to send an InvoicePaid event. If you commit the database and then send to Kafka, an instance could crash and the event is lost. If you send to Kafka first and then the database rolls back, other systems receive a “phantom” event.
The outbox pattern solves this by writing events to the outbox_event table in the same 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)));
}
}
This function runs in its own transaction, separate from the payment gateway call. A relay runs periodically to read from the outbox and push to 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 also uses FOR UPDATE SKIP LOCKED as in section 6.1. The join() call ensures Kafka has received the message before marking published_at. The message key is aggregateId, so events from the same invoice always go to the same partition and preserve order.
This approach guarantees at-least-once delivery. If the relay crashes after sending but before committing, the event will be resent. Therefore, consumers must be idempotent, for example by tracking processed eventIds.
6.5. Traffic Spikes on the 1st
On the 1st, there might be up to 1 million invoices to charge. The payment gateway only handles 200 TPS. There are three layers of defense:
- Distribute renewal dates:
current_period_enduses the customer’s sign-up time, including hour and minute, instead of rounding to 00:00. This alone spreads the load across 24 hours. - Natural queueing: invoices sit in the database with
next_attempt_at. Workers process them gradually. Nothing is lost, just slower. - Global rate limiting via Redis: all instances share a single counter.
@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;
}
}
Each second gets its own key. INCR is atomic, so with 10 instances, the total calls per second won’t exceed 200. Workers that get false leave the invoice for the next round. This is the fixed window algorithm. It’s simple and works here, though it can slightly exceed the limit at second boundaries.
Note: If Redis is unavailable, fail-closed (pause charging) rather than skip rate limiting. Being slow for a few minutes is better than having the payment gateway lock your merchant account.
7. Proration When Changing Plans Mid-Period and Testing
When a customer upgrades mid-period, the system credits the unused part of the old plan and charges for the remaining part of the new plan:
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;
}
}
The function multiplies before dividing to avoid precision loss. All arithmetic uses long. A negative result means the customer downgraded, and that credit is applied to the next period.
Testing with JUnit 5 and 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();
}
}
The first test upgrades at exactly the mid-point of a 30-day period, from 100,000 to 300,000. Credit is 50,000, charge is 150,000, so the customer pays an extra 100,000. The second test verifies two things: the first retry falls within 1 day plus jitter, and after the fourth failure there’s no more schedule.
BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED
For the contention part in section 6.1, write an additional integration test using Testcontainers PostgreSQL. Have two threads call createDueInvoices simultaneously, then assert that the number of invoices equals the number of subscriptions due.
8. Common Mistakes
- Calling the payment gateway inside
@Transactional: the transaction holds locks and connections while waiting for the network. Split it into read, external call, and result recording steps. - Using
@Scheduledwith no locking when running multiple instances: causes duplicate processing. - Calculating dates with
LocalDateTimein server timezone: causes timezone skew and 31st-day errors. UseInstant. For monthly periods, useZonedDateTime.plusMonths(1)in the customer’s timezone. This method automatically clamps 31/1 to 28 or 29/2. - Using a single idempotency key for all retries: old errors get cached and returned forever.
- Don’t build this from scratch if you only have thousands of customers and a simple pricing model. The payment gateway’s built-in billing service is usually cheaper than maintenance. Self-building only makes sense if you need custom pricing logic, multiple gateways, or strict data control.
9. Conclusion
This article designed a billing and renewal system that handles millions of customers at scale. Key takeaways:
- Split invoice creation and charging into separate steps.
- Use
FOR UPDATE SKIP LOCKEDto distribute work among instances, withUNIQUEconstraints as a safety net. - Use idempotency keys per retry attempt to never charge twice.
- Use the outbox pattern for consistency between database and Kafka.
- Combine distributing renewal dates, database queueing, and Redis rate limiting to handle traffic spikes.
From this framework, you can extend with taxes, discount codes, or usage-based pricing without changing the core flow.