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

51 views
Skip to first unread message

TAMIL THENDRAL SENTHAMIZH

unread,
Aug 6, 2026, 8:36:10 AMAug 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.

karan bansal

unread,
Aug 9, 2026, 4:01:42 AMAug 9
to Kill Bill users mailing-list
Hi Tamil,

Thank you for reporting the issue and providing all the details as always!

I am able to reproduce it and have created the GH issue https://github.com/killbill/killbill/issues/2297 to track it and the fix. Please feel free to add any further info, if you find anything missing.

Regards
Karan

TAMIL THENDRAL SENTHAMIZH

unread,
Aug 12, 2026, 10:51:28 AMAug 12
to Kill Bill users mailing-list
Hi Karan,

Following up on GH issue #2297 with additional findings from our continued testing. The same root cause (`calculateOverdueState()` first-match behavior) affects more scenarios than we initially reported.

---

## 1. State-Skip Applies to ALL Condition Types (Additional Details for #2297)

The state-skip issue is not limited to `timeSinceEarliestUnpaidInvoiceEqualsOrExceeds`. Since `calculateOverdueState()` always returns the first matching state in XML order regardless of the current account state, the same intermediate state-skip will occur with any condition type that can change suddenly:

### State skip with `totalUnpaidInvoiceBalanceEqualsOrExceeds`

Example config:
- WARNING: balance >= $50
- BLOCKED: balance >= $200
- CANCELLATION: balance >= $500

**Scenario:** Account has $40 unpaid balance (CLEAR state). A large external charge of $600 is added. Balance jumps from $40 to $640.

**Expected:** CLEAR → WARNING ($640 >= $50) → then re-evaluate → BLOCKED → CANCELLATION

**Actual:** `calculateOverdueState()` checks CANCELLATION first: $640 >= $500? YES → returns CANCELLATION. Account goes CLEAR → CANCELLATION directly. WARNING and BLOCKED skipped.

### State skip with `numberOfUnpaidInvoicesEqualsOrExceeds`

Example config:
- WARNING: unpaid invoices >= 2
- BLOCKED: unpaid invoices >= 4
- CANCELLATION: unpaid invoices >= 6

**Scenario:** Account has 1 unpaid invoice (CLEAR state). On the next billing date, multiple invoices are generated simultaneously (subscription + usage + add-on charges), pushing the count from 1 to 7 in one cycle.

**Expected:** CLEAR → WARNING (7 >= 2) → then re-evaluate → BLOCKED → CANCELLATION

**Actual:** `calculateOverdueState()` checks CANCELLATION first: 7 >= 6? YES → returns CANCELLATION directly. WARNING and BLOCKED skipped.

### State skip with combined conditions (AND logic)

Example config:
- WARNING: balance >= $50 AND unpaid >= 5 days
- BLOCKED: balance >= $100 AND unpaid >= 7 days

**Scenario:** Account has $40 balance, unpaid for 8 days (CLEAR — balance below $50 threshold, so WARNING never matched). On Day 8, a $150 external charge is added. Balance jumps to $190.

**Expected:** CLEAR → WARNING ($190 >= $50 AND 8 >= 5) → then re-evaluate → BLOCKED

**Actual:** `calculateOverdueState()` checks BLOCKED first: $190 >= $100 AND 8 >= 7? YES → returns BLOCKED. WARNING skipped entirely.

### Summary

All these scenarios share the same root cause: `calculateOverdueState()` does not enforce sequential transitions. The fix for #2297 (making state transitions sequential regardless of which conditions match) would address all of these cases together.

---

## 2. Additional Scenario: Overdue Config Uploaded AFTER Invoices Already Exceed All Thresholds

We also discovered a separate but related scenario that we'd like clarity on.

### Steps to reproduce:

1. **Aug 12**: Account created. Subscription invoice generated ($300 = $100 FIXED + $200 RECURRING). Payment fails. Invoice remains unpaid.
2. **No overdue config exists** at this point — no overdue scheduling happens.
3. **Sep 12** (30 days later): Overdue XML config uploaded to the tenant (WARNING=5d, BLOCKED=7d, CANCELLATION=11d).
4. **Sep 12**: The next billing cycle fires (monthly subscription renewal). This triggers invoice generation → overdue REFRESH is triggered for the account.
5. Overdue evaluates: earliest unpaid invoice = Aug 12. Today = Sep 12. Days unpaid = **31 days**.
6. `calculateOverdueState()` checks conditions:
   - CANCELLATION: 31 >= 11? **YES → returns CANCELLATION immediately**
   - BLOCKED and WARNING are never checked.
7. Subscription is **cancelled immediately** via `subscriptionCancellationPolicy=IMMEDIATE`.

### Result:

- Account went **directly CLEAR → CANCELLATION** — skipping WARNING and BLOCKED entirely.
- No `initialReevaluationInterval` scheduling occurred — the cancellation happened during the overdue REFRESH itself (not via a scheduled `OverdueCheckNotif`).
- No WARNING email notification was sent.
- No grace period for the customer.

### DB Evidence:

**blocking_states table:**
```
| blockable_id | state        | service         | effective_date      |
|--------------|--------------|-----------------|---------------------|
| 8e1d988a...  | CANCELLATION | overdue-service | 2026-09-12 06:58:05 |
```
No WARNING or BLOCKED rows exist.

