[Bug] Overdue intermediate states skipped after partial payment — account goes CLEAR → BLOCKED without WARNING

15 views
Skip to first unread message

TAMIL THENDRAL SENTHAMIZH

unread,
Aug 6, 2026, 8:36:10 AM (yesterday) Aug 6
to Kill Bill users mailing-list
# Overdue State Skip: Intermediate states skipped when earliest unpaid invoice date shifts after partial payment

## Summary


When an account has multiple invoices and older invoices are paid while newer invoices remain unpaid, the overdue notification reschedule logic can cause intermediate overdue states to be completely skipped. In our testing, an account went directly from CLEAR to BLOCKED, completely bypassing the WARNING state.

This means the customer never receives a grace period or warning notification before their service is suspended.

## Environment
- Clock manipulation used for testing: `PUT /1.0/kb/test/clock?days=N`

## Overdue Configuration

```xml
<?xml version="1.0" encoding="UTF-8"?>
<overdueConfig>
  <accountOverdueStates>
    <initialReevaluationInterval>
      <unit>DAYS</unit>
      <number>5</number>
    </initialReevaluationInterval>
    <state name="CANCELLATION">
      <condition>
        <timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>
          <unit>DAYS</unit>
          <number>11</number>
        </timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>
      </condition>
      <subscriptionCancellationPolicy>IMMEDIATE</subscriptionCancellationPolicy>
    </state>
    <state name="BLOCKED">
      <condition>
        <timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>
          <unit>DAYS</unit>
          <number>7</number>
        </timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>
      </condition>
      <blockChanges>true</blockChanges>
      <disableEntitlementAndChangesBlocked>true</disableEntitlementAndChangesBlocked>
      <autoReevaluationInterval>
        <unit>DAYS</unit>
        <number>4</number>
      </autoReevaluationInterval>
    </state>
    <state name="WARNING">
      <condition>
        <timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>
          <unit>DAYS</unit>
          <number>5</number>
        </timeSinceEarliestUnpaidInvoiceEqualsOrExceeds>
      </condition>
      <blockChanges>true</blockChanges>
      <disableEntitlementAndChangesBlocked>false</disableEntitlementAndChangesBlocked>
      <autoReevaluationInterval>
        <unit>DAYS</unit>
        <number>2</number>
      </autoReevaluationInterval>
    </state>
  </accountOverdueStates>
</overdueConfig>
```

## Scenario That Works (Normal Flow)

Account: `cfd53d0e-0ed8-4294-805d-55ba14257437`

1. Account created on Aug 6 with a subscription
2. Invoice generated for $300 — payment fails
3. Invoice remains unpaid from Aug 6 onward (no partial payments, no additional invoices paid)
4. Overdue notification scheduled for Aug 11 (Aug 6 + 5 days)
5. Aug 11: Notification fires. Condition check: Aug 6 + 5 = Aug 11. Today is Aug 11. Match! Account enters **WARNING**.
6. autoReevaluationInterval = 2 days. Next check scheduled for Aug 13.
7. Aug 13: Notification fires. Condition check: Aug 6 + 7 = Aug 13. Today is Aug 13. Match! Account enters **BLOCKED**.

**Result: CLEAR → WARNING (Aug 11) → BLOCKED (Aug 13). Progressive degradation works as expected.**

Evidence from `blocking_states` table:
- Row: state=WARNING, effective_date=2026-08-11 06:07:05
- Row: state=BLOCKED, effective_date=2026-08-13 06:07:05

## Scenario That Does NOT Work (Bug)

Account: `88f031ae-6265-440b-b595-3e5854cdd370`

### Steps to reproduce:

1. **Aug 6**: Account created. Subscription invoice generated ($300). Payment fails.
   - Overdue schedules notification for Aug 11 (Aug 6 + 5 days).
   - Earliest unpaid invoice date = Aug 6.

2. **Aug 6**: External charge added ($500, separate invoice). Payment fails.
   - Earliest unpaid invoice date still = Aug 6.

3. **Aug 8** (clock moved +2 days): External charge added ($700, separate invoice). Payment fails.
   - Earliest unpaid invoice date still = Aug 6 (oldest unpaid).

