Skip to content

feat(pricing): subscription-level amount→cost pricing#207

Merged
v0l merged 4 commits into
masterfrom
feat/181-subscription-cost-by-amount
Jul 23, 2026
Merged

feat(pricing): subscription-level amount→cost pricing#207
v0l merged 4 commits into
masterfrom
feat/181-subscription-cost-by-amount

Conversation

@v0l

@v0l v0l commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes #181.

Summary

Adds PricingEngine::get_subscription_cost_by_amount — the subscription-level equivalent of get_cost_by_amount (which only prices VM-backed subscriptions). It turns an arbitrary paid amount into time for any subscription (IP range, DNS, ASN, …), priced from the subscription’s line-item amounts and billing interval (interval_amount/interval_type):

  1. Sum the per-interval price of all line items (subscription base currency).
  2. Convert that to the payment method currency (get_amount_and_rate).
  3. Scale the interval’s time by input / interval_cost.
  4. Same tax determination + processing-fee handling as the VM path (input is the net amount; tax added on top).

On-chain watcher

OnChainPaymentHandler::regenerate now prices all deposits uniformly through the engine — VMs via get_cost_by_amount, everything else via get_subscription_cost_by_amount. This replaces the previous fallback that scaled the payment’s original (stale) quote by the value received. The now-dead pro_rate / pro_rate_f64 / current_rate helpers (and their test) are removed.

Tests

  • Unit test for get_subscription_cost_by_amount (paying half a month’s price buys ~half the interval’s time; zero tax with no VAT config).
  • The on-chain no-VM test (test_no_vm_prices_from_line_items) now asserts pricing comes from the subscription line items, matching the engine.

No API surface change.

Closes #181

…on_cost_by_amount)

Adds PricingEngine::get_subscription_cost_by_amount, the subscription-
level equivalent of get_cost_by_amount (which only prices VM-backed
subscriptions). It turns an arbitrary paid amount into time, priced from
the subscription's line-item amounts and billing interval
(interval_amount/interval_type), converting the per-interval list price
to the payment method currency and scaling the interval time by
input/interval_cost — with the same tax determination and processing-fee
handling as the VM path.

Wires the on-chain payment watcher to use it for subscriptions without a
VM, replacing the previous quote-scaling fallback (which re-used the
payment's stale quote). regenerate() now prices VM and non-VM deposits
uniformly through the engine; the pro_rate/pro_rate_f64/current_rate
helpers and their test are removed.

Tests: unit test for get_subscription_cost_by_amount (half-interval
payment -> half the time) and the on-chain no-VM test now asserts
line-item pricing.

Closes #181
@v0l

v0l commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Addressed review: the on-chain received amount is always the gross deposit, but regenerate() was backing out net as gross / (1 + tax) — removing the tax but not the processing fee, so a configured on-chain fee stayed inside the "net" and over-credited the purchased time.

Added PricingEngine::net_from_gross which removes both tax and fee:

net = (gross·(1 − fee_rate) − fee_base) / (1 + tax)

(the exact inverse of the net → +tax → +fee build-up). regenerate() now uses it for both the VM and non-VM pricing paths, and the fee-config read is refactored into a shared processing_fee_params helper used by both calculate_processing_fee and net_from_gross. No change where no on-chain fee is configured (net was already exact, e.g. Lightning). Unit test covers a Revolut fee round-trip.

…ut, not on top)

The amount-based pricing (get_cost_by_amount for LNURL/on-chain VM
top-ups, and get_subscription_cost_by_amount) treated its input as the
NET price and added tax + processing fee on top. But the amount a
customer actually pays is the GROSS total — the sats a wallet sends for
an LNURL top-up, or an on-chain deposit — so tax and any fee must be
taken OUT of it to get the net that buys time.

Both methods now back the net out of the gross via a new
PricingEngine::net_from_gross:
  net = (gross*(1 - fee_rate) - fee_base) / (1 + tax)
and return amount/tax/processing_fee that sum to exactly the paid
amount (fee = remainder). The fee-config read is refactored into a
shared processing_fee_params helper used by both calculate_processing_fee
and net_from_gross.

