# AR Table Estimate: Support Fee Algorithm

**Status**: design lock for `fix/ar-table-estimate-now-cap` PR (covers the original `fix/ar-table` PR + subsequent review-followup fixes).

> **Plan types vs. commitment** — `SupportPlan.Type` is one of
> `FixedPrice` / `PercentagePrice` / `TieredPrice`. There is no
> `CommitmentPrice` plan type. Commitment shows up in two unrelated
> places: the `InvoiceType_COMMITMENT` invoice type (a billing artifact
> that backs the floor) and the historical-but-now-removed
> `support-plan:commitment` metering SKU. The estimate algorithm reads
> only `SupportPlan.Type` and the commitment invoice; it does not read
> `user_unit_costs` for support at all.

## Background

The `AREstimatedWorker` (runs on the 1st/2nd of each month) produces an
**early estimate** of the previous month's support fee. It is paired with
`ARActualWorker` (runs on the 4th), which produces the final, metered
figure.

The two have fundamentally different inputs:

- **Actual**: reads `user_unit_costs` (rows written by
  `SendSupportPlanMetering`). This is the source of truth for what was
  actually billed.
- **Estimate**: does **NOT** read `user_unit_costs` for the support
  portion. It computes the support fee from `binded_support_plans`
  metadata, because at the time the estimate runs (1st/2nd) the metering
  pipeline may not have written anything yet for the month being
  estimated — and we cannot block the AR table on metering catching up.

This document locks the algorithm so future contributors do not regress
estimate to "read user_unit_costs" and re-introduce the support-only-tenant
drop bug.

## Inputs

For a given `(tenant_id, billing_config_id, estimate_date)`:

- `BindedSupportPlan` rows with:
  - `status = 'active'`
  - `active_date < month_end` (where `month_end = estimate_date.AddDate(0, 1, 0)`)
  - `closed_date IS NULL OR closed_date >= month_start`
  - `expired_date IS NULL OR expired_date >= month_start`
- `BindSupportPlan` SKU metadata: `FixedChargePrice`,
  `SupportPricePercentage`, `TieredPriceRules`, `SupportPlanType`.
- The tenant's `user_unit_costs` aggregated to date, **only for
  percentage-priced plans** (see Case B). For commitment/floor
  calculations we never read the metered support row.

## Algorithm

### Step 0 — active days in month

For each candidate `BindedSupportPlan`:

```
planStart     = max(plan.ActiveDate, monthStart)
planEnd       = min(plan.ExpiredDate ?? monthEnd, plan.ClosedDate ?? monthEnd, monthEnd)
activeDays    = max(0, days(planEnd) - days(planStart) + 1)  // inclusive
elapsedDays   = max(0, min(now, planEnd) - planStart + 1)    // days so far this month
```

If `activeDays == 0`, the plan contributes nothing — skip it.

### Step A — FixedPrice

```
dailyRate = plan.FixedChargePrice / daysInMonth
estimate  = dailyRate * elapsedDays
```

No commitment floor. The plan name says it: the price is fixed.

### Step B — PercentagePrice

This is where the "estimate cheat" lives. Walk-through:

> The tenant has accumulated `$100` of metered cost for the estimate
> month so far. The plan is `20%` of usage with a commitment floor of
> `$129`. On the 1st, the support meter has only written `$17` of
> cumulative support (`$100 * 20%`, capped at 1 day of usage). Naively
> extrapolating that to the full month would give `$17 * 30 = $510`,
> which is way past the `$129` floor. Since the floor is what the
> tenant has **committed** to pay, we estimate against the floor, not
> the (much-higher) extrapolated percentage value.

```
usageToDate        = getCustomerUsageBase(tenant, billingCfg, now)
                                // sum of metered non-support cost up to today
percentageCost     = usageToDate * plan.SupportPricePercentage
floor              = commitmentInvoiceCost(tenant, billingCfg, month)  // see Case D
// commitment floor is treated as the *full-month* commitment.
// Plan active the whole month → no prorate on the floor.
estimate           = max(percentageCost, floor)
```

If the plan is only active for part of the month (e.g. started on the
15th, ends on the 30th, 16 days active), prorate the floor:

```
floor              = floor * activeDays / daysInMonth
estimate           = max(percentageCost, floor)
```

If the plan is only active for part of the month, the percentage cost
naturally reflects the partial month (we only sum usage that actually
happened), so no separate prorate is needed on the percentage side.

### Step C — TieredPrice

Same as Case B but `percentageCost` is computed by
`tieredPricing(rules, previousCumulative, usageToDate)` (see
`support_plan.go::tieredPricing`). The `previousCumulative` for the
estimate can be read from the last `SupportPlanMeteringRecords` row
for the plan; if none exists, treat it as 0.

```
floor              = commitmentInvoiceCost(tenant, billingCfg, month)
floor              *= (plan active whole month) ? 1 : activeDays / daysInMonth
tieredCost         = tieredPricing(plan.TieredPriceRules, previousCumulative, usageToDate)
estimate           = max(tieredCost, floor)
```