**notifications_history (overdue entries):**
```
| # | class_name              | queue_name                               | effective_date      |
|---|-------------------------|------------------------------------------|---------------------|
| 2 | OverdueAsyncBusNotif    | overdue:service:overdue-async-bus-queue   | 2026-09-12 06:58:05 |
| 3 | OverdueAsyncBusNotif    | overdue:service:overdue-async-bus-queue   | 2026-09-12 06:58:05 |
| 4 | OverdueAsyncBusNotif    | overdue:service:overdue-async-bus-queue   | 2026-09-12 06:58:09 |
```
All are REFRESH actions triggered by billing events on Sep 12. No scheduled `OverdueCheckNotif` was ever created for this account.

### Our Question:

**Is this expected behavior, or should it be handled differently?**

Specifically:
1. When overdue config is uploaded to a tenant that already has accounts with unpaid invoices exceeding all state thresholds, is it expected that those accounts will be immediately cancelled (highest severity) on the next overdue evaluation?

2. Is there any documented recommendation for deploying overdue config to a tenant with existing accounts that already have old unpaid invoices? We could not find guidance on this in the overdue documentation.

3. Should the fix for #2297 (sequential state enforcement) also cover this case — meaning even if an invoice has been unpaid for 31 days, the system should still transition sequentially (CLEAR → WARNING → BLOCKED → CANCELLATION), giving each state a chance to fire its email notifications?

### Production Impact:

If a company enables overdue on an existing tenant, ALL accounts with old unpaid invoices beyond the CANCELLATION threshold would be immediately cancelled on their next billing event — with no prior warning to customers.

### Our Workaround:

Before uploading overdue config, we plan to:
1. Tag all accounts with old unpaid invoices using `OVERDUE_ENFORCEMENT_OFF`
2. Upload the overdue config
3. Gradually remove tags so each account starts fresh

Would appreciate knowing if this is the recommended approach or if this scenario should be handled gracefully by the overdue system itself.

---

Thank you for your continued support on this.

Best regards,
Tamil Thendral

karan bansal

unread,
Aug 14, 2026, 10:11:20 AM (14 days ago) Aug 14
to Kill Bill users mailing-list
Hi Tamil,

Thank you for sending these reports as well. I have checked and evaluated both. 

1) The first one where the system directly jumps the states, is actually the correct behavior. The system evaluates the conditions and takes the decision at the time of evaluation. It is correct in the sense, that the businesses would correctly want to flag the account if its number of invoices or balance would cross a threshold instead of going through the overdue ladder. 

I would suggest to choose the thresholds based on the jump in balance/number of invoices that the business expects. For example, for the scenario that you have raised, I would set the thresholds to be 50/700/1400 ( basically more than the single charge OR gap that you would expect ) and number of invoices at 2/8/14. If each gap is wider than the largest single jump expected, no single event can cross more than one threshold, ensuring that the account moves one state at a time. 

You can refer to this test where the charges are in steps of $250 and overdue config uses thresholds of 260/600/1200 so that the account progresses cleanly through every state. The same file also has the test for the number of invoices case.

2) The second one is where the overdue is introduced late and the older unpaid invoices in the system would push the account to the cancellation state. Adding the tag `OVERDUE_ENFORCEMENT_OFF` is a possible workaround, but it will only suspend the evaluation till the time the tag is there. As soon as it is removed, it will still evaluate the account based on history and not from that day only. 

The better workaround would have been to use control tag exclusion for blocked and cancellation states, however, currently no control tag exists for this situation. In my local tests, I was able to achieve it using the tag "TEST", that way I was able to hold the account at Warning stage and skipped the blocked/cancellation stages, however, using "TEST" tag for production accounts does not seem appropriate. Also the custom tags are not allowed to be used for exclusion or inclusion purpose. I will discuss with the Dev team if we can add this as a feature. 

So for the case that you had mentioned about oldest invoice being 31 days old, I would suggest to use something like 31, 33, 37 days in the config for warning, blocked, cancellation states respectively. That way the account will not jump directly to cancellation and instead go through the overdue ladder. You can then tighten it back to actual values ( example 5/7/11 ) once the legacy accounts have passed the cycle. 

Regards
Karan

TAMIL THENDRAL SENTHAMIZH

unread,
Aug 15, 2026, 1:49:55 PM (13 days ago) Aug 15
to karan bansal, Kill Bill users mailing-list
Hi karan, 

Thanks for looking into this and for the detailed explanation.

As mentioned, we can use the overdue enforcement tag as a workaround for existing accounts. My concern is that we can't guarantee how much an unpaid balance can increase from actions like addon purchases or upgrades, so multiple overdue thresholds could still be crossed at once.

Also, using the tag in production means we would later need to manage and remove it correctly for those accounts to enter the overdue flow, which could become difficult at scale.

Please let me know if there is any safe approach supported by Kill Bill for this. Otherwise, we can handle it externally through our business logic.

Regards, 
Tamil Thendral

--
You received this message because you are subscribed to a topic in the Google Groups "Kill Bill users mailing-list" group.
To unsubscribe from this topic, visit https://groups.google.com/d/topic/killbilling-users/9dWcSDBcZoI/unsubscribe.
To unsubscribe from this group and all its topics, send an email to killbilling-us...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/killbilling-users/43e6e65f-6dc5-4d83-9d7d-88bf38ef84c6n%40googlegroups.com.
Reply all
Reply to author
Forward
0 new messages