Describe the bug
When a customer is subscribed to a plan with a percentage, pay-in-advance charge that has either per_transaction_min_amount or per_transaction_max_amount configured, the persisted fee Lago emits via the fee.created webhook is often wrong. The most common failure is the persisted fee is zero; in a smaller fraction of events the fee is negative. The batch estimate endpoint (POST /events/batch_estimate_instant_fees) returns the correct amount for the same event.
To Reproduce
-
Create a billable metric:
POST /api/v1/billable_metrics
{
"billable_metric": {
"code": "bugrepro-vol-abcd1234",
"name": "Bug repro volume",
"aggregation_type": "sum_agg",
"field_name": "volume",
"filters": [
{ "key": "category", "values": ["X"] }
]
}
}
-
Create a plan whose only charge has per_transaction_min_amount configured on a single filter:
POST /api/v1/plans
{
"plan": {
"code": "bugrepro-plan-abcd1234",
"name": "Bug repro plan",
"interval": "monthly",
"amount_cents": 0,
"amount_currency": "USD",
"pay_in_advance": true,
"charges": [{
"billable_metric_id": "<lago_id from step 1>",
"charge_model": "percentage",
"pay_in_advance": true,
"invoiceable": false,
"properties": { "rate": "0" },
"filters": [{
"invoice_display_name": "Bug repro fee",
"properties": {
"per_transaction_min_amount": "0.50",
"rate": "0.5"
},
"values": { "category": ["X"] }
}]
}]
}
}
-
Create a customer:
POST /api/v1/customers
{ "customer": { "external_id": "bugrepro-cust-abcd1234", "name": "Bug Repro Customer" } }
-
Subscribe customer to the plan:
POST /api/v1/subscriptions
{
"subscription": {
"external_customer_id": "bugrepro-cust-abcd1234",
"plan_code": "bugrepro-plan-abcd1234",
"external_id": "bugrepro-sub-abcd1234"
}
}
Estimate some events, then send them for real. Capture the estimate and persisted fee for each event, and confirm that at least one event has estimate ≠ persisted. Sometimes the persisted fee matches the estimate, but in our testing and in production we see that at least one event in the sequence reliably comes back with a zero or negative persisted fee.
Run events with volumes chosen so that the raw-percentage fee falls below the per_transaction_min_amount, forcing the per-event min/max adjustment on every event. for example: "60.50", "100.00", and "90.00"
-
Estimate the event:
POST /api/v1/events/batch_estimate_instant_fees
{
"events": [{
"transaction_id": "<fresh UUID>",
"code": "bugrepro-vol-abcd1234",
"external_subscription_id": "bugrepro-sub-abcd1234",
"properties": { "category": "X", "volume": "<volume>" }
}]
}
-
Send the same event for real:
POST /api/v1/events
{
"event": {
"transaction_id": "<the same UUID>",
"external_subscription_id": "bugrepro-sub-abcd1234",
"code": "bugrepro-vol-abcd1234",
"properties": { "category": "X", "volume": "<volume>" }
}
}
-
Poll for the persisted fee (or wait for the fee.created webhook):
GET /api/v1/fees?external_subscription_id=bugrepro-sub-abcd1234&event_transaction_id=<the same UUID>
Expected behavior
For a pay-in-advance percentage charge with per_transaction_min_amount: "0.50" and rate: "0.5", the persisted fee for an event with volume: 90.00 should be precise_amount: 0.5 — identical to the estimate endpoint's response. The min_max_adjustment_total_amount should be a positive value of 0.05 (lifting $0.45 to $0.50), not 0 or a negative number.
Observed behavior
We see the affected fees consistently have amount_details like this:
{
"rate": "0.5",
"units": "90.0",
"free_units": "0.0",
"paid_units": "90.0",
"free_events": "0.0",
"paid_events": "1.0",
"fixed_fee_unit_amount": "0.0",
"per_unit_total_amount": "0.45",
"fixed_fee_total_amount": "0.0",
"min_max_adjustment_total_amount": "-0.45"
}
per_unit_total_amount field is correct but min_max_adjustment_total_amount is -$0.45 — i.e., it exactly cancels the per-unit total. The resulting amount_cents/precise_amount is 0.
Additional context
There's a downstream impact for our customers on plans that combine minimum_commitment with the affected charge configuration end up double-billed at the period close:
- our per-event transfers were collected correctly upstream of Lago, against the estimate (which is right).
- But Lago's persisted
amount_cents = 0 is what Commitments::Minimum::InAdvance::CalculateTrueUpFeeService#fees_total_amount_cents sums when computing the period's true-up, effectively double billing.
Version
- Lago: v1.41.2 (self hosted). I also reproduced on v1.46.0 locally.
Describe the bug
When a customer is subscribed to a plan with a percentage, pay-in-advance charge that has either
per_transaction_min_amountorper_transaction_max_amountconfigured, the persisted fee Lago emits via thefee.createdwebhook is often wrong. The most common failure is the persisted fee is zero; in a smaller fraction of events the fee is negative. The batch estimate endpoint (POST /events/batch_estimate_instant_fees) returns the correct amount for the same event.To Reproduce
Create a billable metric:
Create a plan whose only charge has
per_transaction_min_amountconfigured on a single filter:Create a customer:
Subscribe customer to the plan:
Estimate some events, then send them for real. Capture the estimate and persisted fee for each event, and confirm that at least one event has
estimate ≠ persisted. Sometimes the persisted fee matches the estimate, but in our testing and in production we see that at least one event in the sequence reliably comes back with a zero or negative persisted fee.Run events with volumes chosen so that the raw-percentage fee falls below the
per_transaction_min_amount, forcing the per-event min/max adjustment on every event. for example: "60.50", "100.00", and "90.00"Estimate the event:
Send the same event for real:
Poll for the persisted fee (or wait for the
fee.createdwebhook):Expected behavior
For a pay-in-advance percentage charge with
per_transaction_min_amount: "0.50"andrate: "0.5", the persisted fee for an event withvolume: 90.00should beprecise_amount: 0.5— identical to the estimate endpoint's response. Themin_max_adjustment_total_amountshould be a positive value of0.05(lifting$0.45to$0.50), not0or a negative number.Observed behavior
We see the affected fees consistently have
amount_detailslike this:{ "rate": "0.5", "units": "90.0", "free_units": "0.0", "paid_units": "90.0", "free_events": "0.0", "paid_events": "1.0", "fixed_fee_unit_amount": "0.0", "per_unit_total_amount": "0.45", "fixed_fee_total_amount": "0.0", "min_max_adjustment_total_amount": "-0.45" }per_unit_total_amountfield is correct butmin_max_adjustment_total_amountis-$0.45— i.e., it exactly cancels the per-unit total. The resultingamount_cents/precise_amountis0.Additional context
There's a downstream impact for our customers on plans that combine
minimum_commitmentwith the affected charge configuration end up double-billed at the period close:amount_cents = 0is whatCommitments::Minimum::InAdvance::CalculateTrueUpFeeService#fees_total_amount_centssums when computing the period's true-up, effectively double billing.Version