### Step D — sum across plans

The total estimate support fee for a tenant is the sum across all
active plans. In practice a tenant has exactly one active support
plan, but the algorithm is sum-safe.

## Edge cases

| Case | Behaviour |
|---|---|
| Plan `ActiveDate` is in the future of `monthStart` | `planStart = ActiveDate`, prorate starts from there |
| Plan `ExpiredDate` is before `monthEnd` | `planEnd = ExpiredDate`, prorate stops there |
| Plan `ClosedDate` in the middle of the month | `planEnd = ClosedDate`, prorate stops there |
| Plan `activeDays == 0` (entirely outside the month) | skip |
| Multiple active plans for the same tenant | sum across plans (defensive) |
| Commitment invoice missing | floor = 0, estimate reduces to the percentage / tiered cost |
| `binded_support_plans` row missing | estimate support = 0; AR table still has a row for the tenant via the candidate-set fix in this PR |
| `now` is past `monthEnd` (estimate re-run after the month) | `elapsedDays = daysInMonth`, no prorate |

## Why estimate never reads user_unit_costs for support

Two reasons:

1. **Timing**: the estimate runs on the 1st/2nd. `SendSupportPlanMetering`
   may not have written any rows for the estimate month yet — especially
   if the metering pipeline is delayed or hasn't yet processed the
   support plan. Reading user_unit_costs would silently under-report
   support and risk dropping the tenant from the AR table entirely
   (the pre-fix behaviour: pure support tenants got filtered out).
2. **Predictability**: tying estimate to metering would couple AR
   generation to a pipeline the AR table has no control over. The
   contract price is in `binded_support_plans`; the estimate should
   be derivable from contract metadata alone.

Actual does read `user_unit_costs` — it is the final, post-metering
number. Any drift between estimate and actual is reconciled by the
AR reconciliation pipeline downstream.

## Why we still keep the candidate-set fix

The candidate-set fix (accept `internal=true OR unit LIKE
'support-plan:%'`, plus `addActiveSupportPlanTenants`) is a **necessary
precondition** for the estimate to be correct: without it, support-only
tenants never enter `ListEstimatedPayableBills` and the support
estimate helper is never invoked for them. The two changes (candidate
set + estimate support helper) are paired.

## Tests

The helper `EstimateSupportFeeForTenant(tenantID, billingConfigID, date)`
is unit-tested with the following cases. Each test seeds
`binded_support_plans` + `commitment_invoices` + (where relevant)
`user_unit_costs`, then asserts the returned value.

| Test | Plan | Setup | Expected estimate |
|---|---|---|---|
| `TestEstimateSupportFeeForTenant_FixedPrice_FullMonth` | FixedPrice $300, full month | elapsed = full month | $300 |
| `TestEstimateSupportFeeForTenant_FixedPrice_MidMonth` | FixedPrice $300, activated 15th | elapsed = 16 days (15→30) | $300 * 16/30 = $160 |
| `TestEstimateSupportFeeForTenant_Percentage_FloorWins` | 20% + floor $129, no usage yet | floor > percentage | $129 |
| `TestEstimateSupportFeeForTenant_Percentage_ExtrapolatedWins` | 20% + floor $10, $1000 of usage | percentage > floor | $1000 × 20% = $200 |
| `TestEstimateSupportFeeForTenant_Percentage_PartialMonth_FloorProrated` | 20% + floor $300, plan active 15–30 (16 days) | floor prorated to 16/30 | max(percentage, $300 × 16/30) = $160 |
| `TestEstimateSupportFeeForTenant_TieredPrice_TwoTiers_TieredCostWins` | tiered (0–1000 @ 10%, 1000–2000 @ 5%), $1500 usage | tiered cost wins (no floor seeded) | 1000 × 10% + 500 × 5% = $125 |
| `TestEstimateSupportFeeForTenant_NoActivePlan` | no binded_support_plans rows | 0 | 0 |
| `TestEstimateSupportFeeForTenant_PlanOutsideMonth` | ActiveDate in future of monthStart OR ExpiredDate before monthEnd | 0 | 0 |
| `TestEstimateSupportFeeForTenant_UnknownPlanType_ReturnsError` | `SupportPlan.Type = "support_plan:experimental_v2"` (not in supported set) | refuses to estimate | 0 + error |

## Cross-references

- `model/marketing/support_plan.go::sendToBillNormalMetering` — the metered
  equivalent. The estimate helper mirrors this algorithm but does not
  write to `user_unit_costs` or `SupportPlanMeteringRecords`.
- `model/marketing/support_plan.go::tieredPricing` — the tiered pricing
  rule engine. Estimate reuses this verbatim.
- `model/payment/reconciliation/ar_table.go::TenantCommitmentCostForSupport`
  — owner filter for the commitment invoice. The estimate helper takes
  its floor from this filtered total.
- `model/payment/reconciliation/ar_table.go::prorateCommitment` — days-
  elapsed proration for the commitment-only case.