Skip to content

[BUG]: persisted-fee amount does not match estimate for pay-in-advance percentage charges with per_transaction_min_amount / per_transaction_max_amount #5560

Description

@michaelabbott25

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

  1. 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"] }
        ]
      }
    }
  2. 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"] }
          }]
        }]
      }
    }
  3. Create a customer:

    POST /api/v1/customers
    { "customer": { "external_id": "bugrepro-cust-abcd1234", "name": "Bug Repro Customer" } }
  4. 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"

  1. 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>" }
      }]
    }
  2. 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>" }
      }
    }
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions