Skip to main content

billing-review

Review a diff that touches Polar's billing domain — subscriptions, cycles and crons, orders, billing entries, meters and usage, discounts, checkout, payments and dunning, refunds, disputes, payouts, wallets, tax and invoices. Use before opening a PR that changes money movement or subscription lifecycle, when the user asks for a billing review, or when a reviewer needs the domain rules the team enforces in review but that no linter catches.

Ir para a instalação

Informações da origem

Repositório
polarsource/polar
Última atividade na origem
19 de agosto de 2026 às 16:35
Idioma detectado do SKILL.md
inglês
Estrelas
10.274
Forks
789

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
name
billing-review
description
Review a diff that touches Polar's billing domain — subscriptions, cycles and crons, orders, billing entries, meters and usage, discounts, checkout, payments and dunning, refunds, disputes, payouts, wallets, tax and invoices. Use before opening a PR that changes money movement or subscription lifecycle, when the user asks for a billing review, or when a reviewer needs the domain rules the team enforces in review but that no linter catches.
license
MIT
metadata
{"author":"polar","version":"1.0.0"}
# Billing Review (Polar) The handle is the **diff**. The evidence is **where the change breaks a billing invariant the team enforces in review**. Not a general code review. The other lenses know Polar's conventions, contract and deploy shape. This one knows how the billing domain is supposed to behave. Distilled from ~210 review comments François (`frankie567`) left on billing PRs between February and August 2026, plus domain invariants raised by `pieterbeulque`, `psincraian`, `joebon` and `Yopi` on the same PRs. Each rule cites its PR. **Read `.agents/skills/polar-billing.md` first.** That is the map of the domain — entities, services, tasks, the cycle flow, proration, dunning, the ledger. This file is the checklist that assumes it. ## Scope ``` polar/{subscription,order,billing_entry,meter,event,discount,checkout,checkout_link}/ polar/{payment,payment_method,refund,dispute,payout,payout_account,wallet,transaction}/ polar/{invoice,receipt,tax,product,customer_seat,account}/ · polar/benefit/grant/ polar/models/{subscription,order,order_item,product,product_price,discount,checkout, payment,billing_entry,refund,dispute,wallet,transaction}.py migrations/ and server/scripts/ when they touch those tables ``` Owned elsewhere: schema and SDK impact → `api-surface-review`; migration and actor deploy safety → `ship-safety`; `lazy="raise"` and repository conventions → `conventions-check`; `ADR-0006` / `ADR-0007` → `adr-check`; existing helpers → `reuse-check`. For a domain question you cannot settle from the diff, ask it rather than assert a defect. That is how this team reviews. ## Checks ### 1. Money comes from billing entries, not derived state `BillingEntry` is the ledger tying an invoice line back to the events that caused it. Anything computing an amount from a summary or a recomputation loses that trace. - Invoicing metered usage reads billing entries, never the customer meter's computed balance. *"Billing Entries are the source of truth… Otherwise, we lose the ability to track billing down to the events."* (#12510) - If consumption happened, the entries already exist. New code that recomputes usage instead of picking up pending entries is a smell. (#12510) - Order creation is what consumes pending entries. The question for a new billing trigger is "should this create an order", not "should this compute an amount". ### 2. Reuse the lifecycle, do not hand-roll the transition The cycle already moves periods, writes events, and grants or revokes benefits. - Changing plan during a trial: call `update_trial` so periods and events are right, then let it cycle naturally because the new period is already past — same as ending a trial immediately. (#11898) - New periodic behaviour gets a scheduler modelled on `subscription/scheduler.py`, not a bespoke loop. (#12990) - **One cycle, one invoice.** Overages from a meter cycle that coincides with a billing cycle belong on the renewal invoice, not a second order with a second payment. (#12510) ### 3. Dunning decisions live in the dunning entry point - The retry decision belongs in `_handle_first_dunning_attempt`, not the caller. Check the latest payment's decline code, set a retry date if recoverable, leave it `None` otherwise. Everything else (past due, benefit enqueue) is untouched. (#9744) - Never schedule a retry for a non-recoverable decline. (#9744) - Decline-code knowledge lives on the `Payment` model as a `@property` (`UNRECOVERABLE_DECLINE_CODES`), guarded by `if self.processor == PaymentProcessor.stripe` so a second processor does not inherit Stripe's codes. (#9744) - Retry-exhaustion arithmetic is a classic off-by-one. Check the boundary. (#11905) ### 4. Payment locks release on one path - The webhook handler for the payment outcome (`handle_payment_failure`) releases the lock. A caller that also releases it on its own error path is duplicated logic — delete it and its test. (#10653) - **Never release a payment lock on success.** `release_on_success` was removed from the codebase for this reason. A diff reintroducing it is a regression. (#10653) - `with_for_update` belongs in the task that owns the unit of work, not inside `transfer_stripe`. *"This logic should be independent from Stripe behavior."* (#12097) - A wedged lock breaks dunning silently. (#13272) ### 5. Keep processor state in sync, keep processor names out of the domain - Domain statuses mirror the processor's vocabulary. `accepted` was rejected as a dispute status because Stripe uses `lost` either way, and diverging breaks the sync. (#12713) - A merchant action with a processor counterpart makes the processor call in the same flow. *"The risk is way too high to forget… and let the dispute expire."* (#12713) - Name things after the domain: "Payout Account", not "Connect". (#12097) ### 6. Crons and batch jobs - **Catch-up loops hide missed runs.** A `while` walking forward through skipped cycles computes credits wrong for the periods it skipped. Prefer an invariant alert that the cycle did not run. (psincraian, #12510) - Use `repository.stream` rather than a keyset loop over UUIDs. (#12749) - Loading every row in a script or sweep is a review stop. *"Won't that blow up in memory?"* (#11728, #13687) ### 7. Discounts - **Redemption counting is the whole game.** A failed payment should not count. A fully refunded order is an open question the team has not settled. (pieterbeulque, #13328) - Concurrent redemption of the same code needs a customer lock, and the caller acquires it — say so at the call site. (joebon, #13328) - `max_redemptions` and `max_redemptions_per_customer` are separate limits; a guard checking one usually needs both. (#13394) - Expiry is checked in `cycle` but has been missed elsewhere. New paths that apply a discount check it too — prefer extracting the shared check. (psincraian, #12510) - Multi-line discounts apply as a waterfall, not proportionally. Questioned in review as unusual for invoices; a change to allocation is a merchant-visible invoice change. (joebon, #12172) ### 8. Amounts, currency and tax - Fee values are basis points: 4% is `400`. Check the unit before trusting arithmetic. - Per-jurisdiction tax comes straight from the Numeral API response. Recomputing it from rates and `polar_round` introduces fractional-cent drift. (#11211) - Tax sits on transactions of type `payment`, which are on the **Polar** side and not linked to the merchant's account. Transactions reach an organization through `account_id`; `payment_organization_id` is a Pledge-era leftover that does not mean what it looks like. (#12204) - On imports, tax-inclusive versus exclusive must mirror the source provider, or the merchant loses money. (#12502) - Voiding or reversing an order credits back what was applied (`applied_balance_amount`), not the customer's current balance. (#11637) ### 9. Free and zero-amount paths These keep breaking because new code assumes a payment exists. - Keep `ProductPrice.is_free` / `Checkout.is_free_product_price` checks when reworking price handling. (#12225) - A new charge path supports a free price by skipping the payment step, not rejecting it. (#12089) - Renewal emails, invoices and receipts all have free-subscription branches. (#9291) ### 10. Billing-specific additions to rules owned elsewhere Short pointers only — the owning lens reports the general rule. - **Money tables are busy tables.** `orders`, `subscriptions`, `payments`, `customers`, `events`. Heavy backfills go in a script; indexes go in concurrently. → `ship-safety` - **Order foreign keys get `ondelete="restrict"`.** *"A `DELETE` statement is easy to spawn."* (#11206) → `ship-safety` - **Lazy loads in money paths are stuck jobs, not just 500s.** `assert order.customer` does not protect you — it raises the lazy-load error itself (#11900). When a denormalized column lands, the matching `joinedload` usually becomes dead; remove it (#12162). → `conventions-check` - **Customers cannot choose proration behaviour**, so it does not belong in the customer portal API. (#13095) → `api-surface-review` ## Output ``` ## Billing ### 🔴 Blocking - `file:line` — <invariant broken>. Fix: <fix> ### 🟠 Should fix - `file:line` — <claim>. Fix: <fix> ### 🟡 Question - `file:line` — <question> ### Notes - domain context the author may not have: <one line, or omit> ### Verdict ✅ Clean | ❌ n blocking, n should-fix ```
Ver no GitHub