The on-chain watcher now passes the gross deposit straight through
(no pre-stripping, no clamping). Effects: LNURL top-ups for taxed
customers no longer over-credit time / over-invoice; on-chain deposits
remove the processing fee too, not just tax. No change for untaxed,
no-fee payments.

Tests: net_from_gross (lightning + Revolut fee round-trip) and a
subscription gross-input test asserting net+tax==gross with 23% VAT.
@v0l
v0l force-pushed the feat/181-subscription-cost-by-amount branch from 01f3413 to 3cb8947 Compare July 23, 2026 19:51
@v0l

v0l commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — this applies to Lightning/LNURL too. If a wallet pays 1000 sats, that 1000 is the gross the customer paid, so tax (and any fee) must come out of it to get the net that buys time.

Fixed properly now: get_cost_by_amount and get_subscription_cost_by_amount treat their input as the gross paid amount and back out the net internally (net = (gross·(1 − fee_rate) − fee_base) / (1 + tax)), with amount/tax/processing_fee summing to exactly what was paid. The on-chain watcher now passes the gross deposit straight through (no pre-stripping, no clamping).

Effects:

  • LNURL top-up, taxed customer: previously the full amount was treated as net → time over-credited and the invoice came out larger than the requested amount. Now the tax is taken out and the invoice equals the paid amount.
  • On-chain: removes the processing fee too, not just the tax.
  • No change for untaxed, no-fee payments (net already equalled the paid amount).

Added a test asserting net + tax == gross for a 23% VAT customer (Lightning, no fee), plus the net_from_gross fee round-trip.

The e2e suite only exercised LNURL error paths (invalid id / missing
VM). Add a real top-up step to the lifecycle test: hit the
unauthenticated LNURL callback for a live VM, assert the { pr, routes }
response (and no hodl_invoice field), pay the returned bolt11 via the
regtest lnd-payer, and poll until the VM expiry advances — exercising
the amount->time pricing path end-to-end. Falls back gracefully when
lnd-payer is unavailable.
@v0l

v0l commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Re: e2e coverage — it previously only hit the LNURL error paths (invalid id / missing VM). Added a real top-up step to the lifecycle test: it hits the unauthenticated LNURL callback for a live VM, asserts the { pr, routes } shape (and no hodl_invoice), pays the returned bolt11 via the regtest lnd-payer, and polls until the VM expiry advances — so the amount→time pricing path is now exercised end-to-end (with a graceful skip when lnd-payer is unavailable).

…ed customer

Add a taxed (IE, 23% VAT) case to the get_cost_by_amount test: paying a
gross of 1_230_000 msat yields net 1_000_000 + tax 230_000 (components
sum to the gross), and time_value is scaled by the net — strictly less
than the gross-based time. Directly covers the LNURL/VM top-up pricing
path with a non-zero tax rate.
@v0l

v0l commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Re: asserting the time value for a given tax rate —

  • The e2e LNURL step does not assert the exact time; it only checks the expiry advances (settlement smoke test), and the lifecycle user is untaxed, so it wouldn't exercise the tax path anyway.
  • The exact tax-aware math is asserted in unit tests, now for both paths:
    • subscription_cost_by_amount_treats_input_as_gross — non-VM: net + tax == gross at 23% VAT.
    • standard_pricing (new taxed block) — VM/LNURL path: paying gross 1_230_000 msat → net 1_000_000 + tax 230_000 (sum to the gross), and time_value is scaled by the net, asserted == next_expire · net/mo_price and strictly less than the gross-based time.

So the "time credited from net, not gross" behaviour is deterministically pinned in unit tests; e2e stays a settlement smoke test since the taxed path needs a taxed customer + company VAT config that would perturb the shared lifecycle test.

@v0l
v0l merged commit 64a8b4b into master Jul 23, 2026
8 checks passed
@v0l
v0l deleted the feat/181-subscription-cost-by-amount branch July 23, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Subscription-level amount→cost pricing (get_cost_by_amount for non-VM subscriptions)

1 participant