notifyPendingTransactionOfStateChange() breaks the account balance

13 views
Skip to first unread message

tenchiro

unread,
May 3, 2026, 8:14:18 AM (11 days ago) May 3
to Kill Bill users mailing-list
I am working on handling Stripe webhook processing for methods that require customer intervention. I have encountered an issue (and a workaround) that I wanted to alert you about.

This is the heart of the notification processing code:

//
// PROCESS STATUS UPDATES
//
logger.info("Process status updates... type={}, id={}", event.getType(), event.getId());
if ("payment_intent.succeeded".equals(event.getType())
|| "payment_intent.payment_failed".equals(event.getType())
|| "payment_intent.canceled".equals(event.getType())
) {
// This is currently processing only the payment_intent status changes. It will be followed
// by a charge status change (payment_intent.succeeded then charge.succeeded). The payment_intent
// typically reflects the payment at the convenience store, while th charge is the actual
// charge.
logger.info("Payment intent succeeded...");

final PaymentIntent intent;
final var deserializer = event.getDataObjectDeserializer();
if (deserializer.getObject().isPresent()) {
// Attempt to get the object the normal way
intent = (PaymentIntent) deserializer.getObject().get();
} else {
// API version mismatch — parse raw JSON directly
try {
intent = PaymentIntent.GSON.fromJson(
deserializer.getRawJson(), PaymentIntent.class);
} catch (Exception parseEx) {
logger.error("Webhook: failed to deserialize PaymentIntent from raw JSON", parseEx);
return new PluginGatewayNotification(event.getId());
}
}

logger.info("... intent={}...", intent);

if (intent != null) {
logger.info("Intent not null... status={}, id={}", intent.getStatus(), intent.getId());

try {
// Look up the KB transaction by Stripe PI ID and update it
final StripeResponsesRecord record = dao.getResponseByStripeId(intent.getId(), context.getTenantId());
if (record != null) {
Charge lastCharge = null;
if (intent.getLatestCharge() != null) {
try {
lastCharge = Charge.retrieve(intent.getLatestCharge(), buildRequestOptions(context));
} catch (Exception e) {
logger.warn("Could not retrieve charge for PI {}", intent.getId(), e);
}
}
//
// UPDATE DB
//
final UUID kbTransactionId = UUID.fromString(record.getKbPaymentTransactionId());
StripeResponsesRecord updatedRecord = dao.updateResponse(kbTransactionId, intent, lastCharge, context.getTenantId());

logger.info("Webhook: updated response for PI {} → {}", intent.getId(), intent.getStatus());

notifyStateChange(updatedRecord, intent, properties, context);
} else {
logger.warn("Webhook: no response record found for PI {}", intent.getId());
}
} catch (final SQLException e) {
logger.error("Webhook: DB error updating response for PI {}", intent.getId(), e);
}
return new PluginGatewayNotification(event.getId());
}
}
} catch (final SignatureVerificationException e) {
logger.warn("STRIPE WEBHOOK IGNORED: Invalid Stripe webhook signature", e);
} catch (final Exception e) {
logger.error("STRIPE WEBHOOK ERROR: Failed to process Stripe webhook", e);
}

It's basically just pulling out certain events when an intent changes state and attempting to update the payment with the new information. However, after updating the dao, nothing was happening until either I specifically viewed the payment details in Kaui, or the janitor ran.

After research, I was directed towards the internal method: paymentApi.notifyPendingTransactionOfStateChanged() and this seemed to immediately fix the problem. The payment immediately shows as succeeded. Viewing the invoice shows it is linked to the payment. All seems well. But the balance is not adjusted. It never sees the payment. I'm pretty sure even the Janitor does not fix this. Here is the method I used after the updateResponse() call above:

private void notifyStateChangeX(StripeResponsesRecord record, PaymentIntent intent, Iterable<PluginProperty> properties, CallContext context) {
// Immediately notify KillBill to transition the payment state.
// This triggers KillBill to call getPaymentInfo() on this plugin right now,
// read the PROCESSED status we just wrote, and close the invoice.
// Without this, KillBill waits for the Janitor polling cycle (up to hours).
try {
final UUID kbAccountId = UUID.fromString(record.getKbAccountId());
final UUID kbPaymentTransactionId = UUID.fromString(record.getKbPaymentTransactionId());

final Account account = killbillAPI.getAccountUserApi()
.getAccountById(kbAccountId, context);

final boolean isSuccess = "succeeded".equals(intent.getStatus());

killbillAPI.getPaymentApi().notifyPendingTransactionOfStateChanged(
account,
kbPaymentTransactionId,
isSuccess,
context);

logger.info("Webhook: notified KillBill of state change for transaction {} isSuccess={}",
kbPaymentTransactionId, isSuccess);

} catch (final AccountApiException e) {
// Account lookup failed — Janitor will still fix this on next poll.
// Log as warning, not error — payment is correctly recorded in Stripe
// and in our response table. KillBill will eventually converge.
logger.warn("Webhook: could not look up account to notify state change for PI {}",
intent.getId(), e);
} catch (final PaymentApiException e) {
// State transition failed — same reasoning as above.
logger.warn("Webhook: could not notify KillBill of state change for PI {}",
intent.getId(), e);
}
}

As far as I could find, there is no way after this call is made to fix the balance w/o manually adding credits. After some experimentation, and viewing behavior under different conditions, I found this code fixed the problem:

private void notifyStateChange(StripeResponsesRecord record, PaymentIntent intent, Iterable<PluginProperty> properties, CallContext context) {
// Immediately notify KillBill to transition the payment state.
// This triggers KillBill to call getPaymentInfo() on this plugin right now,
// read the PROCESSED status we just wrote, and close the invoice.
// Without this, KillBill waits for the Janitor polling cycle (up to hours).
try {
final UUID kbAccountId = UUID.fromString(record.getKbAccountId());
final UUID kbPaymentId = UUID.fromString(record.getKbPaymentId());

// withPluginInfo=true forces KillBill to call getPaymentInfo() on this plugin,
// which reads the updated PROCESSED status and amount we just wrote to the DB,
// and triggers the full payment state machine including invoice reconciliation
// and account balance update. This is exactly what Kaui does when you click
// the payment manually.
killbillAPI.getPaymentApi().getPayment(
kbPaymentId,
true, // withPluginInfo
false, // withAttempts
Collections.emptyList(), // pluginProperties
context
);

logger.info("Webhook: notified KillBill of state change for transaction {}",
kbPaymentId);

} catch (final PaymentApiException e) {
// State transition failed — same reasoning as above.
logger.warn("Webhook: could not notify KillBill of state change for PI {}",
intent.getId(), e);
}
}

This works. But I am curious if this is the correct api? Did I misunderstand the notifyPendingTransactionOfStateChanged()? It seems it would be generally bad for any api to update internal payment states and cause the balance to become out of sync like that. If so, maybe there's something wrong in that api implemntation that might need attention. Is there anything more efficient than the getPayment() that I should be using?

Kind regards.

karan bansal

unread,
May 7, 2026, 3:24:07 AM (7 days ago) May 7
to Kill Bill users mailing-list
Hi there,

The notifyPendingTransactionOfStateChanged method skips the payment control layer. Either the janitor can handle the full state transition OR the faster workaround is what you are doing with getPayment(withPluginInfo=true). 

Regards
Karan

tenchiro

unread,
May 8, 2026, 9:19:27 AM (6 days ago) May 8
to Kill Bill users mailing-list
Ok, thanks. Yes, the workaround above is working for me. However, it seems like once you call notifyPendingTransactionOfStateChanged, then getPaymentInfo no longer fixes things. So I had to avoid it all together.
Reply all
Reply to author
Forward
0 new messages