4. **Aug 8**: Pay Invoice #4 ($300) and Invoice #5 ($500) using external payment API.
   - Both invoices now paid. Balance = $0.
   - **Earliest unpaid invoice date shifts from Aug 6 to Aug 8** (Invoice #6 with $700 is now the earliest unpaid).
   - Overdue notification at Aug 11 is NOT rescheduled to reflect the new earliest unpaid date.

5. **Aug 9** (clock +1): Another external charge ($200). Payment fails.
   - Earliest unpaid invoice date remains Aug 8.

6. **Aug 11** (clock +2): Scheduled notification fires.
   - Kill Bill evaluates condition: earliestUnpaidDate (Aug 8) + 5 days = Aug 13.
   - Today is Aug 11. Aug 11 < Aug 13. **Condition NOT met.**
   - Kill Bill reschedules: **Aug 11 (fire date) + 5 (initialReevaluationInterval) = Aug 16**.

7. **Aug 13**: Nothing happens. No notification is scheduled for this date.
   - **This is when WARNING should have triggered** (Aug 8 + 5 = Aug 13).

8. **Aug 16** (clock moved to Aug 16): Rescheduled notification fires.
   - Kill Bill evaluates condition: earliestUnpaidDate (Aug 8).
   - Days since earliest unpaid: Aug 16 - Aug 8 = **8 days**.
   - States evaluated in XML order (most severe first):
     - CANCELLATION: 8 >= 11? NO.
     - BLOCKED: 8 >= 7? **YES. Returns BLOCKED immediately.**
     - WARNING: never even checked.
   - Account enters **BLOCKED** directly from CLEAR.

**Result: CLEAR → BLOCKED (Aug 16). WARNING state completely skipped. Customer never received any grace period.**

Evidence from `blocking_states` table:
- Only one row for this account: state=BLOCKED, effective_date=2026-08-16 09:24:20
- **No WARNING row exists.**

Evidence from bus events:
```
eventType: BLOCKING_STATE
metaData: {"stateName":"BLOCKED","effectiveDate":"2026-08-16T09:24:20.000Z",
           "transitionedToBlockedBilling":true,"transitionedToBlockedEntitlement":true}
```

## Root Cause Analysis

Two issues combine to cause this:

### Issue 1: Notification reschedule uses fire date, not earliest unpaid date

When a scheduled overdue notification fires and the condition is not yet met, Kill Bill reschedules the next check as:

```
nextCheck = fireDate + initialReevaluationInterval
```

The correct calculation should be:

```
nextCheck = earliestUnpaidInvoiceDate + timeSinceEarliestThreshold
```

In our case:
- Fire date = Aug 11
- initialReevaluationInterval = 5 days
- Rescheduled to: Aug 11 + 5 = **Aug 16** (wrong)
- Should have been: Aug 8 + 5 = **Aug 13** (correct — this is when WARNING should trigger)

### Issue 2: `calculateOverdueState()` does not enforce sequential transitions

In `DefaultOverdueStateSet.java`:

```java
public DefaultOverdueState calculateOverdueState(final BillingState billingState, final LocalDate now) {
    for (final DefaultOverdueState overdueState : getStates()) {
        if (overdueState.getConditionEvaluation() != null &&
            overdueState.getConditionEvaluation().evaluate(billingState, now)) {
            return overdueState; // Returns FIRST match
        }
    }
    return getClearState();
}
```

The states array follows XML order: CANCELLATION → BLOCKED → WARNING (most severe first). When the notification fires late (Aug 16 instead of Aug 13), multiple conditions are satisfied simultaneously (both WARNING >= 5 and BLOCKED >= 7). The method returns the first match in iteration order, which is BLOCKED — skipping WARNING entirely.

## Impact

- Customer service is suspended without prior warning
- WARNING-state email notifications are never sent
- Progressive degradation (a core feature of the overdue system) is violated
- This can occur in production whenever a customer has multiple invoices and pays some but not all (a common scenario)

## Suggested Fix

**Option A (Reschedule fix):** When rescheduling a no-match notification, compute the next check based on when the condition will actually become true:

```
nextCheck = max(now + 1, earliestUnpaidInvoiceDate + lowestUnmetThreshold)
```

**Option B (Sequential enforcement):** Modify `calculateOverdueState()` to respect the current state and only allow the next state in sequence. If the account is in CLEAR, only WARNING can be returned, even if BLOCKED also matches.

**Option C (Both):** Apply both fixes for defense in depth.

## Notes

- We tested with `initialReevaluationInterval = 5` which equals the lowest state threshold (WARNING = 5 days) as recommended in the documentation.
- The control account (same tenant, same overdue config, no partial payments) followed the correct progressive flow, confirming the bug only triggers when the earliest unpaid invoice date shifts after the initial notification is scheduled.
- We have not found any existing issue or discussion about this behavior in Kill Bill GitHub issues or the killbilling-users Google Group.

Thank you for your time reviewing this. We are happy to provide additional test case details if needed.
Reply all
Reply to author
Forward
0 new messages